feat(capture): ship tape-line calibration (#99)

This commit is contained in:
Ty
2026-09-24 14:47:13 -07:00
parent 6fb92362a4
commit 4ca2ef57a7
13 changed files with 437 additions and 738 deletions
+231 -38
View File
@@ -1,58 +1,251 @@
<script lang="ts">
import type { Capture } from './capture.types';
import PhotoStage from './PhotoStage.svelte';
import { onMount } from 'svelte';
import type { Capture, TapeLine } from './capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { photoToScreen, screenToPhoto, type PhotoPoint, type PhotoViewTransform } from '../png/crop';
export let state: Extract<Capture, { k: 'axis' }>;
export let setAxisAngle: (deg: number) => void;
export let setTapeLine: (tapeLine: TapeLine) => void;
export let approveAxis: () => void;
export let back: () => void;
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
let stage: HTMLDivElement | undefined;
let canvas: HTMLCanvasElement | undefined;
let bitmap: ImageBitmap | null = null;
let sourceW = state.source.widthPx;
let sourceH = state.source.heightPx;
let stageW = 0;
let stageH = 0;
let tickA: PhotoPoint = [...state.axis.tapeLine.start];
let tickB: PhotoPoint = [...state.axis.tapeLine.end];
let angleDeg = 0;
let zoom = 1;
let panX = 0;
let panY = 0;
let dragging: 'start' | 'end' | 'pan' | null = null;
let selected: 'start' | 'end' = 'start';
let lastPointer: PhotoPoint = [0, 0];
$: tickDistance = Math.hypot(tickB[0] - tickA[0], tickB[1] - tickA[1]);
function fittedScale(): number {
if (!(stageW > 0 && stageH > 0 && sourceW > 0 && sourceH > 0)) return 0;
return Math.min(stageW / sourceW, stageH / sourceH);
}
function view(): PhotoViewTransform {
return {
width: sourceW,
height: sourceH,
centerX: stageW / 2,
centerY: stageH / 2,
scale: fittedScale(),
zoom,
angleDeg,
panX,
panY,
};
}
function draw(): void {
if (!canvas || !stageW || !stageH) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(stageW * dpr);
canvas.height = Math.round(stageH * dpr);
canvas.style.width = `${stageW}px`;
canvas.style.height = `${stageH}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, stageW, stageH);
const transform = view();
if (bitmap && transform.scale > 0) {
ctx.save();
ctx.translate(transform.centerX + panX, transform.centerY + panY);
ctx.rotate((angleDeg * Math.PI) / 180);
ctx.scale(transform.scale * zoom, transform.scale * zoom);
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
ctx.restore();
}
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length;
const ny = dx / length;
ctx.save();
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
for (const [point, which] of [[a, 'start'], [b, 'end']] as const) {
if (which === selected) {
ctx.strokeStyle = '#ffd600';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(point[0], point[1], 16, 0, Math.PI * 2); ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
ctx.fillStyle = which === 'start' ? '#ffd600' : '#ff3d71';
ctx.beginPath(); ctx.arc(point[0], point[1], 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
}
ctx.restore();
}
function pointerPosition(event: PointerEvent): PhotoPoint {
const rect = canvas!.getBoundingClientRect();
return [event.clientX - rect.left, event.clientY - rect.top];
}
function onPointerDown(event: PointerEvent): void {
const point = pointerPosition(event);
const transform = view();
const a = photoToScreen(tickA, transform);
const b = photoToScreen(tickB, transform);
const hitA = Math.hypot(point[0] - a[0], point[1] - a[1]) < 28;
const hitB = Math.hypot(point[0] - b[0], point[1] - b[1]) < 28;
dragging = hitA ? 'start' : hitB ? 'end' : 'pan';
if (dragging !== 'pan') selected = dragging;
lastPointer = point;
canvas?.setPointerCapture(event.pointerId);
}
function onPointerMove(event: PointerEvent): void {
if (!dragging) return;
const point = pointerPosition(event);
if (dragging === 'pan') {
panX += point[0] - lastPointer[0];
panY += point[1] - lastPointer[1];
} else {
const sourcePoint = screenToPhoto(point, view());
sourcePoint[0] = Math.max(0, Math.min(sourceW, sourcePoint[0]));
sourcePoint[1] = Math.max(0, Math.min(sourceH, sourcePoint[1]));
if (dragging === 'start') tickA = sourcePoint;
else tickB = sourcePoint;
}
lastPointer = point;
draw();
}
function onPointerUp(event: PointerEvent): void {
if (dragging === 'start' || dragging === 'end') {
setTapeLine({ start: [...tickA], end: [...tickB] });
}
dragging = null;
canvas?.releasePointerCapture(event.pointerId);
}
function zoomAt(x: number, y: number, factor: number): void {
const before = screenToPhoto([x, y], view());
zoom = Math.max(0.5, Math.min(5, zoom * factor));
const after = photoToScreen(before, view());
panX += x - after[0];
panY += y - after[1];
draw();
}
function onWheel(event: WheelEvent): void {
event.preventDefault();
const rect = canvas!.getBoundingClientRect();
zoomAt(event.clientX - rect.left, event.clientY - rect.top, Math.exp(-event.deltaY * 0.0015));
}
function resetView(): void {
angleDeg = 0;
zoom = 1;
panX = 0;
panY = 0;
draw();
}
onMount(() => {
let alive = true;
const resize = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
stageW = entry.contentRect.width;
stageH = entry.contentRect.height;
draw();
});
if (stage) resize.observe(stage);
void decodeOrientedBitmap(state.source.blob).then((decoded) => {
if (!alive) {
decoded.close();
return;
}
bitmap = decoded;
sourceW = decoded.width;
sourceH = decoded.height;
draw();
});
return () => {
alive = false;
resize.disconnect();
bitmap?.close();
bitmap = null;
};
});
</script>
<section class="panel">
<h1>Length axis</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>
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
/>
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
<input
type="range" min="0" max="179" step="1"
value={angleDeg}
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
<span class="val">{angleDeg}°</span>
<h1>Tape-line calibration</h1>
<p class="hint">Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.</p>
<div class="stage" bind:this={stage}>
<canvas
bind:this={canvas}
aria-label="Calibration photo with draggable tape endpoint ticks"
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel|nonpassive={onWheel}
></canvas>
</div>
<div class="control">
<label for="photo-angle">Rotate photo</label>
<input id="photo-angle" type="range" min="-180" max="180" step="0.1" value={angleDeg}
on:input={(event) => { angleDeg = Number(event.currentTarget.value); draw(); }} />
<span class="value">{angleDeg.toFixed(1)}°</span>
</div>
<div class="control">
<label for="photo-zoom">Zoom</label>
<input id="photo-zoom" type="range" min="0.5" max="5" step="0.05" value={zoom}
on:input={(event) => { zoom = Number(event.currentTarget.value); draw(); }} />
<span class="value">{Math.round(zoom * 100)}%</span>
<button type="button" class="minor" on:click={resetView}>Reset view</button>
</div>
<p class="tick-distance">Tick distance in source photo: {tickDistance.toFixed(1)} px</p>
<div class="row">
<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" disabled={tickDistance < 4} on:click={approveAxis}>Use tape line</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
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; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.45; }
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
canvas { display: block; touch-action: none; cursor: grab; }
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
.minor { min-height: 40px; border: 1px solid #444; background: #1d1d1d; color: #ccc; padding: 8px 10px; }
.tick-distance { margin: 10px 0 0; color: #aaa; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>
+11 -2
View File
@@ -3,6 +3,7 @@
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import { calibrationScale } from '../png/crop';
import PhotoStage from './PhotoStage.svelte';
import {
buildPhotosReadyLineup,
@@ -54,6 +55,12 @@
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
$: tapeScale = calibrationScale(
state.axis.tapeLine.start,
state.axis.tapeLine.end,
positiveInches(String(lengthIn)) ?? 0,
);
const commitLength = () => {
const inches = positiveInches(String(lengthIn));
const eighths = inches === null ? 0 : inToEighths(inches);
@@ -67,7 +74,7 @@
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null && tapeScale > 0;
const doCalc = async () => {
calcBusy = true;
@@ -93,7 +100,7 @@
<div class="fields">
<div class="field">
<span class="label">Length L</span>
<span class="label">Tape length L (distance between ticks)</span>
<input
type="number"
aria-label="Length L"
@@ -121,6 +128,7 @@
/>
</div>
</div>
<p class="scale">Tape ticks: {Math.hypot(state.axis.tapeLine.end[0] - state.axis.tapeLine.start[0], state.axis.tapeLine.end[1] - state.axis.tapeLine.start[1]).toFixed(1)} source-photo px · Scale {tapeScale.toFixed(2)} px/in</p>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
@@ -194,6 +202,7 @@
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.scale { margin: 8px 0 0; color: #aaa; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.field input {
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;
-611
View File
@@ -1,611 +0,0 @@
<script lang="ts">
// #99 PROTOTYPE — throwaway design sandbox for the tape-line overlay.
// NOT production. Delete this file (and the App.svelte branch) once the design
// is settled.
//
// Model per issue #99:
// - The MEASURE LINE IS FIXED VERTICAL on screen: it sits at the position the
// crop places the slab's long edge (derived from crop fill + the 3:4 aspect
// constants — the crop line IS the reference line).
// - The SLAB (the photo) is the only thing that pans / zooms / rotates.
// - Two rifle-scope ticks slide ALONG the fixed line (1-D drag). The owner
// rotates the photo until its taped edge sits on the line, then places the
// ticks at the tape start/end.
// - Declared L = source-space distance between the ticks ÷ px-per-inch —
// NOT a projected mask span. Zooming in makes tick placement finer.
// - crop % is a GLOBAL setting (default 90%) and moves the fixed line.
import { onMount, onDestroy } from 'svelte';
import { maskSpanAlong } from '../geometry/rect';
// ---- synthetic slab, portrait (long edge ~vertical) --------------------
const SW = 300; // source px width (slab width)
const SH = 1040; // source px height (slab runs long along Y)
let bitset = new Uint8Array(SW * SH);
const rnd = (n: number): number => {
const x = Math.sin(n * 127.1) * 43758.5453;
return x - Math.floor(x);
};
function vnoise(x: number, y: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const s = (a: number, b: number) => rnd(a * 374.13 + b * 217.91);
const n00 = s(xi, yi), n10 = s(xi + 1, yi), n01 = s(xi, yi + 1), n11 = s(xi + 1, yi + 1);
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const a = n00 * (1 - u) + n10 * u;
const b = n01 * (1 - u) + n11 * u;
return a * (1 - v) + b * v;
}
function buildSlab(): void {
bitset = new Uint8Array(SW * SH);
for (let y = 0; y < SH; y++) {
for (let x = 0; x < SW; x++) {
const edge = Math.min(y, SH - y);
const taper = 1 - 0.22 * Math.max(0, 1 - edge / 170);
const wobble =
1 + 0.05 * Math.sin(y / 53 + 0.4)
+ 0.035 * Math.sin(y / 19 + 1.1)
+ 0.02 * Math.sin(y / 8.1 + 2.2);
const halfW = (SW / 2) * taper * wobble;
const cx = SW / 2 + 4 * Math.sin(y / 71);
if (Math.abs(x - cx) <= halfW) bitset[y * SW + x] = 1;
}
}
}
function woodRGB(x: number, y: number): [number, number, number] {
const cx = SW / 2;
const t = (x - cx) / (SW / 2);
let r = 172 - Math.abs(t) * 48;
let g = 122 - Math.abs(t) * 42;
let b = 78 - Math.abs(t) * 32;
const n = vnoise(x * 0.05, y * 0.02);
r += (n - 0.5) * 28; g += (n - 0.5) * 24; b += (n - 0.5) * 17;
const grain =
Math.sin(y * 0.02 + x * 0.5 + Math.sin(x * 0.09) * 2.2) * 0.5 +
vnoise(x * 0.4, y * 0.02) * 0.6 - 0.3;
const streak = grain * 30;
r += streak; g += streak * 0.85; b += streak * 0.6;
for (const ky of [240, 560, 820]) {
const kx = cx + (rnd(ky) - 0.5) * 34;
const d = Math.hypot((x - kx) * 2.0, y - ky);
if (d < 22) {
const ring = Math.cos(d * 0.7) * 0.5 + 0.5;
const f = Math.max(0, 1 - d / 22);
r -= f * 60 * (0.4 + 0.6 * (1 - ring));
g -= f * 58 * (0.4 + 0.6 * (1 - ring));
b -= f * 44 * (0.4 + 0.6 * (1 - ring));
}
}
return [
Math.max(38, Math.min(214, Math.round(r))),
Math.max(26, Math.min(162, Math.round(g))),
Math.max(18, Math.min(112, Math.round(b))),
];
}
// Silhouette path: left edge top→bottom, right edge bottom→top.
function slabPath(ctx: CanvasRenderingContext2D, scale: number): void {
ctx.beginPath();
let started = false;
for (let y = 0; y < SH; y++) {
for (let x = 0; x < SW; x++) {
if (bitset[y * SW + x]) {
if (!started) { ctx.moveTo(x * scale, y * scale); started = true; }
else ctx.lineTo(x * scale, y * scale);
break;
}
}
}
for (let y = SH - 1; y >= 0; y--) {
for (let x = SW - 1; x >= 0; x--) {
if (bitset[y * SW + x]) { ctx.lineTo(x * scale, y * scale); break; }
}
}
ctx.closePath();
}
let slabCanvas: HTMLCanvasElement | null = null;
function buildSlabCanvas(): void {
const c = document.createElement('canvas');
c.width = SW; c.height = SH;
const ctx = c.getContext('2d')!;
const img = ctx.createImageData(SW, SH);
for (let y = 0; y < SH; y++) {
for (let x = 0; x < SW; x++) {
const i = (y * SW + x) * 4;
if (bitset[y * SW + x]) {
const [r, g, b] = woodRGB(x, y);
img.data[i] = r; img.data[i+1] = g; img.data[i+2] = b; img.data[i+3] = 255;
}
}
}
ctx.putImageData(img, 0, 0);
ctx.save();
slabPath(ctx, 1);
ctx.lineJoin = 'round';
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(38,22,11,0.85)';
ctx.stroke();
ctx.restore();
slabCanvas = c;
}
let canvas: HTMLCanvasElement | undefined;
let parentW = 0, parentH = 0;
let stage: HTMLDivElement | undefined;
// ---- the photo transform (pan/zoom/rotate moves ONLY the slab) ---------
let panX = 0, panY = 0; // screen-space offset of the slab
let zoom = 1;
let photoAngle = 0; // deg — rotate the photo until its taped edge hits the line
// ---- global crop fill (settings.crop_fill, default 90) ------------------
let cropFill = 90;
// ---- ticks: pinned to SOURCE pixels on the photo. Their screen position
// is wherever the photo transform puts that pixel — which is ON the fixed
// line only when the photo is aligned, which is exactly the task. ----
let tickAs = 40, tickBs = 1000; // source-space Y on the slab (tape start/end)
let selectedTick: 'A' | 'B' = 'A'; // the tick arrow/Shift+arrow nudges
let dragging: 'pan' | 'A' | 'B' | null = null;
let lastPointer = { x: 0, y: 0 };
// Active pointers, for mouse drag vs two-finger pinch (#99 control model).
const pointers = new Map<number, { x: number; y: number }>();
let pinchDist = 0;
// ---- layout: a fixed 3:4 crop frame; the line sits where the crop puts
// the slab's long (left) edge at the current fill.
interface Frame { x: number; y: number; w: number; h: number }
function cropFrame(): Frame {
const pad = 16;
const availW = parentW - pad * 2, availH = parentH - pad * 2;
const w = Math.min(availW, availH * (3 / 4));
const h = w * (4 / 3);
return { x: (parentW - w) / 2, y: (parentH - h) / 2, w, h };
}
// Base slab scale so the slab fits the frame at fill% (like composeInventoryFrame).
function baseScale(fr: Frame): number {
return ((fr.h * (cropFill / 100)) / SH);
}
// The FIXED measure line: screen X where the crop puts the slab's long edge.
function refLineX(fr: Frame): number {
const s = baseScale(fr);
return fr.x + (fr.w - SW * s) / 2;
}
// Source-space → screen (slab only; the line never uses this).
function toScreen(sx: number, sy: number): [number, number] {
const fr = cropFrame();
const s = baseScale(fr) * zoom;
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
const rad = (photoAngle * Math.PI) / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const dx = (sx - SW / 2) * s, dy = (sy - SH / 2) * s;
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
}
// Screen → source-space (inverse of toScreen).
function toSource(px: number, py: number): [number, number] {
const fr = cropFrame();
const s = baseScale(fr) * zoom;
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
const rad = (-photoAngle * Math.PI) / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const dx = px - cx, dy = py - cy;
return [SW / 2 + (dx * cos - dy * sin) / s, SH / 2 + (dx * sin + dy * cos) / s];
}
// Synthetic tape scale: the slab spans SH px ≈ 88 inches.
function pxPerIn(): number {
const spanPx = maskSpanAlong(bitset, SW, SH, 0, 1);
return spanPx / 88;
}
// L = distance between the two photo-pinned ticks in source px ÷ scale.
// Exact for the photo's natural pixels — independent of on-screen zoom.
let declaredLenIn = 0;
function recompute(): void {
declaredLenIn = Math.abs(tickBs - tickAs) / (pxPerIn() || 1);
}
function draw(): void {
if (!canvas) return;
if (!parentW || !parentH) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(parentW * dpr);
canvas.height = Math.round(parentH * dpr);
canvas.style.width = parentW + 'px';
canvas.style.height = parentH + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const fr = cropFrame();
const lineX = refLineX(fr);
// 1) workshop backdrop
const bg = ctx.createRadialGradient(parentW/2, parentH*0.4, parentW*0.1, parentW/2, parentH/2, parentW*0.75);
bg.addColorStop(0, '#242424');
bg.addColorStop(1, '#141414');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, parentW, parentH);
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1;
for (let gx = 40; gx < parentW; gx += 40) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, parentH); ctx.stroke(); }
for (let gy = 40; gy < parentH; gy += 40) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(parentW, gy); ctx.stroke(); }
// 2) THE 3:4 CROP FRAME — a fixed screen fixture (where output lands).
ctx.save();
ctx.strokeStyle = 'rgba(0,229,255,0.35)';
ctx.lineWidth = 1.5;
ctx.setLineDash([8, 6]);
ctx.strokeRect(fr.x, fr.y, fr.w, fr.h);
ctx.setLineDash([]);
ctx.font = '600 12px system-ui';
ctx.fillStyle = 'rgba(0,229,255,0.6)';
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText('3:4 crop frame', fr.x + fr.w - 6, Math.max(fr.y - 4, 14));
ctx.restore();
// 3) THE SLAB + the tape + the ticks — all pinned to the photo, so they
// pan / zoom / rotate together. The tape only lands on the fixed line
// once the owner has rotated the photo into alignment.
ctx.save();
if (slabCanvas) {
const s = baseScale(fr) * zoom;
ctx.save();
ctx.translate(fr.x + fr.w/2 + panX, fr.y + fr.h/2 + panY);
ctx.rotate((photoAngle * Math.PI) / 180);
ctx.scale(s, s);
ctx.translate(-SW/2, -SH/2);
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowBlur = 30 / s;
ctx.shadowOffsetY = 12 / s;
ctx.drawImage(slabCanvas, 0, 0);
ctx.restore();
}
// tape + ticks in source space → screen
const [ax, ay] = toScreen(SW / 2, tickAs);
const [bx, by] = toScreen(SW / 2, tickBs);
const invZoom = 1 / (baseScale(fr) * zoom);
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.5; // "2 weight"
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
const tdx = bx - ax, tdy = by - ay, tl = Math.hypot(tdx, tdy) || 1;
const px = -tdy / tl, py = tdx / tl, half = 18;
for (const [ex, ey, id] of [[ax, ay, 'A'], [bx, by, 'B']] as const) {
// The selected tick gets a highlight ring: it is what arrow/Shift+arrow
// nudges for pixel-level tape-mark alignment.
if (id === selectedTick) {
ctx.strokeStyle = 'rgba(255,214,0,0.95)';
ctx.lineWidth = 2.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(ex, ey, half + 8, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
ctx.fillStyle = id === selectedTick ? '#ffd600' : '#ff3d71';
ctx.beginPath(); ctx.arc(ex, ey, 6, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
}
void invZoom;
ctx.restore();
// 4) THE FIXED MEASURE LINE — a screen fixture, drawn ON TOP of the photo,
// never pans/zooms/rotates. crop fill moves it (it is where the crop
// puts the slab's long edge). The goal: rotate the photo until the black
// tape lines up with this line.
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 5;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(lineX, fr.y - 18);
ctx.lineTo(lineX, fr.y + fr.h + 18);
ctx.stroke();
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(lineX, fr.y - 18);
ctx.lineTo(lineX, fr.y + fr.h + 18);
ctx.stroke();
ctx.setLineDash([]);
ctx.font = '600 12px system-ui';
ctx.fillStyle = '#00e5ff';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
// Clamp above the frame top so the label can never be clipped off-canvas.
const lineLabelY = Math.max(fr.y - 22, 15);
ctx.fillText(`fixed reference line · crop ${cropFill}%`, lineX, lineLabelY);
ctx.restore();
// 5) length chip beside the fixed line, mid-height (a screen fixture)
const my = (ay + by) / 2;
const fs = 16;
ctx.font = `700 ${fs}px system-ui, -apple-system, sans-serif`;
const label = declaredLenIn.toFixed(1) + '\u2033';
const tw = ctx.measureText(label).width;
const padX = 10, padY = 7, chipX = lineX + 44;
ctx.fillStyle = 'rgba(15,15,15,0.92)';
ctx.strokeStyle = 'rgba(255,61,113,0.9)';
ctx.lineWidth = 1.5;
roundRect(ctx, chipX, my - fs/2 - padY, tw + padX*2, fs + padY*2, 8);
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#ff6b8f';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, chipX + (tw + padX*2)/2, my);
// Prototype-only debug hook so the behavior tests read geometry directly.
(window as any).__tp = {
lineX, declaredLenIn, photoAngle, zoom, panX, panY,
tickA: { ax, ay }, tickB: { bx, by }, cropFill, frame: fr,
tickAs, tickBs, selectedTick, dragging, pointersSize: pointers.size,
};
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function frame(): void { draw(); } // helper: re-render + re-measure
onMount(() => {
buildSlab();
buildSlabCanvas();
if (stage) { parentW = stage.clientWidth; parentH = stage.clientHeight; }
recompute();
const ro = new ResizeObserver((es) => {
const e = es[0];
if (!e) return;
parentW = e.contentRect.width;
parentH = e.contentRect.height;
recompute();
draw();
});
if (stage) ro.observe(stage);
draw();
return () => ro.disconnect();
});
onDestroy(() => {});
// ---- Pointer Events: pan/zoom the PHOTO; drag ticks ALONG THE LINE -----
function pointerPos(e: PointerEvent): { x: number; y: number } {
const rect = canvas!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
const TICK_HIT = 24; // ≥44×44 effective hit area
function tickScreen(): { ax: number; ay: number; bx: number; by: number } {
const [ax, ay] = toScreen(SW / 2, tickAs);
const [bx, by] = toScreen(SW / 2, tickBs);
return { ax, ay, bx, by };
}
function hitTick(px: number, py: number): 'A' | 'B' | null {
const t = tickScreen();
if (Math.hypot(px - t.ax, py - t.ay) <= TICK_HIT) return 'A';
if (Math.hypot(px - t.bx, py - t.by) <= TICK_HIT) return 'B';
return null;
}
// Zoom about a screen point: the source pixel under the cursor stays put
// (#99 control model — "zoom-to-cursor").
function zoomAt(px: number, py: number, factor: number): void {
const before = toSource(px, py);
zoom = Math.max(0.4, Math.min(6, zoom * factor));
const [nx, ny] = toScreen(before[0], before[1]);
panX += px - nx;
panY += py - ny;
recompute();
draw();
}
const pinch = () => {
const pts = [...pointers.values()];
if (pts.length < 2) return { d: 0, mx: 0, my: 0 };
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
return { d, mx: (pts[0].x + pts[1].x) / 2, my: (pts[0].y + pts[1].y) / 2 };
};
function onPointerDown(e: PointerEvent): void {
const p = pointerPos(e);
pointers.set(e.pointerId, p);
if (pointers.size === 2) {
const { d } = pinch();
pinchDist = d;
dragging = null; // two fingers = pinch, never a pan/tick drag
return;
}
const t = hitTick(p.x, p.y);
if (t) selectedTick = t; // arrow/Shift+arrow then nudges THIS tick
dragging = t ?? 'pan';
lastPointer = p;
canvas?.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent): void {
const p = pointerPos(e);
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p);
if (pointers.size >= 2) {
const { d, mx, my } = pinch();
if (pinchDist > 0 && d > 0) zoomAt(mx, my, d / pinchDist);
pinchDist = d;
return;
}
if (!dragging) {
canvas?.style.setProperty('cursor', hitTick(p.x, p.y) ? 'ns-resize' : 'grab');
return;
}
if (dragging === 'pan') {
panX += p.x - lastPointer.x;
panY += p.y - lastPointer.y;
} else {
// The tick slides ALONG the taped edge — source-space Y on the photo.
const [, sy] = toSource(p.x, p.y);
const y = Math.max(0, Math.min(SH, sy));
if (dragging === 'A') tickAs = Math.min(y, tickBs - 4);
else tickBs = Math.max(y, tickAs + 4);
}
lastPointer = p;
recompute();
draw();
}
function onPointerUp(e: PointerEvent): void {
pointers.delete(e.pointerId);
if (pointers.size < 2) pinchDist = 0;
if (pointers.size === 0) dragging = null;
canvas?.releasePointerCapture(e.pointerId);
canvas?.style.setProperty('cursor', 'grab');
}
function onWheel(e: WheelEvent): void {
e.preventDefault();
const rect = canvas!.getBoundingClientRect();
// Trackpad pinch arrives as a wheel with ctrlKey set; make it zoom harder
// per event than a plain mouse wheel.
const f = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
zoomAt(e.clientX - rect.left, e.clientY - rect.top, f);
}
// #99: arrow keys rotate the photo 1° (coarse) / 0.1° (Shift, fine);
// with a tick selected they nudge that tick instead — pixel-level alignment.
function onKey(e: KeyboardEvent): void {
const fine = e.shiftKey;
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const d = (e.key === 'ArrowUp' ? -1 : 1) * (fine ? 0.1 : 1);
if (selectedTick === 'A') tickAs = Math.max(0, Math.min(tickAs + d, tickBs - 4));
else tickBs = Math.min(SH, Math.max(tickBs + d, tickAs + 4));
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const d = (e.key === 'ArrowLeft' ? -1 : 1) * (fine ? 0.1 : 1);
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
} else {
return;
}
e.preventDefault();
recompute();
draw();
}
function pickTick(t: 'A' | 'B'): void {
selectedTick = t;
draw();
}
function nudgePhoto(d: number): void {
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
recompute();
draw();
}
function setPhotoAngle(d: number): void {
photoAngle = ((d % 360) + 360) % 360;
recompute();
draw();
}
function setCrop(d: number): void {
cropFill = d;
recompute();
draw();
}
</script>
<section class="tp">
<h1>Length axis — tape line</h1>
<p class="hint">
The measure line is <strong>fixed</strong>. Rotate / pan / zoom the <strong>photo</strong>
until its taped edge sits on the line, then drag the two ticks along it to where your
tape started and ended. L = the distance between the ticks.
</p>
<div class="stage" bind:this={stage} role="application" tabindex="0" on:keydown={onKey}
aria-label="Tape-line calibration. Arrow keys rotate the photo; with a tick selected they nudge that tick.">
<canvas
bind:this={canvas}
style="cursor:grab"
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel={onWheel}
></canvas>
</div>
<p class="stage-hint">Drag each tick to where your tape started and ended.</p>
<p class="stage-sub">Arrow keys rotate 1° (Shift 0.1°) · with a tick selected the arrows nudge it · wheel or pinch zooms at the cursor</p>
<!-- global crop fill (settings.crop_fill) — moves the fixed line -->
<div class="crop">
<label for="crop">Global crop fill</label>
<input id="crop" type="range" min="50" max="100" step="1" value={cropFill}
on:input={(e) => setCrop(Number(e.currentTarget.value))} />
<span class="val">{cropFill}%</span>
</div>
<p class="sub">Default 90% · single setting replaces both duplicated FILL=0.9 · moves the fixed line + re-crops future slabs (non-retro).</p>
<!-- rotate/pan/zoom the PHOTO (coarse → fine, Adobe nudge model) -->
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate photo counter-clockwise"
on:click={() => nudgePhoto(-1)}>−</button>
<input type="range" min="0" max="359" step="1" value={Math.round(photoAngle % 360)}
on:input={(e) => setPhotoAngle(Number(e.currentTarget.value))} />
<button type="button" class="thumb" aria-label="Rotate photo clockwise"
on:click={() => nudgePhoto(1)}>+</button>
<span class="val">{Math.round(photoAngle % 360)}°</span>
<button type="button" class="ghost" on:click={() => { panX = 0; panY = 0; zoom = 1; photoAngle = 0; recompute(); frame(); }}>reset</button>
</div>
<!-- declared length = distance between the two ticks -->
<div class="len">
<div class="len-head">
<span>Declared length (between ticks)</span>
<span class="eff">{declaredLenIn.toFixed(1)}&#8243;</span>
</div>
<div class="ticks">
<button type="button" class="tickbtn" class:sel={selectedTick === 'A'} on:click={() => pickTick('A')}>
tick A {selectedTick === 'A' ? '· arrows nudge' : ''}
</button>
<button type="button" class="tickbtn" class:sel={selectedTick === 'B'} on:click={() => pickTick('B')}>
tick B {selectedTick === 'B' ? '· arrows nudge' : ''}
</button>
</div>
</div>
</section>
<style>
.tp { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.5; }
.stage {
position: relative; width: 100%; height: 56vh; min-height: 360px;
background: #181818; border-radius: 10px; overflow: hidden;
border: 1px solid #2c2c2c;
}
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.stage-hint {
font-size: 0.78rem; color: #bbb; margin: 8px 0 0;
}
.stage-sub { font-size: 0.72rem; color: #777; margin: 4px 0 0; }
.stage:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
.ticks { display: flex; gap: 10px; margin-top: 8px; }
.tickbtn {
flex: 1; padding: 10px; font-size: 0.85rem; border-radius: 4px;
border: 1px solid #444; background: #1d1d1d; color: #ddd;
}
.tickbtn.sel { border-color: #ffd600; color: #ffd600; }
.crop, .angle { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.crop label { font-size: 0.9rem; color: #ccc; }
input[type='range'] { flex: 1; accent-color: #00e5ff; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
.ghost {
border: 1px solid #444; background: #1d1d1d; color: #ccc; font-size: 0.85rem;
padding: 8px 12px; border-radius: 4px;
}
.sub { color: #888; font-size: 0.82rem; margin-top: 8px; }
.thumb {
width: 44px; height: 44px; border-radius: 50%; font-size: 1.5rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
.len { margin-top: 18px; }
.len-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
.len-head span:first-child { font-size: 0.9rem; color: #aaa; }
.eff { color: #ff6b8f; font-weight: 700; font-size: 1.3rem; font-variant-numeric: tabular-nums; }
</style>
+8
View File
@@ -46,10 +46,17 @@ export interface Mask {
slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%)
}
/** The owner-positioned start/end points of the tape in oriented source pixels. */
export interface TapeLine {
start: [number, number];
end: [number, number];
}
// Direction only: an oriented rectangle over the slab. The arrow reads
// corners[3] → corners[0] (the long side).
export interface Axis {
bbox: BBox;
tapeLine: TapeLine;
}
export interface Widths {
@@ -138,6 +145,7 @@ export interface Draft {
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
@@ -378,9 +378,10 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
s.setSheet('black');
await settleOverlay(s);
s.approveMask();
s.setTapeLine({ start: [0, 10], end: [39, 10] });
s.approveAxis();
// L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L
// 39 source-photo px between ticks / 40 owner-declared inches = 0.975 px/in.
s.setLength(320);
s.setThickness(16);
await s.calculate();
@@ -393,7 +394,7 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
const r = cap.results!;
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
assert.equal(typeof r.scale, 'number');
assert.ok(r.scale > 0, 'scale px/in > 0');
assert.equal(r.scale, 39 / 40, 'scale comes from the positioned tape endpoints');
assert.equal(typeof r.sqft, 'number');
assert.equal(typeof r.bdft, 'number');
// CAPTURE.md: bdft = sqft × thickness_in
@@ -443,6 +444,8 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
assert.equal(before!.batch![2].blob, c);
s.approveMask();
const tapeLine = { start: [2, 3] as [number, number], end: [35, 14] as [number, number] };
s.setTapeLine(tapeLine);
await new Promise((r) => setTimeout(r, 20));
const after = store.peek();
@@ -450,6 +453,7 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
assert.equal(after!.stage, 'axis');
assert.equal(after!.calibrationIndex, 1);
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
assert.deepEqual(after!.tapeLine, tapeLine, 'draft persists the owner-positioned tape endpoints');
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
assert.equal(after!.batch![1].blob, b);
@@ -464,6 +468,7 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
assert.equal(meas!.batch![0].blob, a);
assert.equal(meas!.batch![2].blob, c);
assert.equal(meas!.calibrationIndex, 1);
assert.deepEqual(meas!.tapeLine, tapeLine, 'measure draft keeps the same calibration endpoints');
s.dispose();
});
+32 -13
View File
@@ -1,16 +1,16 @@
import { writable } from 'svelte/store';
import type {
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, WorkerIn, WorkerOut,
} from './capture.types';
import {
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
} from './capture.types';
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { initialTapeLine, rectAlongLine } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { FILL_DEFAULT, fillFromSetting } from '../png/crop';
import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
const DEBOUNCE_MS = 2000;
@@ -41,7 +41,7 @@ export interface CaptureSession {
setSensitivity(n: number): void;
setSheet(s: Sheet): void;
approveMask(): void;
setAxisAngle(deg: number): void;
setTapeLine(tapeLine: TapeLine): void;
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
setCropFill(pctOrFraction: number): void;
approveAxis(): void;
@@ -212,6 +212,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
tapeLine: state.axis.tapeLine,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
@@ -222,6 +223,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
tapeLine: state.axis.tapeLine,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
@@ -316,6 +318,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
// fall back to the mask's own bbox.
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
const draftTapeLine = (d: Draft) => d.tapeLine ?? initialTapeLine(d.source.widthPx, d.source.heightPx);
const restoreFromDraft = async (d: Draft) => {
batchPhotos = batchPhotosOf(d);
@@ -343,7 +346,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
const tapeLine = draftTapeLine(d);
set({ k: 'axis', source, approvedMask: mask, axis: {
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine,
} });
return;
}
if (d.inventoryPng) {
@@ -352,7 +358,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}
set({
k: 'measure', source, approvedMask: mask,
axis: { bbox: draftAxisBbox(d) },
axis: {
bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end),
tapeLine: draftTapeLine(d),
},
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? {
@@ -481,14 +490,20 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
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 tapeLine = initialTapeLine(state.source.widthPx, state.source.heightPx);
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: {
bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine,
} });
void persist();
},
setAxisAngle(deg: number) {
setTapeLine(tapeLine: TapeLine) {
if (state.k !== 'axis') return;
const a = ((Math.round(deg) % 180) + 180) % 180;
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
const start: [number, number] = [...tapeLine.start];
const end: [number, number] = [...tapeLine.end];
const bbox = rectAlongLine(state.axis.bbox, start, end);
if (!(bbox.longSidePx > 0)) return;
set({ ...state, axis: { bbox, tapeLine: { start, end } } });
void persist();
},
@@ -501,7 +516,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'axis') return;
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: { bbox: state.axis.bbox },
axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine },
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
@@ -525,10 +540,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return;
const scale = calibrationScale(axis.tapeLine.start, axis.tapeLine.end, eighthsToIn(lengthEighths));
if (!(scale > 0) || !Number.isFinite(scale)) return;
const bitmap = await decodeOrientedBitmap(source.blob);
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale,
);
let pngBlob: Blob;
let belowFloor = false;
@@ -636,7 +653,9 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine,
} });
}
},