feat(capture): merge tape orientation into Measure with species

After mask approve, land on one Measure screen: interactive tape
PhotoStage plus Length L, Thickness, and Species (Woo taxonomy leaves).
Approve tape is folded into Calculate. Persist species_id on the device
draft and pass it through createSlab so Review pre-fills.
This commit is contained in:
grokbot_ty
2026-09-26 08:22:08 -07:00
parent 4ea2618475
commit b55344ec05
9 changed files with 269 additions and 57 deletions
+53 -18
View File
@@ -50,6 +50,7 @@ export interface CaptureSession {
approveAxis(): void;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
setSpecies(id: number | null): void;
calculate(): Promise<void>;
back(): void;
getInventoryUrl(): string | null;
@@ -224,6 +225,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
species_id: state.speciesId,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
overlayPng: state.results?.overlayPng ?? null,
@@ -375,13 +377,20 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
// Legacy axis-only drafts land on the combined tape+measure screen.
const bbox = draftAxisBbox(d);
const w = source.widthPx;
const h = source.heightPx;
const axis = d.tape
? axisWithTape(bbox, d.tape, w, h)
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
set({ k: 'axis', source, approvedMask: mask, axis });
set({
k: 'measure', source, approvedMask: mask, axis,
lengthEighths: d.lengthEighths || 0,
thicknessEighths: d.thicknessEighths || 0,
speciesId: d.species_id ?? null,
results: null,
});
return;
}
if (d.inventoryPng) {
@@ -399,6 +408,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
k: 'measure', source, approvedMask: mask,
axis,
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
speciesId: d.species_id ?? null,
results: d.results
? {
...d.results,
@@ -522,7 +532,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
runPass();
},
// #61: Axis uses the same calibration source; draft.batch siblings stay untouched.
// #61: combined tape+measure uses the same calibration source; draft.batch siblings stay untouched.
approveMask() {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
@@ -537,28 +547,38 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
const tape0 = defaultTape(getCropFill(), seedTilt);
const axis = axisWithTape(bbox, tape0, w, h);
set({ k: 'axis', source: state.source, approvedMask: overlay, axis });
set({
k: 'measure', source: state.source, approvedMask: overlay, axis,
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
});
void persist();
},
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
setAxisAngle(deg: number) {
if (state.k !== 'axis') return;
const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) };
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) };
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
}
},
setTape(tape: TapeTransform) {
if (state.k !== 'axis') return;
const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) };
const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) };
const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
}
},
// Folded into Calculate on the combined screen; kept for tests / legacy axis drafts.
approveAxis() {
if (state.k === 'measure') {
void persist();
return;
}
if (state.k !== 'axis') return;
const axis = axisWithTape(
state.axis.bbox,
@@ -569,20 +589,27 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis,
lengthEighths: 0, thicknessEighths: 0, results: null,
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
});
void persist();
},
setLength(e: Eighths) {
if (state.k !== 'measure') return;
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
set({ ...state, lengthEighths: Math.max(0, Math.round(e)), results: null });
void persist();
},
setThickness(e: Eighths) {
if (state.k !== 'measure') return;
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)), results: null });
void persist();
},
setSpecies(id: number | null) {
if (state.k !== 'measure') return;
const speciesId = id != null && Number.isFinite(id) ? Math.round(id) : null;
set({ ...state, speciesId });
void persist();
},
@@ -740,11 +767,19 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
set({ k: 'pickCalibration', photos: [...batchPhotos] });
void persist();
} else if (state.k === 'axis') {
// back to mask keeps the last approved mask as the live overlay
// Legacy: 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: state.axis });
if (state.results) {
// Photos ready → re-edit tape + L/thickness/species on the same screen.
set({ ...state, results: null, batchApply: undefined });
if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; }
void persist();
} else {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
}
}
},