import { writable } from 'svelte/store'; import type { Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut, } from './capture.types'; import { DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, 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; const BUSY_MS = 300; export interface DraftStore { loadDraft(): Promise; saveDraft(d: Draft): Promise; clearDraft(): Promise; } export interface CaptureSession { capture: import('svelte/store').Readable; busy: import('svelte/store').Readable; resumeProbe(): Promise; continueDraft(): void; startFresh(): void; /** Append a photo to the batch; stays on batch. */ addToBatch(file: File): Promise; /** 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. */ continueBatch(): void; retakes(): void; setSensitivity(n: number): void; setSheet(s: Sheet): void; approveMask(): void; setAxisAngle(deg: number): void; approveAxis(): void; setLength(e: Eighths): void; setThickness(e: Eighths): void; calculate(): Promise; back(): void; getInventoryUrl(): string | null; dispose(): void; } export interface SessionOpts { draft?: DraftStore; } const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft }; const batchPhotosOf = (d: Draft): Source[] => d.batch && d.batch.length > 0 ? d.batch : [d.source]; // The one writer. Owns the worker port, the single debounce timer, and the draft. // Every transition to an approved branch persists; no explicit "save". export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const drafts = opts.draft ?? defaultDraft; const capture = writable({ k: 'batch', photos: [] }); const busy = writable(false); let state: Capture = { k: 'batch', photos: [] }; let batchPhotos: Source[] = []; let worker: Worker | null = null; let jobId = 0; let timer: ReturnType | null = null; let passStarted = 0; let busyTimer: ReturnType | null = null; let pngUrl: string | null = null; let resumed = false; const set = (next: Capture) => { state = next; capture.set(next); }; const teardownWorker = () => { if (timer) { clearTimeout(timer); timer = null; } if (worker) { try { worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn); worker.terminate(); } catch { /* worker unavailable */ } worker = null; } if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; } }; const makeWorker = (blob: Blob) => { if (typeof Worker === 'undefined') return; if (worker) worker.terminate(); try { worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' }); worker.onmessage = (e: MessageEvent) => onWorkerOut(e.data); worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn); } catch { worker = null; } }; const onWorkerOut = (msg: WorkerOut) => { if (msg.id < jobId - 1) return; // stale if (msg.kind === 'decoded') { if (state.k === 'mask') { set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } }); } schedulePass(); return; } busy.set(false); if (busyTimer) { clearTimeout(busyTimer); busyTimer = null; } if (state.k !== 'mask') return; if (msg.kind === 'failed') { set({ ...state, failed: true }); return; } const mask: Mask = { params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity }, slabPx: msg.slabPx, bitset: msg.bitset, bbox: msg.bbox, slabBounds: msg.slabBounds, }; set({ ...state, overlay: mask, failed: false }); }; const runPass = () => { if (state.k !== 'mask' || !worker) return; passStarted = Date.now(); busyTimer = setTimeout(() => busy.set(true), BUSY_MS); worker.postMessage({ id: ++jobId, kind: 'mask', sheet: state.control.sheet, sensitivity: state.control.sensitivity, } satisfies WorkerIn); }; // The 2.0s debounce: re-armed on every control change; a sheet change // recomputes immediately (spec allows), so it clears the pending pass. const schedulePass = () => { if (timer) clearTimeout(timer); timer = setTimeout(runPass, DEBOUNCE_MS); }; const draftFromState = (): Draft | null => { const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds }); if (state.k === 'batch') { if (state.photos.length === 0) return null; return { version: 1, stage: 'batch', 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, }; } if (state.k === 'axis') { return { version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source], source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } if (state.k === 'measure') { const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures); return { version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: state.results ? rest : null, inventoryPng: state.results?.inventoryPng ?? null, }; } if (state.k === 'mask' && state.overlay) { return { version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source], source: state.source, maskParams: state.overlay.params, mask: strip(state.overlay), lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } return null; }; const persist = async () => { const d = draftFromState(); if (d) await drafts.saveDraft(d); else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft(); }; // Re-derive the mask bitset from the stored photo + params so Measure can run. const rederiveMask = (source: Source, params: MaskParams, stub: Omit): Promise => new Promise((resolve, reject) => { if (typeof Worker === 'undefined') { reject(new Error('Worker unavailable')); return; } const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' }); let id = 0; const fail = (reason: string) => { w.terminate(); reject(new Error(reason)); }; w.onmessage = (e: MessageEvent) => { const msg = e.data; if (msg.kind === 'decoded') { w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn); return; } if (msg.kind === 'failed') { fail(msg.reason); return; } if (msg.kind === 'ready') { w.terminate(); resolve({ params, slabPx: msg.slabPx || stub.slabPx, bitset: msg.bitset, bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox, slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds, }); } }; 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) => { batchPhotos = batchPhotosOf(d); if (d.stage === 'batch') { teardownWorker(); set({ k: 'batch', photos: [...batchPhotos] }); return; } const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob }; if (d.stage === 'mask') { teardownWorker(); set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false }); makeWorker(source.blob); return; } teardownWorker(); try { const mask = await rederiveMask(source, d.maskParams, d.mask); if (d.stage === 'axis') { set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } }); return; } if (d.inventoryPng) { if (pngUrl) URL.revokeObjectURL(pngUrl); pngUrl = URL.createObjectURL(d.inventoryPng); } set({ k: 'measure', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) }, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, results: d.results ? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() } : null, }); } catch { set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true }); makeWorker(source.blob); } }; const enterMaskFromBatch = () => { if (batchPhotos.length === 0) return; const source = batchPhotos[0]; teardownWorker(); set({ k: 'mask', source, control: { ...DEFAULT_MASK_PARAMS }, overlay: null, failed: false, }); makeWorker(source.blob); void persist(); }; return { capture, busy, async resumeProbe() { if (resumed) return; resumed = true; const d = await drafts.loadDraft(); if (d) set({ k: 'resume', draft: d }); }, continueDraft() { if (state.k !== 'resume') return; void restoreFromDraft(state.draft); }, async startFresh() { if (state.k !== 'resume') return; teardownWorker(); batchPhotos = []; await drafts.clearDraft(); set({ k: 'batch', photos: [] }); }, async addToBatch(file: File) { if (state.k !== 'batch') return; const photo: Source = { widthPx: 0, heightPx: 0, blob: file }; batchPhotos = [...state.photos, photo]; set({ k: 'batch', photos: batchPhotos }); await persist(); }, async addPhoto(file: File) { // M2: addPhoto is an alias for addToBatch (no single-photo-only shortcut). if (state.k !== 'batch') { teardownWorker(); batchPhotos = []; set({ k: 'batch', photos: [] }); } if (state.k !== 'batch') return; const photo: Source = { widthPx: 0, heightPx: 0, blob: file }; batchPhotos = [...state.photos, photo]; set({ k: 'batch', photos: batchPhotos }); await persist(); }, removeFromBatch(index: number) { if (state.k !== 'batch') return; if (index < 0 || index >= state.photos.length) return; batchPhotos = state.photos.filter((_, i) => i !== index); set({ k: 'batch', photos: batchPhotos }); void persist(); }, continueBatch() { if (state.k !== 'batch' || state.photos.length === 0) return; batchPhotos = [...state.photos]; enterMaskFromBatch(); }, retakes() { teardownWorker(); // Return to the batch (photos kept) so the user can add/replace before continuing. set({ k: 'batch', photos: [...batchPhotos] }); void persist(); }, setSensitivity(n: number) { if (state.k !== 'mask') return; const s = Math.max(0, Math.min(100, Math.round(n))); set({ ...state, control: { ...state.control, sensitivity: s } }); schedulePass(); }, setSheet(s: Sheet) { if (state.k !== 'mask') return; set({ ...state, control: { ...state.control, sheet: s } }); runPass(); }, approveMask() { if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; teardownWorker(); 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(); }, setAxisAngle(deg: number) { if (state.k !== 'axis') return; const a = ((Math.round(deg) % 180) + 180) % 180; set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } }); void persist(); }, approveAxis() { if (state.k !== 'axis') return; set({ k: 'measure', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox }, lengthEighths: 0, thicknessEighths: 0, results: null, }); void persist(); }, setLength(e: Eighths) { if (state.k !== 'measure') return; set({ ...state, lengthEighths: Math.max(0, Math.round(e)) }); void persist(); }, setThickness(e: Eighths) { if (state.k !== 'measure') return; set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) }); void persist(); }, async calculate() { if (state.k !== 'measure') return; const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state; if (lengthEighths === 0 || thicknessEighths === 0) return; const bitmap = await createImageBitmap(source.blob); const base = measure( approvedMask.bitset, source.widthPx, source.heightPx, axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, ); let pngBlob: Blob; let belowFloor = false; try { const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source }); pngBlob = r.blob; belowFloor = r.sourceBelowFloor; } catch { pngBlob = new Blob(); } bitmap.close(); if (pngUrl) URL.revokeObjectURL(pngUrl); pngUrl = URL.createObjectURL(pngBlob); const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor }; set({ ...state, results }); void persist(); }, back() { if (state.k === 'mask') { teardownWorker(); set({ k: 'batch', 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; 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, axis: { bbox: state.axis.bbox } }); } }, getInventoryUrl() { return pngUrl; }, dispose() { teardownWorker(); }, }; } // Display helper: format eighths as inches with fractions. export function fmtEighths(e: Eighths): string { const whole = Math.floor(eighthsToIn(e)); const rem = e - whole * 8; if (rem === 0) return `${whole}"`; const frac: Record = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' }; const part = frac[rem] ?? `${rem}/8`; return whole > 0 ? `${whole} ${part}"` : `${part}"`; }