diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index 9e071be..b38c1f1 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -385,35 +385,55 @@ test('buildOverlayPlan: length axial span = tip span; inventory y stays within w `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) + // Length endpoints = robust tip pixels + common screen offset → tip-to-tip span 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; + 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 * ux + y * uy); + } + } + 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 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; - if (p < tipMin) tipMin = p; - if (p > tipMax) tipMax = p; + 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 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)); + 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]); assert.ok( - Math.abs(lenSpan - tipSpan) <= 2, - `length axial span ${lenSpan.toFixed(1)} must equal tip span ${tipSpan.toFixed(1)}`, + Math.abs(lenEuclid - tipEuclid) <= 2, + `length span ${lenEuclid.toFixed(1)} must equal tip-to-tip ${tipEuclid.toFixed(1)}`, ); + // 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); // 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; diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index 73eb341..6841fd8 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -83,7 +83,16 @@ function maskAxisSegment( w: number, h: number, bbox: BBox, -): { start: [number, number]; end: [number, number]; ux: number; uy: number; span: number } { +): { + start: [number, number]; + end: [number, number]; + /** Actual mask pixels achieving min/max axial projection (wood tips). */ + tipStart: [number, number]; + tipEnd: [number, number]; + ux: number; + uy: number; + span: number; +} { const { start: a, end: b } = axisEndpoints(bbox); const ex = b[0] - a[0]; const ey = b[1] - a[1]; @@ -93,21 +102,50 @@ function maskAxisSegment( 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 cProj = cx * ux + cy * uy; - let minP = Infinity; - let maxP = -Infinity; + // Collect axial projections; absolute min/max tip pixels can be mask specks + // past the visible wood footprint (Ty UAT #69). Use robust percentiles and + // pick tip pixels near the centerline at those stations. + 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); + 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 tipEnd: [number, number] = b; + let bestStart = Infinity; + let bestEnd = Infinity; + const stationTol = Math.max(1.5, (maxP - minP) * 0.001); for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { if (!bitset[row + x]) continue; const p = x * ux + y * uy; - if (p < minP) minP = p; - if (p > maxP) maxP = p; + const across = Math.abs((x - cx) * -uy + (y - cy) * ux); // |(p - c) cross u| via r + if (Math.abs(p - minP) <= stationTol && across < bestStart) { + bestStart = across; + tipStart = [x, y]; + } + if (Math.abs(p - maxP) <= stationTol && across < bestEnd) { + bestEnd = across; + tipEnd = [x, y]; + } } } - if (maxP < minP) return { start: a, end: b, ux, uy, span: 0 }; return { start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)], end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)], + tipStart, + tipEnd, ux, uy, span: maxP - minP, @@ -174,7 +212,8 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP]; const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP]; // Width number sits axis-left outside the slab (past left tip). - const labelOff = extent.minP - pad - scale * 0.4; + // Extra clearance so glyph box (textAlign grows away from wood) stays off footprint. + 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 widthIn = (extent.maxP - extent.minP + 1) / scale; widths.push({ @@ -198,10 +237,31 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { if (extent && extent.maxP > maxRight) maxRight = extent.maxP; } const outward = maxRight + pad; - const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward]; - const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward]; + // Tip pixels = wood ends. Translate them by a single screen-axis offset so the + // dim line stays || tip-to-tip (measured span = wood tip-to-tip) and arrowheads + // share tip y (vertical-ish) / tip x (horizontal-ish) — no past-footprint + // overshoot from (rx,ry)*outward (#69). Clearance magnitude matches classic + // axis-right mid (centerline + r*outward). + const tipOriented = pickBottomTowardTop(seg.tipStart, seg.tipEnd); + const aClassic: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward]; + const bClassic: [number, number] = [top[0] + rx * outward, top[1] + ry * outward]; + const tipMidX = (tipOriented.bottom[0] + tipOriented.top[0]) / 2; + const tipMidY = (tipOriented.bottom[1] + tipOriented.top[1]) / 2; + const classicMidX = (aClassic[0] + bClassic[0]) / 2; + const classicMidY = (aClassic[1] + bClassic[1]) / 2; + let aSrc: [number, number]; + let bSrc: [number, number]; + if (Math.abs(uy) >= Math.abs(ux)) { + const dx = classicMidX - tipMidX; + aSrc = [tipOriented.bottom[0] + dx, tipOriented.bottom[1]]; + bSrc = [tipOriented.top[0] + dx, tipOriented.top[1]]; + } else { + const dy = classicMidY - tipMidY; + aSrc = [tipOriented.bottom[0], tipOriented.bottom[1] + dy]; + bSrc = [tipOriented.top[0], tipOriented.top[1] + dy]; + } const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2]; - // Nudge label further outward a bit + // 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)); @@ -264,11 +324,15 @@ export function drawOverlayPlan( // Length arrow(plan.length.a, plan.length.b); + ctx.textAlign = 'center'; ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]); - // Widths + // Widths — grow text away from wood (labelAt is axis-left of left tip) for (const w of plan.widths) { arrow(w.left, w.right); + // labelAt is on the axis-left side of the station; align so glyphs extend further out + const towardLeft = w.labelAt[0] <= w.center[0]; + ctx.textAlign = towardLeft ? 'right' : 'left'; ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]); }