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:
+3
-3
@@ -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.
|
||||
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.
|
||||
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).
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
||||
- `bdft = sqft × thickness_in` (2 decimals)
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
{:else if cap.k === 'axis'}
|
||||
<AxisPanel
|
||||
state={cap}
|
||||
rotateAxis={session.rotateAxis}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
approveAxis={session.approveAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
|
||||
@@ -3,18 +3,37 @@
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let rotateAxis: () => void;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let approveAxis: () => 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>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">The arrow points along the slab length. If it does not follow the slab, flip it.</p>
|
||||
<PhotoStage blob={state.source.blob} bbox={state.approvedMask.bbox} heading={state.heading} />
|
||||
<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.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">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -23,6 +42,13 @@
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.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; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
|
||||
<section class="panel">
|
||||
<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="field">
|
||||
|
||||
@@ -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<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;
|
||||
thicknessEighths: Eighths;
|
||||
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Measures, 'inventoryPng'> & { 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);
|
||||
|
||||
Reference in New Issue
Block a user