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:
+6
-10
@@ -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,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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('/')}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user