From dc19cffcc012ebfac21de56999e7c8f0a0fcdc61 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Wed, 16 Sep 2026 04:35:38 +0000 Subject: [PATCH] test(photos): length/width dims follow approved axis (#70) --- frontend/src/png/overlay.test.ts | 196 +++++++++++++++++++++++-------- 1 file changed, 149 insertions(+), 47 deletions(-) diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index b38c1f1..527193c 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -338,7 +338,7 @@ function maskWoodAabb(bitset: Uint8Array, W: number, H: number) { 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 scale = 10; const wood = maskWoodAabb(bitset, W, H); @@ -360,37 +360,24 @@ test('buildOverlayPlan: length axial span = tip span; inventory y stays within w mapToInventory: inv.mapToInventory, }); - // Wood AABB in inventory space - let mMinY = Infinity, mMaxY = -Infinity; + // Wood AABB in inventory space (for axis-right clearance only) let mMinX = Infinity, mMaxX = -Infinity; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (!bitset[y * W + x]) continue; - const [mx, my] = inv.mapToInventory(x, y); - if (my < mMinY) mMinY = my; - if (my > mMaxY) mMaxY = my; + const [mx] = inv.mapToInventory(x, y); if (mx < mMinX) mMinX = 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 + // Robust tip stations along approved bbox axis (0.2% / 99.8%) const { start: aa, end: bb } = axisEndpoints(bbox); const ex = bb[0] - aa[0], ey = bb[1] - aa[1], elen = Math.hypot(ex, ey) || 1; const ux = ex / elen, uy = ey / elen; const cx = bbox.corners.reduce((s, c) => s + c[0], 0) / 4; const cy = bbox.corners.reduce((s, c) => s + c[1], 0) / 4; + const cProj = cx * ux + cy * uy; const projs: number[] = []; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { @@ -403,37 +390,35 @@ test('buildOverlayPlan: length axial span = tip span; inventory y stays within w const hi = Math.max(Math.floor(projs.length * 0.998), lo); const tipMin = projs[lo]; const tipMax = projs[hi]; - const stationTol = Math.max(1.5, (tipMax - tipMin) * 0.001); - let tipMinPt: [number, number] = [0, 0]; - 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 mapTipBot = inv.mapToInventory(tipOr.bottom[0], tipOr.bottom[1]); - const mapTipTop = inv.mapToInventory(tipOr.top[0], tipOr.top[1]); - const tipEuclid = Math.hypot(mapTipTop[0] - mapTipBot[0], mapTipTop[1] - mapTipBot[1]); - const lenEuclid = Math.hypot(plan.length.b[0] - plan.length.a[0], plan.length.b[1] - plan.length.a[1]); + // Centerline tip positions (same as maskAxisSegment start/end) + const tipStart: [number, number] = [cx + ux * (tipMin - cProj), cy + uy * (tipMin - cProj)]; + const tipEnd: [number, number] = [cx + ux * (tipMax - cProj), cy + uy * (tipMax - 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]); assert.ok( - Math.abs(lenEuclid - tipEuclid) <= 2, - `length span ${lenEuclid.toFixed(1)} must equal tip-to-tip ${tipEuclid.toFixed(1)}`, + Math.abs(lenAxial - tipAxial) <= 2, + `length span ${lenAxial.toFixed(1)} must equal centerline tip span ${tipAxial.toFixed(1)} (no speck overshoot)`, ); - // 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); - assert.ok(Math.abs(plan.length.b[1] - mapTipBot[1]) <= 1.5 || Math.abs(plan.length.b[1] - mapTipTop[1]) <= 1.5); + + // Axial stations of length ends = robust tip stations (pure axis-right offset does not change along-u). + // Inventory map is isotropic scale+translate, so axis direction is preserved. + 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]) <= 2, `length min station ${lenProjs[0]} ≈ tip ${tipProjs[0]}`); + assert.ok(Math.abs(lenProjs[1] - tipProjs[1]) <= 2, `length max station ${lenProjs[1]} ≈ tip ${tipProjs[1]}`); // 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; @@ -497,3 +482,120 @@ test('buildOverlayPlan: width labels fully left of wood AABB after inventory map 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: 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 projs: number[] = []; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (!bitset[y * W + x]) continue; + projs.push(x * uxB + y * uyB); + } + } + 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 tipStart: [number, number] = [cx + uxB * (projs[lo] - cProj), cy + uyB * (projs[lo] - cProj)]; + const tipEnd: [number, number] = [cx + uxB * (projs[hi] - cProj), cy + uyB * (projs[hi] - 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; + assert.ok( + Math.abs(along) <= 1.5, + `length end offset must be pure axis-right (along=${along.toFixed(2)} must be ~0; 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)})`); + } +});