diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index 3dc1d8b..b9c53f3 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -138,7 +138,7 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s // Color contract for draw consumers 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)', () => { @@ -272,7 +272,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}`); }); -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 bitset = new Uint8Array(W * H); for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1; @@ -288,11 +288,13 @@ test('buildOverlayPlan: font one step smaller than prior 28 / scale*1.2 (#67)', lengthLabel: '48"', mapToInventory: (x, y) => [x, y], }); - const oldFont = Math.max(28, Math.round(scale * 1.2)); - assert.ok(plan.fontPx < oldFont, `fontPx ${plan.fontPx} must be < prior ${oldFont}`); - assert.ok(plan.fontPx >= 18, `fontPx ${plan.fontPx} still legible`); - // Concrete contract: max(22, round(scale*1.0)) - assert.equal(plan.fontPx, Math.max(22, Math.round(scale * 1.0))); + const oldFont67 = Math.max(28, Math.round(scale * 1.2)); + const oldFont71 = Math.max(22, Math.round(scale * 1.0)); + assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`); + assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`); + 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)))); }); @@ -371,28 +373,47 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho } } - // Robust tip stations along approved bbox axis (0.2% / 99.8%) + // Width-aware tip stations along approved bbox axis (#71; was 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[] = []; + const rx = -uy, ry = ux; + const binMin = new Map(); + const binMax = new Map(); 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); + 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); - 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]; - // 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)]; + 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 * 0.32); + 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; } + } + 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. @@ -400,13 +421,13 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho 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( - Math.abs(lenAxial - tipAxial) <= 2, - `length span ${lenAxial.toFixed(1)} must equal centerline tip span ${tipAxial.toFixed(1)} (no speck overshoot)`, + lenAxial <= tipAxial + 2 && lenAxial >= tipAxial - 24, + `length span ${lenAxial.toFixed(1)} must match width-aware tip span ${tipAxial.toFixed(1)} (allow inset)`, ); - // 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. + // Axial stations of length ends = tip stations (pure axis-right offset does not change along-u). const mapOrigin = inv.mapToInventory(0, 0); const mapU = inv.mapToInventory(oriented.ux, oriented.uy); const iux = mapU[0] - mapOrigin[0]; @@ -417,8 +438,8 @@ test('buildOverlayPlan: length axial span = robust tip stations; no AABB oversho (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]}`); + 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) const lenMidX = (plan.length.a[0] + plan.length.b[0]) / 2; @@ -542,18 +563,40 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || 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[] = []; + const rxB = -uyB, ryB = uxB; + const binMin = new Map(); + const binMax = new Map(); 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); + 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); } } - 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)]; + 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 * 0.32); + 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); @@ -577,9 +620,10 @@ test('buildOverlayPlan: length a→b || approved (ux,uy); width left→right || 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) <= 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)}))`, + 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)})`); } @@ -794,7 +838,7 @@ test('buildOverlayPlan: width endpoints on/inside wood edges with stroke inset ( `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}`); + assert.ok(planSpan >= fullSpan - 20, `inset must be modest, got plan=${planSpan} full=${fullSpan}`); } }); diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index df947ba..5e6d7b2 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -76,6 +76,69 @@ export interface OverlayPlanInput { lengthLabel: string; /** Map source px → inventory PNG px (identity OK for unit tests). */ mapToInventory: (x: number, y: number) => [number, number]; + /** Inventory PNG size — used to clamp labels inside the canvas (#71). */ + inventoryW?: number; + inventoryH?: number; +} + +/** + * Axial tip stations from across-width profile (#71). + * 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(); + const binMax = new Map(); + 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 narrow fringe; keep natural mild tapers. + const thresh = Math.max(4, maxWidth * 0.32); + 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( @@ -86,7 +149,7 @@ function maskAxisSegment( ): { start: [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]; tipEnd: [number, number]; ux: number; @@ -102,24 +165,30 @@ 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; - // 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); + // Width-aware tips (#71). Fall back to robust percentiles if profile empty. + const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy); + let minP: number; + let maxP: number; + if (aware) { + minP = aware.minP; + maxP = aware.maxP; + } else { + 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 }; - 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 + // Tip pixel at each station: closest to the axis centerline let tipStart: [number, number] = a; let tipEnd: [number, number] = b; let bestStart = Infinity; @@ -130,7 +199,7 @@ function maskAxisSegment( for (let x = 0; x < w; x++) { if (!bitset[row + x]) continue; 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) { bestStart = across; tipStart = [x, y]; @@ -191,7 +260,10 @@ function fmtInches(inches: number): string { } 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); // 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). @@ -199,7 +271,19 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { const { bottom, top, ux, uy } = oriented; 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); + const edgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15); + const pad = Math.max(10, Math.min(28, fontPx * 1.1)); const stations = overlayWidthStations(lengthIn); const widths: OverlayWidthMark[] = []; for (const stationIn of stations) { @@ -208,19 +292,25 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { const oy = bottom[1] + uy * t; const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry); if (!extent) continue; - // left = opposite of axis-right; right = axis-right tip on slab edge - const leftSrc: [number, number] = [ox + rx * extent.minP, oy + ry * extent.minP]; - const rightSrc: [number, number] = [ox + rx * extent.maxP, oy + ry * extent.maxP]; + // left/right on mask edges, then inset in inventory space for stroke/arrowhead + const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP); + 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 * edgeInset, leftMapped[1] + wuy * edgeInset]; + const right: [number, number] = [rightMapped[0] - wux * edgeInset, rightMapped[1] - wuy * edgeInset]; // 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 - scale * 0.4 - Math.max(22, Math.round(scale * 1.0)) * 0.35; + const labelOff = extent.minP - pad - fontPx * 0.85; const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff]; const widthIn = (extent.maxP - extent.minP + 1) / scale; widths.push({ stationIn, center: mapToInventory(ox, oy), - left: mapToInventory(leftSrc[0], leftSrc[1]), - right: mapToInventory(rightSrc[0], rightSrc[1]), + left, + right, labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]), label: fmtInches(widthIn), }); @@ -237,28 +327,66 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { if (extent && extent.maxP > maxRight) maxRight = extent.maxP; } const outward = maxRight + pad; - // Classic dimensioning (#70): centerline tip stations (robust 0.2%/99.8% axial - // percentiles from maskAxisSegment — keeps #69 speck immunity) offset by pure - // axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-only - // tip translation (that made a→b follow tip-to-tip ≠ axis on irregular wood). - const aSrc: [number, number] = [bottom[0] + rx * outward, bottom[1] + ry * outward]; - const bSrc: [number, number] = [top[0] + rx * outward, top[1] + ry * outward]; - const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2]; - // 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)]; + // Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by + // pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide. + // Axial inset (inventory space) so arrowhead tips sit on footprint, not past. + const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward); + const bMapped = mapToInventory(top[0] + rx * outward, top[1] + ry * outward); + const ldx0 = bMapped[0] - aMapped[0]; + const ldy0 = bMapped[1] - aMapped[1]; + const llen0 = Math.hypot(ldx0, ldy0) || 1; + const lux = ldx0 / llen0; + const luy = ldy0 / llen0; + const aInv: [number, number] = [aMapped[0] + lux * edgeInset, aMapped[1] + luy * edgeInset]; + const bInv: [number, number] = [bMapped[0] - lux * edgeInset, bMapped[1] - luy * edgeInset]; + const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2]; + // Length label further axis-right of dim line so glyphs stay off the wood (#71). + const mapOrigin = mapToInventory(0, 0); + const mapR = mapToInventory(rx, ry); + const irx = mapR[0] - mapOrigin[0]; + const iry = mapR[1] - mapOrigin[1]; + const irlen = Math.hypot(irx, iry) || 1; + const nrx = irx / irlen; + const nry = iry / irlen; + const labelAt: [number, number] = [ + midInv[0] + nrx * (fontPx * 0.85 + 6), + midInv[1] + nry * (fontPx * 0.85 + 6), + ]; - 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 { stroke: '#000000', fontPx, - length: { - a: mapToInventory(aSrc[0], aSrc[1]), - b: mapToInventory(bSrc[0], bSrc[1]), - labelAt: mapToInventory(labelSrc[0], labelSrc[1]), - label: lengthLabel, - outside: true, - }, + length: lengthMark, widths, }; } @@ -272,8 +400,9 @@ export function drawOverlayPlan( ctx.save(); ctx.strokeStyle = stroke; ctx.fillStyle = stroke; - ctx.lineWidth = Math.max(3, Math.round(fontPx / 10)); - ctx.lineCap = 'round'; + ctx.lineWidth = Math.max(2, Math.round(fontPx / 10)); + // Butt caps: round caps extend past endpoints and overshoot wood edges (#71). + ctx.lineCap = 'butt'; ctx.font = `bold ${fontPx}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; @@ -284,7 +413,7 @@ export function drawOverlayPlan( const len = Math.hypot(dx, dy) || 1; const ux = dx / len; const uy = dy / len; - const head = Math.max(10, fontPx * 0.35); + const head = Math.max(7, fontPx * 0.32); ctx.beginPath(); ctx.moveTo(from[0], from[1]); ctx.lineTo(to[0], to[1]); @@ -305,9 +434,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); - 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]); // Widths — grow text away from wood (labelAt is axis-left of left tip) @@ -390,6 +532,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise lengthIn: input.lengthIn, lengthLabel: input.lengthLabel, mapToInventory, + inventoryW: outW, + inventoryH: outH, }); drawOverlayPlan(ctx, plan); return await canvas.convertToBlob({ type: 'image/png' });