20 Commits

Author SHA1 Message Date
grokbot_ty be2ea6c4ea feat(capture): tape-line overlay UI replaces axis rectangle (#99)
Pointer-Events pan/zoom/tilt under a fixed black ~2pt line with
rifle-scope end ticks (≥44×44 hit). AxisPanel becomes Tape line step;
session persists tape transform; scale = tick distance / L.
sveltedrag unavailable for Svelte 4 — Pointer Events path used.
2026-09-25 17:41:29 -07:00
grokbot_ty 323f29b649 feat(capture): shared crop_fill + tape geometry for #99
- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay
- Thin localStorage settings store; crop % only moves long-axis end insets
- Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px
- measure() prefers tape tick span for scale; maskSpanAlong demoted
- rotateCalibration bakes cal tilt into inventory + overlay only
- Fix stale 80%-fill comment on slabBounds
2026-09-25 17:41:29 -07:00
grokbot_ty b44e231038 docs(design): add approved #99 tape-line overlay wireframe
Wireframe settles fixed middle line + movable slab, tick-declared L,
and shared crop_fill. Scaffolding scrubbed before shipping into the app.
2026-09-25 17:41:29 -07:00
grokbot_ty abef27cf2a docs(deploy): fallback UAT on Grok Bot computer :18181
When Tailscale cannot reach ubudual5060, Capture UAT serves from the
bot computer on :18181. Primary overnight remains :10881. Never touch
Ty local :18080 or legacy :10880.
2026-09-25 17:30:14 -07:00
grokbot_ty 03d3fcf460 fix(photos): preserve tapered tips and dark live edge (#73 #74)
Lower the axial body threshold while retaining fringe rejection, minimize length-only tip inset, and make sheet sensitivity curves testable and conservative at zero.
2026-09-16 15:18:06 +00:00
grokbot_ty 037a4b870a fix(photos): shared EXIF-oriented decode for axis/overlay space (#72)
PhotoStage used new Image() while mask worker and calculate used bare
createImageBitmap; Orientation-8 matched-set JPEGs can disagree across
those paths so the approved pink axis is not the overlay (ux,uy).

One decodeOrientedBitmap helper always requests imageOrientation
from-image (with bare fallback), wired into PhotoStage, mask.worker,
and session calculate/siblings.
2026-09-16 05:08:13 +00:00
grokbot_ty b8e69b5239 test(photos): refresh e2e evidence after tip/label fix (#71) 2026-09-16 04:55:19 +00:00
grokbot_ty 495f179c2f fix(photos): width-aware tips, stroke inset, smaller in-bounds labels (#71)
Root cause: 0.2%/99.8% axial percentiles still included dense fringe past
the visible body; round line caps and scale-tied font overshot edges and
clipped width labels. Use across-width tip stations, inset length/width
ends for stroke/arrowhead, butt caps, inventory-capped font, axis-right
length label, clamp labels to PNG.
2026-09-16 04:54:01 +00:00
grokbot_ty b386ab9670 test(photos): overlay tips on footprint + labels in-bounds (#71)
Failing UAT proofs: length span must exclude fringe whiskers past solid
body; width endpoints inset for stroke/arrowhead; smaller font; labels
clamped inside inventory PNG.
2026-09-16 04:46:48 +00:00
grokbot_ty 2be2e7d615 test(photos): refresh e2e evidence after axis parallel fix (#70) 2026-09-16 04:36:59 +00:00
grokbot_ty 6d182db81b fix(photos): length dim parallel to approved axis (#70) 2026-09-16 04:35:53 +00:00
grokbot_ty dc19cffcc0 test(photos): length/width dims follow approved axis (#70) 2026-09-16 04:35:38 +00:00
grokbot_ty 05447c92fc fix(photos): length dim snaps to wood footprint (#69) 2026-09-16 02:29:05 +00:00
grokbot_ty 415158d4d3 test(photos): length dim snaps to wood tips (#69) 2026-09-16 02:17:45 +00:00
grokbot_ty 9bc35f7edd fix(photos): overlay UAT nits + 90% crop fill (#67,#68)
Length dim already mask tip-to-tip (locked by test). Width labels move
axis-left off the wood via labelAt; font steps down to max(22, scale*1.0).
Inventory/overlay crop FILL 0.8→0.9; CAPTURE/PHOTOS docs match.
2026-09-15 21:46:58 +00:00
grokbot_ty 15895cae0c test(photos): overlay UAT nits + 90% fill expect (#67,#68)
Lock length dim to mask tip-to-tip (not oversized bbox), require width
labelAt on axis-left off the wood, smaller font contract, and 90% crop
fill extent. Red before green.
2026-09-15 21:46:27 +00:00
grokbot_ty 5650255e49 deploy: overnight M2 UAT on ubudual :10881 (#66) 2026-09-15 21:41:50 +00:00
grokbot_ty 2be400e455 test(e2e): Playwright photos-ready flow + visual evidence (#65)
Drive batch → pick calibration → Mask (Black) → Axis → Measure Calculate →
Photos ready against set1-48x2 fixtures on local vite preview. Capture
screenshots under tests/e2e/artifacts/; commit README + small evidence JPEGs.
2026-09-15 21:38:45 +00:00
grokbot_ty 2c9a50ddf9 feat(photos): Photos-ready lineup after Calculate (#64)
Add buildPhotosReadyLineup (clean cal → overlay → batch inventory PNGs
in capture order, plus empty-mask/floor error slots). MeasurePanel
switches to a Photos ready end screen with Milestone 1 numbers and the
inventory PNG lineup; Back still returns to Length axis.
2026-09-15 21:33:37 +00:00
grokbot_ty 4c3c8b49eb test(photos): Photos-ready lineup order + error slots (#64)
Failing TDD for M2 Photos-ready end screen: pure buildPhotosReadyLineup
orders clean calibration inventory PNG → dimension-overlay copy → other
batch inventory PNGs in capture order (skipping cal null), surfaces
batchApply empty-mask/floor errors, and MeasurePanel must show Photos
ready with Milestone 1 numbers (not source thumbs).
2026-09-15 21:33:00 +00:00
43 changed files with 3938 additions and 256 deletions
+8
View File
@@ -35,3 +35,11 @@ images/
# Logs # Logs
*.log *.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
View File
@@ -1,19 +1,35 @@
# Host # Host
## Overnight Milestone 1 Capture (poteto Build) ## Overnight Capture UAT (primary — when Tailscale is up)
- Host: `ubudual5060` / Tailscale `100.64.0.9` - 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 - Port: **10881** → 80
- URL: `http://100.64.0.9:10881` - 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` - Host: `ubudual5060` / `100.64.0.9`
- Container: `slabuploader-m1` - Container: `slabuploader-m1`
- Port: **10880** → 80 - Port: **18080** → 80
- URL: `http://100.64.0.9:10880` - 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. Do not put a host name in the product spec.
+4 -4
View File
@@ -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. 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. 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). 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. 8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
Calculate does not create a WooCommerce product. 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. - 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) - `sqft = (mask_pixels / scale²) / 144` (2 decimals)
- `bdft = sqft × thickness_in` (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 - 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. 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. 1. Keep slab pixels from the source photo. Removed pixels are transparent.
2. Place the slab in a 3:4 portrait frame, centered. 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. 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. 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`) | | Format | PNG, 8-bit RGBA (`image/png`) |
| Alpha | Kept through encode and upload | | Alpha | Kept through encode and upload |
| Aspect | 3:4 portrait | | 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 | | 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. Source below that floor: warn and require a new photo. No upscale from an undersized source.
+1 -1
View File
@@ -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). 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. 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). 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). 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. 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.
+713
View File
@@ -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">&#8635;</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 &#8635; 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">&#8722;</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}&#176;</span>
</div>
<p class="nudge">&larr; &rarr; tilt 1&#176; &nbsp;·&nbsp; Shift + &larr; &rarr; tilt 0.1&#176; &nbsp;·&nbsp; or drag the &#8635; 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">&#8722;</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 &nbsp;·&nbsp; <b>+</b> / <b>&#8722;</b> keys step 10% &nbsp;·&nbsp; 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&#215;44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>&#8593; &#8595;</b> nudge 1px, Shift = 0.1px &mdash; 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}&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; 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 &#10003;"
: `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}&#176;`}.`}</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)}&#8243;</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}&#176; &nbsp;·&nbsp; zoom ${S.zoom.toFixed(2)}&#215; <span class="dim">(moving these never changes the line)</span></p>
<div class="swap">
<figure>${oldOverlayMini()}<figcaption class="gone">&#10007; 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}&#176;</span></li>
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}&#176;</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&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; 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", "&#9881; 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>
+43
View File
@@ -12,6 +12,7 @@
"svelte": "^4.2.20" "svelte": "^4.2.20"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"svelte-preprocess": "^5.1.4", "svelte-preprocess": "^5.1.4",
"tsx": "^4.23.13", "tsx": "^4.23.13",
@@ -494,6 +495,21 @@
"node": "^22.20 || ^24.12 || >=25" "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.2", "version": "4.63.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", "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==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true "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": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+4 -1
View File
@@ -7,13 +7,16 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "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": { "dependencies": {
"idb-keyval": "^6.3.0", "idb-keyval": "^6.3.0",
"svelte": "^4.2.20" "svelte": "^4.2.20"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"svelte-preprocess": "^5.1.4", "svelte-preprocess": "^5.1.4",
"tsx": "^4.23.13", "tsx": "^4.23.13",
+30
View File
@@ -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',
});
+1
View File
@@ -75,6 +75,7 @@
<AxisPanel <AxisPanel
state={cap} state={cap}
setAxisAngle={session.setAxisAngle} setAxisAngle={session.setAxisAngle}
setTape={session.setTape}
approveAxis={session.approveAxis} approveAxis={session.approveAxis}
back={session.back} back={session.back}
/> />
+113 -17
View File
@@ -1,58 +1,154 @@
<script lang="ts"> <script lang="ts">
import type { Capture } from './capture.types'; import type { Capture } from './capture.types';
import type { TapeTransform } from './tapeGeometry';
import { defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte'; 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 state: Extract<Capture, { k: 'axis' }>;
export let setAxisAngle: (deg: number) => void; export let setAxisAngle: (deg: number) => void;
export let setTape: (tape: TapeTransform) => void;
export let approveAxis: () => void; export let approveAxis: () => void;
export let back: () => void; export let back: () => void;
let cropFill = getCropFill();
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0; $: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 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> </script>
<section class="panel"> <section class="panel">
<h1>Length axis</h1> <h1>Tape line</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> <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 <PhotoStage
mode="tape"
blob={state.source.blob} blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset} maskBitset={state.approvedMask.bitset}
{maskW} {maskW}
{maskH} {maskH}
{tape}
{cropFill}
onTapeChange={onTapeChange}
/> />
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button> <div class="controls">
<input <div>
type="range" min="0" max="179" step="1" <p class="ctl-label">Tilt the slab</p>
value={angleDeg} <div class="angle">
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))} <button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
/> <input
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button> type="range" min="0" max="359" step="0.1"
<span class="val">{angleDeg}°</span> value={((tiltShown % 360) + 360) % 360}
on:input={(e) => setTilt(Number(e.currentTarget.value))}
/>
<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>
<div class="row"> <div class="row">
<button type="button" class="ghost" on:click={back}>Back</button> <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> </div>
</section> </section>
<style> <style>
.panel { padding: 16px; color: #eee; } .panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; } h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; } .hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; } .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 { .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; border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
} }
input[type='range'] { flex: 1; } input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; } .val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 10px; margin-top: 16px; } .row { display: flex; gap: 10px; margin-top: 16px; }
.row.tight { margin-top: 8px; }
.primary { .primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0; flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700; background: #00e5ff; color: #002; font-weight: 700;
} }
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; } .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> </style>
+136 -53
View File
@@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte';
import type { Capture } from './capture.types'; import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types'; import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session'; import { fmtEighths } from './session';
import PhotoStage from './PhotoStage.svelte'; import PhotoStage from './PhotoStage.svelte';
import {
buildPhotosReadyLineup,
photosReadyPngAlt,
type PhotosReadyLineupItem,
} from './photosReadyLineup';
export let state: Extract<Capture, { k: 'measure' }>; export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void; export let setLength: (e: number) => void;
@@ -15,6 +21,32 @@
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths)); let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths)); let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = [];
$: photosReady = !!state.results;
$: {
for (const u of lineupUrls) URL.revokeObjectURL(u);
lineupUrls = [];
if (state.results) {
lineupItems = buildPhotosReadyLineup({
inventoryPng: state.results.inventoryPng,
overlayPng: state.results.overlayPng,
batchApply: state.batchApply,
sourceBelowFloor: state.results.sourceBelowFloor,
});
lineupUrls = lineupItems.map((it) =>
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
);
} else {
lineupItems = [];
}
}
onDestroy(() => {
for (const u of lineupUrls) URL.revokeObjectURL(u);
});
const positiveInches = (raw: string): number | null => { const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw); const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null; return Number.isFinite(inches) && inches > 0 ? inches : null;
@@ -46,60 +78,63 @@
</script> </script>
<section class="panel"> <section class="panel">
<h1>Measure</h1> <h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
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}
/>
<div class="fields"> {#if !photosReady}
<div class="field"> <PhotoStage
<span class="label">Length L</span> mode="tape"
<input interactive={false}
type="number" blob={state.source.blob}
aria-label="Length L" tape={state.axis.tape}
inputmode="decimal" maskBitset={state.approvedMask.bitset}
step="0.125" maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
min="0" maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
placeholder="0" />
bind:value={lengthIn}
on:blur={commitLength} <div class="fields">
on:change={commitLength} <div class="field">
/> <span class="label">Length L</span>
<input
type="number"
aria-label="Length L"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={lengthIn}
on:blur={commitLength}
on:change={commitLength}
/>
</div>
<div class="field">
<span class="label">Thickness</span>
<input
type="number"
aria-label="Thickness"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={thicknessIn}
on:blur={commitThickness}
on:change={commitThickness}
/>
</div>
</div> </div>
<div class="field">
<span class="label">Thickness</span> <div class="row">
<input <button type="button" class="ghost" on:click={back}>Back</button>
type="number" <button
aria-label="Thickness" type="button"
inputmode="decimal" class="primary"
step="0.125" disabled={calcBusy || !canCalc}
min="0" on:click={doCalc}
placeholder="0" >
bind:value={thicknessIn} {calcBusy ? 'Calculating…' : 'Calculate'}
on:blur={commitThickness} </button>
on:change={commitThickness}
/>
</div> </div>
</div> {:else if state.results}
<div class="results" aria-label="Photos ready">
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
type="button"
class="primary"
disabled={calcBusy || !canCalc}
on:click={doCalc}
>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
{#if state.results}
<div class="results">
<div class="nums"> <div class="nums">
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div> <div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div> <div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
@@ -109,12 +144,42 @@
{#if state.results.sourceBelowFloor} {#if state.results.sourceBelowFloor}
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div> <div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
{/if} {/if}
{#if pngUrl && !state.results.sourceBelowFloor}
<ol class="lineup" aria-label="Inventory PNG lineup">
{#each lineupItems as item, i (i)}
{#if item.kind === 'png'}
<li class="lineup-item png">
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
<span class="role">
{#if item.role === 'calibration'}
Clean calibration
{:else if item.role === 'overlay'}
Dimension overlay
{:else}
Batch photo {(item.batchIndex ?? 0) + 1}
{/if}
</span>
</li>
{:else}
<li class="lineup-item error" role="alert">
<span class="err-title">Photo {item.batchIndex + 1} failed</span>
<span class="err-msg">{item.message}</span>
</li>
{/if}
{/each}
</ol>
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
<div class="png-wrap"> <div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" /> <img src={pngUrl} alt="Inventory PNG" />
</div> </div>
{/if} {/if}
</div> </div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
</div>
{/if} {/if}
</section> </section>
@@ -136,11 +201,29 @@
} }
.primary:disabled { background: #333; color: #777; } .primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; } .ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.results { margin-top: 20px; } .results { margin-top: 8px; }
.nums { display: flex; flex-direction: column; gap: 8px; } .nums { display: flex; flex-direction: column; gap: 8px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; } .nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
.nums span:first-child { color: #aaa; } .nums span:first-child { color: #aaa; }
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; } .warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
.lineup {
list-style: none; padding: 0; margin: 16px 0 0;
display: flex; flex-direction: column; gap: 14px;
}
.lineup-item.png {
text-align: center;
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0;
background-color: #eee; border-radius: 8px; padding: 8px;
}
.lineup-item.png img { max-width: 100%; border-radius: 4px; display: block; margin: 0 auto; }
.role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; }
.lineup-item.error {
border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px;
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
}
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; } .png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
.png-wrap img { max-width: 100%; border-radius: 4px; } .png-wrap img { max-width: 100%; border-radius: 4px; }
</style> </style>
+339 -81
View File
@@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, tick } from 'svelte'; import { onDestroy, onMount, tick } from 'svelte';
import type { BBox } from './capture.types'; 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 { 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 // Display-only: renders the photo fitted to device width (height capped) plus
// one overlay layer. All math lives on source pixels, not this canvas. // 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 blob: Blob;
export let maxH = 420; export let maxH = 420;
export let bbox: BBox | null = null; export let bbox: BBox | null = null;
@@ -12,24 +18,42 @@
export let maskW = 0; export let maskW = 0;
export let maskH = 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 canvas: HTMLCanvasElement | undefined;
let stage: HTMLDivElement | 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 parentW = 0;
let decodeGen = 0;
let decoding = false;
let focusTick: 'A' | 'B' = 'A';
const setBlobUrl = (b: Blob) => { // Local editable copy while dragging; flushed via onTapeChange.
if (url) URL.revokeObjectURL(url); let live: TapeTransform = tape ?? {
url = URL.createObjectURL(b); tiltDeg: 0, zoom: 1, panX: 0, panY: 0, ...defaultTicks(cropFill),
}; };
$: if (tape) live = { ...tape };
$: if (blob) { const emit = (next: TapeTransform) => {
setBlobUrl(blob); live = next;
imgW = 0; onTapeChange?.(next);
} };
const applyFit = () => { const applyFit = () => {
if (!canvas || !imgW) return; 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; if (parentW <= 0 && stage) parentW = stage.clientWidth;
const fit = fitDisplay(parentW, imgW, imgH, maxH); const fit = fitDisplay(parentW, imgW, imgH, maxH);
if (!fit) return; if (!fit) return;
@@ -37,102 +61,336 @@
canvas.style.height = `${fit.fitH}px`; canvas.style.height = `${fit.fitH}px`;
}; };
const redraw = () => { const paintOverlays = (ctx: CanvasRenderingContext2D) => {
if (!url || !canvas) return; if (!canvas || mode === 'tape') return;
const ctx = canvas.getContext('2d')!; if (maskBitset && maskW > 0 && maskH > 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height); const off = document.createElement('canvas');
const img = new Image(); off.width = maskW;
img.onload = () => { off.height = maskH;
if (!canvas) return; const octx = off.getContext('2d')!;
imgW = img.width; const d = octx.createImageData(maskW, maskH);
imgH = img.height; const tile = 8;
for (let p = 0; p < maskW * maskH; p++) {
if (!maskBitset[p]) {
const x = p % maskW;
const y = (p / maskW) | 0;
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
d.data[p * 4] = light ? 220 : 180;
d.data[p * 4 + 1] = light ? 220 : 180;
d.data[p * 4 + 2] = light ? 220 : 180;
d.data[p * 4 + 3] = 220;
}
}
octx.putImageData(d, 0, 0);
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
}
if (bbox && bbox.corners.length === 4) {
const c = bbox.corners;
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = Math.max(2, canvas.width / 400);
ctx.beginPath();
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
ctx.closePath();
ctx.stroke();
const a = c[3];
const b = c[0];
ctx.strokeStyle = '#ff3d71';
ctx.lineWidth = Math.max(3, canvas.width / 300);
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
ctx.fillStyle = '#ff3d71';
ctx.beginPath();
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
const r = Math.max(10, canvas.width / 60);
ctx.moveTo(b[0], b[1]);
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fill();
}
};
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.width = imgW;
canvas.height = imgH; canvas.height = imgH;
applyFit(); applyFit();
ctx.drawImage(img, 0, 0); const ctx = canvas.getContext('2d')!;
if (maskBitset && maskW > 0 && maskH > 0) { ctx.clearRect(0, 0, canvas.width, canvas.height);
const off = document.createElement('canvas'); ctx.drawImage(bmp, 0, 0);
off.width = maskW; paintOverlays(ctx);
off.height = maskH; } finally {
const octx = off.getContext('2d')!; if (bmp) bmp.close();
const d = octx.createImageData(maskW, maskH); if (gen === decodeGen) decoding = false;
const tile = 8; }
for (let p = 0; p < maskW * maskH; p++) {
if (!maskBitset[p]) {
const x = p % maskW;
const y = (p / maskW) | 0;
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
d.data[p * 4] = light ? 220 : 180;
d.data[p * 4 + 1] = light ? 220 : 180;
d.data[p * 4 + 2] = light ? 220 : 180;
d.data[p * 4 + 3] = 220;
}
}
octx.putImageData(d, 0, 0);
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
}
if (bbox && bbox.corners.length === 4) {
const c = bbox.corners;
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = Math.max(2, canvas.width / 400);
ctx.beginPath();
ctx.moveTo(c[0][0], c[0][1]);
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';
ctx.lineWidth = Math.max(3, canvas.width / 300);
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
ctx.fillStyle = '#ff3d71';
ctx.beginPath();
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
const r = Math.max(10, canvas.width / 60);
ctx.moveTo(b[0], b[1]);
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fill();
}
};
img.src = url;
}; };
$: 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(() => { onMount(() => {
if (stage) parentW = stage.clientWidth; if (stage) parentW = stage.clientWidth;
updateVbScale();
const ro = new ResizeObserver((entries) => { const ro = new ResizeObserver((entries) => {
const e = entries[0]; const e = entries[0];
if (!e) return; if (!e) return;
const w = e.contentRect.width; const w = e.contentRect.width;
if (mode === 'tape') {
updateVbScale();
}
if (w === parentW) return; if (w === parentW) return;
parentW = w; parentW = w;
applyFit(); applyFit();
}); });
if (stage) ro.observe(stage); if (stage) ro.observe(stage);
if (tapeWrap) ro.observe(tapeWrap);
return () => ro.disconnect(); return () => ro.disconnect();
}); });
onDestroy(() => { onDestroy(() => {
if (url) URL.revokeObjectURL(url); decodeGen += 1;
}); });
</script> </script>
<div class="stage" bind:this={stage}> {#if mode === 'tape'}
{#if url} <div
<canvas bind:this={canvas}></canvas> class="tape-stage"
{:else} bind:this={stage}
<div class="loading">Decoding photo…</div> tabindex="0"
{/if} role="application"
</div> 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 imgW > 0 || decoding || blob}
<canvas bind:this={canvas}></canvas>
{:else}
<div class="loading">Decoding photo…</div>
{/if}
</div>
{/if}
<style> <style>
.stage { width: 100%; display: flex; justify-content: center; } .stage { width: 100%; display: flex; justify-content: center; }
canvas { display: block; border-radius: 8px; background: #222; } canvas { display: block; border-radius: 8px; background: #222; width: 100%; height: 100%; }
.loading { padding: 40px; color: #999; } .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> </style>
+18 -4
View File
@@ -33,7 +33,7 @@ export interface ABox {
// Minimum-area bounding rectangle of the mask, source px. // Minimum-area bounding rectangle of the mask, source px.
export interface BBox { export interface BBox {
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side 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) angleDeg: number; // long-side angle, degrees, [0, 180)
} }
@@ -43,13 +43,26 @@ export interface Mask {
slabPx: number; // 0 ⇒ approve blocked, user retakes slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox; 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 { export interface Axis {
bbox: BBox; bbox: BBox;
tape: TapeLine;
} }
export interface Widths { export interface Widths {
@@ -138,6 +151,7 @@ export interface Draft {
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch') maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
lengthEighths: Eighths; lengthEighths: Eighths;
thicknessEighths: Eighths; thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed 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();
}
});
@@ -0,0 +1,207 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import type { BatchPhotoResult } from './capture.types';
import {
buildPhotosReadyLineup,
type PhotosReadyLineupItem,
} from './photosReadyLineup';
const here = dirname(fileURLToPath(import.meta.url));
function png(label: string): Blob {
return new Blob([label], { type: 'image/png' });
}
function jpeg(label: string): Blob {
return new Blob([label], { type: 'image/jpeg' });
}
function roles(items: PhotosReadyLineupItem[]): string[] {
return items.map((it) =>
it.kind === 'png' ? `png:${it.role}` : `error:${it.batchIndex}`,
);
}
// --- pure helper: lineup order + error slots (#64) ---
test('test(photos): lineup is clean cal → overlay → other batch PNGs in capture order (#64)', () => {
const clean = png('clean-cal');
const overlay = png('overlay-cal');
const sib0 = png('sib-0');
const sib2 = png('sib-2');
const batchApply: BatchPhotoResult[] = [
{ status: 'ok', inventoryPng: sib0 },
null, // calibration at index 1
{ status: 'ok', inventoryPng: sib2 },
];
const lineup = buildPhotosReadyLineup({
inventoryPng: clean,
overlayPng: overlay,
batchApply,
});
assert.deepEqual(roles(lineup), [
'png:calibration',
'png:overlay',
'png:batch',
'png:batch',
]);
assert.equal(lineup[0].kind, 'png');
if (lineup[0].kind === 'png') {
assert.equal(lineup[0].blob, clean);
assert.equal(lineup[0].role, 'calibration');
}
assert.equal(lineup[1].kind, 'png');
if (lineup[1].kind === 'png') {
assert.equal(lineup[1].blob, overlay);
assert.equal(lineup[1].role, 'overlay');
}
assert.equal(lineup[2].kind, 'png');
if (lineup[2].kind === 'png') {
assert.equal(lineup[2].blob, sib0);
assert.equal(lineup[2].batchIndex, 0);
}
assert.equal(lineup[3].kind, 'png');
if (lineup[3].kind === 'png') {
assert.equal(lineup[3].blob, sib2);
assert.equal(lineup[3].batchIndex, 2);
}
});
test('test(photos): single-photo batch lineup is clean + overlay only (#64)', () => {
const clean = png('only-clean');
const overlay = png('only-overlay');
const lineup = buildPhotosReadyLineup({
inventoryPng: clean,
overlayPng: overlay,
batchApply: [null],
});
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay']);
assert.ok(lineup.every((it) => it.kind === 'png'));
});
test('test(photos): batchApply errors become clear error slots (empty mask / floor) (#64)', () => {
const clean = png('clean');
const overlay = png('overlay');
const ok = png('ok-sib');
const batchApply: BatchPhotoResult[] = [
null,
{ status: 'error', message: 'empty mask: no slab pixels detected' },
{ status: 'ok', inventoryPng: ok },
{ status: 'error', message: 'PNG size floor: shorter side is below 800 px' },
];
const lineup = buildPhotosReadyLineup({
inventoryPng: clean,
overlayPng: overlay,
batchApply,
});
assert.deepEqual(roles(lineup), [
'png:calibration',
'png:overlay',
'error:1',
'png:batch',
'error:3',
]);
const err1 = lineup[2];
assert.equal(err1.kind, 'error');
if (err1.kind === 'error') {
assert.match(err1.message, /empty mask/i);
assert.equal(err1.batchIndex, 1);
}
const err3 = lineup[4];
assert.equal(err3.kind, 'error');
if (err3.kind === 'error') {
assert.match(err3.message, /floor|800/i);
}
// Successful sibling still appears after cal+overlay, in capture order among siblings
const okItem = lineup[3];
assert.equal(okItem.kind, 'png');
if (okItem.kind === 'png') {
assert.equal(okItem.blob, ok);
assert.equal(okItem.batchIndex, 2);
}
});
test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)', () => {
const clean = png('clean');
const overlay = png('overlay');
const thumb = jpeg('source-thumb');
// Dishonest proxy would pass source thumbs; helper must only emit image/png blobs for png slots
const lineup = buildPhotosReadyLineup({
inventoryPng: clean,
overlayPng: overlay,
batchApply: [
null,
{ status: 'ok', inventoryPng: png('sib') },
],
});
for (const it of lineup) {
if (it.kind === 'png') {
assert.equal(it.blob.type, 'image/png', 'lineup PNG slots must be inventory PNGs');
assert.notEqual(it.blob, thumb);
}
}
});
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
const lineup = buildPhotosReadyLineup({
inventoryPng: png('below'),
overlayPng: png('overlay'),
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
sourceBelowFloor: true,
});
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
});
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
assert.deepEqual(buildPhotosReadyLineup({}), []);
assert.deepEqual(
buildPhotosReadyLineup({ inventoryPng: null, overlayPng: null, batchApply: undefined }),
[],
);
});
// --- light UI / session wiring (#64) ---
test('test(photos): MeasurePanel shows Photos ready lineup + M1 numbers + batch errors (#64)', () => {
const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8');
assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate');
assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper');
assert.match(panel, /Widths/, 'M1 widths still visible');
assert.match(panel, /Sqft/, 'M1 sqft still visible');
assert.match(panel, /Bdft/, 'M1 bdft still visible');
assert.match(panel, /Scale/, 'M1 scale still visible');
// Dishonest proxies
assert.ok(
!/lineup[\s\S]{0,200}state\.source\.blob/.test(panel),
'must not lineup source thumbs',
);
assert.match(
panel,
/error|batchApply|message/i,
'per-photo batchApply errors must surface in the panel',
);
assert.match(panel, /Back/, 'Back must still work from Photos ready');
});
test('test(photos): App still routes measure results through Measure (or PhotosReady) with Back (#64)', () => {
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
// Stay on Measure with results, or dedicated panel — either is fine
assert.ok(
/MeasurePanel/.test(app) || /PhotosReady/.test(app),
'App must render Photos-ready end screen',
);
// Must not invent a Mask loop for the lineup
assert.ok(
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
'App must not #each batch into Mask for Photos ready',
);
});
+75
View File
@@ -0,0 +1,75 @@
import type { BatchPhotoResult } from './capture.types';
/** One slot in the Photos-ready end screen (#64 / docs/PHOTOS.md). */
export type PhotosReadyLineupItem =
| {
kind: 'png';
role: 'calibration' | 'overlay' | 'batch';
blob: Blob;
/** Capture-order index for non-calibration batch inventory PNGs. */
batchIndex?: number;
}
| {
kind: 'error';
batchIndex: number;
message: string;
};
export interface PhotosReadyLineupInput {
inventoryPng?: Blob | null;
overlayPng?: Blob | null;
batchApply?: BatchPhotoResult[] | null;
/** When true, omit the clean calibration PNG (M1 floor warn path). */
sourceBelowFloor?: boolean;
}
/**
* Build the Photos-ready lineup:
* 1. Clean calibration inventory PNG
* 2. Dimension-overlay copy
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
* with error slots for empty mask / floor failures.
*/
export function buildPhotosReadyLineup(
input: PhotosReadyLineupInput,
): PhotosReadyLineupItem[] {
const items: PhotosReadyLineupItem[] = [];
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
if (inventoryPng && !sourceBelowFloor) {
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
}
if (overlayPng) {
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
}
if (batchApply) {
for (let i = 0; i < batchApply.length; i++) {
const slot = batchApply[i];
if (slot === null) continue; // calibration lives in results.inventoryPng
if (slot.status === 'ok') {
items.push({
kind: 'png',
role: 'batch',
blob: slot.inventoryPng,
batchIndex: i,
});
} else {
items.push({
kind: 'error',
batchIndex: i,
message: slot.message,
});
}
}
}
return items;
}
export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string {
if (item.role === 'calibration') return 'Calibration inventory PNG';
if (item.role === 'overlay') return 'Calibration dimension-overlay PNG';
const n = (item.batchIndex ?? 0) + 1;
return `Batch inventory PNG ${n}`;
}
+118 -18
View File
@@ -10,6 +10,11 @@ import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect'; import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; 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 DEBOUNCE_MS = 2000;
const BUSY_MS = 300; const BUSY_MS = 300;
@@ -40,6 +45,8 @@ export interface CaptureSession {
setSheet(s: Sheet): void; setSheet(s: Sheet): void;
approveMask(): void; approveMask(): void;
setAxisAngle(deg: number): void; setAxisAngle(deg: number): void;
/** #99: replace axis rectangle nudge with full tape transform. */
setTape(tape: TapeTransform): void;
approveAxis(): void; approveAxis(): void;
setLength(e: Eighths): void; setLength(e: Eighths): void;
setThickness(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], version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx, calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params, 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, 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], version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx, calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params, 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, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null, results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null, inventoryPng: state.results?.inventoryPng ?? null,
@@ -309,6 +316,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// fall back to the mask's own bbox. // fall back to the mask's own bbox.
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.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) => { const restoreFromDraft = async (d: Draft) => {
batchPhotos = batchPhotosOf(d); batchPhotos = batchPhotosOf(d);
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null; calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
@@ -335,16 +373,29 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try { try {
const mask = await rederiveMask(source, d.maskParams, d.mask); const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') { 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; return;
} }
if (d.inventoryPng) { if (d.inventoryPng) {
if (pngUrl) URL.revokeObjectURL(pngUrl); if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(d.inventoryPng); 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({ set({
k: 'measure', source, approvedMask: mask, k: 'measure', source, approvedMask: mask,
axis: { bbox: draftAxisBbox(d) }, axis,
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results results: d.results
? { ? {
@@ -355,6 +406,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
: null, : null,
batchApply: d.batchApply, batchApply: d.batchApply,
}); });
}
} catch { } catch {
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true }); set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
makeWorker(source.blob); makeWorker(source.blob);
@@ -473,22 +525,47 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker(); teardownWorker();
const overlay = state.overlay; 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(); void persist();
}, },
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
setAxisAngle(deg: number) { setAxisAngle(deg: number) {
if (state.k !== 'axis') return; if (state.k !== 'axis') return;
const a = ((Math.round(deg) % 180) + 180) % 180; const tape = { ...state.axis.tape, tiltDeg: deg };
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } }); 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(); void persist();
}, },
approveAxis() { approveAxis() {
if (state.k !== 'axis') return; if (state.k !== 'axis') return;
const axis = axisWithTape(
state.axis.bbox,
state.axis.tape,
state.source.widthPx,
state.source.heightPx,
);
set({ set({
k: 'measure', source: state.source, approvedMask: state.approvedMask, k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: { bbox: state.axis.bbox }, axis,
lengthEighths: 0, thicknessEighths: 0, results: null, lengthEighths: 0, thicknessEighths: 0, results: null,
}); });
void persist(); void persist();
@@ -512,15 +589,36 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'measure') return; if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state; const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return; 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( const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx, approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
tapeAxis,
); );
let pngBlob: Blob; let pngBlob: Blob;
let belowFloor = false; 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 { 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; pngBlob = r.blob;
belowFloor = r.sourceBelowFloor; belowFloor = r.sourceBelowFloor;
} catch { } catch {
@@ -528,24 +626,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
pngBlob = new Blob([], { type: 'image/png' }); pngBlob = new Blob([], { type: 'image/png' });
} }
bitmap.close(); bitmap.close();
if (rotatedExtra) rotatedExtra.close();
if (pngUrl) URL.revokeObjectURL(pngUrl); if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(pngBlob); pngUrl = URL.createObjectURL(pngBlob);
// #63: second copy of the calibration inventory PNG with dimension overlays. // #63: second copy of the calibration inventory PNG with dimension overlays.
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal]. // 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; let overlayBlob: Blob;
try { try {
overlayBlob = await encodeOverlay({ overlayBlob = await encodeOverlay({
inventoryPng: pngBlob, inventoryPng: pngBlob,
bitset: approvedMask.bitset, bitset: calMask.bitset,
widthPx: source.widthPx, widthPx: calSource.widthPx,
heightPx: source.heightPx, heightPx: calSource.heightPx,
bbox: axis.bbox, bbox: calMask.bbox,
scale: base.scale, scale: base.scale,
lengthIn: eighthsToIn(lengthEighths), lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths), lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds, slabBounds: calMask.slabBounds,
source, source: calSource,
}); });
} catch { } catch {
// Deterministic tests may inject a non-decodable inventory blob; still // Deterministic tests may inject a non-decodable inventory blob; still
@@ -587,7 +687,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
widthPx: derived.widthPx, widthPx: derived.widthPx,
heightPx: derived.heightPx, heightPx: derived.heightPx,
}; };
const siblingBitmap = await createImageBitmap(photo.blob); const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean }; let encoded: { blob: Blob; sourceBelowFloor: boolean };
try { try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource }); encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
@@ -623,7 +723,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const m = state.approvedMask; const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false }); set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') { } 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 });
} }
}, },
+53
View File
@@ -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);
});
+117
View File
@@ -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 };
}
+29
View File
@@ -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;
}
}
}
+2
View File
@@ -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 // Project every slab pixel onto the unit vector (ux, uy); return max − min
// extent in px. 0 when the mask is empty. // 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 { export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
let min = Infinity, max = -Infinity; let min = Infinity, max = -Infinity;
for (let y = 0; y < h; y++) { for (let y = 0; y < h; y++) {
+59
View File
@@ -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');
});
+36
View File
@@ -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';
}
+18
View File
@@ -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}`);
});
+36 -3
View File
@@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb
return { start: bbox.corners[3], end: bbox.corners[0] }; 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 // 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. // 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( function maskAxisSegment(
bitset: Uint8Array, w: number, h: number, bbox: BBox, bitset: Uint8Array, w: number, h: number, bbox: BBox,
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } { ): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
@@ -71,16 +82,38 @@ function sampleWidths(
return out; 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( export function measure(
bitset: Uint8Array, w: number, h: number, bitset: Uint8Array, w: number, h: number,
bbox: BBox, bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths, lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number, slabPx: number,
tape?: TapeAxis | null,
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } { ): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8; const lengthIn = lengthEighths / 8;
const seg = maskAxisSegment(bitset, w, h, bbox); let start: [number, number];
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn; let ux: number;
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn); 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);
// 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; let widths: Widths;
if (every6In.length > 0) { if (every6In.length > 0) {
const min = Math.min(...every6In); const min = Math.min(...every6In);
+56
View File
@@ -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}`);
});
+21
View File
@@ -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));
}
+3 -3
View File
@@ -81,7 +81,7 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
assert.equal(out.width, 800); 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 source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1); const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame( 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 xs = [minx, maxx], ys = [miny, maxy];
const wExtent = Math.max(...xs) - Math.min(...xs) + 1; const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
const hExtent = Math.max(...ys) - Math.min(...ys) + 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(wExtent - 720) <= 2, `width extent ${wExtent} ≈ 720 (0.9×800)`);
assert.ok(Math.abs(hExtent - 576) <= 2, `height extent ${hExtent} ≈ 576 (1800×0.32)`); 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)', () => { test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
+10 -7
View File
@@ -1,9 +1,6 @@
import type { Mask, Source } from '../capture/capture.types'; import type { Mask, Source } from '../capture/capture.types';
import { ASPECT_H, ASPECT_W, FLOOR_PX, DEFAULT_CROP_FILL } from './crop';
const FLOOR = 800; // shorter side, px import { getCropFill } from '../settings/cropFill';
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab
const ASPECT_W = 3;
const ASPECT_H = 4;
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>; type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>; type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
@@ -12,6 +9,8 @@ export interface InventoryInput {
bitmap: ImageBitmap; bitmap: ImageBitmap;
mask: MaskPick; mask: MaskPick;
source: SourcePick; source: SourcePick;
/** Optional override; default reads live settings.crop_fill (#99). */
cropFill?: number;
} }
export interface Frame { export interface Frame {
@@ -25,7 +24,9 @@ export function composeInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray }, src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick, mask: MaskPick,
source: SourcePick, source: SourcePick,
cropFill: number = DEFAULT_CROP_FILL,
): Frame { ): Frame {
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
const b = mask.slabBounds; const b = mask.slabBounds;
const x0 = Math.max(0, Math.round(b.x)); const x0 = Math.max(0, Math.round(b.x));
const y0 = Math.max(0, Math.round(b.y)); 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 y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
const sw = x1 - x0; const sw = x1 - x0;
const sh = y1 - y0; const sh = y1 - y0;
const belowFloor = Math.min(sw, sh) < FLOOR; const belowFloor = Math.min(sw, sh) < FLOOR_PX;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR; const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR_PX)) : FLOOR_PX;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W); 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 scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
const dw = sw * scale; const dw = sw * scale;
@@ -66,6 +67,7 @@ export async function encodeInventoryPng(
input: InventoryInput, input: InventoryInput,
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> { ): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
const { bitmap, mask, source } = input; const { bitmap, mask, source } = input;
const fill = input.cropFill ?? getCropFill();
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
const srcCtx = srcCanvas.getContext('2d')!; const srcCtx = srcCanvas.getContext('2d')!;
srcCtx.drawImage(bitmap, 0, 0); srcCtx.drawImage(bitmap, 0, 0);
@@ -74,6 +76,7 @@ export async function encodeInventoryPng(
{ width: source.widthPx, height: source.heightPx, rgba: img.data }, { width: source.widthPx, height: source.heightPx, rgba: img.data },
mask, mask,
source, source,
fill,
); );
const out = new OffscreenCanvas(frame.width, frame.height); const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!; const ctx = out.getContext('2d')!;
+933 -1
View File
@@ -5,9 +5,12 @@ import {
pickBottomTowardTop, pickBottomTowardTop,
axisRightUnit, axisRightUnit,
buildOverlayPlan, buildOverlayPlan,
inventorySourceMap,
BODY_WIDTH_FRAC,
type OverlayPlan, type OverlayPlan,
} from './overlay.ts'; } from './overlay.ts';
import { orientedRect, minAreaRect } from '../geometry/rect.ts'; import { orientedRect, minAreaRect } from '../geometry/rect.ts';
import { axisEndpoints } from '../measure/measure.ts';
// --- Station math (#63) --- // --- 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 // Color contract for draw consumers
assert.equal(plan.stroke, '#000000'); 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)', () => { 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]); 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})`); 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
View File
@@ -1,5 +1,7 @@
import type { BBox } from '../capture/capture.types'; import type { BBox } from '../capture/capture.types';
import { axisEndpoints } from '../measure/measure'; 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). */ /** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
export function overlayWidthStations(lengthIn: number): number[] { export function overlayWidthStations(lengthIn: number): number[] {
@@ -45,6 +47,8 @@ export interface OverlayWidthMark {
center: [number, number]; center: [number, number];
left: [number, number]; left: [number, number];
right: [number, number]; right: [number, number];
/** Axis-left of the slab, outside the wood edge (opposite of length dim). */
labelAt: [number, number];
label: string; label: string;
} }
@@ -74,6 +78,75 @@ export interface OverlayPlanInput {
lengthLabel: string; lengthLabel: string;
/** Map source px → inventory PNG px (identity OK for unit tests). */ /** Map source px → inventory PNG px (identity OK for unit tests). */
mapToInventory: (x: number, y: number) => [number, number]; 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( function maskAxisSegment(
@@ -81,7 +154,16 @@ function maskAxisSegment(
w: number, w: number,
h: number, h: number,
bbox: BBox, 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 { start: a, end: b } = axisEndpoints(bbox);
const ex = b[0] - a[0]; const ex = b[0] - a[0];
const ey = b[1] - a[1]; 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 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 cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
const cProj = cx * ux + cy * uy; const cProj = cx * ux + cy * uy;
let minP = Infinity; // Width-aware tips (#71). Fall back to robust percentiles if profile empty.
let maxP = -Infinity; 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++) { for (let y = 0; y < h; y++) {
const row = y * w; const row = y * w;
for (let x = 0; x < w; x++) { for (let x = 0; x < w; x++) {
if (!bitset[row + x]) continue; if (!bitset[row + x]) continue;
const p = x * ux + y * uy; const p = x * ux + y * uy;
if (p < minP) minP = p; const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
if (p > maxP) maxP = p; 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 { return {
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)], start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)], end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
tipStart,
tipEnd,
ux, ux,
uy, uy,
span: maxP - minP, span: maxP - minP,
@@ -151,7 +268,10 @@ function fmtInches(inches: number): string {
} }
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { 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); const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
// Re-orient span ends so EXIF bottom establishes station origin; axis still // 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). // 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 { bottom, top, ux, uy } = oriented;
const [rx, ry] = axisRightUnit(ux, uy); 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 stations = overlayWidthStations(lengthIn);
const widths: OverlayWidthMark[] = []; const widths: OverlayWidthMark[] = [];
for (const stationIn of stations) { for (const stationIn of stations) {
@@ -167,15 +303,26 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const oy = bottom[1] + uy * t; const oy = bottom[1] + uy * t;
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry); const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
if (!extent) continue; if (!extent) continue;
// left = opposite of axis-right; right = axis-right tip on slab edge // left/right on mask edges, then inset in inventory space for stroke/arrowhead
const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP]; const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP);
const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP]; 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; const widthIn = (extent.maxP - extent.minP + 1) / scale;
widths.push({ widths.push({
stationIn, stationIn,
center: mapToInventory(ox, oy), center: mapToInventory(ox, oy),
left: mapToInventory(leftSrc[0], leftSrc[1]), left,
right: mapToInventory(rightSrc[0], rightSrc[1]), right,
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
label: fmtInches(widthIn), label: fmtInches(widthIn),
}); });
} }
@@ -190,26 +337,67 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry); const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
if (extent && extent.maxP > maxRight) maxRight = extent.maxP; if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
} }
const pad = Math.max(16, scale * 0.75);
const outward = maxRight + pad; const outward = maxRight + pad;
const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward]; // Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward]; // pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2]; // Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
// Nudge label further outward a bit const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)]; 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 { return {
stroke: '#000000', stroke: '#000000',
fontPx, fontPx,
length: { length: lengthMark,
a: mapToInventory(aSrc[0], aSrc[1]),
b: mapToInventory(bSrc[0], bSrc[1]),
labelAt: mapToInventory(labelSrc[0], labelSrc[1]),
label: lengthLabel,
outside: true,
},
widths, widths,
}; };
} }
@@ -223,8 +411,9 @@ export function drawOverlayPlan(
ctx.save(); ctx.save();
ctx.strokeStyle = stroke; ctx.strokeStyle = stroke;
ctx.fillStyle = stroke; ctx.fillStyle = stroke;
ctx.lineWidth = Math.max(3, Math.round(fontPx / 10)); ctx.lineWidth = Math.max(2, Math.round(fontPx / 10));
ctx.lineCap = 'round'; // Butt caps: round caps extend past endpoints and overshoot wood edges (#71).
ctx.lineCap = 'butt';
ctx.font = `bold ${fontPx}px sans-serif`; ctx.font = `bold ${fontPx}px sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
@@ -235,7 +424,7 @@ export function drawOverlayPlan(
const len = Math.hypot(dx, dy) || 1; const len = Math.hypot(dx, dy) || 1;
const ux = dx / len; const ux = dx / len;
const uy = dy / len; const uy = dy / len;
const head = Math.max(10, fontPx * 0.35); const head = Math.max(7, fontPx * 0.32);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(from[0], from[1]); ctx.moveTo(from[0], from[1]);
ctx.lineTo(to[0], to[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); 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]); 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) { for (const w of plan.widths) {
arrow(w.left, w.right); 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(); ctx.restore();
@@ -281,8 +487,9 @@ export function inventorySourceMap(
source: { widthPx: number; heightPx: number }, source: { widthPx: number; heightPx: number },
outW: number, outW: number,
outH: number, outH: number,
cropFill: number = DEFAULT_CROP_FILL,
): InventoryMap { ): 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 x0 = Math.max(0, Math.round(slabBounds.x));
const y0 = Math.max(0, Math.round(slabBounds.y)); const y0 = Math.max(0, Math.round(slabBounds.y));
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w))); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
@@ -313,6 +520,8 @@ export interface EncodeOverlayInput {
lengthLabel: string; lengthLabel: string;
slabBounds: { x: number; y: number; w: number; h: number }; slabBounds: { x: number; y: number; w: number; h: number };
source: { widthPx: number; heightPx: 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 canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0); 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({ const plan = buildOverlayPlan({
bitset: input.bitset, bitset: input.bitset,
widthPx: input.widthPx, widthPx: input.widthPx,
@@ -337,6 +547,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
lengthIn: input.lengthIn, lengthIn: input.lengthIn,
lengthLabel: input.lengthLabel, lengthLabel: input.lengthLabel,
mapToInventory, mapToInventory,
inventoryW: outW,
inventoryH: outH,
}); });
drawOverlayPlan(ctx, plan); drawOverlayPlan(ctx, plan);
return await canvas.convertToBlob({ type: 'image/png' }); return await canvas.convertToBlob({ type: 'image/png' });
+92
View File
@@ -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 },
};
}
+55
View File
@@ -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 */
}
}
}
+4 -22
View File
@@ -1,31 +1,13 @@
import { minAreaRect, slabBounds } from '../geometry/rect'; import { minAreaRect, slabBounds } from '../geometry/rect';
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types'; 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 bitmap: ImageBitmap | null = null;
let data: Uint8ClampedArray | null = null; let data: Uint8ClampedArray | null = null;
let w = 0, h = 0; let w = 0, h = 0;
let current = 0; // highest job id issued by main; older replies are dropped 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 { function runMask(params: MaskParams, id: number): WorkerOut {
if (!data) return { id, kind: 'failed', reason: 'no source decoded' }; if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
let sheet = params.sheet; let sheet = params.sheet;
@@ -36,7 +18,7 @@ function runMask(params: MaskParams, id: number): WorkerOut {
const i = (y * w + x) * 4; const i = (y * w + x) * 4;
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] }); 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 keep = sheet === 'green' ? greenKeep : blackKeep;
const s = Math.max(0, Math.min(100, params.sensitivity)); const s = Math.max(0, Math.min(100, params.sensitivity));
@@ -69,7 +51,7 @@ self.onmessage = (e: MessageEvent<WorkerIn>) => {
} }
data = null; data = null;
w = h = 0; w = h = 0;
createImageBitmap(msg.blob) decodeOrientedBitmap(msg.blob)
.then((bmp) => { .then((bmp) => {
w = bmp.width; w = bmp.width;
h = bmp.height; h = bmp.height;
+40
View File
@@ -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.
+135
View File
@@ -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');
});
});