From 4ca2ef57a768a665f808f1658f2d8cce4db002b8 Mon Sep 17 00:00:00 2001 From: Ty Date: Thu, 24 Sep 2026 14:47:13 -0700 Subject: [PATCH] feat(capture): ship tape-line calibration (#99) --- frontend/src/App.svelte | 27 +- frontend/src/capture/AxisPanel.svelte | 269 ++++++-- frontend/src/capture/MeasurePanel.svelte | 13 +- frontend/src/capture/TapePrototype.svelte | 611 ------------------ frontend/src/capture/capture.types.ts | 8 + .../src/capture/session.calibrate.test.ts | 9 +- frontend/src/capture/session.ts | 45 +- frontend/src/geometry/rect.test.ts | 40 +- frontend/src/geometry/rect.ts | 50 +- frontend/src/measure/measure.test.ts | 15 +- frontend/src/measure/measure.ts | 6 +- frontend/src/png/crop.test.ts | 31 + frontend/src/png/crop.ts | 51 ++ 13 files changed, 437 insertions(+), 738 deletions(-) delete mode 100644 frontend/src/capture/TapePrototype.svelte create mode 100644 frontend/src/png/crop.test.ts diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6bfd274..a6b950b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -12,10 +12,7 @@ import AxisPanel from './capture/AxisPanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte'; import Settings from './create/settings/Settings.svelte'; - // #99 PROTOTYPE: tape-line overlay + per-slab length input + crop setting. - // Throwaway — driven by the `tape-prototype` query param. Delete this branch - // and the component after the design is settled. - import TapePrototype from './capture/TapePrototype.svelte'; + import SlabList from './create/slabs/SlabList.svelte'; import Review from './create/review/Review.svelte'; import Publish from './create/publish/Publish.svelte'; @@ -26,10 +23,6 @@ import { clearDraft } from './draft/idb'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; - // #99 PROTOTYPE entry: `?tape-prototype` boots the tape-line design sandbox. - const isTapeProto = - typeof window !== 'undefined' && - new URLSearchParams(window.location.search).has('tape-prototype'); const session: CaptureSession = createCaptureSession(); let cap: Capture = { k: 'batch', photos: [] }; @@ -157,15 +150,6 @@

Missing: {gate}

-{:else if isTapeProto} - -
-
- #99 tape-line prototype - exit → -
- -
{:else}
{#if curFlow.phase === 'review'} @@ -238,7 +222,7 @@ {:else if cap.k === 'axis'} @@ -282,12 +266,7 @@ font-family: system-ui, -apple-system, Segoe UI, sans-serif; } .app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; } - .proto-bar { - display: flex; align-items: center; justify-content: space-between; - padding: 8px 16px; background: #1d1d1d; color: #00e5ff; font-size: 0.85rem; - border-bottom: 1px solid #333; - } - .proto-bar a { color: #aaa; text-decoration: none; } + .hardstop { padding: 48px 24px; color: #eee; text-align: center; } .hardstop h1 { font-size: 1.6rem; } .detail { color: #888; font-size: 0.85rem; } diff --git a/frontend/src/capture/AxisPanel.svelte b/frontend/src/capture/AxisPanel.svelte index 014384b..94f597b 100644 --- a/frontend/src/capture/AxisPanel.svelte +++ b/frontend/src/capture/AxisPanel.svelte @@ -1,58 +1,251 @@
-

Length axis

-

Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.

- -
- - setAxisAngle(Number(e.currentTarget.value))} - /> - - {angleDeg}° +

Tape-line calibration

+

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.

+
+
+
+ + { angleDeg = Number(event.currentTarget.value); draw(); }} /> + {angleDeg.toFixed(1)}° +
+
+ + { zoom = Number(event.currentTarget.value); draw(); }} /> + {Math.round(zoom * 100)}% + +
+

Tick distance in source photo: {tickDistance.toFixed(1)} px

- +
diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index 58b071e..bd04675 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -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 @@
- Length L + Tape length L (distance between ticks)
+

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

@@ -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; diff --git a/frontend/src/capture/TapePrototype.svelte b/frontend/src/capture/TapePrototype.svelte deleted file mode 100644 index b0fc521..0000000 --- a/frontend/src/capture/TapePrototype.svelte +++ /dev/null @@ -1,611 +0,0 @@ - - -
-

