fix(m4): keep tape calibration on one page (#99)

This commit is contained in:
Ty
2026-09-24 22:33:23 -07:00
parent 6000ac2b0f
commit 7908a99ec3
10 changed files with 335 additions and 247 deletions
+2 -8
View File
@@ -26,7 +26,6 @@
let cap: Capture = { k: 'batch', photos: [] };
let busy = false;
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
@@ -37,7 +36,6 @@
const unsubCap = session.capture.subscribe((c) => {
cap = c;
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
});
const unsubBusy = session.busy.subscribe((b) => (busy = b));
@@ -228,17 +226,13 @@
session.setCropFill(pct);
void updateSettings({ crop_fill: pct }).catch(() => {});
}}
approveAxis={session.approveAxis}
calculateFromAxis={session.calculateFromAxis}
back={session.back}
/>
{:else}
{:else if cap.k === 'measure'}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
calculate={session.calculate}
back={session.back}
{pngUrl}
onContinue={saving ? undefined : () => void continueToReview()}
/>
{/if}
+51 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { Capture, PhotoTransform } from './capture.types';
import { eighthsToIn, inToEighths } from './capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import {
CALIBRATION_FRAME,
@@ -20,7 +21,7 @@
sourceH: number;
}) => void;
export let setCropFill: (pct: number) => void = () => {};
export let approveAxis: () => void;
export let calculateFromAxis: (lengthEighths: number, thicknessEighths: number) => Promise<void>;
export let back: () => void;
const frame = CALIBRATION_FRAME;
@@ -36,6 +37,9 @@
let tickB = state.axis.tickEndY;
let focus: 'A' | 'B' = 'A';
let fill = cropFill;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let calcBusy = false;
let drag: 'tick' | 'tilt' | 'pan' | 'pinch' | null = null;
let pointers = new Map<number, [number, number]>();
let pinch0 = 0;
@@ -266,6 +270,31 @@
reseat(fill);
}
const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
const commitInches = (raw: string): number => {
const inches = positiveInches(raw);
return inches === null ? 0 : inToEighths(inches);
};
async function onCalculate() {
const length = commitInches(String(lengthIn));
const thickness = commitInches(String(thicknessIn));
lengthIn = length === 0 ? '' : String(eighthsToIn(length));
thicknessIn = thickness === 0 ? '' : String(eighthsToIn(thickness));
if (length === 0 || thickness === 0) return;
calcBusy = true;
try {
publish();
await calculateFromAxis(length, thickness);
} finally {
calcBusy = false;
}
}
onMount(async () => {
const bmp = await decodeOrientedBitmap(state.source.blob);
bitmap = bmp;
@@ -332,9 +361,22 @@
<span class="val">{fill}%</span>
</div>
<div class="fields">
<label class="field">
<span class="ctl">Length L (tape reading)</span>
<input type="number" aria-label="Length L" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={lengthIn} />
</label>
<label class="field">
<span class="ctl">Thickness</span>
<input type="number" aria-label="Thickness" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={thicknessIn} />
</label>
</div>
<div class="actions">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
<button type="button" class="primary" disabled={calcBusy || positiveInches(String(lengthIn)) === null || positiveInches(String(thicknessIn)) === null} on:click={onCalculate}>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
</section>
@@ -354,6 +396,13 @@
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
.fit { margin-top: 8px; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.fields { display: flex; gap: 12px; margin-top: 8px; }
.field { flex: 1; }
.field input {
box-sizing: border-box; width: 100%; min-height: 44px; padding: 10px; font-size: 1rem;
border-radius: 3px; border: 1px solid #444; background: #1d1d1d; color: #eee;
}
.primary:disabled { background: #333; color: #777; }
.primary {
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
+4 -106
View File
@@ -1,9 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import PhotoStage from './PhotoStage.svelte';
import {
buildPhotosReadyLineup,
photosReadyPngAlt,
@@ -11,22 +9,13 @@
} from './photosReadyLineup';
export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void;
export let setThickness: (e: number) => void;
export let calculate: () => Promise<void>;
export let back: () => void;
export let pngUrl: string | null;
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
export let onContinue: (() => void) | undefined = undefined;
let calcBusy = false;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = [];
$: photosReady = !!state.results;
$: {
for (const u of lineupUrls) URL.revokeObjectURL(u);
lineupUrls = [];
@@ -48,92 +37,17 @@
onDestroy(() => {
for (const u of lineupUrls) URL.revokeObjectURL(u);
});
const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
const commitLength = () => {
const inches = positiveInches(String(lengthIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setLength(eighths);
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
const commitThickness = () => {
const inches = positiveInches(String(thicknessIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setThickness(eighths);
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
const doCalc = async () => {
calcBusy = true;
try {
commitLength();
commitThickness();
await calculate();
} finally { calcBusy = false; }
};
</script>
<section class="panel">
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#if !photosReady}
<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">
<span class="label">Length L (tape reading)</span>
<input
type="number"
aria-label="Length L"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={lengthIn}
on:blur={commitLength}
on:change={commitLength}
/>
</div>
<div class="field">
<span class="label">Thickness</span>
<input
type="number"
aria-label="Thickness"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={thicknessIn}
on:blur={commitThickness}
on:change={commitThickness}
/>
</div>
</div>
<h1>Photos ready</h1>
{#if !state.results}
<p class="hint">Calculating…</p>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
type="button"
class="primary"
disabled={calcBusy || !canCalc}
on:click={doCalc}
>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
{:else if state.results}
{:else}
<div class="results" aria-label="Photos ready">
<div class="nums">
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
@@ -169,12 +83,6 @@
{/each}
</ol>
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
<div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" />
</div>
{/if}
</div>
<div class="row">
@@ -191,14 +99,6 @@
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.field input {
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
font-variant-numeric: tabular-nums;
}
.row { display: flex; gap: 10px; margin-top: 20px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
@@ -229,6 +129,4 @@
}
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
.png-wrap img { max-width: 100%; border-radius: 4px; }
</style>
+3 -1
View File
@@ -108,7 +108,9 @@ export type Capture =
k: 'axis';
source: Source;
approvedMask: Mask; // frozen footprint
axis: Axis; // live axis.bbox being nudged
axis: Axis; // live tape line
lengthEighths: Eighths;
thicknessEighths: Eighths;
}
| {
k: 'measure';
+10 -1
View File
@@ -6,11 +6,15 @@ import { test } from 'node:test';
import { inToEighths } from './capture.types';
const panelSrc = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'AxisPanel.svelte'),
'utf8',
);
const measureSrc = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
'utf8',
);
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
test('tape page exposes typed number inputs for Length L and Thickness', () => {
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
assert.ok(
@@ -31,6 +35,11 @@ test('MeasurePanel exposes typed number inputs for Length L and Thickness', () =
false,
'Length must not be a read-only fmtEighths span',
);
assert.equal(
/aria-label="Length L"/.test(measureSrc),
false,
'the separate measure page must not collect length',
);
});
test('typed inches snap to nearest 1/8 via inToEighths', () => {
@@ -358,6 +358,51 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
s.dispose();
});
test('calculateFromAxis stays on the tape page until the calibrated result exists', async () => {
installFakeMaskWorker();
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap;
let releaseCalibration!: () => void;
const calibrationGate = new Promise<void>((resolve) => { releaseCalibration = resolve; });
const s = createCaptureSession({
draft: store,
encodeInventory: async () => {
await calibrationGate;
return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false };
},
encodeOverlay: async () => new Blob(['overlay'], { type: 'image/png' }),
});
const a = tinyJpeg('a');
await s.addToBatch(a);
s.continueBatch();
s.pickCalibration(0);
s.setSheet('black');
await settleOverlay(s);
s.approveMask();
assert.equal(get(s.capture).k, 'axis');
const calculating = s.calculateFromAxis(320, 16);
await new Promise((r) => setTimeout(r, 0));
const during = get(s.capture);
assert.equal(during.k, 'axis', 'tape page stays mounted while Calculate runs');
if (during.k === 'axis') {
assert.equal(during.lengthEighths, 320);
assert.equal(during.thicknessEighths, 16);
}
releaseCalibration();
await calculating;
const after = get(s.capture);
assert.equal(after.k, 'measure');
if (after.k === 'measure') {
assert.ok(after.results, 'Photos ready opens only with a completed result');
assert.equal(after.lengthEighths, 320);
assert.equal(after.thicknessEighths, 16);
}
s.dispose();
});
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
installFakeMaskWorker();
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
@@ -520,6 +565,53 @@ test('#61: draft resume through measure restores calibration source (not photos[
s2.dispose();
});
test('legacy measure draft without results returns to the tape calibration page', async () => {
installFakeMaskWorker();
const source = tinyJpeg('legacy');
store.saveDraft({
version: 1,
stage: 'measure',
batch: [source],
calibrationIndex: 0,
source,
maskParams: { sheet: 'black', sensitivity: 50 },
mask: {
slabPx: MASK_W * MASK_H,
bbox: {
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
longSidePx: MASK_W,
angleDeg: 0,
},
slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H },
},
axisBbox: {
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
longSidePx: MASK_W,
angleDeg: 0,
},
lengthEighths: 320,
thicknessEighths: 16,
results: null,
inventoryPng: null,
});
const s = createCaptureSession({ draft: store });
await s.resumeProbe();
s.continueDraft();
const start = Date.now();
let cap = get(s.capture);
while (cap.k !== 'axis' && Date.now() - start < 1500) {
await new Promise((r) => setTimeout(r, 10));
cap = get(s.capture);
}
assert.equal(cap.k, 'axis', 'legacy resultless measure draft must not open a dead result screen');
if (cap.k === 'axis') {
assert.equal(cap.lengthEighths, 320);
assert.equal(cap.thicknessEighths, 16);
}
s.dispose();
});
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
assert.match(app, /MaskPanel/);
+162 -114
View File
@@ -48,12 +48,14 @@ export interface CaptureSession {
sourceH: number;
}): void;
setCropFill(pct: number): void;
/** Legacy resultless transition retained for old callers/tests; the shipped UI uses calculateFromAxis. */
approveAxis(): void;
/** Length and thickness are entered on the tape page. Calculate leaves that page for Photos ready. */
calculateFromAxis(lengthEighths: Eighths, thicknessEighths: Eighths): Promise<void>;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
calculate(): Promise<void>;
back(): void;
getInventoryUrl(): string | null;
/** The current `measure` branch, or null while not on it (C2 handoff). */
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
dispose(): void;
@@ -72,6 +74,19 @@ const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
const batchPhotosOf = (d: Draft): Source[] =>
d.batch && d.batch.length > 0 ? d.batch : [d.source];
type CalibrationInput = {
source: Source;
approvedMask: Mask;
axis: Axis;
lengthEighths: Eighths;
thicknessEighths: Eighths;
};
type CalibrationOutput = {
results: Measures;
batchApply: BatchPhotoResult[];
};
// 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 {
@@ -88,7 +103,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
let timer: ReturnType<typeof setTimeout> | null = null;
let passStarted = 0;
let busyTimer: ReturnType<typeof setTimeout> | null = null;
let pngUrl: string | null = null;
let resumed = false;
let cropFill = 90;
let poseTimer: ReturnType<typeof setTimeout> | null = null;
@@ -130,10 +144,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}
worker = null;
}
if (pngUrl) {
URL.revokeObjectURL(pngUrl);
pngUrl = null;
}
};
const makeWorker = (blob: Blob) => {
@@ -239,7 +249,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
tickEndY: state.axis.tickEndY,
tapeStart: state.axis.tapeStart,
tapeEnd: state.axis.tapeEnd,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: null, inventoryPng: null,
};
}
if (state.k === 'measure') {
@@ -345,6 +355,91 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
stub: Omit<Mask, 'bitset' | 'params'>,
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
const runCalibration = async (input: CalibrationInput): Promise<CalibrationOutput> => {
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = input;
if (lengthEighths === 0 || thicknessEighths === 0) {
throw new Error('length and thickness are required');
}
const bitmap = await decodeOrientedBitmap(source.blob);
let pngBlob: Blob;
let belowFloor = false;
try {
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
{ start: axis.tapeStart, end: axis.tapeEnd },
);
try {
const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill });
pngBlob = encoded.blob;
belowFloor = encoded.sourceBelowFloor;
} catch {
pngBlob = new Blob([], { type: 'image/png' });
}
const overlayBlob = await (async () => {
try {
return await encodeOverlay({
inventoryPng: pngBlob,
bitset: approvedMask.bitset,
widthPx: source.widthPx,
heightPx: source.heightPx,
bbox: axis.bbox,
scale: base.scale,
lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
cropFill,
});
} catch {
return new Blob(['overlay'], { type: 'image/png' });
}
})();
const results: Measures = {
...base,
inventoryPng: pngBlob,
overlayPng: overlayBlob,
sourceBelowFloor: belowFloor,
};
const calIndex = calibrationIndex != null && calibrationIndex >= 0
? calibrationIndex
: 0;
const batch = batchPhotos.length ? batchPhotos : [source];
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
status: 'error',
message: 'Batch photo was not processed',
}));
for (let index = 0; index < batch.length; index++) {
if (index === calIndex) continue;
const photo = batch[index];
try {
const derived = await rederiveMaskDetailed(photo, approvedMask.params, {
slabPx: 0,
bbox: EMPTY_BBOX,
slabBounds: EMPTY_ABOX,
});
if (derived.mask.slabPx === 0) throw new Error('empty mask: no slab pixels detected');
const siblingSource: Source = { ...photo, widthPx: derived.widthPx, heightPx: derived.heightPx };
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill });
} finally {
siblingBitmap.close();
}
if (encoded.sourceBelowFloor) throw new Error('PNG size floor: shorter side is below 800 px');
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
} catch (error) {
const message = error instanceof Error ? error.message : 'unable to process photo';
batchApply[index] = { status: 'error', message };
}
}
return { results, batchApply };
} finally {
bitmap.close();
}
};
// 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;
@@ -375,12 +470,14 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d) });
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
return;
}
if (d.inventoryPng) {
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(d.inventoryPng);
// A resultless legacy measure draft predates the merged tape page. Do not
// open the result-only screen; return to tape calibration with its inputs.
if (d.stage === 'measure' && !d.results) {
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
return;
}
set({
k: 'measure', source, approvedMask: mask,
@@ -414,7 +511,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
void persist();
};
return {
const session: CaptureSession = {
capture,
busy,
@@ -514,7 +611,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
teardownWorker();
const overlay = state.overlay;
const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg };
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx) });
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx), lengthEighths: 0, thicknessEighths: 0 });
void persist();
},
@@ -563,107 +660,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
void persist();
},
// #62: Calculate the calibration photo, then apply its frozen mask params
// to siblings. This stays in Measure; siblings never enter Mask controls.
// Legacy compatibility path; the shipped tape page calls calculateFromAxis.
async calculate() {
if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return;
const bitmap = await decodeOrientedBitmap(source.blob);
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
{ start: axis.tapeStart, end: axis.tapeEnd },
);
let pngBlob: Blob;
let belowFloor = false;
try {
const r = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
} catch {
// CAPTURE.md: inventory is image/png even when encode is unavailable.
pngBlob = new Blob([], { type: 'image/png' });
}
bitmap.close();
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(pngBlob);
// #63: second copy of the calibration inventory PNG with dimension overlays.
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
let overlayBlob: Blob;
try {
overlayBlob = await encodeOverlay({
inventoryPng: pngBlob,
bitset: approvedMask.bitset,
widthPx: source.widthPx,
heightPx: source.heightPx,
bbox: axis.bbox,
scale: base.scale,
lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
cropFill,
});
} catch {
// Deterministic tests may inject a non-decodable inventory blob; still
// return a distinct image/png so callers never confuse it with clean.
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
}
const results: Measures = {
...base,
inventoryPng: pngBlob,
overlayPng: overlayBlob,
sourceBelowFloor: belowFloor,
const input: CalibrationInput = {
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: state.lengthEighths,
thicknessEighths: state.thicknessEighths,
};
const params = approvedMask.params;
const calIndex = calibrationIndex != null && calibrationIndex >= 0
? calibrationIndex
: 0;
const batch = batchPhotos.length ? batchPhotos : [source];
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
status: 'error',
message: 'Batch photo was not processed',
}));
const output = await runCalibration(input);
if (state.k !== 'measure') return;
set({ ...state, ...output });
void persist();
},
// Process serially so each worker owns exactly one sibling photo and a
// failing decode/mask/encode cannot prevent later siblings from running.
for (let index = 0; index < batch.length; index++) {
if (index === calIndex) continue;
const photo = batch[index];
try {
const derived = await rederiveMaskDetailed(photo, params, {
slabPx: 0,
bbox: EMPTY_BBOX,
slabBounds: EMPTY_ABOX,
});
if (derived.mask.slabPx === 0) {
throw new Error('empty mask: no slab pixels detected');
}
const siblingSource: Source = {
...photo,
widthPx: derived.widthPx,
heightPx: derived.heightPx,
};
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill });
} finally {
siblingBitmap.close();
}
if (encoded.sourceBelowFloor) {
throw new Error('PNG size floor: shorter side is below 800 px');
}
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
} catch (error) {
const message = error instanceof Error ? error.message : 'unable to process photo';
batchApply[index] = { status: 'error', message };
}
async calculateFromAxis(lengthEighths, thicknessEighths) {
if (state.k !== 'axis') return;
const length = Math.max(0, Math.round(lengthEighths));
const thickness = Math.max(0, Math.round(thicknessEighths));
if (length === 0 || thickness === 0) return;
if (poseTimer) {
clearTimeout(poseTimer);
poseTimer = null;
}
set({ ...state, results, batchApply });
const input: CalibrationInput = {
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: length,
thicknessEighths: thickness,
};
// Keep the tape page mounted while the PNG, overlay, and batch apply run.
// The old measure form was a real screen; the result state is not entered
// until there is something complete to show.
set({ ...state, lengthEighths: length, thicknessEighths: thickness });
const output = await runCalibration(input);
if (state.k !== 'axis') return;
set({
k: 'measure',
source: input.source,
approvedMask: input.approvedMask,
axis: input.axis,
lengthEighths: input.lengthEighths,
thicknessEighths: input.thicknessEighths,
results: output.results,
batchApply: output.batchApply,
});
void persist();
},
@@ -682,14 +726,17 @@ export function createCaptureSession(opts: SessionOpts = {}): 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, axis: state.axis });
set({
k: 'axis',
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: state.lengthEighths,
thicknessEighths: state.thicknessEighths,
});
}
},
getInventoryUrl() {
return pngUrl;
},
currentMeasure() {
return state.k === 'measure' ? state : null;
},
@@ -698,6 +745,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
teardownWorker();
},
};
return session;
}
// Display helper: format eighths as inches with fractions.
+2 -2
View File
@@ -28,8 +28,8 @@ Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=
| `01-batch.png` | Batch with fixtures loaded |
| `02-pick-calibration.png` | Pick calibration |
| `03-mask.png` | Mask (Black sheet) ready to approve |
| `04-axis.png` | Length axis |
| `05-measure.png` | Measure with L / thickness filled |
| `04-axis.png` | Tape calibration page |
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
+1 -1
View File
@@ -1,5 +1,5 @@
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
// the Resume path restores straight to the Photos-ready Measure step — the
// the Resume path restores straight to the Photos-ready result step — the
// deterministic driver for the create→review→publish flow (no live camera/canvas
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
+8 -12
View File
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
};
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
test('batch → pick → mask → tape calibration → photos ready + screenshots', async ({ page }) => {
test.setTimeout(180_000);
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
@@ -87,20 +87,16 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
await shot(page, '03-mask.png');
await page.getByRole('button', { name: 'Approve mask' }).click();
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Measure' })).toHaveCount(0);
await shot(page, '04-axis.png');
await page.getByRole('button', { name: 'Approve axis' }).click();
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2');
// blur/change commits eighths into session before Calculate.
await page.getByLabel('Length L').blur();
await page.getByLabel('Thickness').blur();
await shot(page, '05-measure.png');
const calc = page.getByRole('button', { name: 'Calculate' });
await expect(calc).toBeDisabled();
await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2');
await expect(calc).toBeEnabled();
await shot(page, '05-tape-calibration.png');
await calc.click();
// Calculate encodes calibration PNG + overlay + sibling batch apply.
@@ -117,7 +113,7 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
// Write a short run log for parent AI visual review (not a substitute for eyes).
const logPath = path.join(ARTIFACTS, 'run-log.txt');
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-tape-calibration.png', '06-photos-ready.png'];
const lines = [
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,