import type { BBox } from '../capture/capture.types'; import { axisEndpoints } from '../measure/measure'; /** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */ export function overlayWidthStations(lengthIn: number): number[] { if (!(lengthIn > 6)) return []; const last = lengthIn - 6; const out: number[] = []; for (let s = 6; s <= last + 1e-9; s += 12) out.push(s); return out; } /** * EXIF-oriented photo bottom = larger canvas y. Pick which end of the approved * length-axis span is bottom; return unit from bottom → top along that span. */ export function pickBottomTowardTop( start: [number, number], end: [number, number], ): { bottom: [number, number]; top: [number, number]; ux: number; uy: number; } { // Larger y is closer to the bottom edge of the oriented photo. // Tie-break (horizontal axis): prefer larger x for a stable choice. const startIsBottom = start[1] > end[1] || (start[1] === end[1] && start[0] >= end[0]); const bottom = startIsBottom ? start : end; const top = startIsBottom ? end : start; const dx = top[0] - bottom[0]; const dy = top[1] - bottom[1]; const len = Math.hypot(dx, dy) || 1; return { bottom, top, ux: dx / len, uy: dy / len }; } /** Screen-space right of travel (canvas y↓): (ux,uy) → (−uy, ux). */ export function axisRightUnit(ux: number, uy: number): [number, number] { return [-uy, ux]; } export interface OverlayWidthMark { stationIn: number; center: [number, number]; left: [number, number]; right: [number, number]; /** Axis-left of the slab, outside the wood edge (opposite of length dim). */ labelAt: [number, number]; label: string; } export interface OverlayLengthMark { a: [number, number]; b: [number, number]; labelAt: [number, number]; label: string; /** True when the line sits outside the slab on the axis-right side. */ outside: boolean; } export interface OverlayPlan { stroke: '#000000'; fontPx: number; length: OverlayLengthMark; widths: OverlayWidthMark[]; } export interface OverlayPlanInput { bitset: Uint8Array; widthPx: number; heightPx: number; bbox: BBox; scale: number; // px per inch lengthIn: number; 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; } /** * Min across-width as a fraction of max bin width for an axial bin to count as a tip. * #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain. */ export const BODY_WIDTH_FRAC = 0.15; /** * Axial tip stations from across-width profile (#71/#73). * 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 ~10% fringe whiskers; keep 15–25% live-edge tapers (#73). const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC); 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( bitset: Uint8Array, w: number, h: number, bbox: BBox, ): { start: [number, number]; end: [number, number]; /** Actual mask pixels nearest centerline at tip stations. */ 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]; const elen = Math.hypot(ex, ey) || 1; const ux = ex / elen; const uy = ey / elen; 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; // 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]; } // Tip pixel at each 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; const across = Math.abs((x - cx) * -uy + (y - cy) * ux); 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]; } } } 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, }; } /** Probe slab extent along unit (px, py) through origin (ox, oy). */ function probeExtent( bitset: Uint8Array, w: number, h: number, ox: number, oy: number, px: number, py: number, ): { minP: number; maxP: number } | null { const half = Math.max(2, Math.round((w + h) / 4)); let minP = Infinity; let maxP = -Infinity; for (let p = -half; p <= half; p++) { const x = Math.round(ox + px * p); const y = Math.round(oy + py * 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; } } if (maxP < minP) return null; return { minP, maxP }; } function fmtInches(inches: number): string { const whole = Math.floor(inches + 1e-9); const rem = Math.round((inches - whole) * 8); if (rem === 0) return `${whole}"`; const frac: Record = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '', }; if (rem === 8) return `${whole + 1}"`; const part = frac[rem] ?? `${rem}/8`; return whole > 0 ? `${whole} ${part}"` : `${part}"`; } export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan { 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). const oriented = pickBottomTowardTop(seg.start, seg.end); const { bottom, top, ux, uy } = oriented; const [rx, ry] = axisRightUnit(ux, uy); // 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); // Width tips retain stroke/arrowhead protection. Length endpoints need only // half-stroke protection: larger arrowhead-derived inset visibly under-reached tips (#73). const widthEdgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15); const lengthEdgeInset = Math.max(0.5, lineW / 2); const pad = Math.max(10, Math.min(28, fontPx * 1.1)); const stations = overlayWidthStations(lengthIn); const widths: OverlayWidthMark[] = []; for (const stationIn of stations) { const t = stationIn * scale; const ox = bottom[0] + ux * t; const oy = bottom[1] + uy * t; const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry); if (!extent) continue; // 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 * widthEdgeInset, leftMapped[1] + wuy * widthEdgeInset]; const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset]; // Width number sits axis-left outside the slab (past left tip). 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, right, labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]), label: fmtInches(widthIn), }); } // Length graphic: beside wood on axis-right, outside max right extent. let maxRight = 0; const samples = Math.max(8, Math.round(lengthIn)); for (let i = 0; i <= samples; i++) { const t = (i / samples) * (lengthIn * scale); const ox = bottom[0] + ux * t; const oy = bottom[1] + uy * t; const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry); if (extent && extent.maxP > maxRight) maxRight = extent.maxP; } const outward = maxRight + pad; // 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 * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset]; const bInv: [number, number] = [bMapped[0] - lux * lengthEdgeInset, bMapped[1] - luy * lengthEdgeInset]; 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 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: lengthMark, widths, }; } /** Draw black dimension graphics onto an existing canvas (inventory PNG size). */ export function drawOverlayPlan( ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D, plan: OverlayPlan, ): void { const { stroke, fontPx } = plan; ctx.save(); ctx.strokeStyle = stroke; ctx.fillStyle = stroke; 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'; const arrow = (from: [number, number], to: [number, number]) => { const dx = to[0] - from[0]; const dy = to[1] - from[1]; const len = Math.hypot(dx, dy) || 1; const ux = dx / len; const uy = dy / len; const head = Math.max(7, fontPx * 0.32); ctx.beginPath(); ctx.moveTo(from[0], from[1]); ctx.lineTo(to[0], to[1]); ctx.stroke(); // arrow tips at both ends for (const [sx, sy, dir] of [ [from[0], from[1], 1], [to[0], to[1], -1], ] as const) { const px = -uy; const py = ux; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + dir * ux * head + px * head * 0.4, sy + dir * uy * head + py * head * 0.4); ctx.lineTo(sx + dir * ux * head - px * head * 0.4, sy + dir * uy * head - py * head * 0.4); ctx.closePath(); ctx.fill(); } }; // Length — grow text further axis-right of the dim line (away from wood) arrow(plan.length.a, plan.length.b); { 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) 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]); } ctx.restore(); } export interface InventoryMap { mapToInventory: (x: number, y: number) => [number, number]; outW: number; outH: number; } /** Same crop math as composeInventoryFrame — source → inventory PNG pixels. */ export function inventorySourceMap( slabBounds: { x: number; y: number; w: number; h: number }, source: { widthPx: number; heightPx: number }, outW: number, outH: number, ): InventoryMap { const FILL = 0.9; const x0 = Math.max(0, Math.round(slabBounds.x)); const y0 = Math.max(0, Math.round(slabBounds.y)); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w))); const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h))); const sw = x1 - x0; const sh = y1 - y0; const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1)); const dw = sw * scale; const dh = sh * scale; const ox = (outW - dw) / 2; const oy = (outH - dh) / 2; return { outW, outH, mapToInventory: (sx, sy) => [ox + (sx - x0) * scale, oy + (sy - y0) * scale], }; } export interface EncodeOverlayInput { /** Clean calibration inventory PNG bytes. */ inventoryPng: Blob; bitset: Uint8Array; widthPx: number; heightPx: number; bbox: BBox; scale: number; lengthIn: number; lengthLabel: string; slabBounds: { x: number; y: number; w: number; h: number }; source: { widthPx: number; heightPx: number }; } /** * Burn black dimension overlays onto a copy of the clean calibration inventory PNG. * Returns a new image/png blob; never mutates the clean inventory. */ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise { const bmp = await createImageBitmap(input.inventoryPng); try { const outW = bmp.width; const outH = bmp.height; const canvas = new OffscreenCanvas(outW, outH); const ctx = canvas.getContext('2d')!; ctx.drawImage(bmp, 0, 0); const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH); const plan = buildOverlayPlan({ bitset: input.bitset, widthPx: input.widthPx, heightPx: input.heightPx, bbox: input.bbox, scale: input.scale, lengthIn: input.lengthIn, lengthLabel: input.lengthLabel, mapToInventory, inventoryW: outW, inventoryH: outH, }); drawOverlayPlan(ctx, plan); return await canvas.convertToBlob({ type: 'image/png' }); } finally { bmp.close(); } }