Length axis — tape line

-

- The measure line is fixed. Rotate / pan / zoom the photo - 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. -

- -
- -
-

Drag each tick to where your tape started and ended.

-

Arrow keys rotate 1° (Shift 0.1°) · with a tick selected the arrows nudge it · wheel or pinch zooms at the cursor

- - -
- - setCrop(Number(e.currentTarget.value))} /> - {cropFill}% -
-

Default 90% · single setting replaces both duplicated FILL=0.9 · moves the fixed line + re-crops future slabs (non-retro).

- - -
- - setPhotoAngle(Number(e.currentTarget.value))} /> - - {Math.round(photoAngle % 360)}° - -
- - -
-
- Declared length (between ticks) - {declaredLenIn.toFixed(1)}″ -
-
- - -
-
-
- - diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index 0877fff..85e95a0 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -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; // 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 | null; // present when results computed diff --git a/frontend/src/capture/session.calibrate.test.ts b/frontend/src/capture/session.calibrate.test.ts index 22d6441..558b714 100644 --- a/frontend/src/capture/session.calibrate.test.ts +++ b/frontend/src/capture/session.calibrate.test.ts @@ -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(); }); diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 4d60990..5249fa8 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -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, + } }); } }, diff --git a/frontend/src/geometry/rect.test.ts b/frontend/src/geometry/rect.test.ts index 8490ff9..386e4bd 100644 --- a/frontend/src/geometry/rect.test.ts +++ b/frontend/src/geometry/rect.test.ts @@ -1,20 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts'; -import { measure } from '../measure/measure.ts'; +import { minAreaRect, orientedRect, rectAlongLine } from './rect.ts'; // 100×20 horizontal slab const W = 100, H = 20; const bitset = new Uint8Array(W * H); for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1; -test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => { - const sx = maskSpanAlong(bitset, W, H, 1, 0); - const sy = maskSpanAlong(bitset, W, H, 0, 1); - assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`); - assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`); -}); - test('orientedRect changes angleDeg and preserves longSidePx', () => { const prev = minAreaRect(bitset, W, H); assert.ok(prev, 'minAreaRect returned a box'); @@ -29,23 +21,15 @@ test('orientedRect changes angleDeg and preserves longSidePx', () => { assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180); }); -test('measure scale uses mask span along the axis, not longSidePx', () => { - // Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is - // the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which - // crosses only the 20-px slab width — span 19, not longSidePx 99. - const TW = 20, TH = 100; - const thin = new Uint8Array(TW * TH).fill(1); - const thinBox = minAreaRect(thin, TW, TH); - assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`); - const rotated = orientedRect(thinBox, 0); - const lengthEighths = 24; // 3 in - const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH); - // Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in, - // far from the old longSidePx/3 ≈ 33. - const ex = rotated.corners[0][0] - rotated.corners[3][0]; - const ey = rotated.corners[0][1] - rotated.corners[3][1]; - const elen = Math.hypot(ex, ey); - const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen); - assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9); - assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`); +test('rectAlongLine sets the approved axis from the owner-positioned tape endpoints', () => { + const previous = minAreaRect(bitset, W, H)!; + const start: [number, number] = [20, 30]; + const end: [number, number] = [80, 70]; + const calibrated = rectAlongLine(previous, start, end); + const center: [number, number] = [50, 50]; + assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9); + assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9); + assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9); + assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9); + assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle'); }); diff --git a/frontend/src/geometry/rect.ts b/frontend/src/geometry/rect.ts index 8e6cbf3..6449834 100644 --- a/frontend/src/geometry/rect.ts +++ b/frontend/src/geometry/rect.ts @@ -1,4 +1,4 @@ -import type { ABox, BBox } from '../capture/capture.types'; +import type { ABox, BBox, TapeLine } from '../capture/capture.types'; export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox { let minX = w, minY = h, maxX = -1, maxY = -1; @@ -113,18 +113,40 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox { return { corners, longSidePx: prev.longSidePx, angleDeg: a }; } -// Project every slab pixel onto the unit vector (ux, uy); return max − min -// extent in px. 0 when the mask is empty. -export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number { - let min = Infinity, max = -Infinity; - for (let y = 0; y < h; y++) { - const row = y * w; - for (let x = 0; x < w; x++) { - if (!bitset[row + x]) continue; - const p = x * ux + y * uy; - if (p < min) min = p; - if (p > max) max = p; - } +/** Build the approved axis rectangle from the owner's two tape-line endpoints. */ +export function rectAlongLine(prev: BBox, start: [number, number], end: [number, number]): BBox { + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const length = Math.hypot(dx, dy); + if (!(length > 0) || !Number.isFinite(length)) return prev; + + const halfW = Math.hypot( + prev.corners[0][0] - prev.corners[1][0], + prev.corners[0][1] - prev.corners[1][1], + ) / 2; + const cx = (start[0] + end[0]) / 2; + const cy = (start[1] + end[1]) / 2; + const ux = dx / length; + const uy = dy / length; + const vx = -uy; + const vy = ux; + const halfL = length / 2; + const corners: [number, number][] = []; + for (const [along, across] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) { + corners.push([ + cx + along * halfL * ux + across * halfW * vx, + cy + along * halfL * uy + across * halfW * vy, + ]); } - return max < min ? 0 : max - min; + const angleDeg = ((Math.atan2(uy, ux) * 180) / Math.PI + 180) % 180; + return { corners, longSidePx: length, angleDeg }; +} + +/** Initial guide positions; the owner still places both ticks at the tape ends. */ +export function initialTapeLine(width: number, height: number): TapeLine { + const w = Math.max(1, width); + const h = Math.max(1, height); + return w >= h + ? { start: [w * 0.1, h / 2], end: [w * 0.9, h / 2] } + : { start: [w / 2, h * 0.1], end: [w / 2, h * 0.9] }; } diff --git a/frontend/src/measure/measure.test.ts b/frontend/src/measure/measure.test.ts index 9a9ba57..1bebd3d 100644 --- a/frontend/src/measure/measure.test.ts +++ b/frontend/src/measure/measure.test.ts @@ -27,7 +27,7 @@ function tapered(W: number, H: number, lengthEighths: number, tipPx: number) { test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => { const { bitset, box, slabPx, lengthEighths } = tapered(480, 100, 384, 20); // tips 2in, bulk 10in - const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx); + const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx, 10); assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`); for (const e of m.widths.every6In) { assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`); @@ -38,7 +38,7 @@ test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => { test('uniform slab: interior stations keep the full width', () => { const { bitset, box, slabPx } = uniform(480, 100); - const m = measure(bitset, 480, 100, box, 384, 8, slabPx); + const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 10); assert.equal(m.widths.every6In.length, 7); for (const e of m.widths.every6In) { assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`); @@ -47,12 +47,19 @@ test('uniform slab: interior stations keep the full width', () => { test('short slab L=12in: no interior stations, widths are empty/zero', () => { const { bitset, box, slabPx } = uniform(120, 100); - const m = measure(bitset, 120, 100, box, 96, 8, slabPx); + const m = measure(bitset, 120, 100, box, 96, 8, slabPx, 10); assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] }); }); test('short slab L=10in: no interior stations, widths are empty/zero', () => { const { bitset, box, slabPx } = uniform(100, 100); - const m = measure(bitset, 100, 100, box, 80, 8, slabPx); + const m = measure(bitset, 100, 100, box, 80, 8, slabPx, 10); assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] }); }); + +test('measurement scale is supplied by tape calibration, not inferred from mask span', () => { + const { bitset, box, slabPx } = uniform(480, 100); + const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 20); + assert.equal(m.scale, 20); + assert.ok(m.sqft < 1, `area uses tape scale, got ${m.sqft}`); +}); diff --git a/frontend/src/measure/measure.ts b/frontend/src/measure/measure.ts index 074bc88..ddd10ad 100644 --- a/frontend/src/measure/measure.ts +++ b/frontend/src/measure/measure.ts @@ -1,5 +1,4 @@ import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types'; -import { maskSpanAlong } from '../geometry/rect'; // Display / direction: corners[3] → corners[0] is the long side. export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } { @@ -76,10 +75,13 @@ export function measure( bbox: BBox, lengthEighths: Eighths, thicknessEighths: Eighths, slabPx: number, + scale: number, ): Omit & { sourceBelowFloor: false } { const lengthIn = lengthEighths / 8; const seg = maskAxisSegment(bitset, w, h, bbox); - const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn; + if (!(scale > 0) || !Number.isFinite(scale)) { + throw new RangeError('Calibration scale must be a positive finite value'); + } const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn); let widths: Widths; if (every6In.length > 0) { diff --git a/frontend/src/png/crop.test.ts b/frontend/src/png/crop.test.ts new file mode 100644 index 0000000..4a6e243 --- /dev/null +++ b/frontend/src/png/crop.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { calibrationScale, photoToScreen, screenToPhoto } from './crop.ts'; + +test('tick scale uses endpoint distance in source-photo pixels', () => { + assert.equal(calibrationScale([12, 34], [312, 434], 25), 20); + assert.equal(calibrationScale([12, 34], [12, 34], 25), 0); + assert.equal(calibrationScale([12, 34], [312, 434], 0), 0); +}); + +test('tick distance and scale survive pan, zoom, and rotation in the photo view', () => { + const a: [number, number] = [830, 420]; + const b: [number, number] = [2890, 2510]; + const declaredLengthIn = 80; + const expectedScale = Math.hypot(b[0] - a[0], b[1] - a[1]) / declaredLengthIn; + const views = [ + { width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 1, angleDeg: 0, panX: 0, panY: 0 }, + { width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 2.4, angleDeg: 37, panX: 48, panY: -22 }, + { width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 0.7, angleDeg: 121, panX: -70, panY: 35 }, + ]; + + for (const view of views) { + const screenA = photoToScreen(a, view); + const screenB = photoToScreen(b, view); + const sourceA = screenToPhoto(screenA, view); + const sourceB = screenToPhoto(screenB, view); + assert.ok(Math.hypot(sourceA[0] - a[0], sourceA[1] - a[1]) < 1e-8); + assert.ok(Math.hypot(sourceB[0] - b[0], sourceB[1] - b[1]) < 1e-8); + assert.ok(Math.abs(calibrationScale(sourceA, sourceB, declaredLengthIn) - expectedScale) < 1e-10); + } +}); diff --git a/frontend/src/png/crop.ts b/frontend/src/png/crop.ts index 29d771b..e94ad55 100644 --- a/frontend/src/png/crop.ts +++ b/frontend/src/png/crop.ts @@ -14,6 +14,57 @@ export const ASPECT_H = 4; /** Shorter side of the output frame, px. */ export const FLOOR = 800; +export type PhotoPoint = [number, number]; + +export interface PhotoViewTransform { + width: number; + height: number; + centerX: number; + centerY: number; + /** Source-photo pixels to CSS pixels before the user zoom factor. */ + scale: number; + zoom: number; + angleDeg: number; + panX: number; + panY: number; +} + +/** Map a point in oriented source-photo pixels into the transformed photo view. */ +export function photoToScreen(point: PhotoPoint, view: PhotoViewTransform): PhotoPoint { + const scale = view.scale * view.zoom; + const radians = (view.angleDeg * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const dx = (point[0] - view.width / 2) * scale; + const dy = (point[1] - view.height / 2) * scale; + return [ + view.centerX + view.panX + dx * cos - dy * sin, + view.centerY + view.panY + dx * sin + dy * cos, + ]; +} + +/** Invert {@link photoToScreen}; screen zoom/pan/rotation never alter source pixels. */ +export function screenToPhoto(point: PhotoPoint, view: PhotoViewTransform): PhotoPoint { + const scale = view.scale * view.zoom; + if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN]; + const radians = (-view.angleDeg * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const dx = point[0] - view.centerX - view.panX; + const dy = point[1] - view.centerY - view.panY; + return [ + view.width / 2 + (dx * cos - dy * sin) / scale, + view.height / 2 + (dx * sin + dy * cos) / scale, + ]; +} + +/** Calibrated source-photo pixels per inch from the two tape endpoints. */ +export function calibrationScale(start: PhotoPoint, end: PhotoPoint, lengthIn: number): number { + if (!(lengthIn > 0) || !Number.isFinite(lengthIn)) return 0; + if (![...start, ...end].every(Number.isFinite)) return 0; + return Math.hypot(end[0] - start[0], end[1] - start[1]) / lengthIn; +} + /** * Coerce a `settings.crop_fill` value into a 0 < fill <= 1 scale. * Accepts a percentage (90) or a fraction (0.9); anything unusable falls back