diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index 527193c..3dc1d8b 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -599,3 +599,280 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || 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 (narrower than body) past top/bottom — denser than 0.2% + for (let y = 95; y < 120; y++) { + for (let x = 130; x <= 150; x++) bitset[y * W + x] = 1; + } + for (let y = 441; y <= 465; y++) { + for (let x = 130; x <= 150; 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 - 8, `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)})`, + ); + } +});