fix(m4): keep tape calibration on one page (#99)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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` +
|
||||
|
||||
@@ -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('/')}`,
|
||||
|
||||
Reference in New Issue
Block a user