diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index 77fa22e..9e071be 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -5,9 +5,11 @@ import { pickBottomTowardTop, axisRightUnit, buildOverlayPlan, + inventorySourceMap, type OverlayPlan, } from './overlay.ts'; import { orientedRect, minAreaRect } from '../geometry/rect.ts'; +import { axisEndpoints } from '../measure/measure.ts'; // --- Station math (#63) --- @@ -292,3 +294,186 @@ test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)', // Concrete contract: max(22, round(scale*1.0)) assert.equal(plan.fontPx, Math.max(22, Math.round(scale * 1.0))); }); + + +// --- Length dim must not overshoot wood footprint (#69) --- + +/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */ +function tiltedSlabFixture() { + const W = 300, H = 600; + const bitset = new Uint8Array(W * H); + for (let y = 50; y <= 550; y++) { + const t = (y - 50) / 500; + const x0 = Math.round(80 + t * 20); + const x1 = Math.round(180 + t * 40); + for (let x = x0; x <= x1; x++) bitset[y * W + x] = 1; + } + const ang = (10 * Math.PI) / 180; + const ux0 = Math.sin(ang); + const uy0 = Math.cos(ang); + const rx0 = -uy0; + const ry0 = ux0; + const cx = 140, cy = 300, halfL = 280, halfW = 90; + const corners: [number, number][] = [ + [cx + ux0 * halfL + rx0 * halfW, cy + uy0 * halfL + ry0 * halfW], + [cx + ux0 * halfL - rx0 * halfW, cy + uy0 * halfL - ry0 * halfW], + [cx - ux0 * halfL - rx0 * halfW, cy - uy0 * halfL - ry0 * halfW], + [cx - ux0 * halfL + rx0 * halfW, cy - uy0 * halfL + ry0 * halfW], + ]; + const bbox = { corners, longSidePx: halfL * 2, angleDeg: 80 }; + return { W, H, bitset, bbox }; +} + +function maskWoodAabb(bitset: Uint8Array, W: number, H: number) { + let minX = W, maxX = 0, minY = H, maxY = 0; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (!bitset[y * W + x]) continue; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + return { minX, maxX, minY, maxY }; +} + +test('buildOverlayPlan: length axial span = tip span; inventory y stays within wood AABB (#69)', () => { + const { W, H, bitset, bbox } = tiltedSlabFixture(); + const scale = 10; + const wood = maskWoodAabb(bitset, W, H); + const slabBounds = { + x: wood.minX - 8, + y: wood.minY - 8, + w: wood.maxX - wood.minX + 16, + h: wood.maxY - wood.minY + 16, + }; + const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200); + const plan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox, + scale, + lengthIn: 50, + lengthLabel: '50"', + mapToInventory: inv.mapToInventory, + }); + + // Wood AABB in inventory space + let mMinY = Infinity, mMaxY = -Infinity; + 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; + 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)}`, + ); + + // Measured axial span equals mask tip-to-tip (dim line parallel beside wood) + 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; + let tipMin = Infinity, tipMax = -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; + if (p < tipMin) tipMin = p; + if (p > tipMax) tipMax = p; + } + } + 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); + const mapBottom = inv.mapToInventory(oriented.bottom[0], oriented.bottom[1]); + const mapTop = inv.mapToInventory(oriented.top[0], oriented.top[1]); + const proj = (p: [number, number], o: [number, number]) => + (p[0] - o[0]) * oriented.ux + (p[1] - o[1]) * oriented.uy; + const tipSpan = Math.abs(proj(mapTop, mapBottom)); + const lenSpan = Math.abs(proj(plan.length.b, plan.length.a)); + assert.ok( + Math.abs(lenSpan - tipSpan) <= 2, + `length axial span ${lenSpan.toFixed(1)} must equal tip span ${tipSpan.toFixed(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; + assert.ok(lenMidX > mMaxX + 4, `length dim must sit outside wood on axis-right (x=${lenMidX} vs maxX=${mMaxX})`); +}); + +test('buildOverlayPlan: width labels fully left of wood AABB after inventory map (#69)', () => { + const { W, H, bitset, bbox } = tiltedSlabFixture(); + const scale = 10; + const wood = maskWoodAabb(bitset, W, H); + const slabBounds = { + x: wood.minX - 8, + y: wood.minY - 8, + w: wood.maxX - wood.minX + 16, + h: wood.maxY - wood.minY + 16, + }; + const inv = inventorySourceMap(slabBounds, { widthPx: W, heightPx: H }, 900, 1200); + const plan = buildOverlayPlan({ + bitset, + widthPx: W, + heightPx: H, + bbox, + scale, + lengthIn: 50, + lengthLabel: '50"', + mapToInventory: inv.mapToInventory, + }); + assert.ok(plan.widths.length > 0, 'expected width stations'); + + let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (!bitset[y * W + x]) continue; + const [mx, my] = inv.mapToInventory(x, y); + if (mx < mMinX) mMinX = mx; + if (mx > mMaxX) mMaxX = mx; + if (my < mMinY) mMinY = my; + if (my > mMaxY) mMaxY = my; + } + } + + for (const w of plan.widths) { + // Label anchor fully off the wood footprint (axis-left), with margin for glyph half-width + const margin = plan.fontPx * 0.55; // textAlign will grow away from wood; still keep anchor clear + const onWood = + w.labelAt[0] >= mMinX - 1 && + w.labelAt[0] <= mMaxX + 1 && + w.labelAt[1] >= mMinY - 1 && + w.labelAt[1] <= mMaxY + 1; + assert.ok( + !onWood, + `width labelAt (${w.labelAt[0].toFixed(0)},${w.labelAt[1].toFixed(0)}) must be outside wood AABB`, + ); + // Axis-left = past the left tip (smaller across); for this nearly-vertical slab, left of wood in x + assert.ok( + w.labelAt[0] < mMinX - 2, + `width label ${w.label} x=${w.labelAt[0].toFixed(0)} must be left of wood minX=${mMinX.toFixed(0)} (margin~${margin.toFixed(0)})`, + ); + // Width arrows still touch wood edges (left/right tips inside/on AABB) + assert.ok(w.left[0] >= mMinX - 3 && w.left[0] <= mMaxX + 3); + assert.ok(w.right[0] >= mMinX - 3 && w.right[0] <= mMaxX + 3); + } +});