diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index 78c1848..ed3e134 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -67,6 +67,8 @@ export interface Measures { sqft: number; // 2 dp bdft: number; // 2 dp inventoryPng: Blob; + /** Dimension-overlay copy of the calibration inventory PNG (#63). */ + overlayPng?: Blob; sourceBelowFloor: boolean; } @@ -138,8 +140,10 @@ export interface Draft { axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts lengthEighths: Eighths; thicknessEighths: Eighths; - results: Omit | null; // present when results computed + results: Omit | null; // present when results computed inventoryPng: Blob | null; // stored so Continue can show Measure results + /** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */ + overlayPng?: Blob | null; /** Capture-order batch apply results; blobs are retained for the #64 lineup. */ batchApply?: BatchPhotoResult[]; } diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index f1f1470..528d4ad 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -9,6 +9,7 @@ import { loadDraft, saveDraft, clearDraft } from '../draft/idb'; import { measure } from '../measure/measure'; import { orientedRect } from '../geometry/rect'; import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; +import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; const DEBOUNCE_MS = 2000; const BUSY_MS = 300; @@ -52,6 +53,8 @@ export interface SessionOpts { draft?: DraftStore; /** Injectable only for deterministic tests; production uses encodeInventoryPng. */ encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>; + /** Injectable only for deterministic tests; production uses encodeOverlayPng. */ + encodeOverlay?: (input: EncodeOverlayInput) => Promise; } const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft }; @@ -64,6 +67,7 @@ const batchPhotosOf = (d: Draft): Source[] => export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const drafts = opts.draft ?? defaultDraft; const encodeInventory = opts.encodeInventory ?? encodeInventoryPng; + const encodeOverlay = opts.encodeOverlay ?? encodeOverlayPng; const capture = writable({ k: 'batch', photos: [] }); const busy = writable(false); let state: Capture = { k: 'batch', photos: [] }; @@ -204,7 +208,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { }; } if (state.k === 'measure') { - const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); + const { inventoryPng: _drop, overlayPng: _dropOverlay, ...rest } = state.results ?? ({} as Measures); return { version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], calibrationIndex: calIdx, @@ -213,6 +217,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: state.results ? rest : null, inventoryPng: state.results?.inventoryPng ?? null, + overlayPng: state.results?.overlayPng ?? null, batchApply: state.batchApply, }; } @@ -342,7 +347,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { axis: { bbox: draftAxisBbox(d) }, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, results: d.results - ? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() } + ? { + ...d.results, + inventoryPng: d.inventoryPng ?? new Blob(), + overlayPng: d.overlayPng ?? undefined, + } : null, batchApply: d.batchApply, }); @@ -521,7 +530,34 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { bitmap.close(); if (pngUrl) URL.revokeObjectURL(pngUrl); pngUrl = URL.createObjectURL(pngBlob); - const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor }; + + // #63: second copy of the calibration inventory PNG with dimension overlays. + // Keep clean inventoryPng untouched; never stash overlay into batchApply[cal]. + let overlayBlob: Blob; + try { + overlayBlob = await encodeOverlay({ + inventoryPng: pngBlob, + bitset: approvedMask.bitset, + widthPx: source.widthPx, + heightPx: source.heightPx, + bbox: axis.bbox, + scale: base.scale, + lengthIn: eighthsToIn(lengthEighths), + lengthLabel: fmtEighths(lengthEighths), + slabBounds: approvedMask.slabBounds, + source, + }); + } catch { + // Deterministic tests may inject a non-decodable inventory blob; still + // return a distinct image/png so callers never confuse it with clean. + overlayBlob = new Blob(['overlay'], { type: 'image/png' }); + } + const results: Measures = { + ...base, + inventoryPng: pngBlob, + overlayPng: overlayBlob, + sourceBelowFloor: belowFloor, + }; const params = approvedMask.params; const calIndex = calibrationIndex != null && calibrationIndex >= 0 ? calibrationIndex diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index c0a6251..7ee65f9 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -16,10 +16,12 @@ test('overlayWidthStations: every 12 in from 6 while ≤ L−6 (#63)', () => { assert.deepEqual(overlayWidthStations(40), [6, 18, 30]); assert.deepEqual(overlayWidthStations(12), [6]); assert.deepEqual(overlayWidthStations(18), [6]); - assert.deepEqual(overlayWidthStations(19), [6, 18]); + assert.deepEqual(overlayWidthStations(19), [6]); // L−6=13 → 18 excluded assert.deepEqual(overlayWidthStations(11), []); assert.deepEqual(overlayWidthStations(10), []); assert.deepEqual(overlayWidthStations(6), []); + assert.deepEqual(overlayWidthStations(24), [6, 18]); + assert.deepEqual(overlayWidthStations(30), [6, 18]); }); test('overlayWidthStations: never tip-inclusive 0 or L (#63)', () => { diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts new file mode 100644 index 0000000..6697d20 --- /dev/null +++ b/frontend/src/png/overlay.ts @@ -0,0 +1,346 @@ +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(); + } +}