diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 300a03c..5d4d0a7 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -5,6 +5,7 @@ import type { Capture } from './capture/capture.types'; import Resume from './capture/Resume.svelte'; import BatchPhotos from './capture/BatchPhotos.svelte'; + import PickCalibration from './capture/PickCalibration.svelte'; import MaskPanel from './capture/MaskPanel.svelte'; import AxisPanel from './capture/AxisPanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte'; @@ -54,6 +55,12 @@ onRemove={session.removeFromBatch} onContinue={session.continueBatch} /> + {:else if cap.k === 'pickCalibration'} + {:else if cap.k === 'mask'} + import { onDestroy } from 'svelte'; + import type { Source } from './capture.types'; + + export let photos: Source[]; + export let onPick: (index: number) => void; + export let onBack: () => void; + + let urls: string[] = []; + + $: { + for (const u of urls) URL.revokeObjectURL(u); + urls = photos.map((p) => URL.createObjectURL(p.blob)); + } + + onDestroy(() => { + for (const u of urls) URL.revokeObjectURL(u); + }); + + +
+

Pick calibration photo

+

+ Choose which photo is the top-down reference for length and thickness. + Mask, length axis, and Measure run on that photo only. +

+ +
    + {#each photos as _p, i (urls[i])} +
  • + {`Photo + +
  • + {/each} +
+ + +
+ + diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index e57d0db..14b3b6a 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -71,10 +71,11 @@ export interface Measures { } // The machine. 'resume' exists only while a draft exists and has not been chosen. -// M2: 'batch' replaces M1 'addPhoto' — accumulate 1–N photos before calibration. +// M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration. export type Capture = | { k: 'resume'; draft: Draft } | { k: 'batch'; photos: Source[] } + | { k: 'pickCalibration'; photos: Source[] } | { k: 'mask'; source: Source; @@ -117,10 +118,12 @@ export const DEFAULT_MASK_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50 // M2 extends with `batch` (capture-order photo blobs). Legacy M1 drafts omit it. export interface Draft { version: 1; - stage: 'batch' | 'mask' | 'axis' | 'measure'; // last approved / persisted state + stage: 'batch' | 'pickCalibration' | 'mask' | 'axis' | 'measure'; // last approved / persisted state /** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */ batch?: Source[]; - source: Source; // calibration / active photo; for stage==='batch' = photos[0] + /** Index into batch of the user-picked calibration photo. Set once picked. */ + calibrationIndex?: number; + source: Source; // calibration / active photo; for stage==='batch'|'pickCalibration' = photos[0] placeholder maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch') mask: Omit; // slabPx, bbox, slabBounds axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index c80a42c..d421d25 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -30,8 +30,10 @@ export interface CaptureSession { /** Alias: same as addToBatch (no longer jumps to mask). */ addPhoto(file: File): Promise; removeFromBatch(index: number): void; - /** Advance when batch has ≥1 photo. M1 bridge: mask on first photo. */ + /** Advance when batch has ≥1 photo → pickCalibration. */ continueBatch(): void; + /** User picks which batch photo is the calibration reference → Mask on that Source. */ + pickCalibration(index: number): void; retakes(): void; setSensitivity(n: number): void; setSheet(s: Sheet): void; @@ -63,6 +65,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const busy = writable(false); let state: Capture = { k: 'batch', photos: [] }; let batchPhotos: Source[] = []; + let calibrationIndex: number | null = null; let worker: Worker | null = null; let jobId = 0; let timer: ReturnType | null = null; @@ -173,9 +176,25 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { inventoryPng: null, }; } + if (state.k === 'pickCalibration') { + return { + version: 1, + stage: 'pickCalibration', + batch: state.photos, + source: state.photos[0], + maskParams: DEFAULT_MASK_PARAMS, + mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, + lengthEighths: 0, + thicknessEighths: 0, + results: null, + inventoryPng: null, + }; + } + const calIdx = calibrationIndex ?? undefined; if (state.k === 'axis') { return { version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source], + calibrationIndex: calIdx, source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, @@ -185,6 +204,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); return { version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], + calibrationIndex: calIdx, source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, @@ -195,11 +215,22 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { if (state.k === 'mask' && state.overlay) { return { version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], + calibrationIndex: calIdx, source: state.source, maskParams: state.overlay.params, mask: strip(state.overlay), lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } + // Mask before first overlay settles: still persist calibration choice + batch. + if (state.k === 'mask') { + return { + version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], + calibrationIndex: calIdx, + source: state.source, maskParams: state.control, + mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX }, + lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, + }; + } return null; }; @@ -252,11 +283,19 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const restoreFromDraft = async (d: Draft) => { batchPhotos = batchPhotosOf(d); + calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null; if (d.stage === 'batch') { teardownWorker(); + calibrationIndex = null; set({ k: 'batch', photos: [...batchPhotos] }); return; } + if (d.stage === 'pickCalibration') { + teardownWorker(); + calibrationIndex = null; + set({ k: 'pickCalibration', photos: [...batchPhotos] }); + return; + } const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob }; if (d.stage === 'mask') { teardownWorker(); @@ -289,9 +328,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { } }; - const enterMaskFromBatch = () => { - if (batchPhotos.length === 0) return; - const source = batchPhotos[0]; + const enterMaskOnSource = (source: Source) => { teardownWorker(); set({ k: 'mask', @@ -324,6 +361,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { if (state.k !== 'resume') return; teardownWorker(); batchPhotos = []; + calibrationIndex = null; await drafts.clearDraft(); set({ k: 'batch', photos: [] }); }, @@ -361,11 +399,23 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { continueBatch() { if (state.k !== 'batch' || state.photos.length === 0) return; batchPhotos = [...state.photos]; - enterMaskFromBatch(); + calibrationIndex = null; + teardownWorker(); + set({ k: 'pickCalibration', photos: [...batchPhotos] }); + void persist(); + }, + + pickCalibration(index: number) { + if (state.k !== 'pickCalibration') return; + if (index < 0 || index >= state.photos.length) return; + batchPhotos = [...state.photos]; + calibrationIndex = index; + enterMaskOnSource(batchPhotos[index]); }, retakes() { teardownWorker(); + calibrationIndex = null; // Return to the batch (photos kept) so the user can add/replace before continuing. set({ k: 'batch', photos: [...batchPhotos] }); void persist(); @@ -448,10 +498,15 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { }, back() { - if (state.k === 'mask') { - teardownWorker(); + if (state.k === 'pickCalibration') { + calibrationIndex = null; set({ k: 'batch', photos: [...batchPhotos] }); void persist(); + } else if (state.k === 'mask') { + teardownWorker(); + calibrationIndex = null; + set({ k: 'pickCalibration', photos: [...batchPhotos] }); + void persist(); } else if (state.k === 'axis') { // back to mask keeps the last approved mask as the live overlay const m = state.approvedMask;