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
+6 -10
View File
@@ -9,7 +9,6 @@
import BatchPhotos from './capture/BatchPhotos.svelte'; import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte'; import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte'; import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte'; import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte'; import SlabList from './create/slabs/SlabList.svelte';
@@ -72,6 +71,7 @@
const capture: CalibratedCapture = { const capture: CalibratedCapture = {
lengthIn: eighthsToIn(m.lengthEighths), lengthIn: eighthsToIn(m.lengthEighths),
thicknessIn: eighthsToIn(m.thicknessEighths), thicknessIn: eighthsToIn(m.thicknessEighths),
speciesId: m.speciesId,
sqft: r.sqft, sqft: r.sqft,
bdft: r.bdft, bdft: r.bdft,
widthMinIn: eighthsToIn(r.widths.min), widthMinIn: eighthsToIn(r.widths.min),
@@ -100,6 +100,7 @@
width_min_in: capture.widthMinIn, width_min_in: capture.widthMinIn,
width_max_in: capture.widthMaxIn, width_max_in: capture.widthMaxIn,
width_avg_in: capture.widthAvgIn, width_avg_in: capture.widthAvgIn,
species_id: capture.speciesId,
price: null, price: null,
price_source: 'recommendation', price_source: 'recommendation',
content_source: 'manual', content_source: 'manual',
@@ -213,19 +214,14 @@
retakes={session.retakes} retakes={session.retakes}
back={session.back} back={session.back}
/> />
{:else if cap.k === 'axis'} {:else if cap.k === 'measure'}
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
setTape={session.setTape}
approveAxis={session.approveAxis}
back={session.back}
/>
{:else}
<MeasurePanel <MeasurePanel
state={cap} state={cap}
setLength={session.setLength} setLength={session.setLength}
setThickness={session.setThickness} setThickness={session.setThickness}
setSpecies={session.setSpecies}
setAxisAngle={session.setAxisAngle}
setTape={session.setTape}
calculate={session.calculate} calculate={session.calculate}
back={session.back} back={session.back}
{pngUrl} {pngUrl}
+1
View File
@@ -1,3 +1,4 @@
<!-- DEPRECATED: tape orientation merged into MeasurePanel (overnight-whole-app). Kept for reference. -->
<script lang="ts"> <script lang="ts">
import type { Capture } from './capture.types'; import type { Capture } from './capture.types';
import type { TapeTransform } from './tapeGeometry'; import type { TapeTransform } from './tapeGeometry';
+178 -10
View File
@@ -1,9 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import type { Capture } from './capture.types'; import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types'; import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session'; import { fmtEighths } from './session';
import type { TapeTransform } from './tapeGeometry';
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte'; import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill';
import { cropMarginFraction } from '../png/crop';
import { getTaxonomy } from '../api/slabs';
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
import { import {
buildPhotosReadyLineup, buildPhotosReadyLineup,
photosReadyPngAlt, photosReadyPngAlt,
@@ -13,6 +19,9 @@
export let state: Extract<Capture, { k: 'measure' }>; export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void; export let setLength: (e: number) => void;
export let setThickness: (e: number) => void; export let setThickness: (e: number) => void;
export let setSpecies: (id: number | null) => void;
export let setAxisAngle: (deg: number) => void;
export let setTape: (tape: TapeTransform) => void;
export let calculate: () => Promise<void>; export let calculate: () => Promise<void>;
export let back: () => void; export let back: () => void;
export let pngUrl: string | null; export let pngUrl: string | null;
@@ -22,6 +31,15 @@
let calcBusy = false; let calcBusy = false;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths)); let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths)); let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let speciesSel = state.speciesId == null ? '' : String(state.speciesId);
let speciesOpts: { id: number; name: string }[] = [];
let speciesLoadErr = '';
let cropFill = getCropFill();
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: tape = state.axis.tape;
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
let lineupUrls: string[] = []; let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = []; let lineupItems: PhotosReadyLineupItem[] = [];
@@ -45,10 +63,56 @@
} }
} }
// Keep local species select in sync when draft restore / session updates it.
$: {
const next = state.speciesId == null ? '' : String(state.speciesId);
if (speciesSel !== next) speciesSel = next;
}
onMount(() => {
void getTaxonomy()
.then((tax) => {
speciesOpts = speciesLeavesFromTaxonomy(tax.categories).map((c) => ({
id: c.id,
name: c.name,
}));
})
.catch(() => {
speciesLoadErr = 'Could not load species list.';
speciesOpts = [];
});
});
onDestroy(() => { onDestroy(() => {
for (const u of lineupUrls) URL.revokeObjectURL(u); for (const u of lineupUrls) URL.revokeObjectURL(u);
}); });
const onTapeChange = (t: TapeTransform) => setTape(t);
const nudgeTilt = (delta: number) => {
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
};
const setTilt = (deg: number) => {
const clamped = clampTiltDeg(deg);
setTape({ ...tape, tiltDeg: clamped });
setAxisAngle(clamped);
};
const nudgeZoom = (dir: number) => {
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
setTape({ ...tape, zoom: z });
};
const onFill = (pct: number) => {
const fill = Math.min(1, Math.max(0.01, pct / 100));
cropFill = setCropFill(fill);
const ticks = defaultTicks(cropFill);
setTape({ ...tape, ...ticks });
};
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
const positiveInches = (raw: string): number | null => { const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw); const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null; return Number.isFinite(inches) && inches > 0 ? inches : null;
@@ -66,6 +130,9 @@
setThickness(eighths); setThickness(eighths);
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths)); thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
}; };
const commitSpecies = () => {
setSpecies(speciesSel === '' ? null : Number(speciesSel));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null; $: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
@@ -74,6 +141,7 @@
try { try {
commitLength(); commitLength();
commitThickness(); commitThickness();
commitSpecies();
await calculate(); await calculate();
} finally { calcBusy = false; } } finally { calcBusy = false; }
}; };
@@ -83,16 +151,77 @@
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1> <h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#if !photosReady} {#if !photosReady}
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
<PhotoStage <PhotoStage
mode="tape" mode="tape"
interactive={false}
blob={state.source.blob} blob={state.source.blob}
tape={state.axis.tape}
maskBitset={state.approvedMask.bitset} maskBitset={state.approvedMask.bitset}
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0} {maskW}
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0} {maskH}
{tape}
{cropFill}
onTapeChange={onTapeChange}
/> />
<div class="controls">
<div>
<p class="ctl-label">Tilt the slab</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
<input
type="range" min="-90" max="90" step="0.1"
value={clampTiltDeg(tiltShown)}
on:input={(e) => setTilt(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
<span class="val">{tiltShown}°</span>
</div>
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist · or drag the ↻ handle</p>
</div>
<div>
<p class="ctl-label">Zoom the slab</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
<input
type="range" min="50" max="300" step="1"
value={Math.round(tape.zoom * 100)}
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
/>
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
<span class="val">{Math.round(tape.zoom * 100)}%</span>
</div>
<div class="row tight">
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
</div>
</div>
<div>
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
<div class="croprow">
<input
type="range" min="50" max="100" step="1"
value={Math.round(cropFill * 100)}
aria-label="Crop fill percent"
on:input={(e) => onFill(Number(e.currentTarget.value))}
/>
<span class="val">{Math.round(cropFill * 100)}%</span>
</div>
<p class="nudge">
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
</p>
</div>
<div class="nums">
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
</div>
</div>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<span class="label">Length L</span> <span class="label">Length L</span>
@@ -124,6 +253,26 @@
</div> </div>
</div> </div>
<div class="field species">
<label class="label" for="measure-species">Species</label>
<select
id="measure-species"
class="sel"
data-testid="measure-species"
aria-label="Species"
bind:value={speciesSel}
on:change={commitSpecies}
>
<option value="">Select species…</option>
{#each speciesOpts as o (o.id)}
<option value={String(o.id)}>{o.name}</option>
{/each}
</select>
{#if speciesLoadErr}
<p class="species-err" role="status">{speciesLoadErr}</p>
{/if}
</div>
<div class="row"> <div class="row">
<button type="button" class="ghost" on:click={back}>Back</button> <button type="button" class="ghost" on:click={back}>Back</button>
<button <button
@@ -192,26 +341,45 @@
<style> <style>
.panel { padding: 16px; color: #eee; } .panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; } h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
.ctl-label .sub { font-size: 0.7rem; color: #777; }
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.fields { display: flex; gap: 16px; margin-top: 16px; } .fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; } .field { flex: 1; }
.field.species { margin-top: 16px; flex: none; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; } .label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.field input { .field input, .sel {
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem; 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; border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.species-err { color: #ffb4a2; font-size: 0.85rem; margin: 8px 0 0; }
.row { display: flex; gap: 10px; margin-top: 20px; } .row { display: flex; gap: 10px; margin-top: 20px; }
.row.tight { margin-top: 8px; }
.primary { .primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0; flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700; background: #00e5ff; color: #002; font-weight: 700;
} }
.primary:disabled { background: #333; color: #777; } .primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; } .ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.results { margin-top: 8px; } .nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.nums { display: flex; flex-direction: column; gap: 8px; } .nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
.nums span:first-child { color: #aaa; } .nums span:first-child { color: #aaa; }
.tab { font-variant-numeric: tabular-nums; text-align: right; }
.dim { color: #777; }
.results { margin-top: 8px; }
.results .nums { font-size: 1rem; }
.results .nums div { font-size: 1rem; }
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; } .warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
.lineup { .lineup {
list-style: none; padding: 0; margin: 16px 0 0; list-style: none; padding: 0; margin: 16px 0 0;
+5 -1
View File
@@ -115,9 +115,11 @@ export type Capture =
k: 'measure'; k: 'measure';
source: Source; source: Source;
approvedMask: Mask; approvedMask: Mask;
axis: Axis; // frozen axis: Axis; // editable tape until Calculate; frozen after results
lengthEighths: Eighths; lengthEighths: Eighths;
thicknessEighths: Eighths; thicknessEighths: Eighths;
/** Woo species leaf id; optional until Review. Persisted on draft + createSlab. */
speciesId: number | null;
results: Measures | null; results: Measures | null;
/** Capture-order sibling results from the post-Calculate batch pass. */ /** Capture-order sibling results from the post-Calculate batch pass. */
batchApply?: BatchPhotoResult[]; batchApply?: BatchPhotoResult[];
@@ -154,6 +156,8 @@ export interface Draft {
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
lengthEighths: Eighths; lengthEighths: Eighths;
thicknessEighths: Eighths; thicknessEighths: Eighths;
/** Woo species leaf; absent on legacy drafts. */
species_id?: number | null;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
inventoryPng: Blob | null; // stored so Continue can show Measure results inventoryPng: Blob | null; // stored so Continue can show Measure results
/** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */ /** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */
@@ -40,3 +40,16 @@ test('typed inches snap to nearest 1/8 via inToEighths', () => {
assert.equal(inToEighths(0.07), 1); assert.equal(inToEighths(0.07), 1);
assert.equal(inToEighths(1.999), 16); assert.equal(inToEighths(1.999), 16);
}); });
test('MeasurePanel exposes Species select (Woo taxonomy leaves)', () => {
assert.match(panelSrc, /data-testid="measure-species"/);
assert.match(panelSrc, /speciesLeavesFromTaxonomy|getTaxonomy/);
assert.match(panelSrc, /aria-label="Species"/);
});
test('MeasurePanel keeps interactive tape controls (merged Axis)', () => {
assert.match(panelSrc, /mode="tape"/);
assert.match(panelSrc, /onTapeChange/);
assert.ok(!/Approve tape line/.test(panelSrc), 'Approve tape folded into Calculate');
assert.match(panelSrc, /Calculate/);
});
+8 -12
View File
@@ -343,16 +343,11 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
s.approveMask(); s.approveMask();
cap = get(s.capture); cap = get(s.capture);
assert.equal(cap.k, 'axis', 'approve Mask → Length axis'); assert.equal(cap.k, 'measure', 'approve Mask → combined Measure (tape+fields)');
if (cap.k === 'axis') {
assert.equal(cap.source.blob, b, 'Axis must keep the calibration source');
}
s.approveAxis();
cap = get(s.capture);
assert.equal(cap.k, 'measure', 'approve Axis → Measure');
if (cap.k === 'measure') { if (cap.k === 'measure') {
assert.equal(cap.source.blob, b, 'Measure must keep the calibration source'); assert.equal(cap.source.blob, b, 'Measure must keep the calibration source');
assert.equal(cap.results, null);
assert.equal(cap.speciesId, null);
} }
s.dispose(); s.dispose();
@@ -447,15 +442,16 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
const after = store.peek(); const after = store.peek();
assert.ok(after); assert.ok(after);
assert.equal(after!.stage, 'axis'); assert.equal(after!.stage, 'measure', 'mask approve lands on combined tape+measure');
assert.equal(after!.calibrationIndex, 1); assert.equal(after!.calibrationIndex, 1);
assert.equal(after!.source.blob, b, 'draft.source is calibration only'); assert.equal(after!.source.blob, b, 'draft.source is calibration only');
assert.equal((after!.batch ?? []).length, 3, 'full batch retained'); assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched'); assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
assert.equal(after!.batch![1].blob, b); assert.equal(after!.batch![1].blob, b);
assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots'); assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots');
assert.equal(after!.species_id ?? null, null);
// Approve axis too — batch still untouched. // approveAxis is a no-op on measure — batch still untouched.
s.approveAxis(); s.approveAxis();
await new Promise((r) => setTimeout(r, 20)); await new Promise((r) => setTimeout(r, 20));
const meas = store.peek(); const meas = store.peek();
@@ -520,10 +516,10 @@ test('#61: draft resume through measure restores calibration source (not photos[
s2.dispose(); s2.dispose();
}); });
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => { test('#61: App runs single Mask/Measure panels (no per-batch Mask loop)', () => {
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8'); const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
assert.match(app, /MaskPanel/); assert.match(app, /MaskPanel/);
assert.match(app, /AxisPanel/); assert.ok(!/AxisPanel/.test(app), 'AxisPanel folded into MeasurePanel');
assert.match(app, /MeasurePanel/); assert.match(app, /MeasurePanel/);
// Dishonest proxy: must not iterate batch photos into Mask UI. // Dishonest proxy: must not iterate batch photos into Mask UI.
assert.ok( assert.ok(
+53 -18
View File
@@ -50,6 +50,7 @@ export interface CaptureSession {
approveAxis(): void; approveAxis(): void;
setLength(e: Eighths): void; setLength(e: Eighths): void;
setThickness(e: Eighths): void; setThickness(e: Eighths): void;
setSpecies(id: number | null): void;
calculate(): Promise<void>; calculate(): Promise<void>;
back(): void; back(): void;
getInventoryUrl(): string | null; getInventoryUrl(): string | null;
@@ -224,6 +225,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
source: state.source, maskParams: state.approvedMask.params, source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
species_id: state.speciesId,
results: state.results ? rest : null, results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null, inventoryPng: state.results?.inventoryPng ?? null,
overlayPng: state.results?.overlayPng ?? null, overlayPng: state.results?.overlayPng ?? null,
@@ -375,13 +377,20 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try { try {
const mask = await rederiveMask(source, d.maskParams, d.mask); const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') { if (d.stage === 'axis') {
// Legacy axis-only drafts land on the combined tape+measure screen.
const bbox = draftAxisBbox(d); const bbox = draftAxisBbox(d);
const w = source.widthPx; const w = source.widthPx;
const h = source.heightPx; const h = source.heightPx;
const axis = d.tape const axis = d.tape
? axisWithTape(bbox, d.tape, w, h) ? axisWithTape(bbox, d.tape, w, h)
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, 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; return;
} }
if (d.inventoryPng) { if (d.inventoryPng) {
@@ -399,6 +408,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
k: 'measure', source, approvedMask: mask, k: 'measure', source, approvedMask: mask,
axis, axis,
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
speciesId: d.species_id ?? null,
results: d.results results: d.results
? { ? {
...d.results, ...d.results,
@@ -522,7 +532,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
runPass(); 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() { approveMask() {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker(); teardownWorker();
@@ -537,28 +547,38 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const seedTilt = clampTiltDeg(90 - bbox.angleDeg); const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
const tape0 = defaultTape(getCropFill(), seedTilt); const tape0 = defaultTape(getCropFill(), seedTilt);
const axis = axisWithTape(bbox, tape0, w, h); 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(); void persist();
}, },
// Legacy slider path — maps to photo tilt under the fixed tape line (#99). // Legacy slider path — maps to photo tilt under the fixed tape line (#99).
setAxisAngle(deg: number) { setAxisAngle(deg: number) {
if (state.k !== 'axis') return; if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) }; const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) };
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx); const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
set({ ...state, axis }); set({ ...state, axis });
void persist(); void persist();
}
}, },
setTape(tape: TapeTransform) { setTape(tape: TapeTransform) {
if (state.k !== 'axis') return; if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) }; const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) };
const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx); const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx);
set({ ...state, axis }); set({ ...state, axis });
void persist(); void persist();
}
}, },
// Folded into Calculate on the combined screen; kept for tests / legacy axis drafts.
approveAxis() { approveAxis() {
if (state.k === 'measure') {
void persist();
return;
}
if (state.k !== 'axis') return; if (state.k !== 'axis') return;
const axis = axisWithTape( const axis = axisWithTape(
state.axis.bbox, state.axis.bbox,
@@ -569,20 +589,27 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
set({ set({
k: 'measure', source: state.source, approvedMask: state.approvedMask, k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis, axis,
lengthEighths: 0, thicknessEighths: 0, results: null, lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
}); });
void persist(); void persist();
}, },
setLength(e: Eighths) { setLength(e: Eighths) {
if (state.k !== 'measure') return; 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(); void persist();
}, },
setThickness(e: Eighths) { setThickness(e: Eighths) {
if (state.k !== 'measure') return; 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(); void persist();
}, },
@@ -740,11 +767,19 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
set({ k: 'pickCalibration', photos: [...batchPhotos] }); set({ k: 'pickCalibration', photos: [...batchPhotos] });
void persist(); void persist();
} else if (state.k === 'axis') { } 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; const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false }); set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') { } 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 });
}
} }
}, },
+2
View File
@@ -11,6 +11,8 @@ import type { PhotosReadyLineupItem } from '../capture/photosReadyLineup';
export interface CalibratedCapture { export interface CalibratedCapture {
lengthIn: number; lengthIn: number;
thicknessIn: number; thicknessIn: number;
/** Woo species leaf from Measure; null if unset. Review can still change. */
speciesId: number | null;
sqft: number; sqft: number;
bdft: number; bdft: number;
widthMinIn: number; widthMinIn: number;
+3 -6
View File
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
}; };
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => { test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => { test('batch → pick → mask → measure (tape+fields) → photos ready + screenshots', async ({ page }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy(); expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy(); expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
@@ -87,11 +87,8 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
await shot(page, '03-mask.png'); await shot(page, '03-mask.png');
await page.getByRole('button', { name: 'Approve mask' }).click(); await page.getByRole('button', { name: 'Approve mask' }).click();
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
await shot(page, '04-axis.png');
await page.getByRole('button', { name: 'Approve axis' }).click();
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
await shot(page, '04-tape-measure.png');
await page.getByLabel('Length L').fill('48'); await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2'); await page.getByLabel('Thickness').fill('2');
// blur/change commits eighths into session before Calculate. // blur/change commits eighths into session before Calculate.
@@ -117,7 +114,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). // Write a short run log for parent AI visual review (not a substitute for eyes).
const logPath = path.join(ARTIFACTS, 'run-log.txt'); 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-tape-measure.png', '05-measure.png', '06-photos-ready.png'];
const lines = [ const lines = [
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`, `M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`, `baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,