Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be2ea6c4ea | |||
| 323f29b649 | |||
| b44e231038 | |||
| abef27cf2a | |||
| 03d3fcf460 | |||
| 037a4b870a | |||
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 | |||
| 2be2e7d615 | |||
| 6d182db81b | |||
| dc19cffcc0 | |||
| 05447c92fc | |||
| 415158d4d3 | |||
| 9bc35f7edd | |||
| 15895cae0c | |||
| 5650255e49 | |||
| 2be400e455 |
@@ -35,3 +35,11 @@ images/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Playwright e2e (#65) — keep README; ignore churn
|
||||
frontend/tests/e2e/artifacts/*.png
|
||||
frontend/tests/e2e/artifacts/run-log.txt
|
||||
frontend/tests/e2e/test-results/
|
||||
frontend/tests/e2e/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
|
||||
+23
-7
@@ -1,19 +1,35 @@
|
||||
# Host
|
||||
|
||||
## Overnight Milestone 1 Capture (poteto Build)
|
||||
## Overnight Capture UAT (primary — when Tailscale is up)
|
||||
|
||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
||||
- Container: `slabuploader-m1-overnight` (image `slabuploader-m1:overnight`)
|
||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`) — or salvage rebuild of the same role
|
||||
- Port: **10881** → 80
|
||||
- URL: `http://100.64.0.9:10881`
|
||||
- Also available on the Grok Bot box at `http://100.64.0.3:18080` (not LAN-reachable for Ty).
|
||||
- Replaces prior overnight container `slabuploader-m1-overnight` (same port).
|
||||
|
||||
## Ty local Node/SvelteKit (do not overwrite)
|
||||
## Fallback UAT — Grok Bot computer (when Tailscale to 5060 is down)
|
||||
|
||||
Use this path for salvage / Capture UAT until the box can reach `100.64.0.9` again.
|
||||
|
||||
- Host: Grok Bot computer (slab-m1 box)
|
||||
- Serve: static Capture build (`frontend` → `npm run build`, then `vite preview` or equivalent static server)
|
||||
- Port: **18181** (chosen so it never collides with Ty local on 5060)
|
||||
- Bind: `0.0.0.0:18181`
|
||||
- URL (from this box): `http://127.0.0.1:18181` — Ty reaches it via the bot computer / whatever path the app exposes; Tailscale IP of this box when mesh returns
|
||||
- Branch for salvage: `salvage-one-woo-draft` (cut from `m2-ds`)
|
||||
- Do **not** require Docker on the box for this fallback.
|
||||
|
||||
When Tailscale is restored, prefer primary overnight `:10881` again and stop advertising the fallback as the main UAT URL.
|
||||
|
||||
## Ty local Node/SvelteKit on 5060 (do not overwrite)
|
||||
|
||||
- Host: `ubudual5060` / `100.64.0.9`
|
||||
- Container: `slabuploader-m1`
|
||||
- Port: **10880** → 80
|
||||
- URL: `http://100.64.0.9:10880`
|
||||
- Port: **18080** → 80
|
||||
- URL: `http://100.64.0.9:18080`
|
||||
- **Never** recreate or remap this container for overnight or box-fallback UAT.
|
||||
- **Never** bind overnight or fallback UAT to **10880** either (legacy Ty local).
|
||||
|
||||
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than 10880.
|
||||
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local ports.
|
||||
Do not put a host name in the product spec.
|
||||
|
||||
+4
-4
@@ -34,7 +34,7 @@ Otherwise the app opens Add photo.
|
||||
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
|
||||
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 80% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
|
||||
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 90% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
|
||||
8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
|
||||
|
||||
Calculate does not create a WooCommerce product.
|
||||
@@ -97,7 +97,7 @@ User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), th
|
||||
- Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0.
|
||||
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
||||
- `bdft = sqft × thickness_in` (2 decimals)
|
||||
- Crop: 3:4 portrait, slab centered, 80% fill at the widest and tallest points, transparent background
|
||||
- Crop: 3:4 portrait, slab centered, 90% fill at the widest and tallest points, transparent background
|
||||
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
|
||||
|
||||
User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
||||
@@ -108,7 +108,7 @@ On Calculate, the app builds the inventory image from the approved mask:
|
||||
|
||||
1. Keep slab pixels from the source photo. Removed pixels are transparent.
|
||||
2. Place the slab in a 3:4 portrait frame, centered.
|
||||
3. Scale so the widest and tallest points of the slab fill 80% of the frame. Extra margin where the slab is narrower.
|
||||
3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
|
||||
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
||||
5. Encode as PNG with alpha.
|
||||
|
||||
@@ -133,7 +133,7 @@ The first inventory photo is the measurement reference. Later inventory photos u
|
||||
| Format | PNG, 8-bit RGBA (`image/png`) |
|
||||
| Alpha | Kept through encode and upload |
|
||||
| Aspect | 3:4 portrait |
|
||||
| Layout | Slab centered, 80% fill at the widest and tallest points |
|
||||
| Layout | Slab centered, 90% fill at the widest and tallest points |
|
||||
| Size | After crop, shorter side ≥ 800px when the source can supply it |
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Phone capture of one slab → a lined-up set of Woo-compatible inventory PNGs, p
|
||||
1. User adds a batch of one or more photos of the same slab (Setup rules from Milestone 1: green or black sheet, top-down reference expectations still apply to the calibration choice).
|
||||
2. User selects which photo in the batch is the calibration photo.
|
||||
3. App runs the Milestone 1 workflow on that calibration photo only: Mask → Length axis → Measure (length `L`, thickness, Calculate).
|
||||
4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 80% fill, transparent PNG, shorter-side floor), to every other photo in the batch.
|
||||
4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 90% fill, transparent PNG, shorter-side floor), to every other photo in the batch.
|
||||
5. App adds a second copy of the calibration inventory PNG with dimension overlays burned in (see Dimension overlay).
|
||||
6. User sees Photos ready: inventory PNGs lined up in this order — clean calibration PNG, then the dimension-overlay copy of the calibration PNG, then the other batch photos in capture order — plus the Milestone 1 calibration details (widths, sqft, bdft, scale) presented as in Measure.
|
||||
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||||
rectangle (calibration length + crop)".
|
||||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||||
|
||||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||||
in from the top and the bottom — and never changes the aspect ratio.
|
||||
|
||||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||||
production workflow:
|
||||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||||
- the requirements-coverage board below the stage
|
||||
- the "was / now" pair of mini diagrams
|
||||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||||
- the window.__wf debug hook
|
||||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||||
semantics, and the single shared crop_fill.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||||
.panel{padding:10px 4px;color:var(--fg)}
|
||||
h1{font-size:1.3rem;margin:0 0 10px}
|
||||
h3{font-size:.95rem;margin:0 0 8px}
|
||||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||||
.row{display:flex;gap:10px;margin-top:16px}
|
||||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||||
/* ---- the stage: fixed line + movable slab ---- */
|
||||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||||
.tw.dragging svg{cursor:grabbing}
|
||||
.tickg{cursor:ns-resize}
|
||||
.rotg{cursor:ew-resize}
|
||||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||||
input[type=range]{flex:1}
|
||||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||||
.nums span:first-child{color:var(--mut)}
|
||||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||||
.nums .dim{color:#777}
|
||||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||||
.status.ok{background:#12210f;color:var(--ok)}
|
||||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||||
.plan code{color:#ddd;font-size:.72rem}
|
||||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||||
/* replacement two-up */
|
||||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||||
.swap .gone{color:#ff9d8a}
|
||||
/* scope lineup */
|
||||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||||
/* fields + settings */
|
||||
.fields{display:flex;gap:16px;margin-top:16px}
|
||||
.field{flex:1}
|
||||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||||
.settings .msg.ok{color:var(--ok)}
|
||||
/* requirement coverage board (outside the phone) */
|
||||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||||
.cov .where{color:#ddd}
|
||||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||||
@media (max-width:760px){
|
||||
.cov thead{display:none}
|
||||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||||
.cov tr:last-child{border-bottom:0}
|
||||
.cov td{border:0;padding:1px 0}
|
||||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||||
}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||||
<div class="flowbar" id="flowbar"></div>
|
||||
<div class="vp">
|
||||
<span>Viewport</span>
|
||||
<span id="vpval">412</span>
|
||||
<button data-w="390">390</button>
|
||||
<button data-w="412" class="on">412</button>
|
||||
<button data-w="720">720</button>
|
||||
<button data-w="1440">1440</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage"><div id="phone"></div></div>
|
||||
<div class="cov"><div class="cov-inner">
|
||||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||||
<table>
|
||||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
<script>
|
||||
// ===========================================================================
|
||||
// issue #99 — the tape line is a FIXED ENTITY.
|
||||
//
|
||||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||||
// its long axis lies on the line, tick to tick.
|
||||
// * The ticks belong to the line - they slide along it, never with the photo.
|
||||
// * L = the distance between the two ticks (the owner's tape reading).
|
||||
// ===========================================================================
|
||||
const S = {
|
||||
screen: "tape",
|
||||
// slab transform (the only movable entity)
|
||||
tilt: 14, // degrees
|
||||
zoom: 1,
|
||||
panX: 60, panY: 0,
|
||||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom - and the operator drags them onto their tape marks from there.
|
||||
tickA: 32,
|
||||
tickB: 608,
|
||||
focus: "A",
|
||||
fill: 90, // settings.crop_fill - global, default 90
|
||||
// declared values (entered on Measure, shown on both steps)
|
||||
L: "88",
|
||||
thickness: "2.125",
|
||||
savedMsg: "",
|
||||
};
|
||||
|
||||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||||
const VB = { w: 480, h: 640 };
|
||||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||||
// AXIS - the centreline running down the middle of the board, which is where
|
||||
// the tape is laid. (Not a physical side edge.)
|
||||
const SLAB = {
|
||||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||||
};
|
||||
const TICK_LEN = 26; // rifle-scope end tick, total
|
||||
const TICK_GAP = 40; // min separation between ticks
|
||||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||||
const HIT_R = 34;
|
||||
const ALIGN_TOL = 3; // stage units
|
||||
|
||||
const rad = (a) => a * Math.PI / 180;
|
||||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||||
// crop frame, which is where the crop centres the slab's long axis.
|
||||
const lineX = () => VB.w / 2;
|
||||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||||
|
||||
// slab transform: zoom + tilt about the stage centre, then pan
|
||||
function mapPoint(p) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||||
}
|
||||
function slabTransform() {
|
||||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||||
}
|
||||
// where the slab's long axis crosses a given stage y
|
||||
function axisXAt(y) {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||||
}
|
||||
const axisAngle = () => {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||||
};
|
||||
// the axis is vertical when its angle points straight down (or up) the screen
|
||||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||||
|
||||
// ---------------------------------------------------------------- the stage
|
||||
function slabSvg() {
|
||||
return `
|
||||
<g id="slab" transform="${slabTransform()}">
|
||||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||||
<g clip-path="url(#slabClip)" opacity=".8">
|
||||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
</g>
|
||||
</g>`;
|
||||
}
|
||||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||||
function fixtureSvg() {
|
||||
const x = lineX();
|
||||
const tick = (y, id) => {
|
||||
const on = S.focus === id;
|
||||
return `
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||||
};
|
||||
return `
|
||||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||||
}
|
||||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||||
function cropFrameSvg() {
|
||||
const m = cropInset();
|
||||
return `
|
||||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||||
}
|
||||
function rotateHandleSvg() {
|
||||
return `
|
||||
<g class="rotg" data-rot="1">
|
||||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||||
</g>`;
|
||||
}
|
||||
function stageSvg() {
|
||||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||||
${cropFrameSvg()}
|
||||
${slabSvg()}
|
||||
${fixtureSvg()}
|
||||
${rotateHandleSvg()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ screens
|
||||
const screens = {
|
||||
batch() {
|
||||
return `<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||||
</section>`;
|
||||
},
|
||||
mask() {
|
||||
return `<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||||
<div class="nums">
|
||||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-batch">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
tape() {
|
||||
const off = axisOffset(), ok = aligned();
|
||||
return `<section class="panel">
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<div class="tw" id="tw">${stageSvg()}</div>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||||
</div>
|
||||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||||
? "The board's long axis is on the line ✓"
|
||||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||||
</div>
|
||||
|
||||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||
|
||||
<div class="swap">
|
||||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-mask">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||||
<ul>
|
||||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
measure() {
|
||||
const mini = (tilt, marks) => `
|
||||
<svg viewBox="0 0 480 640">
|
||||
<rect width="480" height="640" fill="#16281c"/>
|
||||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||||
</g>
|
||||
</svg>`;
|
||||
return `<section class="panel">
|
||||
<h1>Photos ready</h1>
|
||||
<div class="fields">
|
||||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||||
</div>
|
||||
<div class="nums">
|
||||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||||
</div>
|
||||
<div class="plan">
|
||||
<h3>Rotation scope — req. 6</h3>
|
||||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||||
</div>
|
||||
<ol class="lineup">
|
||||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-tape">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
settings() {
|
||||
return `<section class="panel settings">
|
||||
<h1>Settings</h1>
|
||||
<div class="section">
|
||||
<h3>Crop — global</h3>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||||
<div class="croprow">
|
||||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||||
<button data-act="save-fill">Save Changes</button>
|
||||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Implementation notes</h3>
|
||||
<ul class="plan" style="border:0;padding:0;background:none">
|
||||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||||
</section>`;
|
||||
},
|
||||
};
|
||||
|
||||
// the two mini overlays for the was/now pair
|
||||
function oldOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||||
</svg>`;
|
||||
}
|
||||
function newOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- plumbing
|
||||
const phone = document.getElementById('phone');
|
||||
const flowbar = document.getElementById('flowbar');
|
||||
const STEPS = [
|
||||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||||
];
|
||||
function render() {
|
||||
phone.innerHTML = screens[S.screen]();
|
||||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||||
}
|
||||
flowbar.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-goto]');
|
||||
if (!b) return;
|
||||
S.screen = b.dataset.goto;
|
||||
render();
|
||||
});
|
||||
document.querySelector('.vp').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-w]');
|
||||
if (!b) return;
|
||||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||||
document.getElementById('vpval').textContent = b.dataset.w;
|
||||
});
|
||||
|
||||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||||
// (a full panel re-render would destroy the element under the pointer).
|
||||
function refreshStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
host.innerHTML = stageSvg();
|
||||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||
set('tiltVal', S.tilt + '\u00B0');
|
||||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||||
set('fillVal', S.fill + '%');
|
||||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||||
const st = document.getElementById('alignstatus');
|
||||
if (st) {
|
||||
const ok = aligned(), off = axisOffset();
|
||||
st.className = 'status' + (ok ? ' ok' : '');
|
||||
st.innerHTML = ok
|
||||
? "The board's long axis is on the line \u2713"
|
||||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||||
}
|
||||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||||
axisAngle: axisAngle(), aligned: aligned() };
|
||||
}
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||||
// twin, stepping about the stage centre so the slab stays where it is.
|
||||
function stepZoom(dir) {
|
||||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||||
refreshStage();
|
||||
}
|
||||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||||
function nudgeTick(dir, mult) {
|
||||
const step = (mult === undefined ? 1 : mult);
|
||||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||||
render();
|
||||
}
|
||||
|
||||
function bindStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
const toVB = (e) => {
|
||||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||||
};
|
||||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||||
function zoomAt(p, factor) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2;
|
||||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||||
if (z1 === z0) return;
|
||||
const qx = cx + (p.x - cx - S.panX) / z0;
|
||||
const qy = cy + (p.y - cy - S.panY) / z0;
|
||||
S.zoom = z1;
|
||||
S.panX = p.x - cx - z1 * (qx - cx);
|
||||
S.panY = p.y - cy - z1 * (qy - cy);
|
||||
refreshStage();
|
||||
}
|
||||
host.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||||
const dy = clamp(e.deltaY, -200, 200);
|
||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||||
}, { passive: false });
|
||||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||||
|
||||
let drag = null, tb = null, moved = false, downP = null;
|
||||
const off = { x: 0, y: 0 };
|
||||
host.addEventListener('pointerdown', (e) => {
|
||||
const tick = e.target.closest('[data-tick]');
|
||||
const rot = e.target.closest('[data-rot]');
|
||||
const p = toVB(e);
|
||||
moved = false; downP = p;
|
||||
if (tick) {
|
||||
S.focus = tick.dataset.tick;
|
||||
drag = { kind: "tick", id: S.focus };
|
||||
} else if (rot) {
|
||||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||||
} else {
|
||||
drag = { kind: "slab" };
|
||||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||||
}
|
||||
host.setPointerCapture(e.pointerId);
|
||||
host.classList.add('dragging');
|
||||
refreshStage();
|
||||
e.preventDefault();
|
||||
});
|
||||
host.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const p = toVB(e);
|
||||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||||
if (drag.kind === "tick") {
|
||||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||||
const y = clamp(p.y, 20, VB.h - 20);
|
||||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||||
} else if (drag.kind === "rot") {
|
||||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||||
} else {
|
||||
S.panX = clamp(p.x - off.x, -400, 400);
|
||||
S.panY = clamp(p.y - off.y, -400, 400);
|
||||
}
|
||||
refreshStage();
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag) return;
|
||||
const wasSlab = drag.kind === "slab";
|
||||
const startP = toVB(e);
|
||||
drag = null;
|
||||
host.classList.remove('dragging');
|
||||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||||
// dblclick both stop arriving), so track the taps ourselves.
|
||||
if (wasSlab && !moved) {
|
||||
const now = performance.now();
|
||||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||||
tb = { t: now, x: startP.x, y: startP.y };
|
||||
}
|
||||
refreshStage();
|
||||
};
|
||||
host.addEventListener('pointerup', end);
|
||||
host.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (S.screen !== "tape") return;
|
||||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!e.key.startsWith("Arrow")) return;
|
||||
const fine = e.shiftKey ? 0.1 : 1;
|
||||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
phone.addEventListener('click', (e) => {
|
||||
const t = e.target.closest('[data-act]');
|
||||
if (!t) return;
|
||||
const a = t.dataset.act;
|
||||
if (a === "tilt") tiltBy(+t.dataset.d);
|
||||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||||
else if (a === "fit") fitSlab();
|
||||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||||
});
|
||||
phone.addEventListener('input', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||||
else if (t.dataset.act === "fill-range") {
|
||||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||||
// rectangle's aspect never changes and the line's x never moves.
|
||||
S.fill = +t.value;
|
||||
const ct = cropTicks();
|
||||
S.tickA = ct.a; S.tickB = ct.b;
|
||||
refreshStage();
|
||||
}
|
||||
});
|
||||
phone.addEventListener('change', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+43
@@ -12,6 +12,7 @@
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tsx": "^4.23.13",
|
||||
@@ -494,6 +495,21 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.63.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
|
||||
@@ -1242,6 +1258,33 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts"
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tsx": "^4.23.13",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M2 #65 — Playwright visual evidence against local vite preview (not ubudual).
|
||||
* Artifacts land in tests/e2e/artifacts/ (png churn gitignored; README committed).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
timeout: 180_000,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
...devices['Pixel 7'],
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
outputDir: 'tests/e2e/test-results',
|
||||
});
|
||||
@@ -75,6 +75,7 @@
|
||||
<AxisPanel
|
||||
state={cap}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
setTape={session.setTape}
|
||||
approveAxis={session.approveAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
|
||||
@@ -1,58 +1,154 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { defaultTicks } from './tapeGeometry';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let setTape: (tape: TapeTransform) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
let cropFill = getCropFill();
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
$: tape = state.axis.tape;
|
||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||
|
||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||
|
||||
const nudgeTilt = (delta: number) => {
|
||||
setTape({ ...tape, tiltDeg: tape.tiltDeg + delta });
|
||||
};
|
||||
|
||||
const setTilt = (deg: number) => {
|
||||
setTape({ ...tape, tiltDeg: deg });
|
||||
// Keep legacy path warm for any callers still using setAxisAngle.
|
||||
setAxisAngle(deg);
|
||||
};
|
||||
|
||||
const nudgeZoom = (dir: number) => {
|
||||
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
|
||||
setTape({ ...tape, zoom: z });
|
||||
};
|
||||
|
||||
const onFill = (pct: number) => {
|
||||
const fill = Math.min(1, Math.max(0.01, pct / 100));
|
||||
cropFill = setCropFill(fill);
|
||||
const ticks = defaultTicks(cropFill);
|
||||
setTape({ ...tape, ...ticks });
|
||||
};
|
||||
|
||||
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
{tape}
|
||||
{cropFill}
|
||||
onTapeChange={onTapeChange}
|
||||
/>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="179" step="1"
|
||||
value={angleDeg}
|
||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
||||
type="range" min="0" max="359" step="0.1"
|
||||
value={((tiltShown % 360) + 360) % 360}
|
||||
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
||||
<span class="val">{angleDeg}°</span>
|
||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||
<span class="val">{tiltShown}°</span>
|
||||
</div>
|
||||
<p class="nudge">← → tilt 1° · Shift+← → 0.1° · or drag the ↻ handle</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="50" max="300" step="1"
|
||||
value={Math.round(tape.zoom * 100)}
|
||||
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
|
||||
<span class="val">{Math.round(tape.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div class="row tight">
|
||||
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input
|
||||
type="range" min="50" max="100" step="1"
|
||||
value={Math.round(cropFill * 100)}
|
||||
aria-label="Crop fill percent"
|
||||
on:input={(e) => onFill(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="val">{Math.round(cropFill * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">
|
||||
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
|
||||
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
|
||||
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
|
||||
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.row.tight { margin-top: 8px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.tab { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.dim { color: #777; }
|
||||
</style>
|
||||
|
||||
@@ -82,8 +82,10 @@
|
||||
|
||||
{#if !photosReady}
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
interactive={false}
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
tape={state.axis.tape}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { STAGE_H, STAGE_W, clampTickPair, defaultTicks, lineX } from './tapeGeometry';
|
||||
import { fitDisplay } from './fitDisplay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
// Geometry must use the same EXIF-oriented bitmap space as the mask worker (#72).
|
||||
export let blob: Blob;
|
||||
export let maxH = 420;
|
||||
export let bbox: BBox | null = null;
|
||||
@@ -12,24 +18,42 @@
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
let url: string | null = null;
|
||||
/** #99 tape-line mode: fixed middle line + movable photo. */
|
||||
export let mode: 'bbox' | 'tape' = 'bbox';
|
||||
export let tape: TapeTransform | null = null;
|
||||
export let cropFill: number = getCropFill();
|
||||
export let onTapeChange: ((t: TapeTransform) => void) | null = null;
|
||||
/** When false, tape overlay is display-only (Measure step). */
|
||||
export let interactive = true;
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0; // source px, learned once per blob
|
||||
let tapeWrap: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0;
|
||||
let parentW = 0;
|
||||
let decodeGen = 0;
|
||||
let decoding = false;
|
||||
let focusTick: 'A' | 'B' = 'A';
|
||||
|
||||
const setBlobUrl = (b: Blob) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
url = URL.createObjectURL(b);
|
||||
// Local editable copy while dragging; flushed via onTapeChange.
|
||||
let live: TapeTransform = tape ?? {
|
||||
tiltDeg: 0, zoom: 1, panX: 0, panY: 0, ...defaultTicks(cropFill),
|
||||
};
|
||||
$: if (tape) live = { ...tape };
|
||||
|
||||
$: if (blob) {
|
||||
setBlobUrl(blob);
|
||||
imgW = 0;
|
||||
}
|
||||
const emit = (next: TapeTransform) => {
|
||||
live = next;
|
||||
onTapeChange?.(next);
|
||||
};
|
||||
|
||||
const applyFit = () => {
|
||||
if (!canvas || !imgW) return;
|
||||
if (mode === 'tape') {
|
||||
// Photo is sized to the logical stage; CSS transform handles pan/zoom/tilt.
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
return;
|
||||
}
|
||||
if (parentW <= 0 && stage) parentW = stage.clientWidth;
|
||||
const fit = fitDisplay(parentW, imgW, imgH, maxH);
|
||||
if (!fit) return;
|
||||
@@ -37,19 +61,8 @@
|
||||
canvas.style.height = `${fit.fitH}px`;
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
if (!url || !canvas) return;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!canvas) return;
|
||||
imgW = img.width;
|
||||
imgH = img.height;
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
applyFit();
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas || mode === 'tape') return;
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
@@ -80,7 +93,6 @@
|
||||
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
// The axis reads corners[3] → corners[0], the long side.
|
||||
const a = c[3];
|
||||
const b = c[0];
|
||||
ctx.strokeStyle = '#ff3d71';
|
||||
@@ -100,39 +112,285 @@
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
|
||||
const redraw = async () => {
|
||||
if (!blob || !canvas) return;
|
||||
const gen = ++decodeGen;
|
||||
decoding = true;
|
||||
let bmp: ImageBitmap | null = null;
|
||||
try {
|
||||
bmp = await decodeOrientedBitmap(blob);
|
||||
if (gen !== decodeGen || !canvas) return;
|
||||
imgW = bmp.width;
|
||||
imgH = bmp.height;
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
applyFit();
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
paintOverlays(ctx);
|
||||
} finally {
|
||||
if (bmp) bmp.close();
|
||||
if (gen === decodeGen) decoding = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (url || bbox || maskBitset) void tick().then(redraw);
|
||||
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
|
||||
|
||||
// ---- tape pointer interactions ----
|
||||
type DragKind = null | 'pan' | 'tickA' | 'tickB' | 'rot';
|
||||
let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null;
|
||||
let pointers = new Map<number, { x: number; y: number }>();
|
||||
let pinchStartDist = 0;
|
||||
let pinchStartZoom = 1;
|
||||
|
||||
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
|
||||
const el = tapeWrap;
|
||||
if (!el) return { x: 0, y: 0 };
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - r.left) / Math.max(r.width, 1)) * STAGE_W,
|
||||
y: ((clientY - r.top) / Math.max(r.height, 1)) * STAGE_H,
|
||||
};
|
||||
};
|
||||
|
||||
const hitTick = (x: number, y: number): 'A' | 'B' | null => {
|
||||
// ≥44×44 CSS px hit → ~34 stage units on a ~352px-wide stage (wireframe HIT_R).
|
||||
const HIT = 34;
|
||||
const lx = lineX();
|
||||
if (Math.hypot(x - lx, y - live.tickA) <= HIT) return 'A';
|
||||
if (Math.hypot(x - lx, y - live.tickB) <= HIT) return 'B';
|
||||
return null;
|
||||
};
|
||||
|
||||
const hitRot = (x: number, y: number): boolean => {
|
||||
const cx = STAGE_W / 2;
|
||||
const cy = STAGE_H - 26;
|
||||
return Math.hypot(x - cx, y - cy) <= 22;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
const pts = [...pointers.values()];
|
||||
pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||
pinchStartZoom = live.zoom;
|
||||
drag = null;
|
||||
return;
|
||||
}
|
||||
const p = stagePoint(e.clientX, e.clientY);
|
||||
const tick = hitTick(p.x, p.y);
|
||||
if (tick) {
|
||||
focusTick = tick;
|
||||
drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
return;
|
||||
}
|
||||
if (hitRot(p.x, p.y)) {
|
||||
drag = { kind: 'rot', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
return;
|
||||
}
|
||||
drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
const pts = [...pointers.values()];
|
||||
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||
const z = Math.min(3, Math.max(0.5, pinchStartZoom * (dist / pinchStartDist)));
|
||||
emit({ ...live, zoom: z });
|
||||
return;
|
||||
}
|
||||
if (!drag) return;
|
||||
const p = stagePoint(e.clientX, e.clientY);
|
||||
if (drag.kind === 'pan') {
|
||||
emit({ ...live, panX: drag.panX0 + (p.x - drag.x0), panY: drag.panY0 + (p.y - drag.y0) });
|
||||
} else if (drag.kind === 'tickA' || drag.kind === 'tickB') {
|
||||
const pair = drag.kind === 'tickA'
|
||||
? clampTickPair(p.y, live.tickB)
|
||||
: clampTickPair(live.tickA, p.y);
|
||||
emit({ ...live, ...pair });
|
||||
} else if (drag.kind === 'rot') {
|
||||
const cx = STAGE_W / 2;
|
||||
const cy = STAGE_H / 2;
|
||||
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
|
||||
const a1 = Math.atan2(p.y - cy, p.x - cx);
|
||||
const delta = ((a1 - a0) * 180) / Math.PI;
|
||||
emit({ ...live, tiltDeg: drag.tilt0 + delta });
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) drag = null;
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
||||
emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) });
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
const fine = e.shiftKey;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const step = fine ? 0.1 : 1;
|
||||
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
||||
emit({ ...live, tiltDeg: live.tiltDeg + dir * step });
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const step = fine ? 0.1 : 1;
|
||||
const dir = e.key === 'ArrowUp' ? -1 : 1;
|
||||
if (focusTick === 'A') {
|
||||
emit({ ...live, ...clampTickPair(live.tickA + dir * step, live.tickB) });
|
||||
} else {
|
||||
emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) });
|
||||
}
|
||||
} else if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault();
|
||||
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
e.preventDefault();
|
||||
emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) });
|
||||
}
|
||||
};
|
||||
|
||||
const fitPhoto = () => emit({ ...live, zoom: 1, panX: 0, panY: 0 });
|
||||
|
||||
let vbScale = 1;
|
||||
const updateVbScale = () => {
|
||||
if (!tapeWrap) return;
|
||||
vbScale = tapeWrap.clientWidth / STAGE_W || 1;
|
||||
};
|
||||
|
||||
$: inset = STAGE_H * cropMarginFraction(cropFill);
|
||||
$: photoTransform = `translate(${live.panX}px, ${live.panY}px) translate(${STAGE_W / 2}px, ${STAGE_H / 2}px) rotate(${live.tiltDeg}deg) scale(${live.zoom}) translate(${-STAGE_W / 2}px, ${-STAGE_H / 2}px)`;
|
||||
// Contain-fit the canvas bitmap into the logical stage.
|
||||
$: photoLayerStyle = imgW > 0 && imgH > 0
|
||||
? (() => {
|
||||
const scale0 = Math.min(STAGE_W / imgW, STAGE_H / imgH);
|
||||
const w = imgW * scale0;
|
||||
const h = imgH * scale0;
|
||||
const left = (STAGE_W - w) / 2;
|
||||
const top = (STAGE_H - h) / 2;
|
||||
return `position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;transform-origin:center center;`;
|
||||
})()
|
||||
: 'position:absolute;inset:0;';
|
||||
|
||||
onMount(() => {
|
||||
if (stage) parentW = stage.clientWidth;
|
||||
updateVbScale();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
const w = e.contentRect.width;
|
||||
if (mode === 'tape') {
|
||||
updateVbScale();
|
||||
}
|
||||
if (w === parentW) return;
|
||||
parentW = w;
|
||||
applyFit();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
if (tapeWrap) ro.observe(tapeWrap);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
decodeGen += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if mode === 'tape'}
|
||||
<div
|
||||
class="tape-stage"
|
||||
bind:this={stage}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label="Fixed tape line over a movable slab photo"
|
||||
on:keydown={onKeyDown}
|
||||
>
|
||||
<div
|
||||
class="tw"
|
||||
class:dragging={!!drag}
|
||||
bind:this={tapeWrap}
|
||||
role="group"
|
||||
aria-label="Tape alignment stage"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel={onWheel}
|
||||
on:dblclick={() => interactive && fitPhoto()}
|
||||
>
|
||||
<div class="vb" style="width:{STAGE_W}px;height:{STAGE_H}px;transform:scale({vbScale});">
|
||||
<div class="photo-layer" style="{photoLayerStyle} transform: {photoTransform};">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
<!-- crop end bands (proposal visual; aspect stays 3:4) -->
|
||||
<div class="band top" style="height:{inset}px"></div>
|
||||
<div class="band bot" style="height:{inset}px"></div>
|
||||
<svg class="fixture" viewBox="0 0 {STAGE_W} {STAGE_H}" width={STAGE_W} height={STAGE_H} aria-hidden="true">
|
||||
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round" />
|
||||
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||
{#each [['A', live.tickA], ['B', live.tickB]] as [id, y]}
|
||||
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round" />
|
||||
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||
<circle cx={lineX()} cy={y} r="5" fill={focusTick === id ? '#ff3d71' : '#000'} stroke="#fff" stroke-width="1.5" />
|
||||
<circle class="tick-hit" cx={lineX()} cy={y} r="34" fill="transparent" />
|
||||
{/each}
|
||||
{#if interactive}
|
||||
<g class="rot-handle">
|
||||
<circle cx={STAGE_W / 2} cy={STAGE_H - 26} r="17" fill="rgba(0,0,0,.6)" stroke="#00e5ff" stroke-width="1.5" />
|
||||
<text x={STAGE_W / 2} y={STAGE_H - 21} fill="#00e5ff" font-size="15" text-anchor="middle">↻</text>
|
||||
</g>
|
||||
{/if}
|
||||
</svg>
|
||||
{#if decoding && imgW === 0}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage" bind:this={stage}>
|
||||
{#if url}
|
||||
{#if imgW > 0 || decoding || blob}
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stage { width: 100%; display: flex; justify-content: center; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; }
|
||||
.loading { padding: 40px; color: #999; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; width: 100%; height: 100%; }
|
||||
.loading { padding: 40px; color: #999; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.tape-stage { width: 100%; outline: none; }
|
||||
.tw {
|
||||
position: relative; width: 100%; aspect-ratio: 3 / 4;
|
||||
background: #111; border: 1px solid #333; border-radius: 8px; overflow: hidden;
|
||||
touch-action: none; user-select: none; cursor: grab;
|
||||
}
|
||||
.tw.dragging { cursor: grabbing; }
|
||||
.vb {
|
||||
position: absolute; top: 0; left: 0;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vb > :global(*) { pointer-events: auto; }
|
||||
.photo-layer { will-change: transform; pointer-events: none; }
|
||||
.photo-layer canvas { border-radius: 0; background: transparent; pointer-events: none; }
|
||||
.band { position: absolute; left: 0; right: 0; background: #000; opacity: 0.3; pointer-events: none; z-index: 1; }
|
||||
.band.top { top: 0; }
|
||||
.band.bot { bottom: 0; }
|
||||
.fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
||||
.tick-hit { pointer-events: auto; cursor: ns-resize; }
|
||||
.rot-handle { pointer-events: auto; cursor: ew-resize; }
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ABox {
|
||||
// Minimum-area bounding rectangle of the mask, source px.
|
||||
export interface BBox {
|
||||
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
|
||||
longSidePx: number; // geometric extent only; scale uses mask span along the axis
|
||||
longSidePx: number; // geometric extent only; scale prefers tape tick span (#99)
|
||||
angleDeg: number; // long-side angle, degrees, [0, 180)
|
||||
}
|
||||
|
||||
@@ -43,13 +43,26 @@ export interface Mask {
|
||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||
bbox: BBox;
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the crop_fill PNG crop (default 90%)
|
||||
}
|
||||
|
||||
// Tape-line calibration (#99): photo tilt/pan/zoom under a fixed middle line;
|
||||
// ticks declare L. bbox is derived for overlay/width sampling direction.
|
||||
export interface TapeLine {
|
||||
tiltDeg: number;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
/** Tick Y in logical stage units (STAGE_H = 640). */
|
||||
tickA: number;
|
||||
tickB: number;
|
||||
/** Image-space distance between ticks (px); drives scale = spanPx / L. */
|
||||
spanPx: number;
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
// corners[3] → corners[0] (the long side).
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
tape: TapeLine;
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
@@ -138,6 +151,7 @@ export interface Draft {
|
||||
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
decodeOrientedBitmap,
|
||||
ORIENTED_BITMAP_OPTIONS,
|
||||
} from '../decodeOrientedBitmap.ts';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const fixturePath = join(here, '../../tests/fixtures/set1-48x2/face-a.jpeg');
|
||||
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
afterEach(() => {
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
});
|
||||
|
||||
/** Minimal EXIF Orientation + largest SOF size for the orient-8 fixture. */
|
||||
function readJpegOrientAndSof(buf: Buffer): { orientation: number; sofW: number; sofH: number } {
|
||||
let orientation = 1;
|
||||
const exif = buf.indexOf(Buffer.from('Exif\0\0'));
|
||||
if (exif >= 0) {
|
||||
const tiff = exif + 6;
|
||||
const le = buf[tiff] === 0x49;
|
||||
const u16 = (o: number) => (le ? buf.readUInt16LE(o) : buf.readUInt16BE(o));
|
||||
const u32 = (o: number) => (le ? buf.readUInt32LE(o) : buf.readUInt32BE(o));
|
||||
const ifd = tiff + u32(tiff + 4);
|
||||
const n = u16(ifd);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const e = ifd + 2 + k * 12;
|
||||
const tag = u16(e);
|
||||
const typ = u16(e + 2);
|
||||
if (tag === 0x0112) {
|
||||
orientation = typ === 3 ? u16(e + 8) : u32(e + 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sofW = 0;
|
||||
let sofH = 0;
|
||||
let i = 0;
|
||||
while (i < buf.length - 9) {
|
||||
if (buf[i] === 0xff && buf[i + 1] >= 0xc0 && buf[i + 1] <= 0xc3) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
if (w * h > sofW * sofH) {
|
||||
sofW = w;
|
||||
sofH = h;
|
||||
}
|
||||
const segLen = buf.readUInt16BE(i + 2);
|
||||
i += 2 + segLen;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { orientation, sofW, sofH };
|
||||
}
|
||||
|
||||
/** Orientation 5–8 swap width/height for display. */
|
||||
function orientedSize(sofW: number, sofH: number, orientation: number): { w: number; h: number } {
|
||||
if (orientation >= 5 && orientation <= 8) return { w: sofH, h: sofW };
|
||||
return { w: sofW, h: sofH };
|
||||
}
|
||||
|
||||
test('fixture face-a.jpeg is EXIF Orientation 8 (stored landscape → oriented portrait) (#72)', () => {
|
||||
const buf = readFileSync(fixturePath);
|
||||
const { orientation, sofW, sofH } = readJpegOrientAndSof(buf);
|
||||
assert.equal(orientation, 8, 'matched-set JPEGs carry Orientation 8');
|
||||
assert.equal(sofW, 4000);
|
||||
assert.equal(sofH, 2256);
|
||||
const { w, h } = orientedSize(sofW, sofH, orientation);
|
||||
assert.equal(w, 2256);
|
||||
assert.equal(h, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap always requests imageOrientation from-image (#72)', async () => {
|
||||
let seen: ImageBitmapOptions | undefined;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
seen = opts;
|
||||
return { width: 2256, height: 4000, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const blob = new Blob([readFileSync(fixturePath)], { type: 'image/jpeg' });
|
||||
const bmp = await decodeOrientedBitmap(blob);
|
||||
assert.deepEqual(seen, ORIENTED_BITMAP_OPTIONS);
|
||||
assert.equal(ORIENTED_BITMAP_OPTIONS.imageOrientation, 'from-image');
|
||||
assert.equal(bmp.width, 2256);
|
||||
assert.equal(bmp.height, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap falls back to bare createImageBitmap if option rejected (#72)', async () => {
|
||||
let calls = 0;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
calls += 1;
|
||||
if (opts && 'imageOrientation' in opts) {
|
||||
throw new TypeError('imageOrientation unsupported');
|
||||
}
|
||||
return { width: 90, height: 160, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const bmp = await decodeOrientedBitmap(new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }));
|
||||
assert.equal(calls, 2, 'retry bare after option rejection');
|
||||
assert.equal(bmp.width, 90);
|
||||
assert.equal(bmp.height, 160);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap: Chromium orients fixture to 2256×4000 (#72)', async (t) => {
|
||||
let chromium: typeof import('@playwright/test').chromium;
|
||||
try {
|
||||
({ chromium } = await import('@playwright/test'));
|
||||
} catch {
|
||||
t.skip('playwright not available');
|
||||
return;
|
||||
}
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const buf = readFileSync(fixturePath);
|
||||
const b64 = buf.toString('base64');
|
||||
// Inline the same contract as decodeOrientedBitmap (worker-safe, no bundler).
|
||||
const dims = await page.evaluate(async (b64jpeg: string) => {
|
||||
const bin = atob(b64jpeg);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
const blob = new Blob([arr], { type: 'image/jpeg' });
|
||||
const opts = { imageOrientation: 'from-image' as const };
|
||||
let bmp: ImageBitmap;
|
||||
try {
|
||||
bmp = await createImageBitmap(blob, opts);
|
||||
} catch {
|
||||
bmp = await createImageBitmap(blob);
|
||||
}
|
||||
const out = { w: bmp.width, h: bmp.height };
|
||||
bmp.close();
|
||||
return out;
|
||||
}, b64);
|
||||
assert.equal(dims.w, 2256, `oriented width (got ${dims.w})`);
|
||||
assert.equal(dims.h, 4000, `oriented height (got ${dims.h})`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
+118
-18
@@ -10,6 +10,11 @@ import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { rotateCalibration } from '../png/rotateCal';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
import type { TapeLine } from './capture.types';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
@@ -40,6 +45,8 @@ export interface CaptureSession {
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
/** #99: replace axis rectangle nudge with full tape transform. */
|
||||
setTape(tape: TapeTransform): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
@@ -203,7 +210,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
@@ -213,7 +220,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
@@ -309,6 +316,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
|
||||
const tapeFromDraftOrBbox = (d: Draft, bbox: import('./capture.types').BBox, w: number, h: number): TapeLine => {
|
||||
if (d.tape && Number.isFinite(d.tape.spanPx)) {
|
||||
return { ...d.tape };
|
||||
}
|
||||
// Seed tilt so the mask long side sits near vertical under the fixed line.
|
||||
const seedTilt = 90 - bbox.angleDeg;
|
||||
const t = defaultTape(getCropFill(), seedTilt);
|
||||
const ep = w > 0 && h > 0 ? tapeEndpoints(t, w, h) : { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
return { ...t, spanPx: ep.spanPx };
|
||||
};
|
||||
|
||||
const axisWithTape = (bbox: import('./capture.types').BBox, tape: TapeTransform, w: number, h: number) => {
|
||||
const ep = w > 0 && h > 0
|
||||
? tapeEndpoints(tape, w, h)
|
||||
: { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
const angleDeg = ep.spanPx > 0
|
||||
? ((Math.atan2(ep.uy, ep.ux) * 180) / Math.PI + 180) % 180
|
||||
: bbox.angleDeg;
|
||||
const nextBbox = orientedRect(bbox, angleDeg);
|
||||
const line: TapeLine = {
|
||||
tiltDeg: tape.tiltDeg,
|
||||
zoom: tape.zoom,
|
||||
panX: tape.panX,
|
||||
panY: tape.panY,
|
||||
tickA: tape.tickA,
|
||||
tickB: tape.tickB,
|
||||
spanPx: ep.spanPx,
|
||||
};
|
||||
return { bbox: nextBbox, tape: line };
|
||||
};
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
batchPhotos = batchPhotosOf(d);
|
||||
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
|
||||
@@ -335,16 +373,29 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({ k: 'axis', source, approvedMask: mask, axis });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
{
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
axis,
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? {
|
||||
@@ -355,6 +406,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
: null,
|
||||
batchApply: d.batchApply,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
makeWorker(source.blob);
|
||||
@@ -473,22 +525,47 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
const overlay = state.overlay;
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
||||
const bbox = {
|
||||
corners: overlay.bbox.corners.map((c) => [...c] as [number, number]),
|
||||
longSidePx: overlay.bbox.longSidePx,
|
||||
angleDeg: overlay.bbox.angleDeg,
|
||||
};
|
||||
const w = state.source.widthPx;
|
||||
const h = state.source.heightPx;
|
||||
const seedTilt = 90 - bbox.angleDeg;
|
||||
const tape0 = defaultTape(getCropFill(), seedTilt);
|
||||
const axis = axisWithTape(bbox, tape0, w, h);
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis });
|
||||
void persist();
|
||||
},
|
||||
|
||||
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
|
||||
setAxisAngle(deg: number) {
|
||||
if (state.k !== 'axis') return;
|
||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
||||
const tape = { ...state.axis.tape, tiltDeg: deg };
|
||||
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setTape(tape: TapeTransform) {
|
||||
if (state.k !== 'axis') return;
|
||||
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
const axis = axisWithTape(
|
||||
state.axis.bbox,
|
||||
state.axis.tape,
|
||||
state.source.widthPx,
|
||||
state.source.heightPx,
|
||||
);
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.axis.bbox },
|
||||
axis,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
@@ -512,15 +589,36 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await createImageBitmap(source.blob);
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const tapeAxis = axis.tape.spanPx > 0
|
||||
? (() => {
|
||||
const ep = tapeEndpoints(axis.tape, source.widthPx, source.heightPx);
|
||||
return { start: ep.start, ux: ep.ux, uy: ep.uy, spanPx: ep.spanPx };
|
||||
})()
|
||||
: null;
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
tapeAxis,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
|
||||
let calBitmap = bitmap;
|
||||
let calMask = approvedMask;
|
||||
let calSource = source;
|
||||
let rotatedExtra: ImageBitmap | null = null;
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||
const rotated = await rotateCalibration(bitmap, approvedMask, source, axis.tape.tiltDeg);
|
||||
calBitmap = rotated.bitmap;
|
||||
calMask = rotated.mask;
|
||||
calSource = { ...source, widthPx: rotated.source.widthPx, heightPx: rotated.source.heightPx };
|
||||
if (rotated.bitmap !== bitmap) rotatedExtra = rotated.bitmap;
|
||||
} catch {
|
||||
/* keep unrotated cal if bake fails */
|
||||
}
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
@@ -528,24 +626,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
pngBlob = new Blob([], { type: 'image/png' });
|
||||
}
|
||||
bitmap.close();
|
||||
if (rotatedExtra) rotatedExtra.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
|
||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||
// Overlay uses the same baked orientation as the clean cal PNG (#99 scope).
|
||||
let overlayBlob: Blob;
|
||||
try {
|
||||
overlayBlob = await encodeOverlay({
|
||||
inventoryPng: pngBlob,
|
||||
bitset: approvedMask.bitset,
|
||||
widthPx: source.widthPx,
|
||||
heightPx: source.heightPx,
|
||||
bbox: axis.bbox,
|
||||
bitset: calMask.bitset,
|
||||
widthPx: calSource.widthPx,
|
||||
heightPx: calSource.heightPx,
|
||||
bbox: calMask.bbox,
|
||||
scale: base.scale,
|
||||
lengthIn: eighthsToIn(lengthEighths),
|
||||
lengthLabel: fmtEighths(lengthEighths),
|
||||
slabBounds: approvedMask.slabBounds,
|
||||
source,
|
||||
slabBounds: calMask.slabBounds,
|
||||
source: calSource,
|
||||
});
|
||||
} catch {
|
||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||
@@ -587,7 +687,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
widthPx: derived.widthPx,
|
||||
heightPx: derived.heightPx,
|
||||
};
|
||||
const siblingBitmap = await createImageBitmap(photo.blob);
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
try {
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
||||
@@ -623,7 +723,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
STAGE_H, STAGE_W, clampTickPair, containFit, defaultTape, defaultTicks,
|
||||
lineX, stageToImage, tapeEndpoints,
|
||||
} from './tapeGeometry.ts';
|
||||
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts';
|
||||
|
||||
test('defaultTicks sit at crop insets for 90% fill', () => {
|
||||
const t = defaultTicks(0.9);
|
||||
const inset = STAGE_H * cropMarginFraction(0.9);
|
||||
assert.ok(Math.abs(t.tickA - inset) < 1e-9);
|
||||
assert.ok(Math.abs(t.tickB - (STAGE_H - inset)) < 1e-9);
|
||||
});
|
||||
|
||||
test('lineX is the middle of the 3:4 stage', () => {
|
||||
assert.equal(lineX(), STAGE_W / 2);
|
||||
});
|
||||
|
||||
test('identity tape: stage centre maps near image centre', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const tape = defaultTape(DEFAULT_CROP_FILL, 0);
|
||||
const [ix, iy] = stageToImage(STAGE_W / 2, STAGE_H / 2, tape, imgW, imgH);
|
||||
assert.ok(Math.abs(ix - imgW / 2) < 1e-6, `ix=${ix}`);
|
||||
assert.ok(Math.abs(iy - imgH / 2) < 1e-6, `iy=${iy}`);
|
||||
});
|
||||
|
||||
test('tapeEndpoints span equals tick distance in image px at identity (matching aspect)', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const tape = defaultTape(0.9, 0);
|
||||
const ep = tapeEndpoints(tape, imgW, imgH);
|
||||
const { scale0 } = containFit(imgW, imgH);
|
||||
const expected = Math.abs(tape.tickB - tape.tickA) / scale0;
|
||||
assert.ok(Math.abs(ep.spanPx - expected) < 1e-6, `span ${ep.spanPx} vs ${expected}`);
|
||||
// Direction should be +y (down the stage → down the image)
|
||||
assert.ok(Math.abs(ep.ux) < 1e-6, `ux=${ep.ux}`);
|
||||
assert.ok(ep.uy > 0.99, `uy=${ep.uy}`);
|
||||
});
|
||||
|
||||
test('zoom scales inverse: 2× zoom halves image-space tick span', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const base = defaultTape(0.9, 0);
|
||||
const zoomed = { ...base, zoom: 2 };
|
||||
const a = tapeEndpoints(base, imgW, imgH).spanPx;
|
||||
const b = tapeEndpoints(zoomed, imgW, imgH).spanPx;
|
||||
assert.ok(Math.abs(b * 2 - a) < 1e-6, `base=${a} zoomed=${b}`);
|
||||
});
|
||||
|
||||
test('clampTickPair enforces gap and order', () => {
|
||||
const p = clampTickPair(400, 100, 40);
|
||||
assert.ok(p.tickB - p.tickA >= 40);
|
||||
assert.ok(p.tickA <= p.tickB);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Pure tape-line geometry (#99).
|
||||
* Fixed vertical line in the middle of a 3:4 stage; photo pans/zooms/tilts;
|
||||
* ticks slide on the line; L↔px from tick distance in image space.
|
||||
*/
|
||||
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop';
|
||||
|
||||
/** Logical stage matching the wireframe / inventory aspect. */
|
||||
export const STAGE_W = 480;
|
||||
export const STAGE_H = 640;
|
||||
|
||||
export interface TapeTransform {
|
||||
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
|
||||
tiltDeg: number;
|
||||
zoom: number;
|
||||
/** Pan in stage units. */
|
||||
panX: number;
|
||||
panY: number;
|
||||
/** Tick Y positions in stage units. */
|
||||
tickA: number;
|
||||
tickB: number;
|
||||
}
|
||||
|
||||
export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number; tickB: number } {
|
||||
const inset = STAGE_H * cropMarginFraction(fill);
|
||||
return { tickA: inset, tickB: STAGE_H - inset };
|
||||
}
|
||||
|
||||
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
|
||||
const t = defaultTicks(fill);
|
||||
return {
|
||||
tiltDeg: seedTiltDeg,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
tickA: t.tickA,
|
||||
tickB: t.tickB,
|
||||
};
|
||||
}
|
||||
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
|
||||
/** Contain-fit of the photo into the stage at zoom=1, pan=0. */
|
||||
export function containFit(imgW: number, imgH: number): { scale0: number; offX: number; offY: number } {
|
||||
const scale0 = Math.min(STAGE_W / Math.max(imgW, 1), STAGE_H / Math.max(imgH, 1));
|
||||
return {
|
||||
scale0,
|
||||
offX: (STAGE_W - imgW * scale0) / 2,
|
||||
offY: (STAGE_H - imgH * scale0) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a stage point through the inverse photo transform into source image pixels.
|
||||
* Transform order (forward): contain-fit → zoom+tilt about stage centre → pan.
|
||||
*/
|
||||
export function stageToImage(
|
||||
sx: number,
|
||||
sy: number,
|
||||
tape: TapeTransform,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): [number, number] {
|
||||
const cx = STAGE_W / 2;
|
||||
const cy = STAGE_H / 2;
|
||||
const { scale0, offX, offY } = containFit(imgW, imgH);
|
||||
// undo pan
|
||||
let x = sx - tape.panX - cx;
|
||||
let y = sy - tape.panY - cy;
|
||||
// undo rotate(tilt) scale(zoom) about centre
|
||||
const z = tape.zoom || 1;
|
||||
const a = -rad(tape.tiltDeg);
|
||||
const cos = Math.cos(a);
|
||||
const sin = Math.sin(a);
|
||||
const xr = (x * cos - y * sin) / z;
|
||||
const yr = (x * sin + y * cos) / z;
|
||||
x = xr + cx;
|
||||
y = yr + cy;
|
||||
// undo contain fit
|
||||
const ix = (x - offX) / scale0;
|
||||
const iy = (y - offY) / scale0;
|
||||
return [ix, iy];
|
||||
}
|
||||
|
||||
export function lineX(): number {
|
||||
return STAGE_W / 2;
|
||||
}
|
||||
|
||||
/** Image-space endpoints of the two ticks + span in px. */
|
||||
export function tapeEndpoints(
|
||||
tape: TapeTransform,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): { start: [number, number]; end: [number, number]; spanPx: number; ux: number; uy: number } {
|
||||
const start = stageToImage(lineX(), tape.tickA, tape, imgW, imgH);
|
||||
const end = stageToImage(lineX(), tape.tickB, tape, imgW, imgH);
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const spanPx = Math.hypot(dx, dy);
|
||||
const len = spanPx || 1;
|
||||
return { start, end, spanPx, ux: dx / len, uy: dy / len };
|
||||
}
|
||||
|
||||
export function clampTickPair(a: number, b: number, gap = 40): { tickA: number; tickB: number } {
|
||||
let tickA = a;
|
||||
let tickB = b;
|
||||
if (tickB < tickA) {
|
||||
const t = tickA;
|
||||
tickA = tickB;
|
||||
tickB = t;
|
||||
}
|
||||
const minY = 0;
|
||||
const maxY = STAGE_H;
|
||||
tickA = Math.max(minY, Math.min(tickA, maxY - gap));
|
||||
tickB = Math.min(maxY, Math.max(tickB, tickA + gap));
|
||||
return { tickA, tickB };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared decode for capture photos so PhotoStage, mask worker, inventory, and
|
||||
* calculate all share one EXIF-oriented pixel space.
|
||||
*
|
||||
* Matched-set / DJI JPEGs often carry EXIF Orientation 8 (stored landscape,
|
||||
* display portrait). `new Image()` and bare `createImageBitmap(blob)` can
|
||||
* disagree across browsers → pink axis arrow space ≠ mask/overlay bitset space.
|
||||
*
|
||||
* Contract: always request `imageOrientation: 'from-image'`. If the runtime
|
||||
* rejects that option (older engines), fall back to bare `createImageBitmap`;
|
||||
* engines that already apply EXIF by default still match Image/canvas size.
|
||||
* Engines that never apply EXIF may still diverge after fallback — document
|
||||
* that path rather than inventing a software rotate.
|
||||
*/
|
||||
export const ORIENTED_BITMAP_OPTIONS: ImageBitmapOptions = {
|
||||
imageOrientation: 'from-image',
|
||||
};
|
||||
|
||||
export async function decodeOrientedBitmap(blob: Blob): Promise<ImageBitmap> {
|
||||
try {
|
||||
return await createImageBitmap(blob, ORIENTED_BITMAP_OPTIONS);
|
||||
} catch (first) {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
throw first;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,8 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
||||
|
||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||
// extent in px. 0 when the mask is empty.
|
||||
// #99: demoted to an optional sanity check — declared L comes from tape tick
|
||||
// distance in image space, not this projected guess.
|
||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { blackKeep, greenKeep } from './sheetKeep.ts';
|
||||
|
||||
// --- Black sheet sensitivity (#74) ---
|
||||
|
||||
test('blackKeep: s=0 keeps dark live-edge wood (lum ~25–50), removes near-black sheet (#74)', () => {
|
||||
// Near-black sheet (lum ≈ 5)
|
||||
assert.equal(blackKeep(5, 5, 5, 0), false, 'near-black sheet removed at s=0');
|
||||
assert.equal(blackKeep(0, 0, 0, 0), false, 'pure black sheet removed at s=0');
|
||||
// Dark wood / bark / live edge
|
||||
assert.equal(blackKeep(25, 22, 18, 0), true, 'lum~22 dark wood kept at s=0');
|
||||
assert.equal(blackKeep(40, 35, 30, 0), true, 'lum~35 dark wood kept at s=0');
|
||||
assert.equal(blackKeep(50, 45, 40, 0), true, 'lum~46 wood kept at s=0');
|
||||
// Mid wood always kept
|
||||
assert.equal(blackKeep(120, 90, 60, 0), true, 'mid wood kept at s=0');
|
||||
});
|
||||
|
||||
test('blackKeep: s=100 removes more than s=0; curve monotonic (#74)', () => {
|
||||
// Dark-ish pixel that sits between floors: sheet at high s, wood at low s
|
||||
const darkish = { r: 80, g: 70, b: 60 }; // lum ≈ 72
|
||||
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 0), true, 'lum~72 kept at s=0');
|
||||
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 100), false, 'lum~72 sheet at s=100');
|
||||
|
||||
// Near-black always sheet
|
||||
assert.equal(blackKeep(2, 2, 2, 0), false);
|
||||
assert.equal(blackKeep(2, 2, 2, 100), false);
|
||||
|
||||
// Bright wood always kept
|
||||
assert.equal(blackKeep(200, 160, 100, 0), true);
|
||||
assert.equal(blackKeep(200, 160, 100, 100), true);
|
||||
|
||||
// Monotonic: once removed, stays removed as s increases
|
||||
const probe = { r: 60, g: 55, b: 50 }; // lum ≈ 56
|
||||
let prevKeep = true;
|
||||
for (const s of [0, 25, 50, 75, 100]) {
|
||||
const keep = blackKeep(probe.r, probe.g, probe.b, s);
|
||||
if (!prevKeep) assert.equal(keep, false, `once sheet at lower s, stay sheet at s=${s}`);
|
||||
prevKeep = keep;
|
||||
}
|
||||
// s=100 must remove more pixels than s=0 for this mid-dark band
|
||||
assert.equal(blackKeep(probe.r, probe.g, probe.b, 0), true);
|
||||
assert.equal(blackKeep(probe.r, probe.g, probe.b, 100), false);
|
||||
});
|
||||
|
||||
test('greenKeep: s=0 does not eat mild wood greens; s=100 widens sheet match (#74)', () => {
|
||||
// Neutral wood
|
||||
assert.equal(greenKeep(140, 110, 80, 0), true);
|
||||
assert.equal(greenKeep(140, 110, 80, 100), true);
|
||||
// Mild green cast on wood (g - max(r,b) ≈ 5) — must keep at s=0
|
||||
assert.equal(greenKeep(100, 105, 90, 0), true, 'mild green cast kept at s=0');
|
||||
// Strong sheet green
|
||||
assert.equal(greenKeep(40, 180, 50, 0), false, 'strong green sheet removed at s=0');
|
||||
assert.equal(greenKeep(40, 180, 50, 100), false);
|
||||
// Borderline: g-max ≈ 20 — may become sheet at high s
|
||||
const border = { r: 80, g: 100, b: 70 }; // delta = 20
|
||||
assert.equal(greenKeep(border.r, border.g, border.b, 0), true, 'delta20 kept at s=0');
|
||||
assert.equal(greenKeep(border.r, border.g, border.b, 100), false, 'delta20 sheet at s=100');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Sheet-vs-slab keep predicates used by the mask worker.
|
||||
* Extracted so unit tests can cover sensitivity curves without the worker.
|
||||
*/
|
||||
|
||||
/** Keep slab pixels on a green sheet. Higher s widens the green-sheet match. */
|
||||
export function greenKeep(r: number, g: number, b: number, s: number): boolean {
|
||||
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||
// s=0 removes only strongly green sheet; increasing sensitivity widens the match.
|
||||
const isSheet = g - Math.max(r, b) > 45 - 35 * t;
|
||||
return !isSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep slab pixels on a black sheet.
|
||||
* At s=0 only near-black sheet (very low lum) is removed so dark live-edge /
|
||||
* bark (lum ~25–50) stays. The match widens monotonically toward s=100 (#74).
|
||||
*/
|
||||
export function blackKeep(r: number, g: number, b: number, s: number): boolean {
|
||||
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
// Old floor 40 ate dark wood at minimum sensitivity. Floor 10 isolates near-black.
|
||||
const isSheet = lum < 10 + 140 * t;
|
||||
return !isSheet;
|
||||
}
|
||||
|
||||
export function autoPickSheet(
|
||||
samples: { r: number; g: number; b: number }[],
|
||||
): 'green' | 'black' {
|
||||
let gr = 0, gl = 0;
|
||||
for (const p of samples) {
|
||||
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||
}
|
||||
return gl >= gr ? 'black' : 'green';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
|
||||
import { measure } from './measure.ts';
|
||||
|
||||
test('#99: tape span drives scale, not maskSpanAlong', () => {
|
||||
const W = 480, H = 100;
|
||||
const bitset = new Uint8Array(W * H).fill(1);
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
// Declared L = 48"; tape span = 240 px → 5 px/in (not the full 480 mask span).
|
||||
const m = measure(bitset, W, H, box, 384, 8, W * H, {
|
||||
start: [100, 50],
|
||||
ux: 1,
|
||||
uy: 0,
|
||||
spanPx: 240,
|
||||
});
|
||||
assert.ok(Math.abs(m.scale - 5) < 1e-9, `scale ${m.scale}`);
|
||||
});
|
||||
@@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb
|
||||
return { start: bbox.corners[3], end: bbox.corners[0] };
|
||||
}
|
||||
|
||||
export interface TapeAxis {
|
||||
/** Image-space tape start (tick A). */
|
||||
start: [number, number];
|
||||
/** Image-space unit direction tick A → tick B. */
|
||||
ux: number;
|
||||
uy: number;
|
||||
/** Image-space distance between ticks (owner-declared tape length in px). */
|
||||
spanPx: number;
|
||||
}
|
||||
|
||||
// Wood end→end along the approved direction: project the mask onto the axis
|
||||
// unit vector and return the segment on the bbox center line covering that span.
|
||||
// Used only as a fallback / sanity path when no tape ticks are present (#99).
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array, w: number, h: number, bbox: BBox,
|
||||
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
|
||||
@@ -71,16 +82,38 @@ function sampleWidths(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param tape When provided (#99), scale = tape.spanPx / L and width stations
|
||||
* walk from the tick-A endpoint. maskSpanAlong is not used for scale.
|
||||
*/
|
||||
export function measure(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
tape?: TapeAxis | null,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
let start: [number, number];
|
||||
let ux: number;
|
||||
let uy: number;
|
||||
let scale: number;
|
||||
if (tape && tape.spanPx > 0 && lengthIn > 0) {
|
||||
start = tape.start;
|
||||
ux = tape.ux;
|
||||
uy = tape.uy;
|
||||
scale = tape.spanPx / lengthIn;
|
||||
// Optional sanity: maskSpanAlong remains available for diagnostics only.
|
||||
void maskSpanAlong;
|
||||
} else {
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
// Legacy fallback when no tape ticks — keep prior behaviour for old drafts.
|
||||
scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
start = seg.start;
|
||||
ux = seg.ux;
|
||||
uy = seg.uy;
|
||||
}
|
||||
const every6In = sampleWidths(bitset, w, h, start, ux, uy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
const min = Math.min(...every6In);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { clampFill, cropMarginFraction, DEFAULT_CROP_FILL } from './crop.ts';
|
||||
import { _resetCropFillForTests, getCropFill, setCropFill } from '../settings/cropFill.ts';
|
||||
import { composeInventoryFrame } from './inventory.ts';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
test('cropMarginFraction(0.9) === 0.05', () => {
|
||||
assert.ok(Math.abs(cropMarginFraction(0.9) - 0.05) < 1e-12);
|
||||
assert.equal(DEFAULT_CROP_FILL, 0.9);
|
||||
});
|
||||
|
||||
test('clampFill bounds', () => {
|
||||
assert.equal(clampFill(2), 1);
|
||||
assert.equal(clampFill(0), 0.01);
|
||||
assert.equal(clampFill(Number.NaN), DEFAULT_CROP_FILL);
|
||||
});
|
||||
|
||||
test('getCropFill / setCropFill round-trip', () => {
|
||||
_resetCropFillForTests();
|
||||
assert.equal(getCropFill(), 0.9);
|
||||
assert.equal(setCropFill(0.8), 0.8);
|
||||
assert.equal(getCropFill(), 0.8);
|
||||
_resetCropFillForTests();
|
||||
});
|
||||
|
||||
test('composeInventoryFrame honors cropFill override (#99)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const mask: Pick<Mask, 'bitset' | 'slabBounds'> = {
|
||||
bitset,
|
||||
slabBounds: { x: 100, y: 100, w: 2000, h: 1800 },
|
||||
};
|
||||
const rgba = new Uint8Array(source.widthPx * source.heightPx * 4).fill(255);
|
||||
const out90 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.9);
|
||||
const out80 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.8);
|
||||
|
||||
const extent = (frame: typeof out90) => {
|
||||
let minx = frame.width, maxx = -1, miny = frame.height, maxy = -1;
|
||||
for (let y = 0; y < frame.height; y++) {
|
||||
for (let x = 0; x < frame.width; x++) {
|
||||
if (frame.rgba[(y * frame.width + x) * 4 + 3] !== 0) {
|
||||
if (x < minx) minx = x;
|
||||
if (x > maxx) maxx = x;
|
||||
if (y < miny) miny = y;
|
||||
if (y > maxy) maxy = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { w: maxx - minx + 1, h: maxy - miny + 1 };
|
||||
};
|
||||
const e90 = extent(out90);
|
||||
const e80 = extent(out80);
|
||||
assert.ok(Math.abs(e90.w - 720) <= 2, `90% w ${e90.w}`);
|
||||
assert.ok(e80.w < e90.w - 10, `80% fill must be tighter than 90%: ${e80.w} vs ${e90.w}`);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared crop constants for inventory PNG + tape-line overlay (#99).
|
||||
* Aspect is fixed 3:4; crop_fill only moves the long-axis end insets.
|
||||
* Non-retro: changing fill affects future encodes only, not stored PNGs.
|
||||
*/
|
||||
|
||||
export const DEFAULT_CROP_FILL = 0.9;
|
||||
export const ASPECT_W = 3;
|
||||
export const ASPECT_H = 4;
|
||||
export const FLOOR_PX = 800;
|
||||
|
||||
/** Fraction of the long axis inset from each end: (1 - fill) / 2. */
|
||||
export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number {
|
||||
const f = clampFill(fill);
|
||||
return (1 - f) / 2;
|
||||
}
|
||||
|
||||
export function clampFill(fill: number): number {
|
||||
if (!Number.isFinite(fill)) return DEFAULT_CROP_FILL;
|
||||
return Math.min(1, Math.max(0.01, fill));
|
||||
}
|
||||
@@ -81,7 +81,7 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
|
||||
assert.equal(out.width, 800);
|
||||
});
|
||||
|
||||
test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
|
||||
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
@@ -103,8 +103,8 @@ test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
|
||||
const xs = [minx, maxx], ys = [miny, maxy];
|
||||
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
|
||||
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
|
||||
assert.ok(Math.abs(wExtent - 640) <= 2, `width extent ${wExtent} ≈ 640 (0.8×800)`);
|
||||
assert.ok(Math.abs(hExtent - 576) <= 2, `height extent ${hExtent} ≈ 576 (1800×0.32)`);
|
||||
assert.ok(Math.abs(wExtent - 720) <= 2, `width extent ${wExtent} ≈ 720 (0.9×800)`);
|
||||
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
||||
});
|
||||
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const FLOOR = 800; // shorter side, px
|
||||
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab
|
||||
const ASPECT_W = 3;
|
||||
const ASPECT_H = 4;
|
||||
import { ASPECT_H, ASPECT_W, FLOOR_PX, DEFAULT_CROP_FILL } from './crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
@@ -12,6 +9,8 @@ export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
/** Optional override; default reads live settings.crop_fill (#99). */
|
||||
cropFill?: number;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
@@ -25,7 +24,9 @@ export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
cropFill: number = DEFAULT_CROP_FILL,
|
||||
): Frame {
|
||||
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
const y0 = Math.max(0, Math.round(b.y));
|
||||
@@ -33,8 +34,8 @@ export function composeInventoryFrame(
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR_PX;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR_PX)) : FLOOR_PX;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
@@ -66,6 +67,7 @@ export async function encodeInventoryPng(
|
||||
input: InventoryInput,
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
const { bitmap, mask, source } = input;
|
||||
const fill = input.cropFill ?? getCropFill();
|
||||
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||
const srcCtx = srcCanvas.getContext('2d')!;
|
||||
srcCtx.drawImage(bitmap, 0, 0);
|
||||
@@ -74,6 +76,7 @@ export async function encodeInventoryPng(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
fill,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
pickBottomTowardTop,
|
||||
axisRightUnit,
|
||||
buildOverlayPlan,
|
||||
inventorySourceMap,
|
||||
BODY_WIDTH_FRAC,
|
||||
type OverlayPlan,
|
||||
} from './overlay.ts';
|
||||
import { orientedRect, minAreaRect } from '../geometry/rect.ts';
|
||||
import { axisEndpoints } from '../measure/measure.ts';
|
||||
|
||||
// --- Station math (#63) ---
|
||||
|
||||
@@ -136,7 +139,7 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s
|
||||
|
||||
// Color contract for draw consumers
|
||||
assert.equal(plan.stroke, '#000000');
|
||||
assert.ok(plan.fontPx >= 24, `fontPx ${plan.fontPx} large enough to read`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} large enough to read`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => {
|
||||
@@ -185,3 +188,932 @@ test('buildOverlayPlan: dishonest upright-photo axes rejected — uses approved
|
||||
const ly = Math.abs(plan.length.a[1] - plan.length.b[1]);
|
||||
assert.ok(lx > ly * 2, `length graphic follows approved +x axis (dx=${lx}, dy=${ly})`);
|
||||
});
|
||||
|
||||
// --- Ty UAT nits (#67) ---
|
||||
|
||||
test('buildOverlayPlan: length endpoints snap to mask tips, not oversized bbox corners (#67)', () => {
|
||||
// Wood occupies x=100..299 only; synthetic bbox long side extends past tips (x=50..350).
|
||||
const W = 400, H = 160;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 40; y < 100; y++) for (let x = 100; x < 300; x++) bitset[y * W + x] = 1;
|
||||
const bbox = {
|
||||
corners: [
|
||||
[350, 100],
|
||||
[350, 40],
|
||||
[50, 40],
|
||||
[50, 100],
|
||||
] as [number, number][],
|
||||
longSidePx: 300,
|
||||
angleDeg: 0,
|
||||
};
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale: 10,
|
||||
lengthIn: 20,
|
||||
lengthLabel: '20"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
|
||||
// Length line sits on axis-right; project a/b onto the length axis through the
|
||||
// wood centroid — endpoints must match mask span ends, not bbox corners.
|
||||
const oriented = pickBottomTowardTop([100, 70], [299, 70]);
|
||||
const strip = (p: [number, number]): [number, number] => {
|
||||
const cx = 199.5, cy = 70;
|
||||
const along = (p[0] - cx) * oriented.ux + (p[1] - cy) * oriented.uy;
|
||||
return [cx + oriented.ux * along, cy + oriented.uy * along];
|
||||
};
|
||||
const xs = [strip(plan.length.a)[0], strip(plan.length.b)[0]].sort((a, b) => a - b);
|
||||
// Mask tips ≈ 100 and 299 (inclusive pixels); bbox corners at 50 and 350
|
||||
assert.ok(Math.abs(xs[0] - 100) <= 2, `mask min tip ${xs[0]} ≈ 100, not bbox 50`);
|
||||
assert.ok(Math.abs(xs[1] - 299) <= 2, `mask max tip ${xs[1]} ≈ 299, not bbox 350`);
|
||||
assert.ok(Math.abs(xs[0] - 50) > 20, 'must not use bbox corner at 50');
|
||||
assert.ok(Math.abs(xs[1] - 350) > 20, 'must not use bbox corner at 350');
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () => {
|
||||
// Uniform strip: length +x, width along y. Axis-right of bottom→top is −y (up).
|
||||
const W = 480, H = 200;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: box,
|
||||
scale: 10,
|
||||
lengthIn: 48,
|
||||
lengthLabel: '48"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
assert.ok(plan.widths.length > 0);
|
||||
for (const w of plan.widths) {
|
||||
assert.ok(w.labelAt, 'width mark must expose labelAt');
|
||||
// left/right tips still on wood edges
|
||||
assert.ok(Math.abs(w.left[1] - 149) <= 2 || Math.abs(w.left[1] - 50) <= 2);
|
||||
assert.ok(Math.abs(w.right[1] - 149) <= 2 || Math.abs(w.right[1] - 50) <= 2);
|
||||
// labelAt on axis-left = opposite of axis-right from center
|
||||
// For this geometry, axis-right is toward smaller y; axis-left toward larger y
|
||||
// (outside past the bottom edge y≈149).
|
||||
const cy = w.center[1];
|
||||
assert.ok(
|
||||
w.labelAt[1] > Math.max(w.left[1], w.right[1]) + 4,
|
||||
`width labelAt y=${w.labelAt[1]} must sit past slab edge (left of travel), not on wood center y=${cy}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(w.labelAt[0] - w.center[0]) < 3,
|
||||
'width label stays on the station cross-section (same x as center)',
|
||||
);
|
||||
}
|
||||
// Length stays axis-right (outside opposite side)
|
||||
const lenY = (plan.length.a[1] + plan.length.b[1]) / 2;
|
||||
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#67,#71)', () => {
|
||||
const W = 480, H = 200;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
const scale = 10;
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: box,
|
||||
scale,
|
||||
lengthIn: 48,
|
||||
lengthLabel: '48"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
const oldFont67 = Math.max(28, Math.round(scale * 1.2));
|
||||
const oldFont71 = Math.max(22, Math.round(scale * 1.0));
|
||||
assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`);
|
||||
assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still legible`);
|
||||
// Concrete contract (#71): capped by scale when no inventory size
|
||||
assert.equal(plan.fontPx, Math.max(12, Math.min(16, Math.round(scale * 0.18))));
|
||||
});
|
||||
|
||||
|
||||
// --- Length dim must not overshoot wood footprint (#69) ---
|
||||
|
||||
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
|
||||
function tiltedSlabFixture() {
|
||||
const W = 300, H = 600;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 50; y <= 550; y++) {
|
||||
const t = (y - 50) / 500;
|
||||
const x0 = Math.round(80 + t * 20);
|
||||
const x1 = Math.round(180 + t * 40);
|
||||
for (let x = x0; x <= x1; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const ang = (10 * Math.PI) / 180;
|
||||
const ux0 = Math.sin(ang);
|
||||
const uy0 = Math.cos(ang);
|
||||
const rx0 = -uy0;
|
||||
const ry0 = ux0;
|
||||
const cx = 140, cy = 300, halfL = 280, halfW = 90;
|
||||
const corners: [number, number][] = [
|
||||
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||
];
|
||||
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 80 };
|
||||
return { W, H, bitset, bbox };
|
||||
}
|
||||
|
||||
function maskWoodAabb(bitset: Uint8Array, W: number, H: number) {
|
||||
let minX = W, maxX = 0, minY = H, maxY = 0;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
return { minX, maxX, minY, maxY };
|
||||
}
|
||||
|
||||
test('buildOverlayPlan: length axial span = robust tip stations; no AABB overshoot along axis (#69)', () => {
|
||||
const { W, H, bitset, bbox } = tiltedSlabFixture();
|
||||
const scale = 10;
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
const slabBounds = {
|
||||
x: wood.minX - 8,
|
||||
y: wood.minY - 8,
|
||||
w: wood.maxX - wood.minX + 16,
|
||||
h: wood.maxY - wood.minY + 16,
|
||||
};
|
||||
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 50,
|
||||
lengthLabel: '50"',
|
||||
mapToInventory: inv.mapToInventory,
|
||||
});
|
||||
|
||||
// Wood AABB in inventory space (for axis-right clearance only)
|
||||
let mMinX = Infinity, mMaxX = -Infinity;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
const [mx] = inv.mapToInventory(x, y);
|
||||
if (mx < mMinX) mMinX = mx;
|
||||
if (mx > mMaxX) mMaxX = mx;
|
||||
}
|
||||
}
|
||||
|
||||
// Width-aware tip stations along approved bbox axis (#71; was 0.2%/99.8%)
|
||||
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen, uy = ey / elen;
|
||||
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
|
||||
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
const rx = -uy, ry = ux;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
const along = x * ux + y * uy;
|
||||
const across = (x - cx) * rx + (y - cy) * ry;
|
||||
const bin = Math.round(along);
|
||||
const prevMin = binMin.get(bin);
|
||||
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
|
||||
const prevMax = binMax.get(bin);
|
||||
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
|
||||
}
|
||||
}
|
||||
let maxWidth = 0;
|
||||
const widths: { bin: number; width: number }[] = [];
|
||||
for (const [bin, minA] of binMin) {
|
||||
const maxA = binMax.get(bin)!;
|
||||
const width = maxA - minA + 1;
|
||||
widths.push({ bin, width });
|
||||
if (width > maxWidth) maxWidth = width;
|
||||
}
|
||||
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||
widths.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin;
|
||||
for (const { bin, width } of widths) {
|
||||
if (width >= thresh) { tipLo = bin; break; }
|
||||
}
|
||||
for (let i = widths.length - 1; i >= 0; i--) {
|
||||
if (widths[i].width >= thresh) { tipHi = widths[i].bin; break; }
|
||||
}
|
||||
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
|
||||
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
|
||||
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||
|
||||
// Inventory map is isotropic scale+translate → directions preserved; axial span scales.
|
||||
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
|
||||
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||
// Axial edge-inset (#71) shortens span slightly vs tip stations
|
||||
assert.ok(
|
||||
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
|
||||
`length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow small axial inset only; #73)`,
|
||||
);
|
||||
|
||||
// Axial stations of length ends = tip stations (pure axis-right offset does not change along-u).
|
||||
const mapOrigin = inv.mapToInventory(0, 0);
|
||||
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
|
||||
const iux = mapU[0] - mapOrigin[0];
|
||||
const iuy = mapU[1] - mapOrigin[1];
|
||||
const ilen = Math.hypot(iux, iuy) || 1;
|
||||
const nux = iux / ilen, nuy = iuy / ilen;
|
||||
const lenProjs = [plan.length.a[0] * nux + plan.length.a[1] * nuy, plan.length.b[0] * nux + plan.length.b[1] * nuy].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const tipProjs = [mapBot[0] * nux + mapBot[1] * nuy, mapTop[0] * nux + mapTop[1] * nuy].sort((a, b) => a - b);
|
||||
assert.ok(Math.abs(lenProjs[0] - tipProjs[0]) <= 14, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]} (inset)`);
|
||||
assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 14, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]} (inset)`);
|
||||
|
||||
// Length stays axis-right of wood (clearance to the right in inventory x for this fixture)
|
||||
const lenMidX = (plan.length.a[0] + plan.length.b[0]) / 2;
|
||||
assert.ok(lenMidX > mMaxX + 4, `length dim must sit outside wood on axis-right (x=${lenMidX} vs maxX=${mMaxX})`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: width labels fully left of wood AABB after inventory map (#69)', () => {
|
||||
const { W, H, bitset, bbox } = tiltedSlabFixture();
|
||||
const scale = 10;
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
const slabBounds = {
|
||||
x: wood.minX - 8,
|
||||
y: wood.minY - 8,
|
||||
w: wood.maxX - wood.minX + 16,
|
||||
h: wood.maxY - wood.minY + 16,
|
||||
};
|
||||
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 50,
|
||||
lengthLabel: '50"',
|
||||
mapToInventory: inv.mapToInventory,
|
||||
});
|
||||
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||
|
||||
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
const [mx, my] = inv.mapToInventory(x, y);
|
||||
if (mx < mMinX) mMinX = mx;
|
||||
if (mx > mMaxX) mMaxX = mx;
|
||||
if (my < mMinY) mMinY = my;
|
||||
if (my > mMaxY) mMaxY = my;
|
||||
}
|
||||
}
|
||||
|
||||
for (const w of plan.widths) {
|
||||
// Label anchor fully off the wood footprint (axis-left), with margin for glyph half-width
|
||||
const margin = plan.fontPx * 0.55; // textAlign will grow away from wood; still keep anchor clear
|
||||
const onWood =
|
||||
w.labelAt[0] >= mMinX - 1 &&
|
||||
w.labelAt[0] <= mMaxX + 1 &&
|
||||
w.labelAt[1] >= mMinY - 1 &&
|
||||
w.labelAt[1] <= mMaxY + 1;
|
||||
assert.ok(
|
||||
!onWood,
|
||||
`width labelAt (${w.labelAt[0].toFixed(0)},${w.labelAt[1].toFixed(0)}) must be outside wood AABB`,
|
||||
);
|
||||
// Axis-left = past the left tip (smaller across); for this nearly-vertical slab, left of wood in x
|
||||
assert.ok(
|
||||
w.labelAt[0] < mMinX - 2,
|
||||
`width label ${w.label} x=${w.labelAt[0].toFixed(0)} must be left of wood minX=${mMinX.toFixed(0)} (margin~${margin.toFixed(0)})`,
|
||||
);
|
||||
// Width arrows still touch wood edges (left/right tips inside/on AABB)
|
||||
assert.ok(w.left[0] >= mMinX - 3 && w.left[0] <= mMaxX + 3);
|
||||
assert.ok(w.right[0] >= mMinX - 3 && w.right[0] <= mMaxX + 3);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Length/width dims must follow approved axis, not screen tip hack (#70) ---
|
||||
|
||||
// --- Length/width dims must follow approved axis, not screen tip hack (#70) ---
|
||||
|
||||
/**
|
||||
* ~25° approved axis. Irregular tips sit far off the centerline on opposite
|
||||
* sides so tip-to-tip ≠ (ux,uy). #69 screen-hack follows tip pixels and fails
|
||||
* the parallel-axis / pure axis-right offset asserts; classic offset passes.
|
||||
*/
|
||||
function tiltedAxis25Fixture() {
|
||||
const W = 420, H = 520;
|
||||
const ang = (25 * Math.PI) / 180;
|
||||
const ux0 = Math.sin(ang);
|
||||
const uy0 = Math.cos(ang);
|
||||
const rx0 = -uy0;
|
||||
const ry0 = ux0;
|
||||
const cx = 210, cy = 260, halfL = 160, halfW = 50;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const along = (x - cx) * ux0 + (y - cy) * uy0;
|
||||
const across = (x - cx) * rx0 + (y - cy) * ry0;
|
||||
const t = (along + halfL) / (2 * halfL);
|
||||
// Strong opposite bias at the tips (±0.85*halfW) so tip pixels diverge from axis
|
||||
const centerAcross = (t - 0.5) * 2 * halfW * 0.85;
|
||||
const halfAt = halfW * 0.35;
|
||||
if (Math.abs(along) <= halfL && Math.abs(across - centerAcross) <= halfAt) {
|
||||
bitset[y * W + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const corners: [number, number][] = [
|
||||
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||
];
|
||||
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 25 };
|
||||
return { W, H, bitset, bbox, ux0, uy0, rx0, ry0, halfL, cx, cy };
|
||||
}
|
||||
|
||||
|
||||
test('buildOverlayPlan: angleDeg=90 vertical long side → width left→right within 2° of horizontal (#72)', () => {
|
||||
// Upright slab: approved length axis is vertical (angleDeg 90). Width marks must
|
||||
// run left→right ≈ horizontal (atan2 ≈ 0 or ±π), not slanted with the photo.
|
||||
const W = 200, H = 400;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 40; y <= 360; y++) {
|
||||
for (let x = 60; x <= 140; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const prev = minAreaRect(bitset, W, H)!;
|
||||
const bbox = orientedRect(prev, 90);
|
||||
assert.equal(bbox.angleDeg, 90);
|
||||
const scale = 10; // 320px span ≈ 32"
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||
for (const w of plan.widths) {
|
||||
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
|
||||
// Fold to [-90, 90] so left→right and right→left both count as horizontal.
|
||||
let deg = (ang * 180) / Math.PI;
|
||||
if (deg > 90) deg -= 180;
|
||||
if (deg < -90) deg += 180;
|
||||
assert.ok(
|
||||
Math.abs(deg) <= 2,
|
||||
`width station ${w.stationIn} left→right must be within 2° of horizontal (atan2 deg=${deg.toFixed(2)}; left=${w.left} right=${w.right})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || (rx,ry) (#70)', () => {
|
||||
const { W, H, bitset, bbox, cx, cy } = tiltedAxis25Fixture();
|
||||
const scale = 10;
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
|
||||
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||
const uxB = ex / elen, uyB = ey / elen;
|
||||
const cProj = cx * uxB + cy * uyB;
|
||||
const rxB = -uyB, ryB = uxB;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
const along = x * uxB + y * uyB;
|
||||
const across = (x - cx) * rxB + (y - cy) * ryB;
|
||||
const bin = Math.round(along);
|
||||
const prevMin = binMin.get(bin);
|
||||
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
|
||||
const prevMax = binMax.get(bin);
|
||||
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
|
||||
}
|
||||
}
|
||||
let maxW = 0;
|
||||
const wbins: { bin: number; width: number }[] = [];
|
||||
for (const [bin, minA] of binMin) {
|
||||
const maxA = binMax.get(bin)!;
|
||||
const width = maxA - minA + 1;
|
||||
wbins.push({ bin, width });
|
||||
if (width > maxW) maxW = width;
|
||||
}
|
||||
const thresh = Math.max(4, maxW * BODY_WIDTH_FRAC);
|
||||
wbins.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = wbins[0].bin, tipHi = wbins[wbins.length - 1].bin;
|
||||
for (const { bin, width } of wbins) {
|
||||
if (width >= thresh) { tipLo = bin; break; }
|
||||
}
|
||||
for (let i = wbins.length - 1; i >= 0; i--) {
|
||||
if (wbins[i].width >= thresh) { tipHi = wbins[i].bin; break; }
|
||||
}
|
||||
const tipStart: [number, number] = [cx + uxB * (tipLo - cProj), cy + uyB * (tipLo - cProj)];
|
||||
const tipEnd: [number, number] = [cx + uxB * (tipHi - cProj), cy + uyB * (tipHi - cProj)];
|
||||
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||
const { ux, uy, bottom, top } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
const ldx = plan.length.b[0] - plan.length.a[0];
|
||||
const ldy = plan.length.b[1] - plan.length.a[1];
|
||||
const llen = Math.hypot(ldx, ldy) || 1;
|
||||
const lux = ldx / llen, luy = ldy / llen;
|
||||
const lengthDot = lux * ux + luy * uy;
|
||||
assert.ok(
|
||||
lengthDot > 0.9995,
|
||||
`length a→b must be || approved axis (dot=${lengthDot.toFixed(6)}; got (${lux.toFixed(4)},${luy.toFixed(4)}) vs (${ux.toFixed(4)},${uy.toFixed(4)}))`,
|
||||
);
|
||||
|
||||
// Endpoints = centerline tip stations + pure axis-right (no screen-only tip translation)
|
||||
for (const [endPt, tip] of [
|
||||
[plan.length.a, bottom],
|
||||
[plan.length.b, top],
|
||||
] as const) {
|
||||
const ox = endPt[0] - tip[0];
|
||||
const oy = endPt[1] - tip[1];
|
||||
const along = ox * ux + oy * uy;
|
||||
const across = ox * rx + oy * ry;
|
||||
// Pure axis-right plus small axial inset toward center (#71 edgeInset)
|
||||
assert.ok(
|
||||
Math.abs(along) <= 4,
|
||||
`length end offset must be axis-right (+tiny axial inset) (along=${along.toFixed(2)}; tip=(${tip[0].toFixed(1)},${tip[1].toFixed(1)}))`,
|
||||
);
|
||||
assert.ok(across > 8, `length end must sit axis-right of centerline tip (across=${across.toFixed(1)})`);
|
||||
}
|
||||
|
||||
assert.ok(plan.widths.length > 0, 'expected width marks');
|
||||
for (const w of plan.widths) {
|
||||
const wdx = w.right[0] - w.left[0];
|
||||
const wdy = w.right[1] - w.left[1];
|
||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||
const wrx = wdx / wlen, wry = wdy / wlen;
|
||||
const widthDot = wrx * rx + wry * ry;
|
||||
assert.ok(
|
||||
widthDot > 0.999,
|
||||
`width left→right must be || axis-right (dot=${widthDot.toFixed(6)} at station ${w.stationIn})`,
|
||||
);
|
||||
const perp = wrx * ux + wry * uy;
|
||||
assert.ok(Math.abs(perp) < 0.02, `width must be ⊥ length axis (dot=${perp.toFixed(4)})`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Dim tips on footprint; labels in-bounds; smaller font (#71) ---
|
||||
|
||||
/**
|
||||
* Solid body + dense fringe whiskers past the visual tips.
|
||||
* 0.2%/99.8% axial percentiles still land in the fringe (overshoot);
|
||||
* width-aware tip finding must snap to the solid body ends.
|
||||
*/
|
||||
function fringeTipFixture() {
|
||||
const W = 280, H = 560;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
// Solid body: x=90..190, y=120..440
|
||||
for (let y = 120; y <= 440; y++) {
|
||||
for (let x = 90; x <= 190; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
// Dense fringe whiskers (~10% of body width) past tips — denser than 0.2%.
|
||||
// Must stay below BODY_WIDTH_FRAC (#73 ~0.15) so width-aware tips exclude them.
|
||||
for (let y = 95; y < 120; y++) {
|
||||
for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
for (let y = 441; y <= 465; y++) {
|
||||
for (let x = 135; x <= 145; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
// Approved axis ~vertical (slight tilt)
|
||||
const ang = (8 * Math.PI) / 180;
|
||||
const ux0 = Math.sin(ang);
|
||||
const uy0 = Math.cos(ang);
|
||||
const rx0 = -uy0;
|
||||
const ry0 = ux0;
|
||||
const cx = 140, cy = 280, halfL = 200, halfW = 70;
|
||||
const corners: [number, number][] = [
|
||||
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||
];
|
||||
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 8 };
|
||||
return { W, H, bitset, bbox, solidMinY: 120, solidMaxY: 440, ux0, uy0, cx, cy };
|
||||
}
|
||||
|
||||
test('buildOverlayPlan: length span ≤ solid-body axial extent; fringe whiskers excluded (#71)', () => {
|
||||
const { W, H, bitset, bbox, solidMinY, solidMaxY, ux0, uy0, cx, cy } = fringeTipFixture();
|
||||
const scale = 10;
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
const slabBounds = {
|
||||
x: wood.minX - 10,
|
||||
y: wood.minY - 10,
|
||||
w: wood.maxX - wood.minX + 20,
|
||||
h: wood.maxY - wood.minY + 20,
|
||||
};
|
||||
const outW = 900, outH = 1200;
|
||||
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: inv.mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
|
||||
// Solid-body axial projections (exclude fringe by y gate on this fixture)
|
||||
const solidProjs: number[] = [];
|
||||
for (let y = solidMinY; y <= solidMaxY; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
solidProjs.push(x * ux0 + y * uy0);
|
||||
}
|
||||
}
|
||||
solidProjs.sort((a, b) => a - b);
|
||||
const solidMin = solidProjs[0];
|
||||
const solidMax = solidProjs[solidProjs.length - 1];
|
||||
const cProj = cx * ux0 + cy * uy0;
|
||||
const solidBot: [number, number] = [cx + ux0 * (solidMin - cProj), cy + uy0 * (solidMin - cProj)];
|
||||
const solidTop: [number, number] = [cx + ux0 * (solidMax - cProj), cy + uy0 * (solidMax - cProj)];
|
||||
const oriented = pickBottomTowardTop(solidBot, solidTop);
|
||||
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
|
||||
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||
const solidAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||
|
||||
// Length must not exceed solid body (percentile fringe would overshoot by ≫2px)
|
||||
assert.ok(
|
||||
lenAxial <= solidAxial + 2,
|
||||
`length span ${lenAxial.toFixed(1)} must be ≤ solid-body span ${solidAxial.toFixed(1)} (fringe excluded)`,
|
||||
);
|
||||
|
||||
// Prove 0.2%/99.8% still overshoots solid body — so this fixture is meaningful
|
||||
const allProjs: number[] = [];
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
allProjs.push(x * ux0 + y * uy0);
|
||||
}
|
||||
}
|
||||
allProjs.sort((a, b) => a - b);
|
||||
const lo = Math.min(Math.floor(allProjs.length * 0.002), allProjs.length - 1);
|
||||
const hi = Math.max(Math.floor(allProjs.length * 0.998), lo);
|
||||
const pctSpan = allProjs[hi] - allProjs[lo];
|
||||
const solidSpan = solidMax - solidMin;
|
||||
assert.ok(
|
||||
pctSpan > solidSpan + 5,
|
||||
`fixture sanity: percentile span ${pctSpan.toFixed(1)} must overshoot solid ${solidSpan.toFixed(1)}`,
|
||||
);
|
||||
|
||||
// Still || approved axis (keep #70)
|
||||
const ldx = plan.length.b[0] - plan.length.a[0];
|
||||
const ldy = plan.length.b[1] - plan.length.a[1];
|
||||
const llen = Math.hypot(ldx, ldy) || 1;
|
||||
const mapOrigin = inv.mapToInventory(0, 0);
|
||||
const mapU = inv.mapToInventory(oriented.ux, oriented.uy);
|
||||
const iux = mapU[0] - mapOrigin[0];
|
||||
const iuy = mapU[1] - mapOrigin[1];
|
||||
const ilen = Math.hypot(iux, iuy) || 1;
|
||||
const lengthDot = (ldx / llen) * (iux / ilen) + (ldy / llen) * (iuy / ilen);
|
||||
assert.ok(lengthDot > 0.999, `length must stay || axis (dot=${lengthDot.toFixed(6)})`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset (#71)', () => {
|
||||
const { W, H, bitset, bbox } = fringeTipFixture();
|
||||
const scale = 10;
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
inventoryW: W,
|
||||
inventoryH: H,
|
||||
});
|
||||
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
// Rebuild oriented axis to re-probe full mask extent at each station
|
||||
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||
const uxB = ex / elen, uyB = ey / elen;
|
||||
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
|
||||
const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
|
||||
const cProj = cx * uxB + cy * uyB;
|
||||
// Width-aware tips from the same solid body y-range as fringeTipFixture
|
||||
const solidProjs: number[] = [];
|
||||
for (let y = 120; y <= 440; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
solidProjs.push(x * uxB + y * uyB);
|
||||
}
|
||||
}
|
||||
solidProjs.sort((a, b) => a - b);
|
||||
const tipStart: [number, number] = [cx + uxB * (solidProjs[0] - cProj), cy + uyB * (solidProjs[0] - cProj)];
|
||||
const tipEnd: [number, number] = [cx + uxB * (solidProjs[solidProjs.length - 1] - cProj), cy + uyB * (solidProjs[solidProjs.length - 1] - cProj)];
|
||||
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||
const { ux, uy, bottom } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
for (const w of plan.widths) {
|
||||
assert.ok(
|
||||
w.left[0] >= wood.minX - 1 && w.left[0] <= wood.maxX + 1 &&
|
||||
w.left[1] >= wood.minY - 1 && w.left[1] <= wood.maxY + 1,
|
||||
`width left must be on/inside wood AABB`,
|
||||
);
|
||||
assert.ok(
|
||||
w.right[0] >= wood.minX - 1 && w.right[0] <= wood.maxX + 1 &&
|
||||
w.right[1] >= wood.minY - 1 && w.right[1] <= wood.maxY + 1,
|
||||
`width right must be on/inside wood AABB`,
|
||||
);
|
||||
// Full mask probe at this station (no inset)
|
||||
const t = w.stationIn * scale;
|
||||
const ox = bottom[0] + ux * t;
|
||||
const oy = bottom[1] + uy * t;
|
||||
const half = Math.max(2, Math.round((W + H) / 4));
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let p = -half; p <= half; p++) {
|
||||
const x = Math.round(ox + rx * p);
|
||||
const y = Math.round(oy + ry * p);
|
||||
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
||||
if (bitset[y * W + x]) {
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
const fullSpan = maxP - minP;
|
||||
const planSpan = Math.hypot(w.right[0] - w.left[0], w.right[1] - w.left[1]);
|
||||
// Inset ≥ ~0.5px each end so stroke/arrowhead visual tips do not past the edge
|
||||
assert.ok(
|
||||
planSpan <= fullSpan - 0.9,
|
||||
`width span ${planSpan.toFixed(2)} must be inset from full mask ${fullSpan} (stroke/arrowhead)`,
|
||||
);
|
||||
// Tips still near the edge (not collapsed)
|
||||
assert.ok(planSpan >= fullSpan - 20, `inset must be modest, got plan=${planSpan} full=${fullSpan}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller font (#71)', () => {
|
||||
const { W, H, bitset, bbox } = fringeTipFixture();
|
||||
const scale = 10;
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
// Tight crop so axis-left width labels would clip without clamping
|
||||
const slabBounds = {
|
||||
x: wood.minX - 4,
|
||||
y: wood.minY - 4,
|
||||
w: wood.maxX - wood.minX + 8,
|
||||
h: wood.maxY - wood.minY + 8,
|
||||
};
|
||||
const outW = 600, outH = 800;
|
||||
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, outW, outH);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: inv.mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
|
||||
// Font: one consistent smaller size than prior max(22, scale*1.0)
|
||||
const priorFont = Math.max(22, Math.round(scale * 1.0));
|
||||
assert.ok(plan.fontPx < priorFont, `fontPx ${plan.fontPx} must be < prior ${priorFont}`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still readable`);
|
||||
|
||||
// Wood AABB in inventory
|
||||
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
const [mx, my] = inv.mapToInventory(x, y);
|
||||
if (mx < mMinX) mMinX = mx;
|
||||
if (mx > mMaxX) mMaxX = mx;
|
||||
if (my < mMinY) mMinY = my;
|
||||
if (my > mMaxY) mMaxY = my;
|
||||
}
|
||||
}
|
||||
|
||||
// Length label axis-right off wood
|
||||
const lenLabel = plan.length.labelAt;
|
||||
assert.ok(
|
||||
lenLabel[0] > mMaxX + 2 || lenLabel[1] < mMinY - 2 || lenLabel[1] > mMaxY + 2,
|
||||
`length label (${lenLabel[0].toFixed(0)},${lenLabel[1].toFixed(0)}) must be off wood AABB`,
|
||||
);
|
||||
// Glyph margin: keep anchors inside canvas
|
||||
const margin = Math.max(8, plan.fontPx * 0.6);
|
||||
assert.ok(lenLabel[0] >= margin && lenLabel[0] <= outW - margin, `length label x in canvas`);
|
||||
assert.ok(lenLabel[1] >= margin && lenLabel[1] <= outH - margin, `length label y in canvas`);
|
||||
|
||||
for (const w of plan.widths) {
|
||||
// Width labels axis-left off wood
|
||||
assert.ok(
|
||||
w.labelAt[0] < mMinX - 1 || w.labelAt[1] < mMinY - 1 || w.labelAt[1] > mMaxY + 1,
|
||||
`width label ${w.label} must be off wood`,
|
||||
);
|
||||
// Clamped inside inventory PNG (anchor + estimated glyph extent toward axis-left)
|
||||
const glyphPad = plan.fontPx * Math.max(2, w.label.length * 0.55);
|
||||
assert.ok(
|
||||
w.labelAt[0] >= margin && w.labelAt[0] <= outW - margin,
|
||||
`width label x=${w.labelAt[0].toFixed(0)} must be inside canvas [0,${outW}]`,
|
||||
);
|
||||
assert.ok(
|
||||
w.labelAt[1] >= margin && w.labelAt[1] <= outH - margin,
|
||||
`width label y=${w.labelAt[1].toFixed(0)} must be inside canvas [0,${outH}]`,
|
||||
);
|
||||
// textAlign right grows left — left extent of glyphs must stay ≥ 0
|
||||
assert.ok(
|
||||
w.labelAt[0] - glyphPad >= -1,
|
||||
`width label glyphs must not clip left edge (anchor=${w.labelAt[0].toFixed(0)}, pad=${glyphPad.toFixed(0)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- Tapered live-edge tips must not be dropped (#73) ---
|
||||
|
||||
/**
|
||||
* Live-edge taper: body max width ~100px; tips taper to ~20% of max (~20px).
|
||||
* Old BODY_WIDTH_FRAC=0.32 dropped tip bins; length dim under-reached wood ends.
|
||||
*/
|
||||
function taperedTipFixture() {
|
||||
const W = 240, H = 500;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
const y0 = 40, y1 = 460;
|
||||
const maxHalf = 50; // body half-width → ~100px
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
const t = (y - y0) / (y1 - y0); // 0..1 along length
|
||||
// Cosine taper: tips at ~20% of max width, mid at full
|
||||
const tipFrac = 0.20;
|
||||
const envelope = tipFrac + (1 - tipFrac) * Math.sin(Math.PI * t);
|
||||
const half = Math.max(2, Math.round(maxHalf * envelope));
|
||||
const cx = 120;
|
||||
for (let x = cx - half; x <= cx + half; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const ang = (5 * Math.PI) / 180;
|
||||
const ux0 = Math.sin(ang);
|
||||
const uy0 = Math.cos(ang);
|
||||
const rx0 = -uy0;
|
||||
const ry0 = ux0;
|
||||
const cx = 120, cy = 250, halfL = 220, halfW = 70;
|
||||
const corners: [number, number][] = [
|
||||
[cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW],
|
||||
[cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW],
|
||||
[cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW],
|
||||
];
|
||||
const bbox = { corners, longSidePx: halfL * 2, angleDeg: 90 - 5 };
|
||||
return { W, H, bitset, bbox, y0, y1, tipFrac: 0.20, maxHalf, ux0, uy0, cx, cy };
|
||||
}
|
||||
|
||||
test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ tip span − small inset (#73)', () => {
|
||||
const { W, H, bitset, bbox, y0, y1, tipFrac, maxHalf, ux0, uy0, cx, cy } = taperedTipFixture();
|
||||
// Sanity: tip bins are ~15–25% of max width (would fail old 0.32 thresh)
|
||||
const tipHalf = Math.max(2, Math.round(maxHalf * tipFrac));
|
||||
const tipWidth = tipHalf * 2 + 1;
|
||||
const bodyWidth = maxHalf * 2 + 1;
|
||||
const tipRatio = tipWidth / bodyWidth;
|
||||
assert.ok(tipRatio >= 0.15 && tipRatio <= 0.25, `fixture tip ratio ${tipRatio.toFixed(3)} in [0.15,0.25]`);
|
||||
assert.ok(tipRatio < 0.32, `fixture tip ratio ${tipRatio.toFixed(3)} must be < old 0.32 thresh`);
|
||||
|
||||
const scale = 10;
|
||||
const wood = maskWoodAabb(bitset, W, H);
|
||||
const slabBounds = {
|
||||
x: wood.minX - 8,
|
||||
y: wood.minY - 8,
|
||||
w: wood.maxX - wood.minX + 16,
|
||||
h: wood.maxY - wood.minY + 16,
|
||||
};
|
||||
const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox,
|
||||
scale,
|
||||
lengthIn: 42,
|
||||
lengthLabel: '42"',
|
||||
mapToInventory: inv.mapToInventory,
|
||||
});
|
||||
|
||||
// Width-aware tip stations with current BODY_WIDTH_FRAC
|
||||
const { start: aa, end: bb } = axisEndpoints(bbox);
|
||||
const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen, uy = ey / elen;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
const rx = -uy, ry = ux;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
const along = x * ux + y * uy;
|
||||
const across = (x - cx) * rx + (y - cy) * ry;
|
||||
const bin = Math.round(along);
|
||||
const prevMin = binMin.get(bin);
|
||||
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
|
||||
const prevMax = binMax.get(bin);
|
||||
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
|
||||
}
|
||||
}
|
||||
let maxWidth = 0;
|
||||
const widths: { bin: number; width: number }[] = [];
|
||||
for (const [bin, minA] of binMin) {
|
||||
const maxA = binMax.get(bin)!;
|
||||
const width = maxA - minA + 1;
|
||||
widths.push({ bin, width });
|
||||
if (width > maxWidth) maxWidth = width;
|
||||
}
|
||||
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||
widths.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin;
|
||||
for (const { bin, width } of widths) {
|
||||
if (width >= thresh) { tipLo = bin; break; }
|
||||
}
|
||||
for (let i = widths.length - 1; i >= 0; i--) {
|
||||
if (widths[i].width >= thresh) { tipHi = widths[i].bin; break; }
|
||||
}
|
||||
// Tip bins at the axial extremes must survive the threshold (not dropped)
|
||||
const firstBin = widths[0];
|
||||
const lastBin = widths[widths.length - 1];
|
||||
assert.ok(
|
||||
firstBin.width >= thresh || tipLo <= firstBin.bin + 3,
|
||||
`low tip bin width=${firstBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipLo=${tipLo} first=${firstBin.bin})`,
|
||||
);
|
||||
assert.ok(
|
||||
lastBin.width >= thresh || tipHi >= lastBin.bin - 3,
|
||||
`high tip bin width=${lastBin.width} vs thresh=${thresh.toFixed(1)} must be kept (tipHi=${tipHi} last=${lastBin.bin})`,
|
||||
);
|
||||
// Axial tip span must reach near the physical wood ends (y0/y1)
|
||||
const tipStart: [number, number] = [cx + ux * (tipLo - cProj), cy + uy * (tipLo - cProj)];
|
||||
const tipEnd: [number, number] = [cx + ux * (tipHi - cProj), cy + uy * (tipHi - cProj)];
|
||||
const oriented = pickBottomTowardTop(tipStart, tipEnd);
|
||||
const mapBot = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]);
|
||||
const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]);
|
||||
const tipAxial = Math.hypot(mapTop[0] - mapBot[0], mapTop[1] - mapBot[1]);
|
||||
const lenAxial = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
||||
|
||||
// Length must reach tip stations minus only a small axial inset (tighten old −24px)
|
||||
assert.ok(
|
||||
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
|
||||
`length span ${lenAxial.toFixed(1)} must match tip span ${tipAxial.toFixed(1)} within small inset (#73)`,
|
||||
);
|
||||
|
||||
// Prove old 0.32 thresh would have under-reached (fixture is meaningful)
|
||||
const oldThresh = Math.max(4, maxWidth * 0.32);
|
||||
let oldLo = widths[0].bin, oldHi = widths[widths.length - 1].bin;
|
||||
for (const { bin, width } of widths) {
|
||||
if (width >= oldThresh) { oldLo = bin; break; }
|
||||
}
|
||||
for (let i = widths.length - 1; i >= 0; i--) {
|
||||
if (widths[i].width >= oldThresh) { oldHi = widths[i].bin; break; }
|
||||
}
|
||||
assert.ok(
|
||||
(tipHi - tipLo) > (oldHi - oldLo) + 10,
|
||||
`new tip span ${tipHi - tipLo} must exceed old-0.32 span ${oldHi - oldLo} (taper kept)`,
|
||||
);
|
||||
|
||||
// Still no overshoot past wood AABB along axis (keep #69/#71)
|
||||
const woodProjs: number[] = [];
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!bitset[y * W + x]) continue;
|
||||
woodProjs.push(x * ux + y * uy);
|
||||
}
|
||||
}
|
||||
woodProjs.sort((a, b) => a - b);
|
||||
const woodSpan = woodProjs[woodProjs.length - 1] - woodProjs[0];
|
||||
const tipSpanSrc = tipHi - tipLo;
|
||||
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
|
||||
});
|
||||
|
||||
+246
-34
@@ -1,5 +1,7 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
import { DEFAULT_CROP_FILL } from './crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
@@ -45,6 +47,8 @@ export interface OverlayWidthMark {
|
||||
center: [number, number];
|
||||
left: [number, number];
|
||||
right: [number, number];
|
||||
/** Axis-left of the slab, outside the wood edge (opposite of length dim). */
|
||||
labelAt: [number, number];
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -74,6 +78,75 @@ export interface OverlayPlanInput {
|
||||
lengthLabel: string;
|
||||
/** Map source px → inventory PNG px (identity OK for unit tests). */
|
||||
mapToInventory: (x: number, y: number) => [number, number];
|
||||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||||
inventoryW?: number;
|
||||
inventoryH?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Min across-width as a fraction of max bin width for an axial bin to count as a tip.
|
||||
* #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain.
|
||||
*/
|
||||
export const BODY_WIDTH_FRAC = 0.15;
|
||||
|
||||
/**
|
||||
* Axial tip stations from across-width profile (#71/#73).
|
||||
* Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles;
|
||||
* require each axial bin to have meaningful across-width before it counts as a tip.
|
||||
*/
|
||||
function widthAwareAxialRange(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
ux: number,
|
||||
uy: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
): { minP: number; maxP: number } | null {
|
||||
const rx = -uy;
|
||||
const ry = ux;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const along = x * ux + y * uy;
|
||||
const across = (x - cx) * rx + (y - cy) * ry;
|
||||
const bin = Math.round(along);
|
||||
const prevMin = binMin.get(bin);
|
||||
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
|
||||
const prevMax = binMax.get(bin);
|
||||
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
|
||||
}
|
||||
}
|
||||
if (binMin.size === 0) return null;
|
||||
let maxWidth = 0;
|
||||
const widths: { bin: number; width: number }[] = [];
|
||||
for (const [bin, minA] of binMin) {
|
||||
const maxA = binMax.get(bin)!;
|
||||
const width = maxA - minA + 1;
|
||||
widths.push({ bin, width });
|
||||
if (width > maxWidth) maxWidth = width;
|
||||
}
|
||||
// Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73).
|
||||
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||
widths.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = widths[0].bin;
|
||||
let tipHi = widths[widths.length - 1].bin;
|
||||
for (const { bin, width } of widths) {
|
||||
if (width >= thresh) {
|
||||
tipLo = bin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = widths.length - 1; i >= 0; i--) {
|
||||
if (widths[i].width >= thresh) {
|
||||
tipHi = widths[i].bin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { minP: tipLo, maxP: tipHi };
|
||||
}
|
||||
|
||||
function maskAxisSegment(
|
||||
@@ -81,7 +154,16 @@ function maskAxisSegment(
|
||||
w: number,
|
||||
h: number,
|
||||
bbox: BBox,
|
||||
): { start: [number, number]; end: [number, number]; ux: number; uy: number; span: number } {
|
||||
): {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
/** Actual mask pixels nearest centerline at tip stations. */
|
||||
tipStart: [number, number];
|
||||
tipEnd: [number, number];
|
||||
ux: number;
|
||||
uy: number;
|
||||
span: number;
|
||||
} {
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
@@ -91,21 +173,56 @@ function maskAxisSegment(
|
||||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
||||
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
let minP = Infinity;
|
||||
let maxP = -Infinity;
|
||||
// Width-aware tips (#71). Fall back to robust percentiles if profile empty.
|
||||
const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy);
|
||||
let minP: number;
|
||||
let maxP: number;
|
||||
if (aware) {
|
||||
minP = aware.minP;
|
||||
maxP = aware.maxP;
|
||||
} else {
|
||||
const projs: number[] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
projs.push(x * ux + y * uy);
|
||||
}
|
||||
}
|
||||
if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 };
|
||||
projs.sort((p, q) => p - q);
|
||||
const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1);
|
||||
const hi = Math.max(Math.floor(projs.length * 0.998), lo);
|
||||
minP = projs[lo];
|
||||
maxP = projs[hi];
|
||||
}
|
||||
// Tip pixel at each station: closest to the axis centerline
|
||||
let tipStart: [number, number] = a;
|
||||
let tipEnd: [number, number] = b;
|
||||
let bestStart = Infinity;
|
||||
let bestEnd = Infinity;
|
||||
const stationTol = Math.max(1.5, (maxP - minP) * 0.001);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||||
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
||||
bestStart = across;
|
||||
tipStart = [x, y];
|
||||
}
|
||||
if (Math.abs(p - maxP) <= stationTol && across < bestEnd) {
|
||||
bestEnd = across;
|
||||
tipEnd = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxP < minP) return { start: a, end: b, ux, uy, span: 0 };
|
||||
return {
|
||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||
tipStart,
|
||||
tipEnd,
|
||||
ux,
|
||||
uy,
|
||||
span: maxP - minP,
|
||||
@@ -151,7 +268,10 @@ function fmtInches(inches: number): string {
|
||||
}
|
||||
|
||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const { bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory } = input;
|
||||
const {
|
||||
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||||
inventoryW, inventoryH,
|
||||
} = input;
|
||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
||||
// Re-orient span ends so EXIF bottom establishes station origin; axis still
|
||||
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
|
||||
@@ -159,6 +279,22 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const { bottom, top, ux, uy } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
// Consistent smaller readable font (#71). Cap by inventory size so high
|
||||
// source scale (≈80 px/in) does not dominate the PNG.
|
||||
const fontPx = (() => {
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
return Math.max(12, Math.min(16, Math.round(Math.min(inventoryW, inventoryH) * 0.016)));
|
||||
}
|
||||
return Math.max(12, Math.min(16, Math.round(scale * 0.18)));
|
||||
})();
|
||||
// Inset for stroke + arrowhead so visual tips sit on/inside edges (#71).
|
||||
const lineW = Math.max(2, Math.round(fontPx / 10));
|
||||
const arrowHead = Math.max(7, fontPx * 0.32);
|
||||
// Width tips retain stroke/arrowhead protection. Length endpoints need only
|
||||
// half-stroke protection: larger arrowhead-derived inset visibly under-reached tips (#73).
|
||||
const widthEdgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15);
|
||||
const lengthEdgeInset = Math.max(0.5, lineW / 2);
|
||||
const pad = Math.max(10, Math.min(28, fontPx * 1.1));
|
||||
const stations = overlayWidthStations(lengthIn);
|
||||
const widths: OverlayWidthMark[] = [];
|
||||
for (const stationIn of stations) {
|
||||
@@ -167,15 +303,26 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const oy = bottom[1] + uy * t;
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (!extent) continue;
|
||||
// left = opposite of axis-right; right = axis-right tip on slab edge
|
||||
const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP];
|
||||
const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP];
|
||||
// left/right on mask edges, then inset in inventory space for stroke/arrowhead
|
||||
const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP);
|
||||
const rightMapped = mapToInventory(ox + rx * extent.maxP, oy + ry * extent.maxP);
|
||||
const wdx = rightMapped[0] - leftMapped[0];
|
||||
const wdy = rightMapped[1] - leftMapped[1];
|
||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||
const wux = wdx / wlen;
|
||||
const wuy = wdy / wlen;
|
||||
const left: [number, number] = [leftMapped[0] + wux * widthEdgeInset, leftMapped[1] + wuy * widthEdgeInset];
|
||||
const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset];
|
||||
// Width number sits axis-left outside the slab (past left tip).
|
||||
const labelOff = extent.minP - pad - fontPx * 0.85;
|
||||
const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff];
|
||||
const widthIn = (extent.maxP - extent.minP + 1) / scale;
|
||||
widths.push({
|
||||
stationIn,
|
||||
center: mapToInventory(ox, oy),
|
||||
left: mapToInventory(leftSrc[0], leftSrc[1]),
|
||||
right: mapToInventory(rightSrc[0], rightSrc[1]),
|
||||
left,
|
||||
right,
|
||||
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||
label: fmtInches(widthIn),
|
||||
});
|
||||
}
|
||||
@@ -190,26 +337,67 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||
}
|
||||
const pad = Math.max(16, scale * 0.75);
|
||||
const outward = maxRight + pad;
|
||||
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward];
|
||||
const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward];
|
||||
const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2];
|
||||
// Nudge label further outward a bit
|
||||
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)];
|
||||
// Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
|
||||
// pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
|
||||
// Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
|
||||
const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
|
||||
const bMapped = mapToInventory(top[0] + rx * outward, top[1] + ry * outward);
|
||||
const ldx0 = bMapped[0] - aMapped[0];
|
||||
const ldy0 = bMapped[1] - aMapped[1];
|
||||
const llen0 = Math.hypot(ldx0, ldy0) || 1;
|
||||
const lux = ldx0 / llen0;
|
||||
const luy = ldy0 / llen0;
|
||||
const aInv: [number, number] = [aMapped[0] + lux * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset];
|
||||
const bInv: [number, number] = [bMapped[0] - lux * lengthEdgeInset, bMapped[1] - luy * lengthEdgeInset];
|
||||
const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2];
|
||||
// Length label further axis-right of dim line so glyphs stay off the wood (#71).
|
||||
const mapOrigin = mapToInventory(0, 0);
|
||||
const mapR = mapToInventory(rx, ry);
|
||||
const irx = mapR[0] - mapOrigin[0];
|
||||
const iry = mapR[1] - mapOrigin[1];
|
||||
const irlen = Math.hypot(irx, iry) || 1;
|
||||
const nrx = irx / irlen;
|
||||
const nry = iry / irlen;
|
||||
const labelAt: [number, number] = [
|
||||
midInv[0] + nrx * (fontPx * 0.85 + 6),
|
||||
midInv[1] + nry * (fontPx * 0.85 + 6),
|
||||
];
|
||||
|
||||
const fontPx = Math.max(28, Math.round(scale * 1.2));
|
||||
const lengthMark: OverlayLengthMark = {
|
||||
a: aInv,
|
||||
b: bInv,
|
||||
labelAt,
|
||||
label: lengthLabel,
|
||||
outside: true,
|
||||
};
|
||||
|
||||
// Clamp all labels inside inventory PNG so nothing clips (#71).
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
const margin = Math.max(8, fontPx * 0.65);
|
||||
const clamp = (pt: [number, number], growLeft: boolean, text: string): [number, number] => {
|
||||
let [x, y] = pt;
|
||||
const glyphW = Math.max(fontPx * 1.2, text.length * fontPx * 0.55);
|
||||
if (growLeft) {
|
||||
// textAlign right — glyphs extend left of anchor
|
||||
x = Math.max(margin + glyphW, Math.min(inventoryW - margin, x));
|
||||
} else {
|
||||
x = Math.max(margin, Math.min(inventoryW - margin, x));
|
||||
}
|
||||
y = Math.max(margin, Math.min(inventoryH - margin, y));
|
||||
return [x, y];
|
||||
};
|
||||
lengthMark.labelAt = clamp(lengthMark.labelAt, false, lengthLabel);
|
||||
for (const w of widths) {
|
||||
const growLeft = w.labelAt[0] <= w.center[0];
|
||||
w.labelAt = clamp(w.labelAt, growLeft, w.label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stroke: '#000000',
|
||||
fontPx,
|
||||
length: {
|
||||
a: mapToInventory(aSrc[0], aSrc[1]),
|
||||
b: mapToInventory(bSrc[0], bSrc[1]),
|
||||
labelAt: mapToInventory(labelSrc[0], labelSrc[1]),
|
||||
label: lengthLabel,
|
||||
outside: true,
|
||||
},
|
||||
length: lengthMark,
|
||||
widths,
|
||||
};
|
||||
}
|
||||
@@ -223,8 +411,9 @@ export function drawOverlayPlan(
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.lineWidth = Math.max(3, Math.round(fontPx / 10));
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineWidth = Math.max(2, Math.round(fontPx / 10));
|
||||
// Butt caps: round caps extend past endpoints and overshoot wood edges (#71).
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.font = `bold ${fontPx}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
@@ -235,7 +424,7 @@ export function drawOverlayPlan(
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const head = Math.max(10, fontPx * 0.35);
|
||||
const head = Math.max(7, fontPx * 0.32);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from[0], from[1]);
|
||||
ctx.lineTo(to[0], to[1]);
|
||||
@@ -256,14 +445,31 @@ export function drawOverlayPlan(
|
||||
}
|
||||
};
|
||||
|
||||
// Length
|
||||
// Length — grow text further axis-right of the dim line (away from wood)
|
||||
arrow(plan.length.a, plan.length.b);
|
||||
{
|
||||
const [lax, lay] = plan.length.a;
|
||||
const [lbx, lby] = plan.length.b;
|
||||
const midX = (lax + lbx) / 2;
|
||||
const midY = (lay + lby) / 2;
|
||||
const outwardX = plan.length.labelAt[0] - midX;
|
||||
const outwardY = plan.length.labelAt[1] - midY;
|
||||
// Prefer left/right align so glyphs extend away from wood when offset is mostly ±x
|
||||
if (Math.abs(outwardX) >= Math.abs(outwardY)) {
|
||||
ctx.textAlign = outwardX >= 0 ? 'left' : 'right';
|
||||
} else {
|
||||
ctx.textAlign = 'center';
|
||||
}
|
||||
}
|
||||
ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]);
|
||||
|
||||
// Widths
|
||||
// Widths — grow text away from wood (labelAt is axis-left of left tip)
|
||||
for (const w of plan.widths) {
|
||||
arrow(w.left, w.right);
|
||||
ctx.fillText(w.label, w.center[0], w.center[1]);
|
||||
// labelAt is on the axis-left side of the station; align so glyphs extend further out
|
||||
const towardLeft = w.labelAt[0] <= w.center[0];
|
||||
ctx.textAlign = towardLeft ? 'right' : 'left';
|
||||
ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
@@ -281,8 +487,9 @@ export function inventorySourceMap(
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
cropFill: number = DEFAULT_CROP_FILL,
|
||||
): InventoryMap {
|
||||
const FILL = 0.8;
|
||||
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
|
||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||
@@ -313,6 +520,8 @@ export interface EncodeOverlayInput {
|
||||
lengthLabel: string;
|
||||
slabBounds: { x: number; y: number; w: number; h: number };
|
||||
source: { widthPx: number; heightPx: number };
|
||||
/** Optional override; default reads live settings.crop_fill (#99). */
|
||||
cropFill?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,7 +536,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
|
||||
const fill = input.cropFill ?? getCropFill();
|
||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset: input.bitset,
|
||||
widthPx: input.widthPx,
|
||||
@@ -337,6 +547,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
lengthIn: input.lengthIn,
|
||||
lengthLabel: input.lengthLabel,
|
||||
mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope).
|
||||
* Used only for the calibration inventory PNG + dimension-overlay clone.
|
||||
* Batch siblings are never passed through here.
|
||||
*/
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
|
||||
export interface RotatedCal {
|
||||
bitmap: ImageBitmap;
|
||||
mask: Mask;
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate).
|
||||
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
|
||||
* Identity (≈0°) returns clones of inputs without resampling.
|
||||
*/
|
||||
export async function rotateCalibration(
|
||||
bitmap: ImageBitmap,
|
||||
mask: Mask,
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>,
|
||||
tiltDeg: number,
|
||||
): Promise<RotatedCal> {
|
||||
const a = ((tiltDeg % 360) + 360) % 360;
|
||||
if (a < 0.05 || a > 359.95) {
|
||||
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
|
||||
return { bitmap, mask, source };
|
||||
}
|
||||
const srcW = source.widthPx;
|
||||
const srcH = source.heightPx;
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// Axis-aligned bounds of rotated rectangle corners.
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[srcW, 0],
|
||||
[srcW, srcH],
|
||||
[0, srcH],
|
||||
].map(([x, y]) => {
|
||||
const cx = x - srcW / 2;
|
||||
const cy = y - srcH / 2;
|
||||
return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number];
|
||||
});
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const [x, y] of corners) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
const outW = Math.max(1, Math.ceil(maxX - minX));
|
||||
const outH = Math.max(1, Math.ceil(maxY - minY));
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
ctx.rotate(rad);
|
||||
ctx.drawImage(bitmap, -srcW / 2, -srcH / 2);
|
||||
|
||||
// Nearest-neighbour resample of the bitset into the rotated frame.
|
||||
const bitset = new Uint8Array(outW * outH);
|
||||
const invCos = Math.cos(-rad);
|
||||
const invSin = Math.sin(-rad);
|
||||
for (let y = 0; y < outH; y++) {
|
||||
for (let x = 0; x < outW; x++) {
|
||||
const dx = x - outW / 2;
|
||||
const dy = y - outH / 2;
|
||||
const sx = Math.round(dx * invCos - dy * invSin + srcW / 2);
|
||||
const sy = Math.round(dx * invSin + dy * invCos + srcH / 2);
|
||||
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
|
||||
bitset[y * outW + x] = mask.bitset[sy * srcW + sx];
|
||||
}
|
||||
}
|
||||
let slabPx = 0;
|
||||
for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++;
|
||||
const bounds = slabBounds(bitset, outW, outH);
|
||||
const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox;
|
||||
const outBmp = canvas.transferToImageBitmap();
|
||||
return {
|
||||
bitmap: outBmp,
|
||||
mask: {
|
||||
params: mask.params,
|
||||
slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds: bounds,
|
||||
},
|
||||
source: { widthPx: outW, heightPx: outH },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Thin frontend-only crop_fill setting (#99).
|
||||
* Default 0.9 — replaces duplicated FILL = 0.9 in png/inventory + png/overlay.
|
||||
* Not a full M3 Settings surface; localStorage so it survives reloads.
|
||||
*/
|
||||
import { DEFAULT_CROP_FILL, clampFill } from '../png/crop';
|
||||
|
||||
const KEY = 'slabuploader.crop_fill';
|
||||
|
||||
let cached: number | null = null;
|
||||
|
||||
export function getCropFill(): number {
|
||||
if (cached != null) return cached;
|
||||
if (typeof localStorage === 'undefined') {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw == null) {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
cached = clampFill(Number.parseFloat(raw));
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCropFill(fill: number): number {
|
||||
const next = clampFill(fill);
|
||||
cached = next;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(next));
|
||||
} catch {
|
||||
/* private mode / quota — in-memory still updated */
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Test helper: clear cached value (and optional storage). */
|
||||
export function _resetCropFillForTests(): void {
|
||||
cached = null;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,13 @@
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
||||
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let data: Uint8ClampedArray | null = null;
|
||||
let w = 0, h = 0;
|
||||
let current = 0; // highest job id issued by main; older replies are dropped
|
||||
|
||||
// Keep slab pixels; remove the sheet. Higher sensitivity widens the sheet match.
|
||||
const greenKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const t = s / 100;
|
||||
const isSheet = g - Math.max(r, b) > 10 + 35 * t;
|
||||
return !isSheet;
|
||||
};
|
||||
const blackKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const isSheet = lum < 40 + 140 * (s / 100);
|
||||
return !isSheet;
|
||||
};
|
||||
const autoPick = (samples: { r: number; g: number; b: number }[]): 'green' | 'black' => {
|
||||
let gr = 0, gl = 0;
|
||||
for (const p of samples) {
|
||||
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||
}
|
||||
return gl >= gr ? 'black' : 'green';
|
||||
};
|
||||
|
||||
function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
|
||||
let sheet = params.sheet;
|
||||
@@ -36,7 +18,7 @@ function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
const i = (y * w + x) * 4;
|
||||
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
|
||||
}
|
||||
sheet = autoPick(samples);
|
||||
sheet = autoPickSheet(samples);
|
||||
}
|
||||
const keep = sheet === 'green' ? greenKeep : blackKeep;
|
||||
const s = Math.max(0, Math.min(100, params.sensitivity));
|
||||
@@ -69,7 +51,7 @@ self.onmessage = (e: MessageEvent<WorkerIn>) => {
|
||||
}
|
||||
data = null;
|
||||
w = h = 0;
|
||||
createImageBitmap(msg.blob)
|
||||
decodeOrientedBitmap(msg.blob)
|
||||
.then((bmp) => {
|
||||
w = bmp.width;
|
||||
h = bmp.height;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# E2E visual artifacts (#65)
|
||||
|
||||
Screenshots from `npx playwright test` for Milestone 2 Photos-ready evidence.
|
||||
|
||||
## How to run (local box — not ubudual :10880)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npx playwright install chromium # once per machine
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Or against an already-running preview:
|
||||
|
||||
```bash
|
||||
npm run build && npm run preview -- --host 127.0.0.1 --port 4173
|
||||
# other terminal:
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=31, thickness=2.5). Black sheet — e2e selects **Black** and may nudge sensitivity.
|
||||
|
||||
## Expected screenshots (this folder)
|
||||
|
||||
| File | Step |
|
||||
|---|---|
|
||||
| `01-batch.png` | Batch with fixtures loaded |
|
||||
| `02-pick-calibration.png` | Pick calibration |
|
||||
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
||||
| `04-axis.png` | Length axis |
|
||||
| `05-measure.png` | Measure with L / thickness filled |
|
||||
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
||||
|
||||
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
||||
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
||||
|
||||
## Unit tests
|
||||
|
||||
`npm test` must still pass separately from Playwright.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
@@ -0,0 +1,11 @@
|
||||
# Last green e2e run (#71)
|
||||
|
||||
- **When:** 2026-09-15 9:55 PM PT (`2026-09-16T04:55:14Z`)
|
||||
- **Fixture:** `tests/fixtures/set1-48x2/` (L=48, thickness=2)
|
||||
- **Server:** local `vite preview` at `http://127.0.0.1:4173` (not ubudual)
|
||||
- **Result:** 1 passed (~8s test)
|
||||
- **Mask:** Black sheet — Approve enabled
|
||||
- **Fix under test:** width-aware tip stations; stroke/arrowhead edge inset; inventory-capped font; labels clamped in PNG (#71); #70 axis-parallel kept
|
||||
- **Lineup:** Clean calibration → Dimension overlay → Batch photo 2
|
||||
|
||||
Full-resolution PNGs are produced under `../` on each run (gitignored). These JPEGs are commit-sized evidence for review.
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.resolve(__dirname, '../fixtures/set1-48x2');
|
||||
const ARTIFACTS = path.resolve(__dirname, 'artifacts');
|
||||
const FACE_A = path.join(FIXTURES, 'face-a.jpeg');
|
||||
const FACE_B = path.join(FIXTURES, 'face-b.jpeg');
|
||||
|
||||
const shot = async (page: Page, name: string) => {
|
||||
fs.mkdirSync(ARTIFACTS, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(ARTIFACTS, name),
|
||||
fullPage: true,
|
||||
});
|
||||
};
|
||||
|
||||
/** Discard any device draft so we always start on Batch. */
|
||||
const ensureFreshBatch = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
// resumeProbe is async onMount — wait for either Resume or Batch.
|
||||
const resumeHeading = page.getByRole('heading', { name: /resume/i });
|
||||
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
|
||||
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
|
||||
if (await resumeHeading.isVisible().catch(() => false)) {
|
||||
await page.getByRole('button', { name: /start fresh/i }).click();
|
||||
}
|
||||
await expect(batchHeading).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Black-sheet fixtures: Auto may work, but UAT note says prefer Black + sensitivity
|
||||
* tweaks. Do not invent inference — only UI sheet/sensitivity controls.
|
||||
*/
|
||||
const prepareMaskForApprove = async (page: Page) => {
|
||||
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
|
||||
// Sheet change recomputes immediately (session.setSheet → runPass).
|
||||
await page.getByRole('button', { name: 'Black', exact: true }).click();
|
||||
|
||||
const approve = page.getByRole('button', { name: 'Approve mask' });
|
||||
// First overlay after decode is debounced 2s; Black pass is immediate but
|
||||
// 4K fixtures still need decode + worker time.
|
||||
try {
|
||||
await expect(approve).toBeEnabled({ timeout: 45_000 });
|
||||
return;
|
||||
} catch {
|
||||
/* try sensitivity ladder */
|
||||
}
|
||||
|
||||
// Higher sensitivity widens black sheet match; if still empty, try lower.
|
||||
for (const target of [60, 70, 40, 30, 80]) {
|
||||
const slider = page.locator('input[type="range"]');
|
||||
await slider.fill(String(target));
|
||||
// Sensitivity is debounced 2s — wait past debounce + worker.
|
||||
await page.waitForTimeout(2800);
|
||||
if (await approve.isEnabled()) return;
|
||||
}
|
||||
|
||||
await expect(approve, 'Mask approve stayed disabled after Black + sensitivity tweaks').toBeEnabled({
|
||||
timeout: 15_000,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
||||
|
||||
await ensureFreshBatch(page);
|
||||
|
||||
// Gallery multi-input (not the capture=environment camera input).
|
||||
const gallery = page.locator('input[type="file"][multiple]');
|
||||
await gallery.setInputFiles([FACE_A, FACE_B]);
|
||||
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByAltText('Batch photo 2')).toBeVisible();
|
||||
await shot(page, '01-batch.png');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Pick calibration photo' })).toBeVisible();
|
||||
await shot(page, '02-pick-calibration.png');
|
||||
|
||||
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
|
||||
await prepareMaskForApprove(page);
|
||||
await shot(page, '03-mask.png');
|
||||
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
|
||||
await shot(page, '04-axis.png');
|
||||
await page.getByRole('button', { name: 'Approve axis' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
|
||||
await page.getByLabel('Length L').fill('48');
|
||||
await page.getByLabel('Thickness').fill('2');
|
||||
// blur/change commits eighths into session before Calculate.
|
||||
await page.getByLabel('Length L').blur();
|
||||
await page.getByLabel('Thickness').blur();
|
||||
await shot(page, '05-measure.png');
|
||||
|
||||
const calc = page.getByRole('button', { name: 'Calculate' });
|
||||
await expect(calc).toBeEnabled();
|
||||
await calc.click();
|
||||
|
||||
// Calculate encodes calibration PNG + overlay + sibling batch apply.
|
||||
await expect(page.getByRole('heading', { name: 'Photos ready' })).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(page.getByLabel('Photos ready')).toBeVisible();
|
||||
await expect(page.getByText('Clean calibration')).toBeVisible();
|
||||
await expect(page.getByText('Dimension overlay')).toBeVisible();
|
||||
// Sibling face-b should appear as Batch photo 2 (capture order).
|
||||
await expect(page.getByText(/Batch photo 2|Photo 2 failed/)).toBeVisible();
|
||||
|
||||
await shot(page, '06-photos-ready.png');
|
||||
|
||||
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
||||
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
||||
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
|
||||
const lines = [
|
||||
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
||||
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
||||
`finished: ${new Date().toISOString()}`,
|
||||
`artifacts:`,
|
||||
...shots.map((s) => {
|
||||
const p = path.join(ARTIFACTS, s);
|
||||
const st = fs.existsSync(p) ? fs.statSync(p) : null;
|
||||
return ` - ${s} (${st ? `${st.size} bytes` : 'MISSING'})`;
|
||||
}),
|
||||
`assertions: Photos ready heading; Clean calibration; Dimension overlay; batch sibling slot`,
|
||||
];
|
||||
fs.writeFileSync(logPath, lines.join('\n') + '\n', 'utf8');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user