diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts
index caac078..2e61ba5 100644
--- a/frontend/src/capture/capture.types.ts
+++ b/frontend/src/capture/capture.types.ts
@@ -32,8 +32,8 @@ export interface ABox {
// Minimum-area bounding rectangle of the mask, source px.
export interface BBox {
- corners: [number, number][]; // 4 corners, counter-clockwise
- longSidePx: number; // longest_axis_px → scale numerator
+ corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
+ longSidePx: number; // geometric extent only; scale uses mask span along the axis
angleDeg: number; // long-side angle, degrees, [0, 180)
}
@@ -46,9 +46,10 @@ export interface Mask {
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
}
+// Direction only: an oriented rectangle over the slab. The arrow reads
+// corners[3] → corners[0] (the long side).
export interface Axis {
bbox: BBox;
- heading: 1 | -1; // which way along the long side is forward (180° flip)
}
export interface Widths {
@@ -84,7 +85,7 @@ export type Capture =
k: 'axis';
source: Source;
approvedMask: Mask; // frozen footprint
- heading: 1 | -1;
+ axis: Axis; // live axis.bbox being nudged
}
| {
k: 'measure';
@@ -104,7 +105,7 @@ export interface Draft {
source: Source;
maskParams: MaskParams; // → re-derive mask on resume
mask: Omit; // slabPx, bbox, slabBounds
- heading: 1 | -1; // present when stage >= axis
+ axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Omit | null; // present when results computed
diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts
index c9a14c8..99ebf0d 100644
--- a/frontend/src/capture/session.ts
+++ b/frontend/src/capture/session.ts
@@ -5,6 +5,7 @@ import type {
import { eighthsToIn } from './capture.types';
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure';
+import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng } from '../png/inventory';
const DEBOUNCE_MS = 2000;
@@ -21,7 +22,7 @@ export interface CaptureSession {
setSensitivity(n: number): void;
setSheet(s: Sheet): void;
approveMask(): void;
- rotateAxis(): void;
+ setAxisAngle(deg: number): void;
approveAxis(): void;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
@@ -126,7 +127,7 @@ export function createCaptureSession(): CaptureSession {
if (state.k === 'axis') {
return {
version: 1, stage: 'axis', source: state.source, maskParams: state.approvedMask.params,
- mask: strip(state.approvedMask), heading: state.heading,
+ mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
@@ -134,7 +135,7 @@ export function createCaptureSession(): CaptureSession {
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
return {
version: 1, stage: 'measure', source: state.source, maskParams: state.approvedMask.params,
- mask: strip(state.approvedMask), heading: state.axis.heading,
+ mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
@@ -181,6 +182,10 @@ export function createCaptureSession(): CaptureSession {
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
});
+ // Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
+ // fall back to the mask's own bbox.
+ const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
+
const restoreFromDraft = async (d: Draft) => {
const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
if (d.stage === 'mask') {
@@ -193,7 +198,7 @@ export function createCaptureSession(): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
- set({ k: 'axis', source, approvedMask: mask, heading: d.heading });
+ set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
return;
}
if (d.inventoryPng) {
@@ -202,7 +207,7 @@ export function createCaptureSession(): CaptureSession {
}
set({
k: 'measure', source, approvedMask: mask,
- axis: { bbox: mask.bbox, heading: d.heading },
+ axis: { bbox: draftAxisBbox(d) },
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
@@ -264,13 +269,15 @@ export function createCaptureSession(): CaptureSession {
approveMask() {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
- set({ k: 'axis', source: state.source, approvedMask: state.overlay, heading: 1 });
+ const overlay = state.overlay;
+ set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
void persist();
},
- rotateAxis() {
+ setAxisAngle(deg: number) {
if (state.k !== 'axis') return;
- set({ ...state, heading: state.heading === 1 ? -1 : 1 });
+ const a = ((Math.round(deg) % 180) + 180) % 180;
+ set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
void persist();
},
@@ -278,7 +285,7 @@ export function createCaptureSession(): CaptureSession {
if (state.k !== 'axis') return;
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
- axis: { bbox: state.approvedMask.bbox, heading: state.heading },
+ axis: { bbox: state.axis.bbox },
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
@@ -303,7 +310,7 @@ export function createCaptureSession(): CaptureSession {
const bitmap = await createImageBitmap(source.blob);
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
- axis.bbox, axis.heading, lengthEighths, thicknessEighths, approvedMask.slabPx,
+ axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
);
let pngBlob: Blob;
let belowFloor = false;
@@ -331,7 +338,7 @@ export function createCaptureSession(): CaptureSession {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
- set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, heading: state.axis.heading });
+ set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
}
},
diff --git a/frontend/src/geometry/rect.test.ts b/frontend/src/geometry/rect.test.ts
new file mode 100644
index 0000000..8490ff9
--- /dev/null
+++ b/frontend/src/geometry/rect.test.ts
@@ -0,0 +1,51 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
+import { measure } from '../measure/measure.ts';
+
+// 100×20 horizontal slab
+const W = 100, H = 20;
+const bitset = new Uint8Array(W * H);
+for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
+
+test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
+ const sx = maskSpanAlong(bitset, W, H, 1, 0);
+ const sy = maskSpanAlong(bitset, W, H, 0, 1);
+ assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
+ assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
+});
+
+test('orientedRect changes angleDeg and preserves longSidePx', () => {
+ const prev = minAreaRect(bitset, W, H);
+ assert.ok(prev, 'minAreaRect returned a box');
+ const rotated = orientedRect(prev, 45);
+ assert.ok(Math.abs(rotated.angleDeg - 45) < 1e-9);
+ assert.equal(rotated.longSidePx, prev.longSidePx);
+ // The long side (angleDeg 0 → +x) must be preserved by the rotation.
+ const longOf = (c) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]);
+ assert.ok(Math.abs(longOf(rotated.corners) - longOf(prev.corners)) < 1e-9);
+ assert.ok(Math.abs(longOf(prev.corners) - prev.longSidePx) < 1e-9);
+ // angle normalization to [0, 180)
+ assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
+});
+
+test('measure scale uses mask span along the axis, not longSidePx', () => {
+ // Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
+ // the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
+ // crosses only the 20-px slab width — span 19, not longSidePx 99.
+ const TW = 20, TH = 100;
+ const thin = new Uint8Array(TW * TH).fill(1);
+ const thinBox = minAreaRect(thin, TW, TH);
+ assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
+ const rotated = orientedRect(thinBox, 0);
+ const lengthEighths = 24; // 3 in
+ const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
+ // Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
+ // far from the old longSidePx/3 ≈ 33.
+ const ex = rotated.corners[0][0] - rotated.corners[3][0];
+ const ey = rotated.corners[0][1] - rotated.corners[3][1];
+ const elen = Math.hypot(ex, ey);
+ const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
+ assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
+ assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
+});
diff --git a/frontend/src/geometry/rect.ts b/frontend/src/geometry/rect.ts
index 50e4b20..8e6cbf3 100644
--- a/frontend/src/geometry/rect.ts
+++ b/frontend/src/geometry/rect.ts
@@ -92,3 +92,39 @@ export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | nu
const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
return { corners, longSidePx: halfL * 2, angleDeg };
}
+
+// Rotate the long side to a new angle, keeping center and half-extents.
+// Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the
+// long side (the axis arrow direction).
+export function orientedRect(prev: BBox, angleDeg: number): BBox {
+ const a = ((angleDeg % 180) + 180) % 180;
+ const halfL = prev.longSidePx / 2;
+ const halfS = Math.hypot(prev.corners[0][0] - prev.corners[1][0], prev.corners[0][1] - prev.corners[1][1]) / 2;
+ // corners[i] is a corner point: average of all four = center
+ const cx = (prev.corners[0][0] + prev.corners[1][0] + prev.corners[2][0] + prev.corners[3][0]) / 4;
+ const cy = (prev.corners[0][1] + prev.corners[1][1] + prev.corners[2][1] + prev.corners[3][1]) / 4;
+ const r = (a * Math.PI) / 180;
+ const lu = Math.cos(r), lv = Math.sin(r);
+ const su = -lv, sv = lu;
+ const corners: [number, number][] = [];
+ for (const [aSign, bSign] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
+ corners.push([cx + aSign * halfL * lu + bSign * halfS * su, cy + aSign * halfL * lv + bSign * halfS * sv]);
+ }
+ return { corners, longSidePx: prev.longSidePx, angleDeg: a };
+}
+
+// Project every slab pixel onto the unit vector (ux, uy); return max − min
+// extent in px. 0 when the mask is empty.
+export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
+ let min = Infinity, max = -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 < min) min = p;
+ if (p > max) max = p;
+ }
+ }
+ return max < min ? 0 : max - min;
+}
diff --git a/frontend/src/measure/measure.ts b/frontend/src/measure/measure.ts
index df3269d..cbcfc64 100644
--- a/frontend/src/measure/measure.ts
+++ b/frontend/src/measure/measure.ts
@@ -1,31 +1,60 @@
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
+import { maskSpanAlong } from '../geometry/rect';
-// Long-side start point per heading: heading 1 starts at corners[1] so that
-// the axis reads corners[1] -> corners[0] (see rect.ts corner order).
-export function axisEndpoints(bbox: BBox, heading: 1 | -1): { start: [number, number]; end: [number, number] } {
- return heading === 1 ? { start: bbox.corners[1], end: bbox.corners[0] } : { start: bbox.corners[0], end: bbox.corners[1] };
+// Display / direction: corners[3] → corners[0] is the long side.
+export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
+ return { start: bbox.corners[3], end: bbox.corners[0] };
}
-// Sampled every 6 in along the axis; each sample is a line probe perpendicular
-// to the axis, width = max run of slab px across the probe.
+// Wood end→end along the approved direction: project the mask onto the axis
+// unit vector and return the segment on the bbox center line covering that span.
+function maskAxisSegment(
+ bitset: Uint8Array, w: number, h: number, bbox: BBox,
+): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: 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, 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, 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, span: 0, ux, uy };
+ return {
+ start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
+ end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
+ span: maxP - minP,
+ ux,
+ uy,
+ };
+}
+
+// Sampled every 6 in along the wood axis; each sample is a line probe
+// perpendicular to the axis, width = max run of slab px across the probe.
function sampleWidths(
bitset: Uint8Array, w: number, h: number,
- start: [number, number], end: [number, number],
+ start: [number, number], ux: number, uy: number,
scale: number, lengthIn: number,
): Eighths[] {
- const dx = end[0] - start[0];
- const dy = end[1] - start[1];
- const len = Math.hypot(dx, dy) || 1;
- const ax = dx / len, ay = dy / len;
- const px = -ay, py = ax; // perpendicular
+ const px = -uy, py = ux;
const stepPx = 6 * scale;
const count = Math.max(1, Math.floor(lengthIn / 6) + 1);
const out: Eighths[] = [];
const half = Math.max(2, Math.round((w + h) / 4));
for (let i = 0; i < count; i++) {
- const t = (i / (count - 1 || 1)) * len;
- const ox = start[0] + ax * t;
- const oy = start[1] + ay * t;
+ const t = i * stepPx;
+ const ox = start[0] + ux * t;
+ const oy = start[1] + uy * t;
let minP = Infinity, maxP = -Infinity;
for (let p = -half; p <= half; p++) {
const x = Math.round(ox + px * p);
@@ -36,7 +65,7 @@ function sampleWidths(
if (p > maxP) maxP = p;
}
}
- if (maxP < minP) continue; // no slab px on this probe
+ if (maxP < minP) continue;
out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
}
return out;
@@ -44,14 +73,14 @@ function sampleWidths(
export function measure(
bitset: Uint8Array, w: number, h: number,
- bbox: BBox, heading: 1 | -1,
+ bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
): Omit & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8;
- const scale = bbox.longSidePx / lengthIn;
- const { start, end } = axisEndpoints(bbox, heading);
- const every6In = sampleWidths(bitset, w, h, start, end, scale, lengthIn);
+ const seg = maskAxisSegment(bitset, w, h, bbox);
+ const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
+ const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);