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
+178 -10
View File
@@ -1,9 +1,15 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import type { TapeTransform } from './tapeGeometry';
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
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 {
buildPhotosReadyLineup,
photosReadyPngAlt,
@@ -13,6 +19,9 @@
export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (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 back: () => void;
export let pngUrl: string | null;
@@ -22,6 +31,15 @@
let calcBusy = false;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
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 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(() => {
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 inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
@@ -66,6 +130,9 @@
setThickness(eighths);
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
const commitSpecies = () => {
setSpecies(speciesSel === '' ? null : Number(speciesSel));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
@@ -74,6 +141,7 @@
try {
commitLength();
commitThickness();
commitSpecies();
await calculate();
} finally { calcBusy = false; }
};
@@ -83,16 +151,77 @@
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#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
mode="tape"
interactive={false}
blob={state.source.blob}
tape={state.axis.tape}
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}
{maskW}
{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="field">
<span class="label">Length L</span>
@@ -124,6 +253,26 @@
</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">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
@@ -192,26 +341,45 @@
<style>
.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; }
.field { flex: 1; }
.field.species { margin-top: 16px; flex: none; }
.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;
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
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.tight { margin-top: 8px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.primary:disabled { background: #333; color: #777; }
.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; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
.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; }
.lineup {
list-style: none; padding: 0; margin: 16px 0 0;