Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be2ea6c4ea | |||
| 323f29b649 | |||
| b44e231038 | |||
| abef27cf2a | |||
| 03d3fcf460 | |||
| 037a4b870a | |||
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 | |||
| 2be2e7d615 | |||
| 6d182db81b | |||
| dc19cffcc0 |
@@ -1,20 +1,35 @@
|
|||||||
# Host
|
# Host
|
||||||
|
|
||||||
## Overnight Milestone 2 Capture (UAT)
|
## Overnight Capture UAT (primary — when Tailscale is up)
|
||||||
|
|
||||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
||||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2: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`
|
||||||
- Replaces prior overnight container `slabuploader-m1-overnight` (same port).
|
- 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: **18080** → 80 (inventory previously listed 10880; do not bind overnight there)
|
- Port: **18080** → 80
|
||||||
- URL: `http://100.64.0.9:18080`
|
- URL: `http://100.64.0.9:18080`
|
||||||
- **Never** recreate or remap this container for overnight UAT.
|
- **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 Ty’s local port.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||||||
|
rectangle (calibration length + crop)".
|
||||||
|
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||||||
|
|
||||||
|
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||||||
|
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||||||
|
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||||||
|
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||||||
|
in from the top and the bottom — and never changes the aspect ratio.
|
||||||
|
|
||||||
|
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||||||
|
production workflow:
|
||||||
|
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||||||
|
- the requirements-coverage board below the stage
|
||||||
|
- the "was / now" pair of mini diagrams
|
||||||
|
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||||||
|
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||||||
|
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||||||
|
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||||||
|
- the window.__wf debug hook
|
||||||
|
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||||||
|
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||||||
|
semantics, and the single shared crop_fill.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||||||
|
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||||||
|
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||||||
|
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||||||
|
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||||
|
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||||||
|
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||||||
|
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||||||
|
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||||||
|
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||||||
|
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||||||
|
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||||||
|
.panel{padding:10px 4px;color:var(--fg)}
|
||||||
|
h1{font-size:1.3rem;margin:0 0 10px}
|
||||||
|
h3{font-size:.95rem;margin:0 0 8px}
|
||||||
|
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||||||
|
.row{display:flex;gap:10px;margin-top:16px}
|
||||||
|
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||||||
|
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||||||
|
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||||||
|
/* ---- the stage: fixed line + movable slab ---- */
|
||||||
|
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||||
|
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||||||
|
.tw.dragging svg{cursor:grabbing}
|
||||||
|
.tickg{cursor:ns-resize}
|
||||||
|
.rotg{cursor:ew-resize}
|
||||||
|
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||||||
|
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||||||
|
input[type=range]{flex:1}
|
||||||
|
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||||||
|
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||||||
|
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||||||
|
.nums span:first-child{color:var(--mut)}
|
||||||
|
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||||||
|
.nums .dim{color:#777}
|
||||||
|
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||||||
|
.status.ok{background:#12210f;color:var(--ok)}
|
||||||
|
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||||||
|
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||||||
|
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||||||
|
.plan code{color:#ddd;font-size:.72rem}
|
||||||
|
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||||||
|
/* replacement two-up */
|
||||||
|
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||||||
|
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||||||
|
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||||||
|
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||||||
|
.swap .gone{color:#ff9d8a}
|
||||||
|
/* scope lineup */
|
||||||
|
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||||
|
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||||||
|
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||||||
|
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||||||
|
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||||||
|
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||||||
|
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||||||
|
/* fields + settings */
|
||||||
|
.fields{display:flex;gap:16px;margin-top:16px}
|
||||||
|
.field{flex:1}
|
||||||
|
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||||||
|
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||||||
|
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||||||
|
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||||||
|
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||||||
|
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||||||
|
.settings .msg.ok{color:var(--ok)}
|
||||||
|
/* requirement coverage board (outside the phone) */
|
||||||
|
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||||||
|
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||||||
|
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||||||
|
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||||||
|
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||||||
|
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||||||
|
.cov .where{color:#ddd}
|
||||||
|
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||||||
|
@media (max-width:760px){
|
||||||
|
.cov thead{display:none}
|
||||||
|
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||||||
|
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||||||
|
.cov tr:last-child{border-bottom:0}
|
||||||
|
.cov td{border:0;padding:1px 0}
|
||||||
|
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||||||
|
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||||||
|
}
|
||||||
|
.hidden{display:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top">
|
||||||
|
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||||||
|
<div class="flowbar" id="flowbar"></div>
|
||||||
|
<div class="vp">
|
||||||
|
<span>Viewport</span>
|
||||||
|
<span id="vpval">412</span>
|
||||||
|
<button data-w="390">390</button>
|
||||||
|
<button data-w="412" class="on">412</button>
|
||||||
|
<button data-w="720">720</button>
|
||||||
|
<button data-w="1440">1440</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stage"><div id="phone"></div></div>
|
||||||
|
<div class="cov"><div class="cov-inner">
|
||||||
|
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||||||
|
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||||||
|
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||||||
|
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||||||
|
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||||||
|
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||||||
|
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div></div>
|
||||||
|
<script>
|
||||||
|
// ===========================================================================
|
||||||
|
// issue #99 — the tape line is a FIXED ENTITY.
|
||||||
|
//
|
||||||
|
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||||||
|
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||||||
|
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||||||
|
// its long axis lies on the line, tick to tick.
|
||||||
|
// * The ticks belong to the line - they slide along it, never with the photo.
|
||||||
|
// * L = the distance between the two ticks (the owner's tape reading).
|
||||||
|
// ===========================================================================
|
||||||
|
const S = {
|
||||||
|
screen: "tape",
|
||||||
|
// slab transform (the only movable entity)
|
||||||
|
tilt: 14, // degrees
|
||||||
|
zoom: 1,
|
||||||
|
panX: 60, panY: 0,
|
||||||
|
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||||||
|
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||||
|
// bottom - and the operator drags them onto their tape marks from there.
|
||||||
|
tickA: 32,
|
||||||
|
tickB: 608,
|
||||||
|
focus: "A",
|
||||||
|
fill: 90, // settings.crop_fill - global, default 90
|
||||||
|
// declared values (entered on Measure, shown on both steps)
|
||||||
|
L: "88",
|
||||||
|
thickness: "2.125",
|
||||||
|
savedMsg: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||||||
|
const VB = { w: 480, h: 640 };
|
||||||
|
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||||||
|
// AXIS - the centreline running down the middle of the board, which is where
|
||||||
|
// the tape is laid. (Not a physical side edge.)
|
||||||
|
const SLAB = {
|
||||||
|
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||||||
|
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||||||
|
};
|
||||||
|
const TICK_LEN = 26; // rifle-scope end tick, total
|
||||||
|
const TICK_GAP = 40; // min separation between ticks
|
||||||
|
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||||||
|
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||||||
|
const HIT_R = 34;
|
||||||
|
const ALIGN_TOL = 3; // stage units
|
||||||
|
|
||||||
|
const rad = (a) => a * Math.PI / 180;
|
||||||
|
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||||||
|
// crop frame, which is where the crop centres the slab's long axis.
|
||||||
|
const lineX = () => VB.w / 2;
|
||||||
|
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||||
|
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||||||
|
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||||||
|
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||||||
|
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||||||
|
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||||||
|
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||||||
|
|
||||||
|
// slab transform: zoom + tilt about the stage centre, then pan
|
||||||
|
function mapPoint(p) {
|
||||||
|
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||||||
|
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||||||
|
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||||||
|
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||||||
|
}
|
||||||
|
function slabTransform() {
|
||||||
|
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||||||
|
}
|
||||||
|
// where the slab's long axis crosses a given stage y
|
||||||
|
function axisXAt(y) {
|
||||||
|
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||||
|
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||||||
|
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||||||
|
}
|
||||||
|
const axisAngle = () => {
|
||||||
|
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||||
|
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||||||
|
};
|
||||||
|
// the axis is vertical when its angle points straight down (or up) the screen
|
||||||
|
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||||||
|
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||||||
|
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the stage
|
||||||
|
function slabSvg() {
|
||||||
|
return `
|
||||||
|
<g id="slab" transform="${slabTransform()}">
|
||||||
|
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||||||
|
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||||||
|
<g clip-path="url(#slabClip)" opacity=".8">
|
||||||
|
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||||
|
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||||||
|
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||||||
|
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||||||
|
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||||
|
</g>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||||||
|
function fixtureSvg() {
|
||||||
|
const x = lineX();
|
||||||
|
const tick = (y, id) => {
|
||||||
|
const on = S.focus === id;
|
||||||
|
return `
|
||||||
|
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||||
|
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||||||
|
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||||||
|
};
|
||||||
|
return `
|
||||||
|
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||||
|
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||||||
|
}
|
||||||
|
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||||||
|
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||||||
|
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||||||
|
function cropFrameSvg() {
|
||||||
|
const m = cropInset();
|
||||||
|
return `
|
||||||
|
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||||
|
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||||
|
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||||||
|
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||||||
|
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||||||
|
}
|
||||||
|
function rotateHandleSvg() {
|
||||||
|
return `
|
||||||
|
<g class="rotg" data-rot="1">
|
||||||
|
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||||||
|
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||||||
|
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
function stageSvg() {
|
||||||
|
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||||||
|
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||||||
|
${cropFrameSvg()}
|
||||||
|
${slabSvg()}
|
||||||
|
${fixtureSvg()}
|
||||||
|
${rotateHandleSvg()}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ screens
|
||||||
|
const screens = {
|
||||||
|
batch() {
|
||||||
|
return `<section class="panel">
|
||||||
|
<h1>Batch photos</h1>
|
||||||
|
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||||||
|
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||||||
|
</section>`;
|
||||||
|
},
|
||||||
|
mask() {
|
||||||
|
return `<section class="panel">
|
||||||
|
<h1>Mask</h1>
|
||||||
|
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||||||
|
<div class="nums">
|
||||||
|
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||||||
|
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost" data-act="to-batch">Back</button>
|
||||||
|
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
},
|
||||||
|
tape() {
|
||||||
|
const off = axisOffset(), ok = aligned();
|
||||||
|
return `<section class="panel">
|
||||||
|
<h1>Tape line</h1>
|
||||||
|
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||||
|
<div class="tw" id="tw">${stageSvg()}</div>
|
||||||
|
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||||
|
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div>
|
||||||
|
<p class="ctl-label">Tilt the slab</p>
|
||||||
|
<div class="angle">
|
||||||
|
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||||||
|
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||||||
|
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||||||
|
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||||||
|
</div>
|
||||||
|
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||||||
|
<div class="angle">
|
||||||
|
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||||||
|
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||||||
|
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||||||
|
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||||||
|
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||||||
|
<div class="croprow">
|
||||||
|
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||||||
|
<span class="val" id="fillVal">${S.fill}%</span>
|
||||||
|
</div>
|
||||||
|
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||||||
|
? "The board's long axis is on the line ✓"
|
||||||
|
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||||||
|
|
||||||
|
<div class="nums">
|
||||||
|
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||||||
|
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||||||
|
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||||||
|
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||||||
|
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||||
|
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||||
|
|
||||||
|
<div class="swap">
|
||||||
|
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||||||
|
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost" data-act="to-mask">Back</button>
|
||||||
|
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="plan">
|
||||||
|
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||||||
|
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
},
|
||||||
|
measure() {
|
||||||
|
const mini = (tilt, marks) => `
|
||||||
|
<svg viewBox="0 0 480 640">
|
||||||
|
<rect width="480" height="640" fill="#16281c"/>
|
||||||
|
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||||||
|
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||||||
|
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||||||
|
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||||||
|
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
return `<section class="panel">
|
||||||
|
<h1>Photos ready</h1>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||||||
|
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="nums">
|
||||||
|
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||||||
|
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||||
|
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="plan">
|
||||||
|
<h3>Rotation scope — req. 6</h3>
|
||||||
|
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||||||
|
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||||||
|
</div>
|
||||||
|
<ol class="lineup">
|
||||||
|
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||||||
|
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||||||
|
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||||||
|
</ol>
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost" data-act="to-tape">Back</button>
|
||||||
|
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
},
|
||||||
|
settings() {
|
||||||
|
return `<section class="panel settings">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<div class="section">
|
||||||
|
<h3>Crop — global</h3>
|
||||||
|
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||||||
|
<div class="croprow">
|
||||||
|
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||||||
|
<span class="val" id="fillVal">${S.fill}%</span>
|
||||||
|
</div>
|
||||||
|
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||||||
|
<button data-act="save-fill">Save Changes</button>
|
||||||
|
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h3>Implementation notes</h3>
|
||||||
|
<ul class="plan" style="border:0;padding:0;background:none">
|
||||||
|
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||||||
|
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||||||
|
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||||||
|
</section>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// the two mini overlays for the was/now pair
|
||||||
|
function oldOverlayMini() {
|
||||||
|
return `<svg viewBox="0 0 120 160">
|
||||||
|
<rect width="120" height="160" fill="#141414"/>
|
||||||
|
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||||||
|
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||||||
|
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||||||
|
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
function newOverlayMini() {
|
||||||
|
return `<svg viewBox="0 0 120 160">
|
||||||
|
<rect width="120" height="160" fill="#141414"/>
|
||||||
|
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||||||
|
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||||
|
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||||||
|
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- plumbing
|
||||||
|
const phone = document.getElementById('phone');
|
||||||
|
const flowbar = document.getElementById('flowbar');
|
||||||
|
const STEPS = [
|
||||||
|
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||||||
|
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||||||
|
];
|
||||||
|
function render() {
|
||||||
|
phone.innerHTML = screens[S.screen]();
|
||||||
|
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||||||
|
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||||||
|
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||||||
|
}
|
||||||
|
flowbar.addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-goto]');
|
||||||
|
if (!b) return;
|
||||||
|
S.screen = b.dataset.goto;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.querySelector('.vp').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-w]');
|
||||||
|
if (!b) return;
|
||||||
|
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||||||
|
b.classList.add('on');
|
||||||
|
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||||||
|
document.getElementById('vpval').textContent = b.dataset.w;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||||||
|
// (a full panel re-render would destroy the element under the pointer).
|
||||||
|
function refreshStage() {
|
||||||
|
const host = document.getElementById('tw');
|
||||||
|
if (!host) return;
|
||||||
|
host.innerHTML = stageSvg();
|
||||||
|
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||||
|
set('tiltVal', S.tilt + '\u00B0');
|
||||||
|
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||||||
|
set('fillVal', S.fill + '%');
|
||||||
|
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||||||
|
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||||||
|
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||||||
|
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||||||
|
const st = document.getElementById('alignstatus');
|
||||||
|
if (st) {
|
||||||
|
const ok = aligned(), off = axisOffset();
|
||||||
|
st.className = 'status' + (ok ? ' ok' : '');
|
||||||
|
st.innerHTML = ok
|
||||||
|
? "The board's long axis is on the line \u2713"
|
||||||
|
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||||||
|
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||||||
|
}
|
||||||
|
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||||||
|
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||||||
|
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||||||
|
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||||||
|
axisAngle: axisAngle(), aligned: aligned() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
|
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||||||
|
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||||||
|
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||||||
|
// twin, stepping about the stage centre so the slab stays where it is.
|
||||||
|
function stepZoom(dir) {
|
||||||
|
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||||||
|
refreshStage();
|
||||||
|
}
|
||||||
|
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||||||
|
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||||||
|
function nudgeTick(dir, mult) {
|
||||||
|
const step = (mult === undefined ? 1 : mult);
|
||||||
|
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||||||
|
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindStage() {
|
||||||
|
const host = document.getElementById('tw');
|
||||||
|
if (!host) return;
|
||||||
|
const toVB = (e) => {
|
||||||
|
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||||||
|
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||||||
|
};
|
||||||
|
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||||||
|
function zoomAt(p, factor) {
|
||||||
|
const cx = VB.w / 2, cy = VB.h / 2;
|
||||||
|
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||||||
|
if (z1 === z0) return;
|
||||||
|
const qx = cx + (p.x - cx - S.panX) / z0;
|
||||||
|
const qy = cy + (p.y - cy - S.panY) / z0;
|
||||||
|
S.zoom = z1;
|
||||||
|
S.panX = p.x - cx - z1 * (qx - cx);
|
||||||
|
S.panY = p.y - cy - z1 * (qy - cy);
|
||||||
|
refreshStage();
|
||||||
|
}
|
||||||
|
host.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||||||
|
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||||||
|
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||||||
|
const dy = clamp(e.deltaY, -200, 200);
|
||||||
|
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||||
|
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||||||
|
}, { passive: false });
|
||||||
|
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||||||
|
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||||||
|
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||||||
|
|
||||||
|
let drag = null, tb = null, moved = false, downP = null;
|
||||||
|
const off = { x: 0, y: 0 };
|
||||||
|
host.addEventListener('pointerdown', (e) => {
|
||||||
|
const tick = e.target.closest('[data-tick]');
|
||||||
|
const rot = e.target.closest('[data-rot]');
|
||||||
|
const p = toVB(e);
|
||||||
|
moved = false; downP = p;
|
||||||
|
if (tick) {
|
||||||
|
S.focus = tick.dataset.tick;
|
||||||
|
drag = { kind: "tick", id: S.focus };
|
||||||
|
} else if (rot) {
|
||||||
|
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||||||
|
} else {
|
||||||
|
drag = { kind: "slab" };
|
||||||
|
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||||||
|
}
|
||||||
|
host.setPointerCapture(e.pointerId);
|
||||||
|
host.classList.add('dragging');
|
||||||
|
refreshStage();
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
host.addEventListener('pointermove', (e) => {
|
||||||
|
if (!drag) return;
|
||||||
|
const p = toVB(e);
|
||||||
|
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||||||
|
if (drag.kind === "tick") {
|
||||||
|
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||||||
|
const y = clamp(p.y, 20, VB.h - 20);
|
||||||
|
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||||||
|
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||||||
|
} else if (drag.kind === "rot") {
|
||||||
|
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||||||
|
} else {
|
||||||
|
S.panX = clamp(p.x - off.x, -400, 400);
|
||||||
|
S.panY = clamp(p.y - off.y, -400, 400);
|
||||||
|
}
|
||||||
|
refreshStage();
|
||||||
|
});
|
||||||
|
const end = (e) => {
|
||||||
|
if (!drag) return;
|
||||||
|
const wasSlab = drag.kind === "slab";
|
||||||
|
const startP = toVB(e);
|
||||||
|
drag = null;
|
||||||
|
host.classList.remove('dragging');
|
||||||
|
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||||||
|
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||||||
|
// dblclick both stop arriving), so track the taps ourselves.
|
||||||
|
if (wasSlab && !moved) {
|
||||||
|
const now = performance.now();
|
||||||
|
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||||||
|
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||||||
|
tb = { t: now, x: startP.x, y: startP.y };
|
||||||
|
}
|
||||||
|
refreshStage();
|
||||||
|
};
|
||||||
|
host.addEventListener('pointerup', end);
|
||||||
|
host.addEventListener('pointercancel', end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (S.screen !== "tape") return;
|
||||||
|
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||||||
|
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.key.startsWith("Arrow")) return;
|
||||||
|
const fine = e.shiftKey ? 0.1 : 1;
|
||||||
|
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||||||
|
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||||||
|
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||||||
|
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
phone.addEventListener('click', (e) => {
|
||||||
|
const t = e.target.closest('[data-act]');
|
||||||
|
if (!t) return;
|
||||||
|
const a = t.dataset.act;
|
||||||
|
if (a === "tilt") tiltBy(+t.dataset.d);
|
||||||
|
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||||||
|
else if (a === "fit") fitSlab();
|
||||||
|
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||||||
|
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||||||
|
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||||||
|
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||||||
|
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||||||
|
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||||||
|
});
|
||||||
|
phone.addEventListener('input', (e) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||||||
|
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||||||
|
else if (t.dataset.act === "fill-range") {
|
||||||
|
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||||||
|
// rectangle's aspect never changes and the line's x never moves.
|
||||||
|
S.fill = +t.value;
|
||||||
|
const ct = cropTicks();
|
||||||
|
S.tickA = ct.a; S.tickB = ct.b;
|
||||||
|
refreshStage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
phone.addEventListener('change', (e) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||||||
|
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -82,8 +82,10 @@
|
|||||||
|
|
||||||
{#if !photosReady}
|
{#if !photosReady}
|
||||||
<PhotoStage
|
<PhotoStage
|
||||||
|
mode="tape"
|
||||||
|
interactive={false}
|
||||||
blob={state.source.blob}
|
blob={state.source.blob}
|
||||||
bbox={state.axis.bbox}
|
tape={state.axis.tape}
|
||||||
maskBitset={state.approvedMask.bitset}
|
maskBitset={state.approvedMask.bitset}
|
||||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
||||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
STAGE_H, STAGE_W, clampTickPair, containFit, defaultTape, defaultTicks,
|
||||||
|
lineX, stageToImage, tapeEndpoints,
|
||||||
|
} from './tapeGeometry.ts';
|
||||||
|
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts';
|
||||||
|
|
||||||
|
test('defaultTicks sit at crop insets for 90% fill', () => {
|
||||||
|
const t = defaultTicks(0.9);
|
||||||
|
const inset = STAGE_H * cropMarginFraction(0.9);
|
||||||
|
assert.ok(Math.abs(t.tickA - inset) < 1e-9);
|
||||||
|
assert.ok(Math.abs(t.tickB - (STAGE_H - inset)) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineX is the middle of the 3:4 stage', () => {
|
||||||
|
assert.equal(lineX(), STAGE_W / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identity tape: stage centre maps near image centre', () => {
|
||||||
|
const imgW = 480, imgH = 640;
|
||||||
|
const tape = defaultTape(DEFAULT_CROP_FILL, 0);
|
||||||
|
const [ix, iy] = stageToImage(STAGE_W / 2, STAGE_H / 2, tape, imgW, imgH);
|
||||||
|
assert.ok(Math.abs(ix - imgW / 2) < 1e-6, `ix=${ix}`);
|
||||||
|
assert.ok(Math.abs(iy - imgH / 2) < 1e-6, `iy=${iy}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tapeEndpoints span equals tick distance in image px at identity (matching aspect)', () => {
|
||||||
|
const imgW = 480, imgH = 640;
|
||||||
|
const tape = defaultTape(0.9, 0);
|
||||||
|
const ep = tapeEndpoints(tape, imgW, imgH);
|
||||||
|
const { scale0 } = containFit(imgW, imgH);
|
||||||
|
const expected = Math.abs(tape.tickB - tape.tickA) / scale0;
|
||||||
|
assert.ok(Math.abs(ep.spanPx - expected) < 1e-6, `span ${ep.spanPx} vs ${expected}`);
|
||||||
|
// Direction should be +y (down the stage → down the image)
|
||||||
|
assert.ok(Math.abs(ep.ux) < 1e-6, `ux=${ep.ux}`);
|
||||||
|
assert.ok(ep.uy > 0.99, `uy=${ep.uy}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zoom scales inverse: 2× zoom halves image-space tick span', () => {
|
||||||
|
const imgW = 480, imgH = 640;
|
||||||
|
const base = defaultTape(0.9, 0);
|
||||||
|
const zoomed = { ...base, zoom: 2 };
|
||||||
|
const a = tapeEndpoints(base, imgW, imgH).spanPx;
|
||||||
|
const b = tapeEndpoints(zoomed, imgW, imgH).spanPx;
|
||||||
|
assert.ok(Math.abs(b * 2 - a) < 1e-6, `base=${a} zoomed=${b}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampTickPair enforces gap and order', () => {
|
||||||
|
const p = clampTickPair(400, 100, 40);
|
||||||
|
assert.ok(p.tickB - p.tickA >= 40);
|
||||||
|
assert.ok(p.tickA <= p.tickB);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Pure tape-line geometry (#99).
|
||||||
|
* Fixed vertical line in the middle of a 3:4 stage; photo pans/zooms/tilts;
|
||||||
|
* ticks slide on the line; L↔px from tick distance in image space.
|
||||||
|
*/
|
||||||
|
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop';
|
||||||
|
|
||||||
|
/** Logical stage matching the wireframe / inventory aspect. */
|
||||||
|
export const STAGE_W = 480;
|
||||||
|
export const STAGE_H = 640;
|
||||||
|
|
||||||
|
export interface TapeTransform {
|
||||||
|
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
|
||||||
|
tiltDeg: number;
|
||||||
|
zoom: number;
|
||||||
|
/** Pan in stage units. */
|
||||||
|
panX: number;
|
||||||
|
panY: number;
|
||||||
|
/** Tick Y positions in stage units. */
|
||||||
|
tickA: number;
|
||||||
|
tickB: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number; tickB: number } {
|
||||||
|
const inset = STAGE_H * cropMarginFraction(fill);
|
||||||
|
return { tickA: inset, tickB: STAGE_H - inset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
|
||||||
|
const t = defaultTicks(fill);
|
||||||
|
return {
|
||||||
|
tiltDeg: seedTiltDeg,
|
||||||
|
zoom: 1,
|
||||||
|
panX: 0,
|
||||||
|
panY: 0,
|
||||||
|
tickA: t.tickA,
|
||||||
|
tickB: t.tickB,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||||
|
|
||||||
|
/** Contain-fit of the photo into the stage at zoom=1, pan=0. */
|
||||||
|
export function containFit(imgW: number, imgH: number): { scale0: number; offX: number; offY: number } {
|
||||||
|
const scale0 = Math.min(STAGE_W / Math.max(imgW, 1), STAGE_H / Math.max(imgH, 1));
|
||||||
|
return {
|
||||||
|
scale0,
|
||||||
|
offX: (STAGE_W - imgW * scale0) / 2,
|
||||||
|
offY: (STAGE_H - imgH * scale0) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a stage point through the inverse photo transform into source image pixels.
|
||||||
|
* Transform order (forward): contain-fit → zoom+tilt about stage centre → pan.
|
||||||
|
*/
|
||||||
|
export function stageToImage(
|
||||||
|
sx: number,
|
||||||
|
sy: number,
|
||||||
|
tape: TapeTransform,
|
||||||
|
imgW: number,
|
||||||
|
imgH: number,
|
||||||
|
): [number, number] {
|
||||||
|
const cx = STAGE_W / 2;
|
||||||
|
const cy = STAGE_H / 2;
|
||||||
|
const { scale0, offX, offY } = containFit(imgW, imgH);
|
||||||
|
// undo pan
|
||||||
|
let x = sx - tape.panX - cx;
|
||||||
|
let y = sy - tape.panY - cy;
|
||||||
|
// undo rotate(tilt) scale(zoom) about centre
|
||||||
|
const z = tape.zoom || 1;
|
||||||
|
const a = -rad(tape.tiltDeg);
|
||||||
|
const cos = Math.cos(a);
|
||||||
|
const sin = Math.sin(a);
|
||||||
|
const xr = (x * cos - y * sin) / z;
|
||||||
|
const yr = (x * sin + y * cos) / z;
|
||||||
|
x = xr + cx;
|
||||||
|
y = yr + cy;
|
||||||
|
// undo contain fit
|
||||||
|
const ix = (x - offX) / scale0;
|
||||||
|
const iy = (y - offY) / scale0;
|
||||||
|
return [ix, iy];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lineX(): number {
|
||||||
|
return STAGE_W / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Image-space endpoints of the two ticks + span in px. */
|
||||||
|
export function tapeEndpoints(
|
||||||
|
tape: TapeTransform,
|
||||||
|
imgW: number,
|
||||||
|
imgH: number,
|
||||||
|
): { start: [number, number]; end: [number, number]; spanPx: number; ux: number; uy: number } {
|
||||||
|
const start = stageToImage(lineX(), tape.tickA, tape, imgW, imgH);
|
||||||
|
const end = stageToImage(lineX(), tape.tickB, tape, imgW, imgH);
|
||||||
|
const dx = end[0] - start[0];
|
||||||
|
const dy = end[1] - start[1];
|
||||||
|
const spanPx = Math.hypot(dx, dy);
|
||||||
|
const len = spanPx || 1;
|
||||||
|
return { start, end, spanPx, ux: dx / len, uy: dy / len };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampTickPair(a: number, b: number, gap = 40): { tickA: number; tickB: number } {
|
||||||
|
let tickA = a;
|
||||||
|
let tickB = b;
|
||||||
|
if (tickB < tickA) {
|
||||||
|
const t = tickA;
|
||||||
|
tickA = tickB;
|
||||||
|
tickB = t;
|
||||||
|
}
|
||||||
|
const minY = 0;
|
||||||
|
const maxY = STAGE_H;
|
||||||
|
tickA = Math.max(minY, Math.min(tickA, maxY - gap));
|
||||||
|
tickB = Math.min(maxY, Math.max(tickB, tickA + gap));
|
||||||
|
return { tickA, tickB };
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Shared decode for capture photos so PhotoStage, mask worker, inventory, and
|
||||||
|
* calculate all share one EXIF-oriented pixel space.
|
||||||
|
*
|
||||||
|
* Matched-set / DJI JPEGs often carry EXIF Orientation 8 (stored landscape,
|
||||||
|
* display portrait). `new Image()` and bare `createImageBitmap(blob)` can
|
||||||
|
* disagree across browsers → pink axis arrow space ≠ mask/overlay bitset space.
|
||||||
|
*
|
||||||
|
* Contract: always request `imageOrientation: 'from-image'`. If the runtime
|
||||||
|
* rejects that option (older engines), fall back to bare `createImageBitmap`;
|
||||||
|
* engines that already apply EXIF by default still match Image/canvas size.
|
||||||
|
* Engines that never apply EXIF may still diverge after fallback — document
|
||||||
|
* that path rather than inventing a software rotate.
|
||||||
|
*/
|
||||||
|
export const ORIENTED_BITMAP_OPTIONS: ImageBitmapOptions = {
|
||||||
|
imageOrientation: 'from-image',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function decodeOrientedBitmap(blob: Blob): Promise<ImageBitmap> {
|
||||||
|
try {
|
||||||
|
return await createImageBitmap(blob, ORIENTED_BITMAP_OPTIONS);
|
||||||
|
} catch (first) {
|
||||||
|
try {
|
||||||
|
return await createImageBitmap(blob);
|
||||||
|
} catch {
|
||||||
|
throw first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,8 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
|||||||
|
|
||||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
// 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++) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { blackKeep, greenKeep } from './sheetKeep.ts';
|
||||||
|
|
||||||
|
// --- Black sheet sensitivity (#74) ---
|
||||||
|
|
||||||
|
test('blackKeep: s=0 keeps dark live-edge wood (lum ~25–50), removes near-black sheet (#74)', () => {
|
||||||
|
// Near-black sheet (lum ≈ 5)
|
||||||
|
assert.equal(blackKeep(5, 5, 5, 0), false, 'near-black sheet removed at s=0');
|
||||||
|
assert.equal(blackKeep(0, 0, 0, 0), false, 'pure black sheet removed at s=0');
|
||||||
|
// Dark wood / bark / live edge
|
||||||
|
assert.equal(blackKeep(25, 22, 18, 0), true, 'lum~22 dark wood kept at s=0');
|
||||||
|
assert.equal(blackKeep(40, 35, 30, 0), true, 'lum~35 dark wood kept at s=0');
|
||||||
|
assert.equal(blackKeep(50, 45, 40, 0), true, 'lum~46 wood kept at s=0');
|
||||||
|
// Mid wood always kept
|
||||||
|
assert.equal(blackKeep(120, 90, 60, 0), true, 'mid wood kept at s=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blackKeep: s=100 removes more than s=0; curve monotonic (#74)', () => {
|
||||||
|
// Dark-ish pixel that sits between floors: sheet at high s, wood at low s
|
||||||
|
const darkish = { r: 80, g: 70, b: 60 }; // lum ≈ 72
|
||||||
|
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 0), true, 'lum~72 kept at s=0');
|
||||||
|
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 100), false, 'lum~72 sheet at s=100');
|
||||||
|
|
||||||
|
// Near-black always sheet
|
||||||
|
assert.equal(blackKeep(2, 2, 2, 0), false);
|
||||||
|
assert.equal(blackKeep(2, 2, 2, 100), false);
|
||||||
|
|
||||||
|
// Bright wood always kept
|
||||||
|
assert.equal(blackKeep(200, 160, 100, 0), true);
|
||||||
|
assert.equal(blackKeep(200, 160, 100, 100), true);
|
||||||
|
|
||||||
|
// Monotonic: once removed, stays removed as s increases
|
||||||
|
const probe = { r: 60, g: 55, b: 50 }; // lum ≈ 56
|
||||||
|
let prevKeep = true;
|
||||||
|
for (const s of [0, 25, 50, 75, 100]) {
|
||||||
|
const keep = blackKeep(probe.r, probe.g, probe.b, s);
|
||||||
|
if (!prevKeep) assert.equal(keep, false, `once sheet at lower s, stay sheet at s=${s}`);
|
||||||
|
prevKeep = keep;
|
||||||
|
}
|
||||||
|
// s=100 must remove more pixels than s=0 for this mid-dark band
|
||||||
|
assert.equal(blackKeep(probe.r, probe.g, probe.b, 0), true);
|
||||||
|
assert.equal(blackKeep(probe.r, probe.g, probe.b, 100), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('greenKeep: s=0 does not eat mild wood greens; s=100 widens sheet match (#74)', () => {
|
||||||
|
// Neutral wood
|
||||||
|
assert.equal(greenKeep(140, 110, 80, 0), true);
|
||||||
|
assert.equal(greenKeep(140, 110, 80, 100), true);
|
||||||
|
// Mild green cast on wood (g - max(r,b) ≈ 5) — must keep at s=0
|
||||||
|
assert.equal(greenKeep(100, 105, 90, 0), true, 'mild green cast kept at s=0');
|
||||||
|
// Strong sheet green
|
||||||
|
assert.equal(greenKeep(40, 180, 50, 0), false, 'strong green sheet removed at s=0');
|
||||||
|
assert.equal(greenKeep(40, 180, 50, 100), false);
|
||||||
|
// Borderline: g-max ≈ 20 — may become sheet at high s
|
||||||
|
const border = { r: 80, g: 100, b: 70 }; // delta = 20
|
||||||
|
assert.equal(greenKeep(border.r, border.g, border.b, 0), true, 'delta20 kept at s=0');
|
||||||
|
assert.equal(greenKeep(border.r, border.g, border.b, 100), false, 'delta20 sheet at s=100');
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Sheet-vs-slab keep predicates used by the mask worker.
|
||||||
|
* Extracted so unit tests can cover sensitivity curves without the worker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Keep slab pixels on a green sheet. Higher s widens the green-sheet match. */
|
||||||
|
export function greenKeep(r: number, g: number, b: number, s: number): boolean {
|
||||||
|
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||||
|
// s=0 removes only strongly green sheet; increasing sensitivity widens the match.
|
||||||
|
const isSheet = g - Math.max(r, b) > 45 - 35 * t;
|
||||||
|
return !isSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep slab pixels on a black sheet.
|
||||||
|
* At s=0 only near-black sheet (very low lum) is removed so dark live-edge /
|
||||||
|
* bark (lum ~25–50) stays. The match widens monotonically toward s=100 (#74).
|
||||||
|
*/
|
||||||
|
export function blackKeep(r: number, g: number, b: number, s: number): boolean {
|
||||||
|
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||||
|
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
// Old floor 40 ate dark wood at minimum sensitivity. Floor 10 isolates near-black.
|
||||||
|
const isSheet = lum < 10 + 140 * t;
|
||||||
|
return !isSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoPickSheet(
|
||||||
|
samples: { r: number; g: number; b: number }[],
|
||||||
|
): 'green' | 'black' {
|
||||||
|
let gr = 0, gl = 0;
|
||||||
|
for (const p of samples) {
|
||||||
|
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||||
|
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||||
|
}
|
||||||
|
return gl >= gr ? 'black' : 'green';
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
|
||||||
|
import { measure } from './measure.ts';
|
||||||
|
|
||||||
|
test('#99: tape span drives scale, not maskSpanAlong', () => {
|
||||||
|
const W = 480, H = 100;
|
||||||
|
const bitset = new Uint8Array(W * H).fill(1);
|
||||||
|
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||||
|
// Declared L = 48"; tape span = 240 px → 5 px/in (not the full 480 mask span).
|
||||||
|
const m = measure(bitset, W, H, box, 384, 8, W * H, {
|
||||||
|
start: [100, 50],
|
||||||
|
ux: 1,
|
||||||
|
uy: 0,
|
||||||
|
spanPx: 240,
|
||||||
|
});
|
||||||
|
assert.ok(Math.abs(m.scale - 5) < 1e-9, `scale ${m.scale}`);
|
||||||
|
});
|
||||||
@@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb
|
|||||||
return { start: bbox.corners[3], end: bbox.corners[0] };
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { clampFill, cropMarginFraction, DEFAULT_CROP_FILL } from './crop.ts';
|
||||||
|
import { _resetCropFillForTests, getCropFill, setCropFill } from '../settings/cropFill.ts';
|
||||||
|
import { composeInventoryFrame } from './inventory.ts';
|
||||||
|
import type { Mask, Source } from '../capture/capture.types';
|
||||||
|
|
||||||
|
test('cropMarginFraction(0.9) === 0.05', () => {
|
||||||
|
assert.ok(Math.abs(cropMarginFraction(0.9) - 0.05) < 1e-12);
|
||||||
|
assert.equal(DEFAULT_CROP_FILL, 0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampFill bounds', () => {
|
||||||
|
assert.equal(clampFill(2), 1);
|
||||||
|
assert.equal(clampFill(0), 0.01);
|
||||||
|
assert.equal(clampFill(Number.NaN), DEFAULT_CROP_FILL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCropFill / setCropFill round-trip', () => {
|
||||||
|
_resetCropFillForTests();
|
||||||
|
assert.equal(getCropFill(), 0.9);
|
||||||
|
assert.equal(setCropFill(0.8), 0.8);
|
||||||
|
assert.equal(getCropFill(), 0.8);
|
||||||
|
_resetCropFillForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('composeInventoryFrame honors cropFill override (#99)', () => {
|
||||||
|
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||||
|
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||||
|
const mask: Pick<Mask, 'bitset' | 'slabBounds'> = {
|
||||||
|
bitset,
|
||||||
|
slabBounds: { x: 100, y: 100, w: 2000, h: 1800 },
|
||||||
|
};
|
||||||
|
const rgba = new Uint8Array(source.widthPx * source.heightPx * 4).fill(255);
|
||||||
|
const out90 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.9);
|
||||||
|
const out80 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.8);
|
||||||
|
|
||||||
|
const extent = (frame: typeof out90) => {
|
||||||
|
let minx = frame.width, maxx = -1, miny = frame.height, maxy = -1;
|
||||||
|
for (let y = 0; y < frame.height; y++) {
|
||||||
|
for (let x = 0; x < frame.width; x++) {
|
||||||
|
if (frame.rgba[(y * frame.width + x) * 4 + 3] !== 0) {
|
||||||
|
if (x < minx) minx = x;
|
||||||
|
if (x > maxx) maxx = x;
|
||||||
|
if (y < miny) miny = y;
|
||||||
|
if (y > maxy) maxy = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { w: maxx - minx + 1, h: maxy - miny + 1 };
|
||||||
|
};
|
||||||
|
const e90 = extent(out90);
|
||||||
|
const e80 = extent(out80);
|
||||||
|
assert.ok(Math.abs(e90.w - 720) <= 2, `90% w ${e90.w}`);
|
||||||
|
assert.ok(e80.w < e90.w - 10, `80% fill must be tighter than 90%: ${e80.w} vs ${e90.w}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Shared crop constants for inventory PNG + tape-line overlay (#99).
|
||||||
|
* Aspect is fixed 3:4; crop_fill only moves the long-axis end insets.
|
||||||
|
* Non-retro: changing fill affects future encodes only, not stored PNGs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_CROP_FILL = 0.9;
|
||||||
|
export const ASPECT_W = 3;
|
||||||
|
export const ASPECT_H = 4;
|
||||||
|
export const FLOOR_PX = 800;
|
||||||
|
|
||||||
|
/** Fraction of the long axis inset from each end: (1 - fill) / 2. */
|
||||||
|
export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number {
|
||||||
|
const f = clampFill(fill);
|
||||||
|
return (1 - f) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampFill(fill: number): number {
|
||||||
|
if (!Number.isFinite(fill)) return DEFAULT_CROP_FILL;
|
||||||
|
return Math.min(1, Math.max(0.01, fill));
|
||||||
|
}
|
||||||
@@ -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.9; // 90% 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')!;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
axisRightUnit,
|
axisRightUnit,
|
||||||
buildOverlayPlan,
|
buildOverlayPlan,
|
||||||
inventorySourceMap,
|
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';
|
||||||
@@ -138,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 >= 18, `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)', () => {
|
||||||
@@ -272,7 +273,7 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () =
|
|||||||
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
|
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)', () => {
|
test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#67,#71)', () => {
|
||||||
const W = 480, H = 200;
|
const W = 480, H = 200;
|
||||||
const bitset = new Uint8Array(W * H);
|
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;
|
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
|
||||||
@@ -288,11 +289,13 @@ test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)',
|
|||||||
lengthLabel: '48"',
|
lengthLabel: '48"',
|
||||||
mapToInventory: (x, y) => [x, y],
|
mapToInventory: (x, y) => [x, y],
|
||||||
});
|
});
|
||||||
const oldFont = Math.max(28, Math.round(scale * 1.2));
|
const oldFont67 = Math.max(28, Math.round(scale * 1.2));
|
||||||
assert.ok(plan.fontPx < oldFont, `fontPx ${plan.fontPx} must be < prior ${oldFont}`);
|
const oldFont71 = Math.max(22, Math.round(scale * 1.0));
|
||||||
assert.ok(plan.fontPx >= 18, `fontPx ${plan.fontPx} still legible`);
|
assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`);
|
||||||
// Concrete contract: max(22, round(scale*1.0))
|
assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`);
|
||||||
assert.equal(plan.fontPx, Math.max(22, Math.round(scale * 1.0)));
|
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))));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -338,7 +341,7 @@ function maskWoodAabb(bitset: Uint8Array, W: number, H: number) {
|
|||||||
return { minX, maxX, minY, maxY };
|
return { minX, maxX, minY, maxY };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('buildOverlayPlan: length axial span = tip span; inventory y stays within wood AABB (#69)', () => {
|
test('buildOverlayPlan: length axial span = robust tip stations; no AABB overshoot along axis (#69)', () => {
|
||||||
const { W, H, bitset, bbox } = tiltedSlabFixture();
|
const { W, H, bitset, bbox } = tiltedSlabFixture();
|
||||||
const scale = 10;
|
const scale = 10;
|
||||||
const wood = maskWoodAabb(bitset, W, H);
|
const wood = maskWoodAabb(bitset, W, H);
|
||||||
@@ -360,80 +363,84 @@ test('buildOverlayPlan: length axial span = tip span; inventory y stays within w
|
|||||||
mapToInventory: inv.mapToInventory,
|
mapToInventory: inv.mapToInventory,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wood AABB in inventory space
|
// Wood AABB in inventory space (for axis-right clearance only)
|
||||||
let mMinY = Infinity, mMaxY = -Infinity;
|
|
||||||
let mMinX = Infinity, mMaxX = -Infinity;
|
let mMinX = Infinity, mMaxX = -Infinity;
|
||||||
for (let y = 0; y < H; y++) {
|
for (let y = 0; y < H; y++) {
|
||||||
for (let x = 0; x < W; x++) {
|
for (let x = 0; x < W; x++) {
|
||||||
if (!bitset[y * W + x]) continue;
|
if (!bitset[y * W + x]) continue;
|
||||||
const [mx, my] = inv.mapToInventory(x, y);
|
const [mx] = inv.mapToInventory(x, y);
|
||||||
if (my < mMinY) mMinY = my;
|
|
||||||
if (my > mMaxY) mMaxY = my;
|
|
||||||
if (mx < mMinX) mMinX = mx;
|
if (mx < mMinX) mMinX = mx;
|
||||||
if (mx > mMaxX) mMaxX = mx;
|
if (mx > mMaxX) mMaxX = mx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const lenMinY = Math.min(plan.length.a[1], plan.length.b[1]);
|
|
||||||
const lenMaxY = Math.max(plan.length.a[1], plan.length.b[1]);
|
|
||||||
// Current bug: (rx,ry)*outward pushes length ends past wood tip y when axis is tilted
|
|
||||||
assert.ok(
|
|
||||||
lenMinY >= mMinY - 1.5,
|
|
||||||
`length top y=${lenMinY.toFixed(1)} must not extend past wood AABB minY=${mMinY.toFixed(1)}`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
lenMaxY <= mMaxY + 1.5,
|
|
||||||
`length bottom y=${lenMaxY.toFixed(1)} must not extend past wood AABB maxY=${mMaxY.toFixed(1)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Length endpoints = robust tip pixels + common screen offset → tip-to-tip span
|
// Width-aware tip stations along approved bbox axis (#71; was 0.2%/99.8%)
|
||||||
const { start: aa, end: bb } = axisEndpoints(bbox);
|
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 ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1;
|
||||||
const ux = ex / elen, uy = ey / elen;
|
const ux = ex / elen, uy = ey / elen;
|
||||||
const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4;
|
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 cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4;
|
||||||
const projs: number[] = [];
|
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 y = 0; y < H; y++) {
|
||||||
for (let x = 0; x < W; x++) {
|
for (let x = 0; x < W; x++) {
|
||||||
if (!bitset[y * W + x]) continue;
|
if (!bitset[y * W + x]) continue;
|
||||||
projs.push(x * ux + y * uy);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
projs.sort((p, q) => p - q);
|
let maxWidth = 0;
|
||||||
const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1);
|
const widths: { bin: number; width: number }[] = [];
|
||||||
const hi = Math.max(Math.floor(projs.length * 0.998), lo);
|
for (const [bin, minA] of binMin) {
|
||||||
const tipMin = projs[lo];
|
const maxA = binMax.get(bin)!;
|
||||||
const tipMax = projs[hi];
|
const width = maxA - minA + 1;
|
||||||
const stationTol = Math.max(1.5, (tipMax - tipMin) * 0.001);
|
widths.push({ bin, width });
|
||||||
let tipMinPt: [number, number] = [0, 0];
|
if (width > maxWidth) maxWidth = width;
|
||||||
let tipMaxPt: [number, number] = [0, 0];
|
|
||||||
let bestStart = Infinity, bestEnd = Infinity;
|
|
||||||
for (let y = 0; y < H; y++) {
|
|
||||||
for (let x = 0; x < W; x++) {
|
|
||||||
if (!bitset[y * W + x]) continue;
|
|
||||||
const p = x * ux + y * uy;
|
|
||||||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
|
||||||
if (Math.abs(p - tipMin) <= stationTol && across < bestStart) {
|
|
||||||
bestStart = across;
|
|
||||||
tipMinPt = [x, y];
|
|
||||||
}
|
|
||||||
if (Math.abs(p - tipMax) <= stationTol && across < bestEnd) {
|
|
||||||
bestEnd = across;
|
|
||||||
tipMaxPt = [x, y];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const tipOr = pickBottomTowardTop(tipMinPt, tipMaxPt);
|
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||||
const mapTipBot = inv.mapToInventory(tipOr.bottom[0], tipOr.bottom[1]);
|
widths.sort((a, b) => a.bin - b.bin);
|
||||||
const mapTipTop = inv.mapToInventory(tipOr.top[0], tipOr.top[1]);
|
let tipLo = widths[0].bin, tipHi = widths[widths.length - 1].bin;
|
||||||
const tipEuclid = Math.hypot(mapTipTop[0] - mapTipBot[0], mapTipTop[1] - mapTipBot[1]);
|
for (const { bin, width } of widths) {
|
||||||
const lenEuclid = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]);
|
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(
|
assert.ok(
|
||||||
Math.abs(lenEuclid - tipEuclid) <= 2,
|
lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 8,
|
||||||
`length span ${lenEuclid.toFixed(1)} must equal tip-to-tip ${tipEuclid.toFixed(1)}`,
|
`length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow small axial inset only; #73)`,
|
||||||
);
|
);
|
||||||
// Arrowhead stations share tip screen-y (vertical-ish fixture)
|
|
||||||
assert.ok(Math.abs(plan.length.a[1] - mapTipBot[1]) <= 1.5 || Math.abs(plan.length.a[1] - mapTipTop[1]) <= 1.5);
|
// Axial stations of length ends = tip stations (pure axis-right offset does not change along-u).
|
||||||
assert.ok(Math.abs(plan.length.b[1] - mapTipBot[1]) <= 1.5 || Math.abs(plan.length.b[1] - mapTipTop[1]) <= 1.5);
|
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)
|
// 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;
|
const lenMidX = (plan.length.a[0] + plan.length.b[0]) / 2;
|
||||||
@@ -497,3 +504,616 @@ test('buildOverlayPlan: width labels fully left of wood AABB after inventory map
|
|||||||
assert.ok(w.right[0] >= mMinX - 3 && w.right[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})`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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[] {
|
||||||
@@ -76,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(
|
||||||
@@ -86,7 +157,7 @@ function maskAxisSegment(
|
|||||||
): {
|
): {
|
||||||
start: [number, number];
|
start: [number, number];
|
||||||
end: [number, number];
|
end: [number, number];
|
||||||
/** Actual mask pixels achieving min/max axial projection (wood tips). */
|
/** Actual mask pixels nearest centerline at tip stations. */
|
||||||
tipStart: [number, number];
|
tipStart: [number, number];
|
||||||
tipEnd: [number, number];
|
tipEnd: [number, number];
|
||||||
ux: number;
|
ux: number;
|
||||||
@@ -102,24 +173,30 @@ 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;
|
||||||
// Collect axial projections; absolute min/max tip pixels can be mask specks
|
// Width-aware tips (#71). Fall back to robust percentiles if profile empty.
|
||||||
// past the visible wood footprint (Ty UAT #69). Use robust percentiles and
|
const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy);
|
||||||
// pick tip pixels near the centerline at those stations.
|
let minP: number;
|
||||||
const projs: number[] = [];
|
let maxP: number;
|
||||||
for (let y = 0; y < h; y++) {
|
if (aware) {
|
||||||
const row = y * w;
|
minP = aware.minP;
|
||||||
for (let x = 0; x < w; x++) {
|
maxP = aware.maxP;
|
||||||
if (!bitset[row + x]) continue;
|
} else {
|
||||||
projs.push(x * ux + y * uy);
|
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];
|
||||||
}
|
}
|
||||||
if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 };
|
// Tip pixel at each station: closest to the axis centerline
|
||||||
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);
|
|
||||||
const minP = projs[lo];
|
|
||||||
const maxP = projs[hi];
|
|
||||||
// Tip pixel at each robust station: closest to the axis centerline
|
|
||||||
let tipStart: [number, number] = a;
|
let tipStart: [number, number] = a;
|
||||||
let tipEnd: [number, number] = b;
|
let tipEnd: [number, number] = b;
|
||||||
let bestStart = Infinity;
|
let bestStart = Infinity;
|
||||||
@@ -130,7 +207,7 @@ function maskAxisSegment(
|
|||||||
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;
|
||||||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux); // |(p - c) cross u| via r
|
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||||||
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
||||||
bestStart = across;
|
bestStart = across;
|
||||||
tipStart = [x, y];
|
tipStart = [x, y];
|
||||||
@@ -191,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).
|
||||||
@@ -199,7 +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);
|
||||||
|
|
||||||
const pad = Math.max(16, scale * 0.75);
|
// 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) {
|
||||||
@@ -208,19 +303,25 @@ 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).
|
// Width number sits axis-left outside the slab (past left tip).
|
||||||
// Extra clearance so glyph box (textAlign grows away from wood) stays off footprint.
|
const labelOff = extent.minP - pad - fontPx * 0.85;
|
||||||
const labelOff = extent.minP - pad - scale * 0.4 - Math.max(22, Math.round(scale * 1.0)) * 0.35;
|
|
||||||
const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff];
|
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]),
|
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||||
label: fmtInches(widthIn),
|
label: fmtInches(widthIn),
|
||||||
});
|
});
|
||||||
@@ -237,45 +338,66 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
|||||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||||
}
|
}
|
||||||
const outward = maxRight + pad;
|
const outward = maxRight + pad;
|
||||||
// Tip pixels = wood ends. Translate them by a single screen-axis offset so the
|
// Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
|
||||||
// dim line stays || tip-to-tip (measured span = wood tip-to-tip) and arrowheads
|
// pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
|
||||||
// share tip y (vertical-ish) / tip x (horizontal-ish) — no past-footprint
|
// Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
|
||||||
// overshoot from (rx,ry)*outward (#69). Clearance magnitude matches classic
|
const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
|
||||||
// axis-right mid (centerline + r*outward).
|
const bMapped = mapToInventory(top[0] + rx * outward, top[1] + ry * outward);
|
||||||
const tipOriented = pickBottomTowardTop(seg.tipStart, seg.tipEnd);
|
const ldx0 = bMapped[0] - aMapped[0];
|
||||||
const aClassic: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward];
|
const ldy0 = bMapped[1] - aMapped[1];
|
||||||
const bClassic: [number, number] = [top[0] + rx * outward, top[1] + ry * outward];
|
const llen0 = Math.hypot(ldx0, ldy0) || 1;
|
||||||
const tipMidX = (tipOriented.bottom[0] + tipOriented.top[0]) / 2;
|
const lux = ldx0 / llen0;
|
||||||
const tipMidY = (tipOriented.bottom[1] + tipOriented.top[1]) / 2;
|
const luy = ldy0 / llen0;
|
||||||
const classicMidX = (aClassic[0] + bClassic[0]) / 2;
|
const aInv: [number, number] = [aMapped[0] + lux * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset];
|
||||||
const classicMidY = (aClassic[1] + bClassic[1]) / 2;
|
const bInv: [number, number] = [bMapped[0] - lux * lengthEdgeInset, bMapped[1] - luy * lengthEdgeInset];
|
||||||
let aSrc: [number, number];
|
const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2];
|
||||||
let bSrc: [number, number];
|
// Length label further axis-right of dim line so glyphs stay off the wood (#71).
|
||||||
if (Math.abs(uy) >= Math.abs(ux)) {
|
const mapOrigin = mapToInventory(0, 0);
|
||||||
const dx = classicMidX - tipMidX;
|
const mapR = mapToInventory(rx, ry);
|
||||||
aSrc = [tipOriented.bottom[0] + dx, tipOriented.bottom[1]];
|
const irx = mapR[0] - mapOrigin[0];
|
||||||
bSrc = [tipOriented.top[0] + dx, tipOriented.top[1]];
|
const iry = mapR[1] - mapOrigin[1];
|
||||||
} else {
|
const irlen = Math.hypot(irx, iry) || 1;
|
||||||
const dy = classicMidY - tipMidY;
|
const nrx = irx / irlen;
|
||||||
aSrc = [tipOriented.bottom[0], tipOriented.bottom[1] + dy];
|
const nry = iry / irlen;
|
||||||
bSrc = [tipOriented.top[0], tipOriented.top[1] + dy];
|
const labelAt: [number, number] = [
|
||||||
}
|
midInv[0] + nrx * (fontPx * 0.85 + 6),
|
||||||
const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2];
|
midInv[1] + nry * (fontPx * 0.85 + 6),
|
||||||
// Nudge label further outward a bit (axis-right of dim line)
|
];
|
||||||
const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)];
|
|
||||||
|
|
||||||
const fontPx = Math.max(22, Math.round(scale * 1.0));
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -289,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';
|
||||||
@@ -301,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]);
|
||||||
@@ -322,9 +445,22 @@ 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);
|
||||||
ctx.textAlign = 'center';
|
{
|
||||||
|
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 — grow text away from wood (labelAt is axis-left of left tip)
|
// Widths — grow text away from wood (labelAt is axis-left of left tip)
|
||||||
@@ -351,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.9;
|
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)));
|
||||||
@@ -383,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,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,
|
||||||
@@ -407,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' });
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope).
|
||||||
|
* Used only for the calibration inventory PNG + dimension-overlay clone.
|
||||||
|
* Batch siblings are never passed through here.
|
||||||
|
*/
|
||||||
|
import type { Mask, Source } from '../capture/capture.types';
|
||||||
|
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||||
|
|
||||||
|
export interface RotatedCal {
|
||||||
|
bitmap: ImageBitmap;
|
||||||
|
mask: Mask;
|
||||||
|
source: Pick<Source, 'widthPx' | 'heightPx'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate).
|
||||||
|
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
|
||||||
|
* Identity (≈0°) returns clones of inputs without resampling.
|
||||||
|
*/
|
||||||
|
export async function rotateCalibration(
|
||||||
|
bitmap: ImageBitmap,
|
||||||
|
mask: Mask,
|
||||||
|
source: Pick<Source, 'widthPx' | 'heightPx'>,
|
||||||
|
tiltDeg: number,
|
||||||
|
): Promise<RotatedCal> {
|
||||||
|
const a = ((tiltDeg % 360) + 360) % 360;
|
||||||
|
if (a < 0.05 || a > 359.95) {
|
||||||
|
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
|
||||||
|
return { bitmap, mask, source };
|
||||||
|
}
|
||||||
|
const srcW = source.widthPx;
|
||||||
|
const srcH = source.heightPx;
|
||||||
|
const rad = (a * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
// Axis-aligned bounds of rotated rectangle corners.
|
||||||
|
const corners = [
|
||||||
|
[0, 0],
|
||||||
|
[srcW, 0],
|
||||||
|
[srcW, srcH],
|
||||||
|
[0, srcH],
|
||||||
|
].map(([x, y]) => {
|
||||||
|
const cx = x - srcW / 2;
|
||||||
|
const cy = y - srcH / 2;
|
||||||
|
return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number];
|
||||||
|
});
|
||||||
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
|
for (const [x, y] of corners) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
const outW = Math.max(1, Math.ceil(maxX - minX));
|
||||||
|
const outH = Math.max(1, Math.ceil(maxY - minY));
|
||||||
|
const canvas = new OffscreenCanvas(outW, outH);
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.translate(outW / 2, outH / 2);
|
||||||
|
ctx.rotate(rad);
|
||||||
|
ctx.drawImage(bitmap, -srcW / 2, -srcH / 2);
|
||||||
|
|
||||||
|
// Nearest-neighbour resample of the bitset into the rotated frame.
|
||||||
|
const bitset = new Uint8Array(outW * outH);
|
||||||
|
const invCos = Math.cos(-rad);
|
||||||
|
const invSin = Math.sin(-rad);
|
||||||
|
for (let y = 0; y < outH; y++) {
|
||||||
|
for (let x = 0; x < outW; x++) {
|
||||||
|
const dx = x - outW / 2;
|
||||||
|
const dy = y - outH / 2;
|
||||||
|
const sx = Math.round(dx * invCos - dy * invSin + srcW / 2);
|
||||||
|
const sy = Math.round(dx * invSin + dy * invCos + srcH / 2);
|
||||||
|
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
|
||||||
|
bitset[y * outW + x] = mask.bitset[sy * srcW + sx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let slabPx = 0;
|
||||||
|
for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++;
|
||||||
|
const bounds = slabBounds(bitset, outW, outH);
|
||||||
|
const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox;
|
||||||
|
const outBmp = canvas.transferToImageBitmap();
|
||||||
|
return {
|
||||||
|
bitmap: outBmp,
|
||||||
|
mask: {
|
||||||
|
params: mask.params,
|
||||||
|
slabPx,
|
||||||
|
bitset,
|
||||||
|
bbox,
|
||||||
|
slabBounds: bounds,
|
||||||
|
},
|
||||||
|
source: { widthPx: outW, heightPx: outH },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Thin frontend-only crop_fill setting (#99).
|
||||||
|
* Default 0.9 — replaces duplicated FILL = 0.9 in png/inventory + png/overlay.
|
||||||
|
* Not a full M3 Settings surface; localStorage so it survives reloads.
|
||||||
|
*/
|
||||||
|
import { DEFAULT_CROP_FILL, clampFill } from '../png/crop';
|
||||||
|
|
||||||
|
const KEY = 'slabuploader.crop_fill';
|
||||||
|
|
||||||
|
let cached: number | null = null;
|
||||||
|
|
||||||
|
export function getCropFill(): number {
|
||||||
|
if (cached != null) return cached;
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
cached = DEFAULT_CROP_FILL;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (raw == null) {
|
||||||
|
cached = DEFAULT_CROP_FILL;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
cached = clampFill(Number.parseFloat(raw));
|
||||||
|
return cached;
|
||||||
|
} catch {
|
||||||
|
cached = DEFAULT_CROP_FILL;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCropFill(fill: number): number {
|
||||||
|
const next = clampFill(fill);
|
||||||
|
cached = next;
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(next));
|
||||||
|
} catch {
|
||||||
|
/* private mode / quota — in-memory still updated */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper: clear cached value (and optional storage). */
|
||||||
|
export function _resetCropFillForTests(): void {
|
||||||
|
cached = null;
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,13 @@
|
|||||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
import { 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;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 466 KiB |
@@ -1,11 +1,11 @@
|
|||||||
# Last green e2e run (#65)
|
# Last green e2e run (#71)
|
||||||
|
|
||||||
- **When:** 2026-09-15 2:36 PM PT (`2026-09-15T21:36:28Z`)
|
- **When:** 2026-09-15 9:55 PM PT (`2026-09-16T04:55:14Z`)
|
||||||
- **Fixture:** `tests/fixtures/set1-48x2/` (L=48, thickness=2)
|
- **Fixture:** `tests/fixtures/set1-48x2/` (L=48, thickness=2)
|
||||||
- **Server:** local `vite preview` at `http://127.0.0.1:4173` (not ubudual)
|
- **Server:** local `vite preview` at `http://127.0.0.1:4173` (not ubudual)
|
||||||
- **Result:** 1 passed (~7s test / ~14s wall with build)
|
- **Result:** 1 passed (~8s test)
|
||||||
- **Mask:** Black sheet @ sensitivity 50 — Approve enabled without sensitivity ladder
|
- **Mask:** Black sheet — Approve enabled
|
||||||
- **Photos ready numbers (from UI):** widths 6"–9 3/8" (avg 7 1/2"); sqft 2.13; bdft 4.26; scale 76.95 px/in
|
- **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 (48" length + width stations) → Batch photo 2
|
- **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.
|
Full-resolution PNGs are produced under `../` on each run (gitignored). These JPEGs are commit-sized evidence for review.
|
||||||
|
|||||||