diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index b1afb2b..50f9732 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -26,12 +26,12 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif | CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts | | CAPTURE-50 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — | | CAPTURE-55 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — | -| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/geometry/rect.test.ts | +| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/capture/tapeLine.test.ts | | CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — | | CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts | -| CAPTURE-75 | Scale equals the pixel distance between the two owner-positioned tape-line ticks divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | — | -| CAPTURE-170 | The calibration overlay shows a draggable tape-line with two rifle-scope end ticks; the owner positions the ticks at the tape's actual start and end. | M4 | — | -| CAPTURE-175 | The owner-declared length is the distance represented by the tape-line ticks, and scale uses their pixel distance in source-photo coordinates, independent of mask span. | M4 | — | +| CAPTURE-75 | Scale equals the pixel distance between the two owner-positioned tape-line ticks divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts | +| CAPTURE-170 | The calibration overlay shows a draggable tape-line with two rifle-scope end ticks; the owner positions the ticks at the tape's actual start and end. | M4 | frontend/src/capture/tapeLine.test.ts | +| CAPTURE-175 | The owner-declared length is the distance represented by the tape-line ticks, and scale uses their pixel distance in source-photo coordinates, independent of mask span. | M4 | frontend/src/measure/measure.test.ts | | CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts | | CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts | | CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ac052b6..39bd099 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "slabuploader-frontend", "version": "0.1.0", "dependencies": { - "@neodrag/svelte": "2.3.3", "idb-keyval": "^6.3.0", "svelte": "^4.2.20" }, @@ -496,15 +495,6 @@ "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@neodrag/svelte": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@neodrag/svelte/-/svelte-2.3.3.tgz", - "integrity": "sha512-avXzhrilsBsnMFljhVAQ7h+6hbSIrvRCJ61GCiGbGISkC1QOhjDCNvPZo2+7KVwiYrnUBx4NRH0kTIqrcxv9Lg==", - "license": "MIT", - "peerDependencies": { - "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" - } - }, "node_modules/@playwright/test": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d20c8e3..6e077c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,6 @@ "test:e2e:headed": "playwright test --headed" }, "dependencies": { - "@neodrag/svelte": "2.3.3", "idb-keyval": "^6.3.0", "svelte": "^4.2.20" }, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 372d1e0..ea2e0b5 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -12,14 +12,13 @@ import AxisPanel from './capture/AxisPanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte'; import Settings from './create/settings/Settings.svelte'; - import SlabList from './create/slabs/SlabList.svelte'; import Review from './create/review/Review.svelte'; import Publish from './create/publish/Publish.svelte'; import { flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture, } from './create/flow'; - import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs'; + import { createSlab, uploadPhoto, getSettings, updateSettings, ApiError } from './api/slabs'; import { clearDraft } from './draft/idb'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; @@ -30,7 +29,6 @@ let pngUrl: string | null = null; let showSettings = false; let showSlabs = false; - let cropFill = 90; let curFlow: import('./create/flow').Flow = { phase: 'capture' }; $: curFlow = $flow; @@ -43,20 +41,17 @@ }); const unsubBusy = session.busy.subscribe((b) => (busy = b)); + let cropFill = 90; + onMount(() => { - // e2e boot guard: `?noload` skips the resume probe so a test can seed the - // device draft into IndexedDB after the origin exists, then reload without - // ?noload to trigger the Resume prompt deterministically. const boot = new URLSearchParams(window.location.search); if (!boot.get('noload')) void session.resumeProbe(); - // #99: the global crop fill drives both the reference line and the PNG crop. - // Falls back to the 90% default when settings are unreachable. - void getSettings() - .then((s) => { - cropFill = s.crop_fill ?? 90; - session.setCropFill(cropFill); - }) - .catch(() => {}); + void getSettings().then((s) => { + if (typeof s.crop_fill === 'number') { + cropFill = s.crop_fill; + session.setCropFill(s.crop_fill); + } + }).catch(() => {}); }); onDestroy(() => { unsubCap(); @@ -226,11 +221,15 @@ {:else if cap.k === 'axis'} { + cropFill = pct; + session.setCropFill(pct); + void updateSettings({ crop_fill: pct }).catch(() => {}); + }} approveAxis={session.approveAxis} back={session.back} - {cropFill} - setCropFill={(pct) => { cropFill = pct; session.setCropFill(pct); }} /> {:else} (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs {#if showSettings} - { cropFill = pct; session.setCropFill(pct); }} /> + {/if} {#if showSlabs} @@ -272,7 +271,6 @@ font-family: system-ui, -apple-system, Segoe UI, sans-serif; } .app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; } - .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 f270977..4abf964 100644 --- a/frontend/src/capture/AxisPanel.svelte +++ b/frontend/src/capture/AxisPanel.svelte @@ -1,423 +1,362 @@ + +
-

Tape line

-

Drag each tick to where your tape started and ended.

-
+

Length

+

Move the slab until its long axis sits on the line. Drag the ticks to the tape ends. The line does not move.

+
-

The black line is fixed in the middle of the frame. Move the slab instead: drag to pan, wheel or pinch to zoom, and use the rotate handle or arrow keys to tilt until the slab's long axis lies on the line.

-
- - - { transform.tiltDeg = Number(event.currentTarget.value); redraw(); syncCalibration(); }} /> - - {((transform.tiltDeg % 360) + 360) % 360}° -
-
- - - { changeZoom(Number(event.currentTarget.value) / (transform.zoom * 100)); }} /> - {Math.round(transform.zoom * 100)}% - - -
-
- - - {cropFill}% -
-

Arrow keys tilt the slab; Shift + arrow adjusts by 0.1°. The last tick you grabbed takes the up/down keys (1px, or 0.1px with Shift). Use +/− to zoom; double-click the slab to fit.

+ +

Tilt

- - + + { transform = { ...transform, tiltDeg: Number(e.currentTarget.value) }; publish(); draw(); }} /> + + {transform.tiltDeg.toFixed(1)}° +
+ +

Zoom

+
+ + { const z = Number(e.currentTarget.value) / 100; zoomAt([frame.width / 2, frame.height / 2], z / Math.max(transform.zoom, 0.01)); }} /> + + {Math.round(transform.zoom * 100)}% +
+ +

