fix(capture): rotate length-axis rectangle and scale from mask span

Length axis keeps the approved mask, replaces Flip 180 with a ± angle
slider, and sets scale from wood span along the approved direction rather
than bbox.longSidePx. Widths still sample every 6 in perpendicular to that
axis.
This commit is contained in:
2026-09-14 16:37:00 +00:00
parent b781ffeb66
commit b328871549
9 changed files with 201 additions and 45 deletions
+3 -3
View File
@@ -33,7 +33,7 @@ Otherwise the app opens Add photo.
3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step. 3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step.
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis. 4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate. 5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
6. App sets `scale = longest_axis_px / L`. 6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 80% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA). 7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 80% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
8. User sees the transparent PNG and the numbers. Milestone 1 stops here. 8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
@@ -87,13 +87,13 @@ User approves the mask when only the slab remains. That mask is the footprint fo
## Length axis ## Length axis
On the same photo, draw the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis. On the same photo, keep the approved mask in view and draw the minimum-area bounding rectangle of the mask with the length axis on the long side of that rectangle. User rotates the rectangle (slider with minus and plus) until that direction follows the slab length, then approves the axis.
## Measure ## Measure
User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
- `scale = longest_axis_px / L` - `scale = longest_axis_px / L` where `longest_axis_px` is the mask/wood span along the approved length direction (slab pixels end-to-end on that axis), not the rectangle edge length. The rectangle sets direction only; user-entered `L` is wood end→end along that direction.
- Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it - Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it
- `sqft = (mask_pixels / scale²) / 144` (2 decimals) - `sqft = (mask_pixels / scale²) / 144` (2 decimals)
- `bdft = sqft × thickness_in` (2 decimals) - `bdft = sqft × thickness_in` (2 decimals)
+1 -1
View File
@@ -64,7 +64,7 @@
{:else if cap.k === 'axis'} {:else if cap.k === 'axis'}
<AxisPanel <AxisPanel
state={cap} state={cap}
rotateAxis={session.rotateAxis} setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis} approveAxis={session.approveAxis}
back={session.back} back={session.back}
/> />
+30 -4
View File
@@ -3,18 +3,37 @@
import PhotoStage from './PhotoStage.svelte'; import PhotoStage from './PhotoStage.svelte';
export let state: Extract<Capture, { k: 'axis' }>; export let state: Extract<Capture, { k: 'axis' }>;
export let rotateAxis: () => void; export let setAxisAngle: (deg: number) => void;
export let approveAxis: () => void; export let approveAxis: () => void;
export let back: () => void; export let back: () => void;
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
</script> </script>
<section class="panel"> <section class="panel">
<h1>Length axis</h1> <h1>Length axis</h1>
<p class="hint">The arrow points along the slab length. If it does not follow the slab, flip it.</p> <p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
<PhotoStage blob={state.source.blob} bbox={state.approvedMask.bbox} heading={state.heading} /> <PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
/>
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
<input
type="range" min="0" max="179" step="1"
value={angleDeg}
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
<span class="val">{angleDeg}°</span>
</div>
<div class="row"> <div class="row">
<button type="button" class="ghost" on:click={back}>Back</button> <button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={rotateAxis}>Flip 180°</button>
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button> <button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
</div> </div>
</section> </section>
@@ -23,6 +42,13 @@
.panel { padding: 16px; color: #eee; } .panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; } h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; } .hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 10px; margin-top: 16px; } .row { display: flex; gap: 10px; margin-top: 16px; }
.primary { .primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0; flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
+7 -1
View File
@@ -19,7 +19,13 @@
<section class="panel"> <section class="panel">
<h1>Measure</h1> <h1>Measure</h1>
<PhotoStage blob={state.source.blob} bbox={state.axis.bbox} heading={state.axis.heading} /> <PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
/>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
+6 -5
View File
@@ -32,8 +32,8 @@ export interface ABox {
// Minimum-area bounding rectangle of the mask, source px. // Minimum-area bounding rectangle of the mask, source px.
export interface BBox { export interface BBox {
corners: [number, number][]; // 4 corners, counter-clockwise corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
longSidePx: number; // longest_axis_px → scale numerator longSidePx: number; // geometric extent only; scale uses mask span along the axis
angleDeg: number; // long-side angle, degrees, [0, 180) 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 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 { export interface Axis {
bbox: BBox; bbox: BBox;
heading: 1 | -1; // which way along the long side is forward (180° flip)
} }
export interface Widths { export interface Widths {
@@ -84,7 +85,7 @@ export type Capture =
k: 'axis'; k: 'axis';
source: Source; source: Source;
approvedMask: Mask; // frozen footprint approvedMask: Mask; // frozen footprint
heading: 1 | -1; axis: Axis; // live axis.bbox being nudged
} }
| { | {
k: 'measure'; k: 'measure';
@@ -104,7 +105,7 @@ export interface Draft {
source: Source; source: Source;
maskParams: MaskParams; // → re-derive mask on resume maskParams: MaskParams; // → re-derive mask on resume
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
heading: 1 | -1; // present when stage >= axis axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
lengthEighths: Eighths; lengthEighths: Eighths;
thicknessEighths: Eighths; thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed results: Omit<Measures, 'inventoryPng'> | null; // present when results computed
+18 -11
View File
@@ -5,6 +5,7 @@ import type {
import { eighthsToIn } from './capture.types'; import { eighthsToIn } from './capture.types';
import { loadDraft, saveDraft, clearDraft } from '../draft/idb'; import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure'; import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng } from '../png/inventory'; import { encodeInventoryPng } from '../png/inventory';
const DEBOUNCE_MS = 2000; const DEBOUNCE_MS = 2000;
@@ -21,7 +22,7 @@ export interface CaptureSession {
setSensitivity(n: number): void; setSensitivity(n: number): void;
setSheet(s: Sheet): void; setSheet(s: Sheet): void;
approveMask(): void; approveMask(): void;
rotateAxis(): void; setAxisAngle(deg: number): void;
approveAxis(): void; approveAxis(): void;
setLength(e: Eighths): void; setLength(e: Eighths): void;
setThickness(e: Eighths): void; setThickness(e: Eighths): void;
@@ -126,7 +127,7 @@ export function createCaptureSession(): CaptureSession {
if (state.k === 'axis') { if (state.k === 'axis') {
return { return {
version: 1, stage: 'axis', source: state.source, maskParams: state.approvedMask.params, 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, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
}; };
} }
@@ -134,7 +135,7 @@ export function createCaptureSession(): CaptureSession {
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
return { return {
version: 1, stage: 'measure', source: state.source, maskParams: state.approvedMask.params, 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, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null, results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? 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); 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 restoreFromDraft = async (d: Draft) => {
const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob }; const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
if (d.stage === 'mask') { if (d.stage === 'mask') {
@@ -193,7 +198,7 @@ export function createCaptureSession(): CaptureSession {
try { try {
const mask = await rederiveMask(source, d.maskParams, d.mask); const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') { if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, heading: d.heading }); set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
return; return;
} }
if (d.inventoryPng) { if (d.inventoryPng) {
@@ -202,7 +207,7 @@ export function createCaptureSession(): CaptureSession {
} }
set({ set({
k: 'measure', source, approvedMask: mask, k: 'measure', source, approvedMask: mask,
axis: { bbox: mask.bbox, heading: d.heading }, axis: { bbox: draftAxisBbox(d) },
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results results: d.results
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() } ? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
@@ -264,13 +269,15 @@ export function createCaptureSession(): CaptureSession {
approveMask() { approveMask() {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker(); 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(); void persist();
}, },
rotateAxis() { setAxisAngle(deg: number) {
if (state.k !== 'axis') return; 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(); void persist();
}, },
@@ -278,7 +285,7 @@ export function createCaptureSession(): CaptureSession {
if (state.k !== 'axis') return; if (state.k !== 'axis') return;
set({ set({
k: 'measure', source: state.source, approvedMask: state.approvedMask, 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, lengthEighths: 0, thicknessEighths: 0, results: null,
}); });
void persist(); void persist();
@@ -303,7 +310,7 @@ export function createCaptureSession(): CaptureSession {
const bitmap = await createImageBitmap(source.blob); const bitmap = await createImageBitmap(source.blob);
const base = measure( const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx, approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, axis.heading, lengthEighths, thicknessEighths, approvedMask.slabPx, axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
); );
let pngBlob: Blob; let pngBlob: Blob;
let belowFloor = false; let belowFloor = false;
@@ -331,7 +338,7 @@ export function createCaptureSession(): CaptureSession {
const m = state.approvedMask; const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false }); set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') { } 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 } });
} }
}, },
+51
View File
@@ -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}`);
});
+36
View File
@@ -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; const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
return { corners, longSidePx: halfL * 2, angleDeg }; 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;
}
+49 -20
View File
@@ -1,31 +1,60 @@
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types'; 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 // Display / direction: corners[3] → corners[0] is the long side.
// the axis reads corners[1] -> corners[0] (see rect.ts corner order). export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
export function axisEndpoints(bbox: BBox, heading: 1 | -1): { start: [number, number]; end: [number, number] } { return { start: bbox.corners[3], end: bbox.corners[0] };
return heading === 1 ? { start: bbox.corners[1], end: bbox.corners[0] } : { start: bbox.corners[0], end: bbox.corners[1] };
} }
// Sampled every 6 in along the axis; each sample is a line probe perpendicular // Wood end→end along the approved direction: project the mask onto the axis
// to the axis, width = max run of slab px across the probe. // 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( function sampleWidths(
bitset: Uint8Array, w: number, h: number, bitset: Uint8Array, w: number, h: number,
start: [number, number], end: [number, number], start: [number, number], ux: number, uy: number,
scale: number, lengthIn: number, scale: number, lengthIn: number,
): Eighths[] { ): Eighths[] {
const dx = end[0] - start[0]; const px = -uy, py = ux;
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 stepPx = 6 * scale; const stepPx = 6 * scale;
const count = Math.max(1, Math.floor(lengthIn / 6) + 1); const count = Math.max(1, Math.floor(lengthIn / 6) + 1);
const out: Eighths[] = []; const out: Eighths[] = [];
const half = Math.max(2, Math.round((w + h) / 4)); const half = Math.max(2, Math.round((w + h) / 4));
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const t = (i / (count - 1 || 1)) * len; const t = i * stepPx;
const ox = start[0] + ax * t; const ox = start[0] + ux * t;
const oy = start[1] + ay * t; const oy = start[1] + uy * t;
let minP = Infinity, maxP = -Infinity; let minP = Infinity, maxP = -Infinity;
for (let p = -half; p <= half; p++) { for (let p = -half; p <= half; p++) {
const x = Math.round(ox + px * p); const x = Math.round(ox + px * p);
@@ -36,7 +65,7 @@ function sampleWidths(
if (p > maxP) maxP = p; 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))); out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
} }
return out; return out;
@@ -44,14 +73,14 @@ function sampleWidths(
export function measure( export function measure(
bitset: Uint8Array, w: number, h: number, bitset: Uint8Array, w: number, h: number,
bbox: BBox, heading: 1 | -1, bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths, lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number, slabPx: number,
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } { ): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8; const lengthIn = lengthEighths / 8;
const scale = bbox.longSidePx / lengthIn; const seg = maskAxisSegment(bitset, w, h, bbox);
const { start, end } = axisEndpoints(bbox, heading); const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const every6In = sampleWidths(bitset, w, h, start, end, scale, lengthIn); const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
let widths: Widths; let widths: Widths;
if (every6In.length > 0) { if (every6In.length > 0) {
const min = Math.min(...every6In); const min = Math.min(...every6In);