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,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 } });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user