feat(capture): tape-line overlay UI replaces axis rectangle (#99)
Pointer-Events pan/zoom/tilt under a fixed black ~2pt line with rifle-scope end ticks (≥44×44 hit). AxisPanel becomes Tape line step; session persists tape transform; scale = tick distance / L. sveltedrag unavailable for Svelte 4 — Pointer Events path used.
This commit is contained in:
@@ -75,6 +75,7 @@
|
|||||||
<AxisPanel
|
<AxisPanel
|
||||||
state={cap}
|
state={cap}
|
||||||
setAxisAngle={session.setAxisAngle}
|
setAxisAngle={session.setAxisAngle}
|
||||||
|
setTape={session.setTape}
|
||||||
approveAxis={session.approveAxis}
|
approveAxis={session.approveAxis}
|
||||||
back={session.back}
|
back={session.back}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,58 +1,154 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Capture } from './capture.types';
|
import type { Capture } from './capture.types';
|
||||||
|
import type { TapeTransform } from './tapeGeometry';
|
||||||
|
import { defaultTicks } from './tapeGeometry';
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
import PhotoStage from './PhotoStage.svelte';
|
||||||
|
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||||
|
import { cropMarginFraction } from '../png/crop';
|
||||||
|
|
||||||
export let state: Extract<Capture, { k: 'axis' }>;
|
export let state: Extract<Capture, { k: 'axis' }>;
|
||||||
export let setAxisAngle: (deg: number) => void;
|
export let setAxisAngle: (deg: number) => void;
|
||||||
|
export let setTape: (tape: TapeTransform) => void;
|
||||||
export let approveAxis: () => void;
|
export let approveAxis: () => void;
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
|
|
||||||
|
let cropFill = getCropFill();
|
||||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
$: tape = state.axis.tape;
|
||||||
|
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||||
|
|
||||||
|
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||||
|
|
||||||
|
const nudgeTilt = (delta: number) => {
|
||||||
|
setTape({ ...tape, tiltDeg: tape.tiltDeg + delta });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTilt = (deg: number) => {
|
||||||
|
setTape({ ...tape, tiltDeg: deg });
|
||||||
|
// Keep legacy path warm for any callers still using setAxisAngle.
|
||||||
|
setAxisAngle(deg);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Length axis</h1>
|
<h1>Tape line</h1>
|
||||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
<p class="hint">Drag each tick to where your tape started and ended.</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"
|
||||||
blob={state.source.blob}
|
blob={state.source.blob}
|
||||||
bbox={state.axis.bbox}
|
|
||||||
maskBitset={state.approvedMask.bitset}
|
maskBitset={state.approvedMask.bitset}
|
||||||
{maskW}
|
{maskW}
|
||||||
{maskH}
|
{maskH}
|
||||||
|
{tape}
|
||||||
|
{cropFill}
|
||||||
|
onTapeChange={onTapeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div>
|
||||||
|
<p class="ctl-label">Tilt the slab</p>
|
||||||
<div class="angle">
|
<div class="angle">
|
||||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||||
<input
|
<input
|
||||||
type="range" min="0" max="179" step="1"
|
type="range" min="0" max="359" step="0.1"
|
||||||
value={angleDeg}
|
value={((tiltShown % 360) + 360) % 360}
|
||||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
||||||
/>
|
/>
|
||||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||||
<span class="val">{angleDeg}°</span>
|
<span class="val">{tiltShown}°</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="nudge">← → tilt 1° · Shift+← → 0.1° · 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="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 type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.panel { padding: 16px; color: #eee; }
|
.panel { padding: 16px; color: #eee; }
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
.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 {
|
.thumb {
|
||||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
|
||||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||||
}
|
}
|
||||||
input[type='range'] { flex: 1; }
|
input[type='range'] { flex: 1; }
|
||||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.row.tight { margin-top: 8px; }
|
||||||
.primary {
|
.primary {
|
||||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||||
background: #00e5ff; color: #002; font-weight: 700;
|
background: #00e5ff; color: #002; font-weight: 700;
|
||||||
}
|
}
|
||||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -82,8 +82,10 @@
|
|||||||
|
|
||||||
{#if !photosReady}
|
{#if !photosReady}
|
||||||
<PhotoStage
|
<PhotoStage
|
||||||
|
mode="tape"
|
||||||
|
interactive={false}
|
||||||
blob={state.source.blob}
|
blob={state.source.blob}
|
||||||
bbox={state.axis.bbox}
|
tape={state.axis.tape}
|
||||||
maskBitset={state.approvedMask.bitset}
|
maskBitset={state.approvedMask.bitset}
|
||||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
||||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, tick } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import type { BBox } from './capture.types';
|
import type { BBox } from './capture.types';
|
||||||
|
import type { TapeTransform } from './tapeGeometry';
|
||||||
|
import { STAGE_H, STAGE_W, clampTickPair, defaultTicks, lineX } from './tapeGeometry';
|
||||||
import { fitDisplay } from './fitDisplay';
|
import { fitDisplay } from './fitDisplay';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
import { cropMarginFraction } from '../png/crop';
|
||||||
|
import { getCropFill } from '../settings/cropFill';
|
||||||
|
|
||||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||||
@@ -14,15 +18,42 @@
|
|||||||
export let maskW = 0;
|
export let maskW = 0;
|
||||||
export let maskH = 0;
|
export let maskH = 0;
|
||||||
|
|
||||||
|
/** #99 tape-line mode: fixed middle line + movable photo. */
|
||||||
|
export let mode: 'bbox' | 'tape' = 'bbox';
|
||||||
|
export let tape: TapeTransform | null = null;
|
||||||
|
export let cropFill: number = getCropFill();
|
||||||
|
export let onTapeChange: ((t: TapeTransform) => void) | null = null;
|
||||||
|
/** When false, tape overlay is display-only (Measure step). */
|
||||||
|
export let interactive = true;
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | undefined;
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
let stage: HTMLDivElement | undefined;
|
let stage: HTMLDivElement | undefined;
|
||||||
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
|
let tapeWrap: HTMLDivElement | undefined;
|
||||||
|
let imgW = 0, imgH = 0;
|
||||||
let parentW = 0;
|
let parentW = 0;
|
||||||
let decodeGen = 0;
|
let decodeGen = 0;
|
||||||
let decoding = false;
|
let decoding = false;
|
||||||
|
let focusTick: 'A' | 'B' = 'A';
|
||||||
|
|
||||||
|
// Local editable copy while dragging; flushed via onTapeChange.
|
||||||
|
let live: TapeTransform = tape ?? {
|
||||||
|
tiltDeg: 0, zoom: 1, panX: 0, panY: 0, ...defaultTicks(cropFill),
|
||||||
|
};
|
||||||
|
$: if (tape) live = { ...tape };
|
||||||
|
|
||||||
|
const emit = (next: TapeTransform) => {
|
||||||
|
live = next;
|
||||||
|
onTapeChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
const applyFit = () => {
|
const applyFit = () => {
|
||||||
if (!canvas || !imgW) return;
|
if (!canvas || !imgW) return;
|
||||||
|
if (mode === 'tape') {
|
||||||
|
// Photo is sized to the logical stage; CSS transform handles pan/zoom/tilt.
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '100%';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (parentW <= 0 && stage) parentW = stage.clientWidth;
|
if (parentW <= 0 && stage) parentW = stage.clientWidth;
|
||||||
const fit = fitDisplay(parentW, imgW, imgH, maxH);
|
const fit = fitDisplay(parentW, imgW, imgH, maxH);
|
||||||
if (!fit) return;
|
if (!fit) return;
|
||||||
@@ -31,7 +62,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||||
if (!canvas) return;
|
if (!canvas || mode === 'tape') return;
|
||||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||||
const off = document.createElement('canvas');
|
const off = document.createElement('canvas');
|
||||||
off.width = maskW;
|
off.width = maskW;
|
||||||
@@ -62,7 +93,6 @@
|
|||||||
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// The axis reads corners[3] → corners[0], the long side.
|
|
||||||
const a = c[3];
|
const a = c[3];
|
||||||
const b = c[0];
|
const b = c[0];
|
||||||
ctx.strokeStyle = '#ff3d71';
|
ctx.strokeStyle = '#ff3d71';
|
||||||
@@ -106,19 +136,170 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (blob || bbox || maskBitset) void tick().then(redraw);
|
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
|
||||||
|
|
||||||
|
// ---- tape pointer interactions ----
|
||||||
|
type DragKind = null | 'pan' | 'tickA' | 'tickB' | 'rot';
|
||||||
|
let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null;
|
||||||
|
let pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
let pinchStartDist = 0;
|
||||||
|
let pinchStartZoom = 1;
|
||||||
|
|
||||||
|
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
|
||||||
|
const el = tapeWrap;
|
||||||
|
if (!el) return { x: 0, y: 0 };
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((clientX - r.left) / Math.max(r.width, 1)) * STAGE_W,
|
||||||
|
y: ((clientY - r.top) / Math.max(r.height, 1)) * STAGE_H,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hitTick = (x: number, y: number): 'A' | 'B' | null => {
|
||||||
|
// ≥44×44 CSS px hit → ~34 stage units on a ~352px-wide stage (wireframe HIT_R).
|
||||||
|
const HIT = 34;
|
||||||
|
const lx = lineX();
|
||||||
|
if (Math.hypot(x - lx, y - live.tickA) <= HIT) return 'A';
|
||||||
|
if (Math.hypot(x - lx, y - live.tickB) <= HIT) return 'B';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hitRot = (x: number, y: number): boolean => {
|
||||||
|
const cx = STAGE_W / 2;
|
||||||
|
const cy = STAGE_H - 26;
|
||||||
|
return Math.hypot(x - cx, y - cy) <= 22;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (!interactive || mode !== 'tape') return;
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (pointers.size === 2) {
|
||||||
|
const pts = [...pointers.values()];
|
||||||
|
pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||||
|
pinchStartZoom = live.zoom;
|
||||||
|
drag = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = stagePoint(e.clientX, e.clientY);
|
||||||
|
const tick = hitTick(p.x, p.y);
|
||||||
|
if (tick) {
|
||||||
|
focusTick = tick;
|
||||||
|
drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hitRot(p.x, p.y)) {
|
||||||
|
drag = { kind: 'rot', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!interactive || mode !== 'tape') return;
|
||||||
|
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (pointers.size === 2) {
|
||||||
|
const pts = [...pointers.values()];
|
||||||
|
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||||
|
const z = Math.min(3, Math.max(0.5, pinchStartZoom * (dist / pinchStartDist)));
|
||||||
|
emit({ ...live, zoom: z });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!drag) return;
|
||||||
|
const p = stagePoint(e.clientX, e.clientY);
|
||||||
|
if (drag.kind === 'pan') {
|
||||||
|
emit({ ...live, panX: drag.panX0 + (p.x - drag.x0), panY: drag.panY0 + (p.y - drag.y0) });
|
||||||
|
} else if (drag.kind === 'tickA' || drag.kind === 'tickB') {
|
||||||
|
const pair = drag.kind === 'tickA'
|
||||||
|
? clampTickPair(p.y, live.tickB)
|
||||||
|
: clampTickPair(live.tickA, p.y);
|
||||||
|
emit({ ...live, ...pair });
|
||||||
|
} else if (drag.kind === 'rot') {
|
||||||
|
const cx = STAGE_W / 2;
|
||||||
|
const cy = STAGE_H / 2;
|
||||||
|
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
|
||||||
|
const a1 = Math.atan2(p.y - cy, p.x - cx);
|
||||||
|
const delta = ((a1 - a0) * 180) / Math.PI;
|
||||||
|
emit({ ...live, tiltDeg: drag.tilt0 + delta });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (e: PointerEvent) => {
|
||||||
|
pointers.delete(e.pointerId);
|
||||||
|
if (pointers.size < 2) drag = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (!interactive || mode !== 'tape') return;
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
||||||
|
emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!interactive || mode !== 'tape') return;
|
||||||
|
const fine = e.shiftKey;
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = fine ? 0.1 : 1;
|
||||||
|
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
||||||
|
emit({ ...live, tiltDeg: live.tiltDeg + dir * step });
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = fine ? 0.1 : 1;
|
||||||
|
const dir = e.key === 'ArrowUp' ? -1 : 1;
|
||||||
|
if (focusTick === 'A') {
|
||||||
|
emit({ ...live, ...clampTickPair(live.tickA + dir * step, live.tickB) });
|
||||||
|
} else {
|
||||||
|
emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) });
|
||||||
|
}
|
||||||
|
} else if (e.key === '+' || e.key === '=') {
|
||||||
|
e.preventDefault();
|
||||||
|
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
|
||||||
|
} else if (e.key === '-' || e.key === '_') {
|
||||||
|
e.preventDefault();
|
||||||
|
emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitPhoto = () => emit({ ...live, zoom: 1, panX: 0, panY: 0 });
|
||||||
|
|
||||||
|
let vbScale = 1;
|
||||||
|
const updateVbScale = () => {
|
||||||
|
if (!tapeWrap) return;
|
||||||
|
vbScale = tapeWrap.clientWidth / STAGE_W || 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: inset = STAGE_H * cropMarginFraction(cropFill);
|
||||||
|
$: photoTransform = `translate(${live.panX}px, ${live.panY}px) translate(${STAGE_W / 2}px, ${STAGE_H / 2}px) rotate(${live.tiltDeg}deg) scale(${live.zoom}) translate(${-STAGE_W / 2}px, ${-STAGE_H / 2}px)`;
|
||||||
|
// Contain-fit the canvas bitmap into the logical stage.
|
||||||
|
$: photoLayerStyle = imgW > 0 && imgH > 0
|
||||||
|
? (() => {
|
||||||
|
const scale0 = Math.min(STAGE_W / imgW, STAGE_H / imgH);
|
||||||
|
const w = imgW * scale0;
|
||||||
|
const h = imgH * scale0;
|
||||||
|
const left = (STAGE_W - w) / 2;
|
||||||
|
const top = (STAGE_H - h) / 2;
|
||||||
|
return `position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;transform-origin:center center;`;
|
||||||
|
})()
|
||||||
|
: 'position:absolute;inset:0;';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (stage) parentW = stage.clientWidth;
|
if (stage) parentW = stage.clientWidth;
|
||||||
|
updateVbScale();
|
||||||
const ro = new ResizeObserver((entries) => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
const e = entries[0];
|
const e = entries[0];
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
const w = e.contentRect.width;
|
const w = e.contentRect.width;
|
||||||
|
if (mode === 'tape') {
|
||||||
|
updateVbScale();
|
||||||
|
}
|
||||||
if (w === parentW) return;
|
if (w === parentW) return;
|
||||||
parentW = w;
|
parentW = w;
|
||||||
applyFit();
|
applyFit();
|
||||||
});
|
});
|
||||||
if (stage) ro.observe(stage);
|
if (stage) ro.observe(stage);
|
||||||
|
if (tapeWrap) ro.observe(tapeWrap);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -126,6 +307,58 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if mode === 'tape'}
|
||||||
|
<div
|
||||||
|
class="tape-stage"
|
||||||
|
bind:this={stage}
|
||||||
|
tabindex="0"
|
||||||
|
role="application"
|
||||||
|
aria-label="Fixed tape line over a movable slab photo"
|
||||||
|
on:keydown={onKeyDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tw"
|
||||||
|
class:dragging={!!drag}
|
||||||
|
bind:this={tapeWrap}
|
||||||
|
role="group"
|
||||||
|
aria-label="Tape alignment stage"
|
||||||
|
on:pointerdown={onPointerDown}
|
||||||
|
on:pointermove={onPointerMove}
|
||||||
|
on:pointerup={onPointerUp}
|
||||||
|
on:pointercancel={onPointerUp}
|
||||||
|
on:wheel={onWheel}
|
||||||
|
on:dblclick={() => interactive && fitPhoto()}
|
||||||
|
>
|
||||||
|
<div class="vb" style="width:{STAGE_W}px;height:{STAGE_H}px;transform:scale({vbScale});">
|
||||||
|
<div class="photo-layer" style="{photoLayerStyle} transform: {photoTransform};">
|
||||||
|
<canvas bind:this={canvas}></canvas>
|
||||||
|
</div>
|
||||||
|
<!-- crop end bands (proposal visual; aspect stays 3:4) -->
|
||||||
|
<div class="band top" style="height:{inset}px"></div>
|
||||||
|
<div class="band bot" style="height:{inset}px"></div>
|
||||||
|
<svg class="fixture" viewBox="0 0 {STAGE_W} {STAGE_H}" width={STAGE_W} height={STAGE_H} aria-hidden="true">
|
||||||
|
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round" />
|
||||||
|
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||||
|
{#each [['A', live.tickA], ['B', live.tickB]] as [id, y]}
|
||||||
|
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round" />
|
||||||
|
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||||
|
<circle cx={lineX()} cy={y} r="5" fill={focusTick === id ? '#ff3d71' : '#000'} stroke="#fff" stroke-width="1.5" />
|
||||||
|
<circle class="tick-hit" cx={lineX()} cy={y} r="34" fill="transparent" />
|
||||||
|
{/each}
|
||||||
|
{#if interactive}
|
||||||
|
<g class="rot-handle">
|
||||||
|
<circle cx={STAGE_W / 2} cy={STAGE_H - 26} r="17" fill="rgba(0,0,0,.6)" stroke="#00e5ff" stroke-width="1.5" />
|
||||||
|
<text x={STAGE_W / 2} y={STAGE_H - 21} fill="#00e5ff" font-size="15" text-anchor="middle">↻</text>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{#if decoding && imgW === 0}
|
||||||
|
<div class="loading">Decoding photo…</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="stage" bind:this={stage}>
|
<div class="stage" bind:this={stage}>
|
||||||
{#if imgW > 0 || decoding || blob}
|
{#if imgW > 0 || decoding || blob}
|
||||||
<canvas bind:this={canvas}></canvas>
|
<canvas bind:this={canvas}></canvas>
|
||||||
@@ -133,9 +366,31 @@
|
|||||||
<div class="loading">Decoding photo…</div>
|
<div class="loading">Decoding photo…</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.stage { width: 100%; display: flex; justify-content: center; }
|
.stage { width: 100%; display: flex; justify-content: center; }
|
||||||
canvas { display: block; border-radius: 8px; background: #222; }
|
canvas { display: block; border-radius: 8px; background: #222; width: 100%; height: 100%; }
|
||||||
.loading { padding: 40px; color: #999; }
|
.loading { padding: 40px; color: #999; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.tape-stage { width: 100%; outline: none; }
|
||||||
|
.tw {
|
||||||
|
position: relative; width: 100%; aspect-ratio: 3 / 4;
|
||||||
|
background: #111; border: 1px solid #333; border-radius: 8px; overflow: hidden;
|
||||||
|
touch-action: none; user-select: none; cursor: grab;
|
||||||
|
}
|
||||||
|
.tw.dragging { cursor: grabbing; }
|
||||||
|
.vb {
|
||||||
|
position: absolute; top: 0; left: 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.vb > :global(*) { pointer-events: auto; }
|
||||||
|
.photo-layer { will-change: transform; pointer-events: none; }
|
||||||
|
.photo-layer canvas { border-radius: 0; background: transparent; pointer-events: none; }
|
||||||
|
.band { position: absolute; left: 0; right: 0; background: #000; opacity: 0.3; pointer-events: none; z-index: 1; }
|
||||||
|
.band.top { top: 0; }
|
||||||
|
.band.bot { bottom: 0; }
|
||||||
|
.fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
||||||
|
.tick-hit { pointer-events: auto; cursor: ns-resize; }
|
||||||
|
.rot-handle { pointer-events: auto; cursor: ew-resize; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+115
-16
@@ -10,7 +10,11 @@ import { measure } from '../measure/measure';
|
|||||||
import { orientedRect } from '../geometry/rect';
|
import { orientedRect } from '../geometry/rect';
|
||||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||||
|
import { rotateCalibration } from '../png/rotateCal';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
import { defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
|
||||||
|
import { getCropFill } from '../settings/cropFill';
|
||||||
|
import type { TapeLine } from './capture.types';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
const BUSY_MS = 300;
|
const BUSY_MS = 300;
|
||||||
@@ -41,6 +45,8 @@ export interface CaptureSession {
|
|||||||
setSheet(s: Sheet): void;
|
setSheet(s: Sheet): void;
|
||||||
approveMask(): void;
|
approveMask(): void;
|
||||||
setAxisAngle(deg: number): void;
|
setAxisAngle(deg: number): void;
|
||||||
|
/** #99: replace axis rectangle nudge with full tape transform. */
|
||||||
|
setTape(tape: TapeTransform): void;
|
||||||
approveAxis(): void;
|
approveAxis(): void;
|
||||||
setLength(e: Eighths): void;
|
setLength(e: Eighths): void;
|
||||||
setThickness(e: Eighths): void;
|
setThickness(e: Eighths): void;
|
||||||
@@ -204,7 +210,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
|
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -214,7 +220,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
|
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||||
results: state.results ? rest : null,
|
results: state.results ? rest : null,
|
||||||
inventoryPng: state.results?.inventoryPng ?? null,
|
inventoryPng: state.results?.inventoryPng ?? null,
|
||||||
@@ -310,6 +316,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
// fall back to the mask's own bbox.
|
// fall back to the mask's own bbox.
|
||||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||||
|
|
||||||
|
const tapeFromDraftOrBbox = (d: Draft, bbox: import('./capture.types').BBox, w: number, h: number): TapeLine => {
|
||||||
|
if (d.tape && Number.isFinite(d.tape.spanPx)) {
|
||||||
|
return { ...d.tape };
|
||||||
|
}
|
||||||
|
// Seed tilt so the mask long side sits near vertical under the fixed line.
|
||||||
|
const seedTilt = 90 - bbox.angleDeg;
|
||||||
|
const t = defaultTape(getCropFill(), seedTilt);
|
||||||
|
const ep = w > 0 && h > 0 ? tapeEndpoints(t, w, h) : { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||||
|
return { ...t, spanPx: ep.spanPx };
|
||||||
|
};
|
||||||
|
|
||||||
|
const axisWithTape = (bbox: import('./capture.types').BBox, tape: TapeTransform, w: number, h: number) => {
|
||||||
|
const ep = w > 0 && h > 0
|
||||||
|
? tapeEndpoints(tape, w, h)
|
||||||
|
: { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||||
|
const angleDeg = ep.spanPx > 0
|
||||||
|
? ((Math.atan2(ep.uy, ep.ux) * 180) / Math.PI + 180) % 180
|
||||||
|
: bbox.angleDeg;
|
||||||
|
const nextBbox = orientedRect(bbox, angleDeg);
|
||||||
|
const line: TapeLine = {
|
||||||
|
tiltDeg: tape.tiltDeg,
|
||||||
|
zoom: tape.zoom,
|
||||||
|
panX: tape.panX,
|
||||||
|
panY: tape.panY,
|
||||||
|
tickA: tape.tickA,
|
||||||
|
tickB: tape.tickB,
|
||||||
|
spanPx: ep.spanPx,
|
||||||
|
};
|
||||||
|
return { bbox: nextBbox, tape: line };
|
||||||
|
};
|
||||||
|
|
||||||
const restoreFromDraft = async (d: Draft) => {
|
const restoreFromDraft = async (d: Draft) => {
|
||||||
batchPhotos = batchPhotosOf(d);
|
batchPhotos = batchPhotosOf(d);
|
||||||
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
|
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
|
||||||
@@ -336,16 +373,29 @@ 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') {
|
||||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
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 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.inventoryPng) {
|
if (d.inventoryPng) {
|
||||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
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({
|
set({
|
||||||
k: 'measure', source, approvedMask: mask,
|
k: 'measure', source, approvedMask: mask,
|
||||||
axis: { bbox: draftAxisBbox(d) },
|
axis,
|
||||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||||
results: d.results
|
results: d.results
|
||||||
? {
|
? {
|
||||||
@@ -356,6 +406,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
: null,
|
: null,
|
||||||
batchApply: d.batchApply,
|
batchApply: d.batchApply,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||||
makeWorker(source.blob);
|
makeWorker(source.blob);
|
||||||
@@ -474,22 +525,47 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||||
teardownWorker();
|
teardownWorker();
|
||||||
const overlay = state.overlay;
|
const overlay = state.overlay;
|
||||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
const bbox = {
|
||||||
|
corners: overlay.bbox.corners.map((c) => [...c] as [number, number]),
|
||||||
|
longSidePx: overlay.bbox.longSidePx,
|
||||||
|
angleDeg: overlay.bbox.angleDeg,
|
||||||
|
};
|
||||||
|
const w = state.source.widthPx;
|
||||||
|
const h = state.source.heightPx;
|
||||||
|
const seedTilt = 90 - bbox.angleDeg;
|
||||||
|
const tape0 = defaultTape(getCropFill(), seedTilt);
|
||||||
|
const axis = axisWithTape(bbox, tape0, w, h);
|
||||||
|
set({ k: 'axis', source: state.source, approvedMask: overlay, axis });
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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') return;
|
||||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
const tape = { ...state.axis.tape, tiltDeg: deg };
|
||||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
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 axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||||
|
set({ ...state, axis });
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
approveAxis() {
|
approveAxis() {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
|
const axis = axisWithTape(
|
||||||
|
state.axis.bbox,
|
||||||
|
state.axis.tape,
|
||||||
|
state.source.widthPx,
|
||||||
|
state.source.heightPx,
|
||||||
|
);
|
||||||
set({
|
set({
|
||||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||||
axis: { bbox: state.axis.bbox },
|
axis,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||||
});
|
});
|
||||||
void persist();
|
void persist();
|
||||||
@@ -514,14 +590,35 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||||
|
const tapeAxis = axis.tape.spanPx > 0
|
||||||
|
? (() => {
|
||||||
|
const ep = tapeEndpoints(axis.tape, source.widthPx, source.heightPx);
|
||||||
|
return { start: ep.start, ux: ep.ux, uy: ep.uy, spanPx: ep.spanPx };
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
const base = measure(
|
const base = measure(
|
||||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||||
|
tapeAxis,
|
||||||
);
|
);
|
||||||
let pngBlob: Blob;
|
let pngBlob: Blob;
|
||||||
let belowFloor = false;
|
let belowFloor = false;
|
||||||
|
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
|
||||||
|
let calBitmap = bitmap;
|
||||||
|
let calMask = approvedMask;
|
||||||
|
let calSource = source;
|
||||||
|
let rotatedExtra: ImageBitmap | null = null;
|
||||||
try {
|
try {
|
||||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
const rotated = await rotateCalibration(bitmap, approvedMask, source, axis.tape.tiltDeg);
|
||||||
|
calBitmap = rotated.bitmap;
|
||||||
|
calMask = rotated.mask;
|
||||||
|
calSource = { ...source, widthPx: rotated.source.widthPx, heightPx: rotated.source.heightPx };
|
||||||
|
if (rotated.bitmap !== bitmap) rotatedExtra = rotated.bitmap;
|
||||||
|
} catch {
|
||||||
|
/* keep unrotated cal if bake fails */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
|
||||||
pngBlob = r.blob;
|
pngBlob = r.blob;
|
||||||
belowFloor = r.sourceBelowFloor;
|
belowFloor = r.sourceBelowFloor;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -529,24 +626,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
pngBlob = new Blob([], { type: 'image/png' });
|
pngBlob = new Blob([], { type: 'image/png' });
|
||||||
}
|
}
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
|
if (rotatedExtra) rotatedExtra.close();
|
||||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||||
pngUrl = URL.createObjectURL(pngBlob);
|
pngUrl = URL.createObjectURL(pngBlob);
|
||||||
|
|
||||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
||||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||||
|
// Overlay uses the same baked orientation as the clean cal PNG (#99 scope).
|
||||||
let overlayBlob: Blob;
|
let overlayBlob: Blob;
|
||||||
try {
|
try {
|
||||||
overlayBlob = await encodeOverlay({
|
overlayBlob = await encodeOverlay({
|
||||||
inventoryPng: pngBlob,
|
inventoryPng: pngBlob,
|
||||||
bitset: approvedMask.bitset,
|
bitset: calMask.bitset,
|
||||||
widthPx: source.widthPx,
|
widthPx: calSource.widthPx,
|
||||||
heightPx: source.heightPx,
|
heightPx: calSource.heightPx,
|
||||||
bbox: axis.bbox,
|
bbox: calMask.bbox,
|
||||||
scale: base.scale,
|
scale: base.scale,
|
||||||
lengthIn: eighthsToIn(lengthEighths),
|
lengthIn: eighthsToIn(lengthEighths),
|
||||||
lengthLabel: fmtEighths(lengthEighths),
|
lengthLabel: fmtEighths(lengthEighths),
|
||||||
slabBounds: approvedMask.slabBounds,
|
slabBounds: calMask.slabBounds,
|
||||||
source,
|
source: calSource,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||||
@@ -624,7 +723,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
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: { bbox: state.axis.bbox } });
|
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user