Wheel or pinch zooms the slab. Arrow keys tilt; up and down nudge the last tick. Shift is a fine step. Double-tap the slab to fit.

+ +

Crop fill

+
+ + {fill}% +
+ +
+ +
diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index bd04675..a456a11 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -3,7 +3,6 @@ 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, @@ -55,12 +54,6 @@ 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); @@ -74,7 +67,7 @@ thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths)); }; - $: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null && tapeScale > 0; + $: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null; const doCalc = async () => { calcBusy = true; @@ -100,7 +93,7 @@
- Tape length L (distance between ticks) + Length L (tape reading)
-

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

@@ -202,7 +194,6 @@ .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/capture.types.ts b/frontend/src/capture/capture.types.ts index e2dc346..b05342a 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -43,16 +43,9 @@ export interface Mask { slabPx: number; // 0 ⇒ approve blocked, user retakes bitset: Uint8Array; // source-grid mask, 1 byte/px bbox: BBox; - slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%) + slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop } -/** The owner-positioned start/end points of the tape in oriented source pixels. */ -export interface TapeLine { - start: [number, number]; - end: [number, number]; -} - -/** User-selected transform applied to the calibration photo only. */ export interface PhotoTransform { tiltDeg: number; zoom: number; @@ -60,12 +53,15 @@ export interface PhotoTransform { panY: number; } -// Direction only: an oriented rectangle over the slab. The arrow reads -// corners[3] → corners[0] (the long side). +// The tape line is a stage fixture. bbox is kept for the dimension overlay. +// Scale is the source-pixel distance between tapeStart and tapeEnd. export interface Axis { bbox: BBox; - tapeLine: TapeLine; photoTransform: PhotoTransform; + tickStartY: number; + tickEndY: number; + tapeStart: [number, number]; + tapeEnd: [number, number]; } export interface Widths { @@ -154,8 +150,11 @@ 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 - photoTransform?: PhotoTransform; // calibration photo transform; absent on pre-M4 drafts + photoTransform?: PhotoTransform; + tickStartY?: number; + tickEndY?: number; + tapeStart?: [number, number]; + tapeEnd?: [number, number]; 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 76df5b9..22d6441 100644 --- a/frontend/src/capture/session.calibrate.test.ts +++ b/frontend/src/capture/session.calibrate.test.ts @@ -378,10 +378,9 @@ 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(); - // 39 source-photo px between ticks / 40 owner-declared inches = 0.975 px/in. + // L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L s.setLength(320); s.setThickness(16); await s.calculate(); @@ -394,7 +393,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.equal(r.scale, 39 / 40, 'scale comes from the positioned tape endpoints'); + assert.ok(r.scale > 0, 'scale px/in > 0'); assert.equal(typeof r.sqft, 'number'); assert.equal(typeof r.bdft, 'number'); // CAPTURE.md: bdft = sqft × thickness_in @@ -444,9 +443,6 @@ 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] }; - const photoTransform = { tiltDeg: 12, zoom: 1.25, panX: 5, panY: -9 }; - s.setTapeLine(tapeLine, photoTransform); await new Promise((r) => setTimeout(r, 20)); const after = store.peek(); @@ -454,8 +450,6 @@ 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.deepEqual(after!.photoTransform, photoTransform, 'draft persists the calibration photo transform'); 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); @@ -470,8 +464,6 @@ 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'); - assert.deepEqual(meas!.photoTransform, photoTransform, 'measure draft keeps the calibration transform'); s.dispose(); }); diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts index 58f6731..b1d33b6 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -1,18 +1,16 @@ import { writable } from 'svelte/store'; import type { - BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, Sheet, Source, TapeLine, WorkerIn, WorkerOut, + Axis, BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, Sheet, Source, 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 { rectAlongLine } from '../geometry/rect'; import { encodeInventoryPng, type InventoryInput } from '../png/inventory'; import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay'; -import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop'; -import { cropTickPositions, DEFAULT_PHOTO_TRANSFORM, tapeLineFromStageTicks } from './tapeCalibration'; import { decodeOrientedBitmap } from '../decodeOrientedBitmap'; +import { IDENTITY_TRANSFORM, cropEnds, tapeSegmentSource } from './tapeLine'; const DEBOUNCE_MS = 2000; const BUSY_MS = 300; @@ -42,9 +40,14 @@ export interface CaptureSession { setSensitivity(n: number): void; setSheet(s: Sheet): void; approveMask(): void; - setTapeLine(tapeLine: TapeLine, photoTransform?: PhotoTransform): void; - /** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */ - setCropFill(pctOrFraction: number): void; + setTapePose(pose: { + transform: PhotoTransform; + tickStartY: number; + tickEndY: number; + sourceW: number; + sourceH: number; + }): void; + setCropFill(pct: number): void; approveAxis(): void; setLength(e: Eighths): void; setThickness(e: Eighths): void; @@ -87,8 +90,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { let busyTimer: ReturnType | null = null; let pngUrl: string | null = null; let resumed = false; - // #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise. - let cropFill = FILL_DEFAULT; + let cropFill = 90; + let poseTimer: ReturnType | null = null; + + const axisFrom = ( + bbox: Axis['bbox'], + sourceW: number, + sourceH: number, + pose?: { transform?: PhotoTransform; tickStartY?: number; tickEndY?: number; tapeStart?: [number, number]; tapeEnd?: [number, number] }, + ): Axis => { + const ends = cropEnds(cropFill); + const transform = pose?.transform ?? { ...IDENTITY_TRANSFORM }; + const tickStartY = pose?.tickStartY ?? ends.startY; + const tickEndY = pose?.tickEndY ?? ends.endY; + const w = sourceW > 0 ? sourceW : 1; + const h = sourceH > 0 ? sourceH : 1; + const seg = pose?.tapeStart && pose?.tapeEnd + ? { start: pose.tapeStart, end: pose.tapeEnd } + : tapeSegmentSource(tickStartY, tickEndY, w, h, transform); + return { bbox, photoTransform: transform, tickStartY, tickEndY, tapeStart: seg.start, tapeEnd: seg.end }; + }; const set = (next: Capture) => { state = next; @@ -213,8 +234,11 @@ 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, photoTransform: state.axis.photoTransform, + tickStartY: state.axis.tickStartY, + tickEndY: state.axis.tickEndY, + tapeStart: state.axis.tapeStart, + tapeEnd: state.axis.tapeEnd, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } @@ -225,8 +249,11 @@ 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, photoTransform: state.axis.photoTransform, + tickStartY: state.axis.tickStartY, + tickEndY: state.axis.tickEndY, + tapeStart: state.axis.tapeStart, + tapeEnd: state.axis.tapeEnd, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: state.results ? rest : null, inventoryPng: state.results?.inventoryPng ?? null, @@ -321,13 +348,6 @@ 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 draftPhotoTransform = (d: Draft) => d.photoTransform ?? { ...DEFAULT_PHOTO_TRANSFORM }; - const draftTapeLine = (d: Draft) => { - if (d.tapeLine) return d.tapeLine; - const transform = draftPhotoTransform(d); - const [startY, endY] = cropTickPositions(FILL_DEFAULT); - return tapeLineFromStageTicks(startY, endY, d.source.widthPx, d.source.heightPx, transform); - }; const restoreFromDraft = async (d: Draft) => { batchPhotos = batchPhotosOf(d); @@ -355,11 +375,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { try { const mask = await rederiveMask(source, d.maskParams, d.mask); if (d.stage === 'axis') { - const photoTransform = draftPhotoTransform(d); - const tapeLine = draftTapeLine(d); - set({ k: 'axis', source, approvedMask: mask, axis: { - bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine, photoTransform, - } }); + set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d) }); return; } if (d.inventoryPng) { @@ -368,11 +384,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { } set({ k: 'measure', source, approvedMask: mask, - axis: { - bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end), - tapeLine: draftTapeLine(d), - photoTransform: draftPhotoTransform(d), - }, + axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, results: d.results ? { @@ -501,36 +513,39 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; teardownWorker(); const overlay = state.overlay; - const photoTransform = { ...DEFAULT_PHOTO_TRANSFORM }; - const [startY, endY] = cropTickPositions(cropFill); - const tapeLine = tapeLineFromStageTicks(startY, endY, state.source.widthPx, state.source.heightPx, photoTransform); - set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { - bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine, photoTransform, - } }); + const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg }; + set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx) }); void persist(); }, - setTapeLine(tapeLine: TapeLine, nextTransform?: PhotoTransform) { + setTapePose(pose) { if (state.k !== 'axis') return; - const start: [number, number] = [...tapeLine.start]; - const end: [number, number] = [...tapeLine.end]; - const photoTransform = { ...(nextTransform ?? state.axis.photoTransform) }; - const bbox = rectAlongLine(state.axis.bbox, start, end); - if (!(bbox.longSidePx > 0)) return; - set({ ...state, axis: { bbox, tapeLine: { start, end }, photoTransform } }); - void persist(); + const sourceW = pose.sourceW > 0 ? pose.sourceW : state.source.widthPx; + const sourceH = pose.sourceH > 0 ? pose.sourceH : state.source.heightPx; + const source = sourceW > 0 && sourceH > 0 + ? { ...state.source, widthPx: sourceW, heightPx: sourceH } + : state.source; + set({ + ...state, + source, + axis: axisFrom(state.axis.bbox, sourceW, sourceH, pose), + }); + if (poseTimer) clearTimeout(poseTimer); + poseTimer = setTimeout(() => { + poseTimer = null; + void persist(); + }, 250); }, - setCropFill(pctOrFraction: number) { - // #99: non-retro — only future encodes use this; stored PNGs are untouched. - cropFill = fillFromSetting(pctOrFraction); + setCropFill(pct: number) { + cropFill = Math.max(1, Math.min(100, Math.round(pct))); }, approveAxis() { if (state.k !== 'axis') return; set({ k: 'measure', source: state.source, approvedMask: state.approvedMask, - axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine, photoTransform: state.axis.photoTransform }, + axis: state.axis, lengthEighths: 0, thicknessEighths: 0, results: null, }); void persist(); @@ -554,17 +569,16 @@ 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, scale, + axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, + { start: axis.tapeStart, end: axis.tapeEnd }, ); let pngBlob: Blob; let belowFloor = false; try { - const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill, rotationDeg: axis.photoTransform.tiltDeg }); + const r = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill }); pngBlob = r.blob; belowFloor = r.sourceBelowFloor; } catch { @@ -590,8 +604,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { lengthLabel: fmtEighths(lengthEighths), slabBounds: approvedMask.slabBounds, source, - fill: cropFill, - rotationDeg: axis.photoTransform.tiltDeg, + cropFill, }); } catch { // Deterministic tests may inject a non-decodable inventory blob; still @@ -636,7 +649,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { const siblingBitmap = await decodeOrientedBitmap(photo.blob); let encoded: { blob: Blob; sourceBelowFloor: boolean }; try { - encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill }); + encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill }); } finally { siblingBitmap.close(); } @@ -669,9 +682,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, tapeLine: state.axis.tapeLine, photoTransform: state.axis.photoTransform, - } }); + set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis }); } }, diff --git a/frontend/src/capture/tapeCalibration.test.ts b/frontend/src/capture/tapeCalibration.test.ts deleted file mode 100644 index 30746ec..0000000 --- a/frontend/src/capture/tapeCalibration.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - CALIBRATION_FRAME, - applyApprovedMask, - cropTickPositions, - pinchPhotoTransform, - photoToStage, - stageToPhoto, - tapeLineFromStageTicks, -} from './tapeCalibration.ts'; - -test('the reference line is fixed at the center of the calibration frame', () => { - assert.equal(CALIBRATION_FRAME.width, 480); - assert.equal(CALIBRATION_FRAME.height, 640); - assert.equal(CALIBRATION_FRAME.width / 2, 240); - const [top, bottom] = cropTickPositions(90); - assert.ok(Math.abs(top - 32) < 1e-9); - assert.ok(Math.abs(bottom - 608) < 1e-9); - const transform = { tiltDeg: 21, zoom: 1.4, panX: -18, panY: 11 }; - const tape = tapeLineFromStageTicks(100, 530, 2400, 1800, transform); - const start = photoToStage(tape.start, 2400, 1800, transform); - const end = photoToStage(tape.end, 2400, 1800, transform); - assert.ok(Math.abs(start[0] - 240) < 1e-8 && Math.abs(start[1] - 100) < 1e-8); - assert.ok(Math.abs(end[0] - 240) < 1e-8 && Math.abs(end[1] - 530) < 1e-8); -}); - -test('pan, zoom, and tilt change the photo under the fixed reference line without losing source coordinates', () => { - const transform = { tiltDeg: 31, zoom: 1.7, panX: 15, panY: -24 }; - const sourcePoint: [number, number] = [317, 218]; - const stagePoint = photoToStage(sourcePoint, 1200, 900, transform); - const restored = stageToPhoto(stagePoint, 1200, 900, transform); - assert.ok(Math.abs(restored[0] - sourcePoint[0]) < 1e-8); - assert.ok(Math.abs(restored[1] - sourcePoint[1]) < 1e-8); -}); - -test('approved-mask rendering removes background pixels but preserves slab pixels', () => { - const rgba = new Uint8ClampedArray([ - 180, 90, 30, 255, - 20, 200, 40, 255, - ]); - const masked = applyApprovedMask(rgba, Uint8Array.of(1, 0)); - assert.deepEqual([...masked.slice(0, 4)], [180, 90, 30, 255]); - assert.deepEqual([...masked.slice(4, 8)], [20, 200, 40, 0]); -}); - -test('pinch zoom keeps the photo point under the moving pinch midpoint', () => { - const transform = { tiltDeg: 17, zoom: 1.2, panX: 11, panY: -8 }; - const anchor: [number, number] = [420, 530]; - const before = photoToStage(anchor, 1200, 900, transform); - const midpoint: [number, number] = [before[0] + 18, before[1] - 12]; - const next = pinchPhotoTransform(transform, anchor, midpoint, 1.5, 1200, 900); - const after = photoToStage(anchor, 1200, 900, next); - assert.ok(Math.abs(after[0] - midpoint[0]) < 1e-8); - assert.ok(Math.abs(after[1] - midpoint[1]) < 1e-8); - assert.ok(Math.abs(next.zoom - 1.8) < 1e-12); -}); diff --git a/frontend/src/capture/tapeCalibration.ts b/frontend/src/capture/tapeCalibration.ts deleted file mode 100644 index a2cbfd5..0000000 --- a/frontend/src/capture/tapeCalibration.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { PhotoTransform, TapeLine } from './capture.types'; - -export const CALIBRATION_FRAME = { width: 480, height: 640 } as const; - -export function cropTickPositions(fill: number): [number, number] { - const normalized = Number.isFinite(fill) && fill > 1 ? fill / 100 : fill; - const inset = CALIBRATION_FRAME.height * (1 - Math.max(0.01, Math.min(1, normalized))) / 2; - return [inset, CALIBRATION_FRAME.height - inset]; -} - -export const DEFAULT_PHOTO_TRANSFORM: PhotoTransform = { - tiltDeg: 0, - zoom: 1, - panX: 0, - panY: 0, -}; - -export function photoFitScale(width: number, height: number, tiltDeg: number): number { - const radians = (tiltDeg * Math.PI) / 180; - const cos = Math.abs(Math.cos(radians)); - const sin = Math.abs(Math.sin(radians)); - const rotatedWidth = cos * width + sin * height; - const rotatedHeight = sin * width + cos * height; - return Math.min( - CALIBRATION_FRAME.width / Math.max(rotatedWidth, 1), - CALIBRATION_FRAME.height / Math.max(rotatedHeight, 1), - ); -} - -/** Map oriented source-photo pixels into the stage; the reference line is not transformed. */ -export function photoToStage( - point: [number, number], - width: number, - height: number, - transform: PhotoTransform, -): [number, number] { - const radians = (transform.tiltDeg * Math.PI) / 180; - const scale = photoFitScale(width, height, transform.tiltDeg) * transform.zoom; - const dx = (point[0] - width / 2) * scale; - const dy = (point[1] - height / 2) * scale; - return [ - CALIBRATION_FRAME.width / 2 + transform.panX + dx * Math.cos(radians) - dy * Math.sin(radians), - CALIBRATION_FRAME.height / 2 + transform.panY + dx * Math.sin(radians) + dy * Math.cos(radians), - ]; -} - -/** Zoom about a photo anchor while keeping it under the moving pinch midpoint. */ -export function pinchPhotoTransform( - transform: PhotoTransform, - anchor: [number, number], - midpoint: [number, number], - zoomFactor: number, - width: number, - height: number, -): PhotoTransform { - const next = { ...transform, zoom: Math.max(0.5, Math.min(3, transform.zoom * zoomFactor)) }; - const projected = photoToStage(anchor, width, height, next); - next.panX += midpoint[0] - projected[0]; - next.panY += midpoint[1] - projected[1]; - return next; -} - -/** Invert photoToStage so fixed-line tick positions map back to source pixels. */ -export function stageToPhoto( - point: [number, number], - width: number, - height: number, - transform: PhotoTransform, -): [number, number] { - const radians = (-transform.tiltDeg * Math.PI) / 180; - const scale = photoFitScale(width, height, transform.tiltDeg) * transform.zoom; - if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN]; - const dx = point[0] - CALIBRATION_FRAME.width / 2 - transform.panX; - const dy = point[1] - CALIBRATION_FRAME.height / 2 - transform.panY; - return [ - width / 2 + (dx * Math.cos(radians) - dy * Math.sin(radians)) / scale, - height / 2 + (dx * Math.sin(radians) + dy * Math.cos(radians)) / scale, - ]; -} - -export function tapeLineFromStageTicks( - startY: number, - endY: number, - width: number, - height: number, - transform: PhotoTransform, -): TapeLine { - return { - start: stageToPhoto([CALIBRATION_FRAME.width / 2, startY], width, height, transform), - end: stageToPhoto([CALIBRATION_FRAME.width / 2, endY], width, height, transform), - }; -} - -/** Remove background alpha using the approved mask without changing slab pixels. */ -export function applyApprovedMask(rgba: Uint8ClampedArray, bitset: Uint8Array): Uint8ClampedArray { - if (rgba.length % 4 !== 0 || bitset.length !== rgba.length / 4) { - throw new RangeError('Approved mask dimensions must match photo pixels'); - } - const masked = new Uint8ClampedArray(rgba); - for (let pixel = 0; pixel < bitset.length; pixel++) { - if (bitset[pixel] === 0) masked[pixel * 4 + 3] = 0; - } - return masked; -} diff --git a/frontend/src/capture/tapeLine.test.ts b/frontend/src/capture/tapeLine.test.ts new file mode 100644 index 0000000..37c9c20 --- /dev/null +++ b/frontend/src/capture/tapeLine.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CALIBRATION_FRAME, + IDENTITY_TRANSFORM, + calibrationScale, + cropEnds, + hitRadiusStage, + lineX, + sourceTickDistance, + stageToPhoto, +} from './tapeLine.ts'; + +const moved = { tiltDeg: 17, zoom: 1.8, panX: 40, panY: -25 }; + +test('the line stays in the middle of the frame when the photo moves', () => { + assert.equal(lineX(), CALIBRATION_FRAME.width / 2); + assert.equal(lineX(), 240); + const a = stageToPhoto([lineX(), 32], 1000, 1500, IDENTITY_TRANSFORM); + const b = stageToPhoto([lineX(), 32], 1000, 1500, moved); + assert.equal(a[0] === b[0] && a[1] === b[1], false); + assert.equal(lineX(), CALIBRATION_FRAME.width / 2); +}); + +test('crop fill moves only the ends and never the 3:4 frame', () => { + const at90 = cropEnds(90); + assert.equal(at90.startY, 32); + assert.equal(at90.endY, 608); + const at50 = cropEnds(50); + const at100 = cropEnds(100); + assert.equal(CALIBRATION_FRAME.width / CALIBRATION_FRAME.height, 3 / 4); + assert.equal(at50.startY, 160); + assert.equal(at100.startY, 0); + assert.equal(at100.endY, 640); + assert.equal(lineX(), 240); +}); + +test('tick stage positions are inputs, not a function of the photo transform', () => { + const ends = cropEnds(90); + const still = sourceTickDistance(ends.startY, ends.endY, 800, 1200, IDENTITY_TRANSFORM); + const after = sourceTickDistance(ends.startY, ends.endY, 800, 1200, moved); + assert.ok(still > 0); + assert.notEqual(still, after); + assert.equal(ends.startY, 32); + assert.equal(ends.endY, 608); +}); + +test('scale is source-pixel tick distance divided by the declared length', () => { + const scale = calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 72); + const distance = sourceTickDistance(32, 608, 480, 640, IDENTITY_TRANSFORM); + assert.equal(scale, distance / 72); + assert.equal(calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 0), 0); +}); + +test('the tick hit target is at least 44px on a 320px-wide stage', () => { + const radius = hitRadiusStage(320); + const screen = radius * (320 / CALIBRATION_FRAME.width); + assert.ok(screen >= 44, `screen hit ${screen}`); +}); diff --git a/frontend/src/capture/tapeLine.ts b/frontend/src/capture/tapeLine.ts new file mode 100644 index 0000000..c3544eb --- /dev/null +++ b/frontend/src/capture/tapeLine.ts @@ -0,0 +1,104 @@ +// Fixed tape line for calibration (#99). +// The line is a stage fixture. The photo is the only transformed entity. +// Tick Y values are stage coordinates and are never derived from the photo. + +export const CALIBRATION_FRAME = { width: 480, height: 640 } as const; + +export interface PhotoTransform { + tiltDeg: number; + zoom: number; + panX: number; + panY: number; +} + +export const IDENTITY_TRANSFORM: PhotoTransform = { + tiltDeg: 0, + zoom: 1, + panX: 0, + panY: 0, +}; + +export function lineX(): number { + return CALIBRATION_FRAME.width / 2; +} + +/** (1 - fill) / 2 of the frame's long axis, from the top and the bottom. */ +export function cropEnds(fillPercent: number): { startY: number; endY: number } { + const fill = Math.max(1, Math.min(100, Number.isFinite(fillPercent) ? fillPercent : 90)); + const inset = (CALIBRATION_FRAME.height * (100 - fill)) / 200; + return { startY: inset, endY: CALIBRATION_FRAME.height - inset }; +} + +export function fitScale(sourceW: number, sourceH: number, tiltDeg: number): number { + const radians = (tiltDeg * Math.PI) / 180; + const cos = Math.abs(Math.cos(radians)); + const sin = Math.abs(Math.sin(radians)); + const rotatedW = cos * sourceW + sin * sourceH; + const rotatedH = sin * sourceW + cos * sourceH; + return Math.min( + CALIBRATION_FRAME.width / Math.max(rotatedW, 1), + CALIBRATION_FRAME.height / Math.max(rotatedH, 1), + ); +} + +/** Inverse of the photo transform. Stage points on the fixed line become source pixels. */ +export function stageToPhoto( + point: [number, number], + sourceW: number, + sourceH: number, + transform: PhotoTransform, +): [number, number] { + const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom; + if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN]; + const radians = (-transform.tiltDeg * Math.PI) / 180; + const dx = point[0] - lineX() - transform.panX; + const dy = point[1] - CALIBRATION_FRAME.height / 2 - transform.panY; + return [ + sourceW / 2 + (dx * Math.cos(radians) - dy * Math.sin(radians)) / scale, + sourceH / 2 + (dx * Math.sin(radians) + dy * Math.cos(radians)) / scale, + ]; +} + +export function sourceTickDistance( + startY: number, + endY: number, + sourceW: number, + sourceH: number, + transform: PhotoTransform, +): number { + const a = stageToPhoto([lineX(), startY], sourceW, sourceH, transform); + const b = stageToPhoto([lineX(), endY], sourceW, sourceH, transform); + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} + +export function calibrationScale( + startY: number, + endY: number, + sourceW: number, + sourceH: number, + transform: PhotoTransform, + lengthIn: number, +): number { + if (!(lengthIn > 0)) return 0; + const distance = sourceTickDistance(startY, endY, sourceW, sourceH, transform); + if (!(distance > 0) || !Number.isFinite(distance)) return 0; + return distance / lengthIn; +} + +/** Stage-unit radius whose on-screen size is at least 44px at the given stage width. */ +export function hitRadiusStage(renderedWidthPx: number): number { + return (44 * CALIBRATION_FRAME.width) / Math.max(renderedWidthPx, 1); +} + +export function tapeSegmentSource( + startY: number, + endY: number, + sourceW: number, + sourceH: number, + transform: PhotoTransform, +): { start: [number, number]; end: [number, number] } { + return { + start: stageToPhoto([lineX(), startY], sourceW, sourceH, transform), + end: stageToPhoto([lineX(), endY], sourceW, sourceH, transform), + }; +} diff --git a/frontend/src/geometry/rect.test.ts b/frontend/src/geometry/rect.test.ts index 386e4bd..8490ff9 100644 --- a/frontend/src/geometry/rect.test.ts +++ b/frontend/src/geometry/rect.test.ts @@ -1,12 +1,20 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { minAreaRect, orientedRect, rectAlongLine } from './rect.ts'; +import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts'; +import { measure } from '../measure/measure.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'); @@ -21,15 +29,23 @@ test('orientedRect changes angleDeg and preserves longSidePx', () => { assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180); }); -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'); +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}`); }); diff --git a/frontend/src/geometry/rect.ts b/frontend/src/geometry/rect.ts index 6449834..8e6cbf3 100644 --- a/frontend/src/geometry/rect.ts +++ b/frontend/src/geometry/rect.ts @@ -1,4 +1,4 @@ -import type { ABox, BBox, TapeLine } from '../capture/capture.types'; +import type { ABox, BBox } from '../capture/capture.types'; export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox { let minX = w, minY = h, maxX = -1, maxY = -1; @@ -113,40 +113,18 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox { return { corners, longSidePx: prev.longSidePx, angleDeg: a }; } -/** 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, - ]); +// 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; + } } - 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] }; + return max < min ? 0 : max - min; } diff --git a/frontend/src/measure/measure.test.ts b/frontend/src/measure/measure.test.ts index 1bebd3d..850fb61 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, 10); + const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx); 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, 10); + const m = measure(bitset, 480, 100, box, 384, 8, slabPx); 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,19 +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, 10); + const m = measure(bitset, 120, 100, box, 96, 8, slabPx); assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] }); }); +test('scale follows the tape ticks when they disagree with the mask span', () => { + const { bitset, box, slabPx } = uniform(480, 100); + const tape = { start: [0, 50] as [number, number], end: [240, 50] as [number, number] }; + const m = measure(bitset, 480, 100, box, 384, 8, slabPx, tape); + assert.equal(m.scale, 240 / 48); +}); + 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, 10); + const m = measure(bitset, 100, 100, box, 80, 8, slabPx); 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 ddd10ad..e23c62d 100644 --- a/frontend/src/measure/measure.ts +++ b/frontend/src/measure/measure.ts @@ -1,4 +1,5 @@ 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] } { @@ -75,14 +76,20 @@ export function measure( bbox: BBox, lengthEighths: Eighths, thicknessEighths: Eighths, slabPx: number, - scale: number, + tape?: { start: [number, number]; end: [number, number] }, ): Omit & { sourceBelowFloor: false } { const lengthIn = lengthEighths / 8; const seg = maskAxisSegment(bitset, w, h, bbox); - 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); + const tapeSpan = tape + ? Math.hypot(tape.end[0] - tape.start[0], tape.end[1] - tape.start[1]) + : 0; + const scale = tapeSpan > 0 + ? tapeSpan / lengthIn + : (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn; + const axisStart = tapeSpan > 0 ? tape!.start : seg.start; + const axisUx = tapeSpan > 0 ? (tape!.end[0] - tape!.start[0]) / tapeSpan : seg.ux; + const axisUy = tapeSpan > 0 ? (tape!.end[1] - tape!.start[1]) / tapeSpan : seg.uy; + const every6In = sampleWidths(bitset, w, h, axisStart, axisUx, axisUy, scale, lengthIn); let widths: Widths; if (every6In.length > 0) { const min = Math.min(...every6In); diff --git a/frontend/src/png/crop.test.ts b/frontend/src/png/crop.test.ts deleted file mode 100644 index 4a6e243..0000000 --- a/frontend/src/png/crop.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index e94ad55..0000000 --- a/frontend/src/png/crop.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Single source of truth for the inventory crop geometry (issue #99, req. 4). - * - * The 90% fill and the 3:4 aspect used to be duplicated as `const FILL = 0.9` - * in `png/inventory.ts` and `png/overlay.ts`. Both now read from here, and the - * live fill comes from the global `settings.crop_fill` (default 90%) so the - * reference line and the PNG crop can never disagree. - */ - -/** Default fill (90%) — mirrors the `settings.crop_fill` default. */ -export const FILL_DEFAULT = 0.9; -export const ASPECT_W = 3; -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 - * to {@link FILL_DEFAULT} rather than silently cropping to zero. - */ -export function fillFromSetting(v: unknown): number { - const n = typeof v === 'number' ? v : Number(v); - if (!Number.isFinite(n) || n <= 0) return FILL_DEFAULT; - return Math.min(1, n > 1 ? n / 100 : n); -} - -/** Output frame size: shorter side clamped to {@link FLOOR}. */ -export function cropFrameSize(sw: number, sh: number): { outW: number; outH: number; belowFloor: boolean } { - const belowFloor = Math.min(sw, sh) < FLOOR; - const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR; - const outH = Math.round((outW * ASPECT_H) / ASPECT_W); - return { outW, outH, belowFloor }; -} - -export interface CropGeometry { - scale: number; - dw: number; - dh: number; - ox: number; - oy: number; -} - -/** Scale + centring offsets of a `sw × sh` slab inside an `outW × outH` frame. */ -export function cropOffsets( - sw: number, - sh: number, - outW: number, - outH: number, - fill: number = FILL_DEFAULT, -): CropGeometry { - const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1)); - const dw = sw * scale; - const dh = sh * scale; - return { scale, dw, dh, ox: (outW - dw) / 2, oy: (outH - dh) / 2 }; -} - -/** - * Fraction of the frame's LONG axis taken by the crop margin (issue #99, req. 3): - * the slab's long edge sits at `oy` (or `ox`) = `outH * (1 - fill) / 2`, so the - * fixed reference line is that same fraction in from the frame edge. - */ -export function cropMarginFraction(fill: number = FILL_DEFAULT): number { - return (1 - fill) / 2; -} diff --git a/frontend/src/png/inventory.test.ts b/frontend/src/png/inventory.test.ts index f015762..669c042 100644 --- a/frontend/src/png/inventory.test.ts +++ b/frontend/src/png/inventory.test.ts @@ -118,30 +118,3 @@ test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upsc assert.equal(out.sourceBelowFloor, true); assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`); }); - -test('calibration rotation turns a landscape slab into an upright crop extent', () => { - const width = 6, height = 4; - const bitset = new Uint8Array(width * height); - for (let y = 1; y <= 2; y++) for (let x = 1; x <= 4; x++) bitset[y * width + x] = 1; - const source = { widthPx: width, heightPx: height }; - const mask = maskFor(bitset, { x: 1, y: 1, w: 4, h: 2 }); - const photo = makePhoto(width, height, (x, y) => - bitset[y * width + x] ? [200, 40, 20, 255] : [0, 255, 0, 255], - ); - const extent = (rgba: Uint8Array, outW: number, outH: number) => { - let minX = outW, maxX = -1, minY = outH, maxY = -1; - for (let y = 0; y < outH; y++) for (let x = 0; x < outW; x++) { - if (rgba[(y * outW + x) * 4 + 3] === 0) continue; - minX = Math.min(minX, x); maxX = Math.max(maxX, x); - minY = Math.min(minY, y); maxY = Math.max(maxY, y); - } - return { width: maxX - minX + 1, height: maxY - minY + 1 }; - }; - const unrotated = composeInventoryFrame(photo, mask, source, 1, 0); - const rotated = composeInventoryFrame(photo, mask, source, 1, 90); - assert.ok(extent(unrotated.rgba, unrotated.width, unrotated.height).width > extent(unrotated.rgba, unrotated.width, unrotated.height).height); - assert.ok(extent(rotated.rgba, rotated.width, rotated.height).height > extent(rotated.rgba, rotated.width, rotated.height).width); - for (let i = 0; i < rotated.rgba.length; i += 4) { - if (rotated.rgba[i + 3]) assert.deepEqual([...rotated.rgba.slice(i, i + 3)], [200, 40, 20]); - } -}); diff --git a/frontend/src/png/inventory.ts b/frontend/src/png/inventory.ts index f61fc0c..3ce49e9 100644 --- a/frontend/src/png/inventory.ts +++ b/frontend/src/png/inventory.ts @@ -1,5 +1,8 @@ import type { Mask, Source } from '../capture/capture.types'; -import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop'; + +const FLOOR = 800; // shorter side, px +const ASPECT_W = 3; +const ASPECT_H = 4; type MaskPick = Pick; type SourcePick = Pick; @@ -8,10 +11,8 @@ export interface InventoryInput { bitmap: ImageBitmap; mask: MaskPick; source: SourcePick; - /** Calibration-photo rotation in degrees; batch siblings remain unrotated. */ - rotationDeg?: number; - /** Crop fill, 0 [number, number]; - toSource: (x: number, y: number) => [number, number]; -} - -export function createMaskRotation(bitset: Uint8Array, w: number, h: number, rotationDeg: number): MaskRotation { - const pixels = maskPixelBounds(bitset, w, h); - if (!pixels) { - return { bounds: { x: 0, y: 0, w: 0, h: 0 }, toRotated: (x, y) => [x, y], toSource: (x, y) => [x, y] }; - } - const radians = (rotationDeg * Math.PI) / 180; - const toRotated = (x: number, y: number) => rotatePoint(x, y, pixels, radians); - const toSource = (x: number, y: number) => rotatePoint(x, y, pixels, -radians); - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - if (!bitset[y * w + x]) continue; - const [rx, ry] = toRotated(x, y); - minX = Math.min(minX, rx); minY = Math.min(minY, ry); - maxX = Math.max(maxX, rx); maxY = Math.max(maxY, ry); - } - } - return { - bounds: { x: minX - 0.5, y: minY - 0.5, w: maxX - minX + 1, h: maxY - minY + 1 }, - toRotated, - toSource, - }; -} - -/** Pixel-edge bounds of the approved mask after rotating around its center. */ -export function rotatedMaskBounds(bitset: Uint8Array, w: number, h: number, rotationDeg: number) { - return createMaskRotation(bitset, w, h, rotationDeg).bounds; -} - export function composeInventoryFrame( src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray }, mask: MaskPick, source: SourcePick, - fill: number = FILL_DEFAULT, - rotationDeg = 0, + fill = 0.9, ): Frame { - if (Number.isFinite(rotationDeg) && Math.abs(rotationDeg % 360) > 1e-8) { - return composeRotatedInventoryFrame(src, mask, source, fill, rotationDeg); - } const b = mask.slabBounds; const x0 = Math.max(0, Math.round(b.x)); const y0 = Math.max(0, Math.round(b.y)); @@ -102,8 +35,14 @@ export function composeInventoryFrame( const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h))); const sw = x1 - x0; const sh = y1 - y0; - const { outW, outH, belowFloor } = cropFrameSize(sw, sh); - const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill); + const belowFloor = Math.min(sw, sh) < FLOOR; + const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR; + const outH = Math.round((outW * ASPECT_H) / ASPECT_W); + const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1)); + const dw = sw * scale; + const dh = sh * scale; + const ox = (outW - dw) / 2; + const oy = (outH - dh) / 2; const rgba = new Uint8Array(outW * outH * 4); const xEnd = ox + dw; const yEnd = oy + dh; @@ -125,45 +64,11 @@ export function composeInventoryFrame( return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor }; } -function composeRotatedInventoryFrame( - src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray }, - mask: MaskPick, - source: SourcePick, - fill: number, - rotationDeg: number, -): Frame { - const rotation = createMaskRotation(mask.bitset, source.widthPx, source.heightPx, rotationDeg); - if (!(rotation.bounds.w > 0 && rotation.bounds.h > 0)) throw new RangeError('Cannot rotate an empty approved mask'); - const rotated = rotation.bounds; - const { outW, outH, belowFloor } = cropFrameSize(rotated.w, rotated.h); - const { scale, dw, dh, ox, oy } = cropOffsets(rotated.w, rotated.h, outW, outH, fill); - const rgba = new Uint8Array(outW * outH * 4); - for (let y = 0; y < outH; y++) { - for (let x = 0; x < outW; x++) { - if (x + 0.5 < ox || x + 0.5 >= ox + dw || y + 0.5 < oy || y + 0.5 >= oy + dh) continue; - const rx = rotated.x + (x + 0.5 - ox) / scale; - const ry = rotated.y + (y + 0.5 - oy) / scale; - const [sourceX, sourceY] = rotation.toSource(rx, ry); - const sx = Math.round(sourceX); - const sy = Math.round(sourceY); - if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue; - const siPixel = sy * source.widthPx + sx; - if (mask.bitset[siPixel] !== 1) continue; - const si = siPixel * 4; - const di = (y * outW + x) * 4; - rgba[di] = src.rgba[si]; - rgba[di + 1] = src.rgba[si + 1]; - rgba[di + 2] = src.rgba[si + 2]; - rgba[di + 3] = 255; - } - } - return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor }; -} - export async function encodeInventoryPng( input: InventoryInput, ): Promise<{ blob: Blob; sourceBelowFloor: boolean }> { - const { bitmap, mask, source } = input; + const { bitmap, mask, source, cropFill } = input; + const fill = Math.max(1, Math.min(100, cropFill ?? 90)) / 100; const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx); const srcCtx = srcCanvas.getContext('2d')!; srcCtx.drawImage(bitmap, 0, 0); @@ -172,8 +77,7 @@ export async function encodeInventoryPng( { width: source.widthPx, height: source.heightPx, rgba: img.data }, mask, source, - input.fill, - input.rotationDeg, + fill, ); const out = new OffscreenCanvas(frame.width, frame.height); const ctx = out.getContext('2d')!; diff --git a/frontend/src/png/overlay.test.ts b/frontend/src/png/overlay.test.ts index 2d154b4..d3940d9 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -1117,18 +1117,3 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥ const tipSpanSrc = tipHi - tipLo; assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`); }); - -test('inventory source map rotates dimension graphics with the calibration slab', () => { - const width = 6, height = 4; - const bitset = new Uint8Array(width * height); - for (let y = 1; y <= 2; y++) for (let x = 1; x <= 4; x++) bitset[y * width + x] = 1; - const map = inventorySourceMap( - { x: 1, y: 1, w: 4, h: 2 }, - { widthPx: width, heightPx: height }, - 2, 3, 1, bitset, 90, - ).mapToInventory; - const a = map(1, 1.5); - const b = map(4, 1.5); - assert.ok(Math.abs(b[0] - a[0]) < 1e-8, 'horizontal source axis maps vertical after clockwise rotation'); - assert.ok(b[1] > a[1]); -}); diff --git a/frontend/src/png/overlay.ts b/frontend/src/png/overlay.ts index e374e88..f731be3 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -1,7 +1,5 @@ import type { BBox } from '../capture/capture.types'; import { axisEndpoints } from '../measure/measure'; -import { FILL_DEFAULT, cropOffsets } from './crop'; -import { createMaskRotation } from './inventory'; /** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */ export function overlayWidthStations(lengthIn: number): number[] { @@ -487,29 +485,20 @@ export function inventorySourceMap( source: { widthPx: number; heightPx: number }, outW: number, outH: number, - fill: number = FILL_DEFAULT, - bitset?: Uint8Array, - rotationDeg = 0, + fill = 0.9, ): InventoryMap { - if (bitset && Math.abs(rotationDeg % 360) > 1e-8) { - const rotation = createMaskRotation(bitset, source.widthPx, source.heightPx, rotationDeg); - const { scale, ox, oy } = cropOffsets(rotation.bounds.w, rotation.bounds.h, outW, outH, fill); - return { - outW, - outH, - mapToInventory: (sx, sy) => { - const [rx, ry] = rotation.toRotated(sx, sy); - return [ox + (rx - rotation.bounds.x) * scale, oy + (ry - rotation.bounds.y) * scale]; - }, - }; - } + const fillRatio = fill; const x0 = Math.max(0, Math.round(slabBounds.x)); const y0 = Math.max(0, Math.round(slabBounds.y)); const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w))); const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h))); const sw = x1 - x0; const sh = y1 - y0; - const { scale, ox, oy } = cropOffsets(sw, sh, outW, outH, fill); + const scale = Math.min((outW * fillRatio) / Math.max(sw, 1), (outH * fillRatio) / Math.max(sh, 1)); + const dw = sw * scale; + const dh = sh * scale; + const ox = (outW - dw) / 2; + const oy = (outH - dh) / 2; return { outW, outH, @@ -529,8 +518,7 @@ export interface EncodeOverlayInput { lengthLabel: string; slabBounds: { x: number; y: number; w: number; h: number }; source: { widthPx: number; heightPx: number }; - fill?: number; - rotationDeg?: number; + cropFill?: number; } /** @@ -545,9 +533,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise const canvas = new OffscreenCanvas(outW, outH); const ctx = canvas.getContext('2d')!; ctx.drawImage(bmp, 0, 0); - const { mapToInventory } = inventorySourceMap( - input.slabBounds, input.source, outW, outH, input.fill, input.bitset, input.rotationDeg, - ); + const fill = Math.max(1, Math.min(100, input.cropFill ?? 90)) / 100; + const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill); const plan = buildOverlayPlan({ bitset: input.bitset, widthPx: input.widthPx,