feat(photos): burn axis-oriented dimension overlay PNG (#63)
Add pure station/axis math and canvas burn-in for a second calibration inventory PNG (results.overlayPng). Width stations start 6 in from the EXIF-oriented photo bottom along the approved length axis (6, 18, … ≤ L−6); length graphic sits on axis-right outside the slab. Clean inventoryPng and batchApply[cal]=null stay unchanged.
This commit is contained in:
@@ -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<Measures, 'inventoryPng'> | null; // present when results computed
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | 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[];
|
||||
}
|
||||
|
||||
@@ -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<Blob>;
|
||||
}
|
||||
|
||||
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<Capture>({ 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
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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<Blob> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user