From be2ea6c4ea11fe470faac08203a21cd15bb9d65e Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Fri, 25 Sep 2026 17:41:29 -0700 Subject: [PATCH] feat(capture): tape-line overlay UI replaces axis rectangle (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.svelte | 1 + frontend/src/capture/AxisPanel.svelte | 130 +++++++++-- frontend/src/capture/MeasurePanel.svelte | 4 +- frontend/src/capture/PhotoStage.svelte | 281 +++++++++++++++++++++-- frontend/src/capture/session.ts | 131 +++++++++-- 5 files changed, 500 insertions(+), 47 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5d4d0a7..f60282c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -75,6 +75,7 @@ diff --git a/frontend/src/capture/AxisPanel.svelte b/frontend/src/capture/AxisPanel.svelte index 014384b..529b5ab 100644 --- a/frontend/src/capture/AxisPanel.svelte +++ b/frontend/src/capture/AxisPanel.svelte @@ -1,58 +1,154 @@
-

Length axis

-

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

+

Tape line

+

Drag each tick to where your tape started and ended.

+

The black line is fixed: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.

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

Tilt the slab

+
+ + setTilt(Number(e.currentTarget.value))} + /> + + {tiltShown}° +
+

← → tilt 1° · Shift+← → 0.1° · or drag the ↻ handle

+
+ +
+

Zoom the slab

+
+ + setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })} + /> + + {Math.round(tape.zoom * 100)}% +
+
+ +
+
+ +
+

Crop fill % global setting

+
+ onFill(Number(e.currentTarget.value))} + /> + {Math.round(cropFill * 100)}% +
+

+ 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. +

+
+ +
+
Tape span between ticks{tape.spanPx.toFixed(0)} px
+
Scale (once L entered)span / L → px/in
+
+
- +
diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index f236794..e9881cd 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -82,8 +82,10 @@ {#if !photosReady} 0 ? state.source.widthPx : 0} maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0} diff --git a/frontend/src/capture/PhotoStage.svelte b/frontend/src/capture/PhotoStage.svelte index 7b003c2..b608c33 100644 --- a/frontend/src/capture/PhotoStage.svelte +++ b/frontend/src/capture/PhotoStage.svelte @@ -1,8 +1,12 @@ -
- {#if imgW > 0 || decoding || blob} - - {:else} -
Decoding photo…
- {/if} -
+{#if mode === 'tape'} +
+
interactive && fitPhoto()} + > +
+
+ +
+ +
+
+ + {#if decoding && imgW === 0} +
Decoding photo…
+ {/if} +
+
+
+{:else} +
+ {#if imgW > 0 || decoding || blob} + + {:else} +
Decoding photo…
+ {/if} +
+{/if} diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 59e73fa..177d992 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -10,7 +10,11 @@ import { measure } from '../measure/measure'; import { orientedRect } from '../geometry/rect'; import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; +import { rotateCalibration } from '../png/rotateCal'; 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 BUSY_MS = 300; @@ -41,6 +45,8 @@ export interface CaptureSession { setSheet(s: Sheet): void; approveMask(): void; setAxisAngle(deg: number): void; + /** #99: replace axis rectangle nudge with full tape transform. */ + setTape(tape: TapeTransform): void; approveAxis(): void; setLength(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], calibrationIndex: calIdx, 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, }; } @@ -214,7 +220,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source], calibrationIndex: calIdx, 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, results: state.results ? rest : null, inventoryPng: state.results?.inventoryPng ?? null, @@ -310,6 +316,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { // fall back to the mask's own 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) => { batchPhotos = batchPhotosOf(d); calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null; @@ -336,16 +373,29 @@ 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 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; } if (d.inventoryPng) { if (pngUrl) URL.revokeObjectURL(pngUrl); 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({ k: 'measure', source, approvedMask: mask, - axis: { bbox: draftAxisBbox(d) }, + axis, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, results: d.results ? { @@ -356,6 +406,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { : null, batchApply: d.batchApply, }); + } } catch { set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true }); makeWorker(source.blob); @@ -474,22 +525,47 @@ 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 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(); }, + // Legacy slider path — maps to photo tilt under the fixed tape line (#99). setAxisAngle(deg: number) { if (state.k !== 'axis') return; - const a = ((Math.round(deg) % 180) + 180) % 180; - set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } }); + const tape = { ...state.axis.tape, tiltDeg: deg }; + 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(); }, approveAxis() { if (state.k !== 'axis') return; + const axis = axisWithTape( + state.axis.bbox, + state.axis.tape, + state.source.widthPx, + state.source.heightPx, + ); set({ k: 'measure', source: state.source, approvedMask: state.approvedMask, - axis: { bbox: state.axis.bbox }, + axis, lengthEighths: 0, thicknessEighths: 0, results: null, }); void persist(); @@ -514,14 +590,35 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state; if (lengthEighths === 0 || thicknessEighths === 0) return; 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( approvedMask.bitset, source.widthPx, source.heightPx, axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, + tapeAxis, ); let pngBlob: Blob; 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 { - 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; belowFloor = r.sourceBelowFloor; } catch { @@ -529,24 +626,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { pngBlob = new Blob([], { type: 'image/png' }); } bitmap.close(); + if (rotatedExtra) rotatedExtra.close(); if (pngUrl) URL.revokeObjectURL(pngUrl); pngUrl = URL.createObjectURL(pngBlob); // #63: second copy of the calibration inventory PNG with dimension overlays. // 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; try { overlayBlob = await encodeOverlay({ inventoryPng: pngBlob, - bitset: approvedMask.bitset, - widthPx: source.widthPx, - heightPx: source.heightPx, - bbox: axis.bbox, + bitset: calMask.bitset, + widthPx: calSource.widthPx, + heightPx: calSource.heightPx, + bbox: calMask.bbox, scale: base.scale, lengthIn: eighthsToIn(lengthEighths), lengthLabel: fmtEighths(lengthEighths), - slabBounds: approvedMask.slabBounds, - source, + slabBounds: calMask.slabBounds, + source: calSource, }); } catch { // Deterministic tests may inject a non-decodable inventory blob; still @@ -624,7 +723,7 @@ 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: state.axis }); } },