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]; 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]; } function maskAxisSegment( bitset: Uint8Array, w: number, h: number, bbox: BBox, ): { start: [number, number]; end: [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; let minP = Infinity; let maxP = -Infinity; 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; } } 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)], 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 } = 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); 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 = 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]; 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]), 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 pad = Math.max(16, scale * 0.75); 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]; const midSrc: [number, number] = [(aSrc[0] + bSrc[0]) / 2, (aSrc[1] + bSrc[1]) / 2]; // Nudge label further outward a bit const labelSrc: [number, number] = [midSrc[0] + rx * (scale * 0.4), midSrc[1] + ry * (scale * 0.4)]; const fontPx = Math.max(28, Math.round(scale * 1.2)); 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, }, 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(3, Math.round(fontPx / 10)); ctx.lineCap = 'round'; 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(10, fontPx * 0.35); 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 arrow(plan.length.a, plan.length.b); ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]); // Widths for (const w of plan.widths) { arrow(w.left, w.right); ctx.fillText(w.label, w.center[0], w.center[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.8; 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, }); drawOverlayPlan(ctx, plan); return await canvas.convertToBlob({ type: 'image/png' }); } finally { bmp.close(); } }