From d49ff2fab5fe666bd9ddddadec1e68cf39ac0b4b Mon Sep 17 00:00:00 2001 From: Ty Date: Thu, 24 Sep 2026 16:49:17 -0700 Subject: [PATCH] fix(capture): align masked tape calibration with #99 --- .planning/milestones/v0.5/M4/goals.md | 23 +- .planning/milestones/v0.5/M4/tasks.md | 10 +- docs/MILESTONES.md | 30 +- docs/REQUIREMENTS.md | 13 +- docs/STRUCTURE.md | 4 +- frontend/src/App.svelte | 10 +- frontend/src/capture/AxisPanel.svelte | 458 ++++++++++++------ frontend/src/capture/capture.types.ts | 10 + .../src/capture/session.calibrate.test.ts | 5 +- frontend/src/capture/session.ts | 40 +- frontend/src/capture/tapeCalibration.test.ts | 57 +++ frontend/src/capture/tapeCalibration.ts | 104 ++++ frontend/src/create/settings/Settings.svelte | 3 + frontend/src/png/inventory.test.ts | 27 ++ frontend/src/png/inventory.ts | 106 ++++ frontend/src/png/overlay.test.ts | 15 + frontend/src/png/overlay.ts | 21 +- tools/check_requirements.py | 2 +- 18 files changed, 757 insertions(+), 181 deletions(-) create mode 100644 frontend/src/capture/tapeCalibration.test.ts create mode 100644 frontend/src/capture/tapeCalibration.ts diff --git a/.planning/milestones/v0.5/M4/goals.md b/.planning/milestones/v0.5/M4/goals.md index c7648e4..669fd89 100644 --- a/.planning/milestones/v0.5/M4/goals.md +++ b/.planning/milestones/v0.5/M4/goals.md @@ -1,4 +1,4 @@ -# M4 — Goals (DRAFT — not accepted) +# M4 — Goals Role: goals for Milestone 4. Cites requirement IDs and Gitea issue numbers; defines none new until accepted. Source of truth for scope will be `docs/MILESTONES.md` (M4 ID subset) @@ -22,8 +22,9 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30). 1. **Stabilize + polish the M3 experience (A) — includes the #99 tape-line calibration (B).** - **Tape-line calibration (#99):** replace the axis-rectangle calibration with a draggable tape-line (black line + two rifle-scope end ticks). Declared length = distance between - ticks (owner-declared); `maskSpanAlong` demoted to an optional sanity check. New - requirement row(s); supersedes the as-built length model in CAPTURE-60/75. The `m4` + ticks (owner-declared); scale = tick-pixel distance / declared length, no + `maskSpanAlong` check. New + requirement row(s); supersedes the projected-span length model in CAPTURE-60/75. The `m4` branch already carries `crop_fill` + `TapePrototype.svelte` (`?tape-prototype`); M4 promotes it from prototype to shipped. - **UX polish:** photo-picker look/feel (#91), photo filenames on submit (#92), capture @@ -100,8 +101,8 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30). ## Requirements delta (draft — rows written at execution, after acceptance) -- **New rows (draft):** tape-line calibration behavior (overlay, draggable ticks, - owner-declared length, sanity check). ~1–3 rows under CAPTURE. +- **New rows:** tape-line calibration behavior (overlay, draggable ticks, + owner-declared length, tick-distance scale). ~1–3 rows under CAPTURE. - **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood categories (primary + secondary) / edge / figure / grade / moisture / tags by hand, thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW. @@ -126,7 +127,7 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30). 1. **Tape-line calibration + M3 polish ship together (Goal 1):** the `?tape-prototype` sandbox is replaced by the shipped calibration path (owner drags both ticks; declared - length = tick distance; as-built length model no longer the source of truth), and the + length = tick distance; no `maskSpanAlong` check), and the M3 polish issues (#91–#100, excluding #96 which drops with Call 1) are closed with passing tests and raw runner evidence. 2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick @@ -156,11 +157,11 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30). ## Task-level decisions (settled during the sprint, not at planning) -These are implementation details the task breakdown and wireframe settle — not plan-level: -the exact thumb-first interaction model (wizard vs single-scroll, chips vs pickers), which -of #91–#100 are M4-must vs nice, whether #99 touches the photos-ready lineup (CAPTURE-130), -and the exit-walk shape (single slab vs QUALITY-110 pair; test-inference is text-only now -that Call 1 is out). +The only plan-level open item is the exact thumb-first interaction model +(wizard vs single-scroll, chips vs pickers) — settled by the wireframe. +Settled at kickoff (not reopened here): full #91–#100 polish wave (only #96 drops +with Call 1); #99 confined to the calibration step (CAPTURE-130 lineup untouched); +exit walk is a single slab carrying multiple photos. ## Task breakdown detail bar diff --git a/.planning/milestones/v0.5/M4/tasks.md b/.planning/milestones/v0.5/M4/tasks.md index 059cda6..1e7287c 100644 --- a/.planning/milestones/v0.5/M4/tasks.md +++ b/.planning/milestones/v0.5/M4/tasks.md @@ -34,11 +34,13 @@ exit (QUALITY-30); agents record raw evidence, never declare green. sandbox on the `m4` branch; `docs/design/issue-99-tape-line-overlay.html` (approved wireframe); `frontend/src/png/crop.ts` (projection logic). - **Work:** promote the prototype to the shipped calibration path (owner drags both - ticks), adopt the as-built length model (maskSpanAlong projection), delete the + ticks to the actual tape start/end; scale = tick-pixel distance / owner-declared + length — no `maskSpanAlong` check), delete the sandbox + query-param gating, keep `crop_fill` (already landed in 818874e). **Scope (Ty confirmed):** confined to the calibration step — the photos-ready lineup (CAPTURE-130) stays as-is; #99 does not touch it. -- **Verification:** unit tests in `frontend/src/png/*` (tape geometry), `tsc --noEmit`, +- **Verification:** unit tests in `frontend/src/png/*` (tape geometry + tick-distance + scale math in photo px under pan/zoom/rotate), `tsc --noEmit`, manual calibration on one slab at 375px. - **Sequence:** parallel with T3–T5; no dependency on T1. - **Stop:** prototype shipped, `?tape-prototype` removed, tests green. @@ -76,8 +78,8 @@ exit (QUALITY-30); agents record raw evidence, never declare green. wireframe, 2026-09-24) for the visual language + the interaction model. - **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms: *no tap-bubble chip clouds for long lists → collapsible searchable checklist*; - leftmost-kept-is-primary for wood (primary + optional secondaries); AI-assist - labeled as "suggested, confirm"; every metadata requirement present (primary + leftmost-kept-is-primary for wood (primary + optional secondaries); + no AI-assist surface (Call 1 decommissioned); every metadata requirement present (primary wood-category required, secondaries optional). - **Verification:** Ty reviews the served variants and accepts one (375/768/1280). - **Sequence:** before T5/T6. **Stop:** Ty accepts a variant. diff --git a/docs/MILESTONES.md b/docs/MILESTONES.md index 029a121..a552945 100644 --- a/docs/MILESTONES.md +++ b/docs/MILESTONES.md @@ -41,8 +41,36 @@ OPS-40, QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105, QUALITY-110, QUALITY-120, QUALITY-125 +## M4 — Tape-line, manual taxonomy, Call 2, and UAT exit + +IDs: + +CAPTURE-25, CAPTURE-30, CAPTURE-35, CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, +CAPTURE-55, CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-75, CAPTURE-80, CAPTURE-85, +CAPTURE-90, CAPTURE-95, CAPTURE-100, CAPTURE-105, CAPTURE-110, CAPTURE-115, +CAPTURE-120, CAPTURE-125, CAPTURE-130, CAPTURE-135, CAPTURE-140, CAPTURE-141, +CAPTURE-145, CAPTURE-150, CAPTURE-155, CAPTURE-160, CAPTURE-170, CAPTURE-175, +METADATA-5, METADATA-10, METADATA-15, METADATA-20, METADATA-25, METADATA-30, +METADATA-50, METADATA-55, METADATA-60, METADATA-65, METADATA-66, METADATA-70, +METADATA-75, METADATA-80, METADATA-85, METADATA-90, METADATA-95, METADATA-105, +METADATA-106, METADATA-110, METADATA-115, METADATA-120, +REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-35, +REVIEW-40, REVIEW-45, REVIEW-50, REVIEW-55, +PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-31, +PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-40, PUBLISH-45, +PUBLISH-50, PUBLISH-55, PUBLISH-60, +SETTINGS-5, SETTINGS-10, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30, +SETTINGS-35, SETTINGS-40, SETTINGS-45, SETTINGS-50, SETTINGS-51, SETTINGS-55, +SETTINGS-60, SETTINGS-65, SETTINGS-70, SETTINGS-75, SETTINGS-80, SETTINGS-85, +SETTINGS-90, +APP-15, APP-20, APP-30, APP-35, APP-45, APP-50, APP-55, APP-60, APP-65, +OPS-40, +QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105, +QUALITY-110, QUALITY-120, QUALITY-125, QUALITY-130 + ## Later IDs: -CAPTURE-15, CAPTURE-165, METADATA-107, OPS-30, OPS-35, QUALITY-115 +CAPTURE-15, CAPTURE-165, METADATA-35, METADATA-40, METADATA-45, METADATA-100, +METADATA-107, OPS-30, OPS-35, QUALITY-115 diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 4f962bb..b1afb2b 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -26,10 +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 length axis is the long side of the minimum-area bounding rectangle of the mask; the owner rotates it to the prevailing length and confirms. | shipped | 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/geometry/rect.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 longest_axis_px divided by measured length, where longest_axis_px is the mask span end to end along the approved axis, not the rectangle edge. | shipped | — | +| 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-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 | @@ -71,7 +73,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | METADATA-80 | The server assembles the name and short description deterministically from the primary wood category's patterns and the dimension fact block. The patterns use only synced taxonomy terms and capture facts. | M3 | — | | METADATA-90 | The dimension fact block supplies the exact strings the patterns interpolate and the only numbers Call 2 may print; there is no L by W by T rectangle assumption. | M3 | — | | METADATA-55 | Call 2 runs on demand and produces the long description. | M3 | — | -| METADATA-60 | Call 2 receives the processed photos, the metadata confirmed or changed in review, the primary and secondary wood categories, the parent-category path, the dimension fact block, and brand/geo context. | M3 | — | +| METADATA-60 | Call 2 receives the processed photos, the metadata the owner picked or changed in review, including primary and secondary wood categories and the human-selected taxonomy attributes and tags, the parent-category path, the dimension fact block, and brand/geo context. | M4 | — | | METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — | | METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — | | METADATA-70 | With inference off, the server produces the long description. | M3 | — | @@ -82,6 +84,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | METADATA-107 | Before the prompt revision interface is exposed beyond the trusted network it requires authentication. | later | — | | METADATA-110 | The Generate text control appears only when content generation is enabled and the inference endpoint is reachable, and is disabled when the text service is down. | M3 | — | | METADATA-115 | A runtime inference failure is classified recoverable or unrecoverable for the owner: recoverable offers Retry, unrecoverable gives a plain message explaining it. | M3 | — | +| METADATA-120 | Call 2 is grounded in the owner's human-picked primary and secondary wood categories and taxonomy values from review; it does not receive or depend on Call 1 suggestions. | M4 | — | ## REVIEW — confirm, tweak, ready @@ -97,6 +100,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | REVIEW-40 | The owner can edit any field, including generated title and description, until create, except the calculated measurement fields (widths, sqft, bdft), which are not user mutable. | M3 | — | | REVIEW-45 | SKU is 3 to 24 characters, alphanumeric and hyphen only. | M3 | — | | REVIEW-50 | Title and short_description warn past 60 and 160 characters and never exceed the store's hard limits; description has no maximum. | M3 | docs/contracts/WOO-API.md | +| REVIEW-55 | Review always presents a thumb-first manual taxonomy flow: the owner selects species, primary and optional secondary wood categories, Edge, Figure, Grade, Moisture, and optional synced tags; no Call 1 suggestion, inference pre-fill, or AI marker is shown. | M4 | — | ## PUBLISH — store submission @@ -113,7 +117,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | PUBLISH-36 | Every publish failure is classified for the owner as recoverable (he can act) or unrecoverable (outside his control), and the plain message states which it is and the next step. | M3 | — | | PUBLISH-37 | A recoverable failure points at the field to fix, or offers Retry while the draft and photos are retained. | M3 | — | | PUBLISH-38 | An unrecoverable failure says it is outside the owner's control and offers the available next step. | M3 | — | -| PUBLISH-40 | Images upload as processed PNGs with alpha; the clean primary, the primary dimensioned copy, and every remaining batch photo the owner selected are uploaded, with no upper cap on the number of images. | M3 | — | +| PUBLISH-40 | Images upload as processed PNGs with alpha; the clean primary, the primary dimensioned copy, and every remaining batch photo the owner selected are uploaded, with no upper cap on the number of images. | M4 | — | | PUBLISH-45 | The Woo image order is the photos-ready lineup: clean calibration, dimension-overlay copy, then the rest in capture order. | M3 | — | | PUBLISH-50 | Published means the store create succeeded, not that the product is live when status is draft. | M3 | — | | PUBLISH-55 | Only non-null fields are sent; the payload carries name, type simple, status, sku, slug, regular_price, description, short_description, categories, tags, images, attributes, virtual false, downloadable false, catalog_visibility visible, sold_individually true. The slug is the sanitized product name plus the lowercased SKU. | M3 | docs/contracts/WOO-API.md | @@ -206,3 +210,4 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | QUALITY-115 | Browser end-to-end capture through publish is verified on iOS Safari, Android Chrome, and desktop Chrome, Edge, and Firefox. | later | — | | QUALITY-120 | No raw status code, stack trace, or internal code reaches the owner in any tested path. | M3 | — | | QUALITY-125 | An encrypted credential stored by Settings decrypts back to the value it was given. | M3 | — | +| QUALITY-130 | The M4 exit walk records raw runner evidence for a real `SLAB-UAT-*` WooCommerce draft created through the UI; the draft matches the reviewed ready set and is not published live. | M4 | — | diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 5d88873..6331f93 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -42,6 +42,8 @@ remain the point of record, and `.planning/` files cite requirement IDs without | `.planning/MILESTONES.md` | history | | `.planning/milestones/v0.5/M3/roadmap.md` | roadmap | | `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown | +| `.planning/milestones/v0.5/M4/goals.md` | roadmap | +| `.planning/milestones/v0.5/M4/tasks.md` | breakdown | Roles: @@ -87,7 +89,7 @@ Defined only in `docs/REQUIREMENTS.md`, as table rows: | CAPTURE-5 | Statement of one testable requirement | shipped | frontend/src/measure/measure.test.ts | ``` -- `Scope` is `shipped`, `M3`, or `later`. +- `Scope` is `shipped`, a milestone scope (`M3`, `M4`, ...), or `later`. - `Test` is a repo-relative test path, a requirement ID, or `—`. A path given for a `shipped` requirement must exist; a given ID must resolve to a defined requirement. - IDs advance by 5 within each namespace (`5, 10, 15, ...`), resetting for each prefix. The gaps are intentional room for later insertions; never renumber to close a gap. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a6b950b..372d1e0 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -30,6 +30,7 @@ let pngUrl: string | null = null; let showSettings = false; let showSlabs = false; + let cropFill = 90; let curFlow: import('./create/flow').Flow = { phase: 'capture' }; $: curFlow = $flow; @@ -51,7 +52,10 @@ // #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) => session.setCropFill(s.crop_fill ?? 90)) + .then((s) => { + cropFill = s.crop_fill ?? 90; + session.setCropFill(cropFill); + }) .catch(() => {}); }); onDestroy(() => { @@ -225,6 +229,8 @@ setTapeLine={session.setTapeLine} 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} diff --git a/frontend/src/capture/AxisPanel.svelte b/frontend/src/capture/AxisPanel.svelte index 94f597b..f270977 100644 --- a/frontend/src/capture/AxisPanel.svelte +++ b/frontend/src/capture/AxisPanel.svelte @@ -1,13 +1,25 @@
-

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.

+

Tape line

+

Drag each tick to where your tape started and ended.

+

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.

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

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

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

- - + +
diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts index 85e95a0..e2dc346 100644 --- a/frontend/src/capture/capture.types.ts +++ b/frontend/src/capture/capture.types.ts @@ -52,11 +52,20 @@ export interface TapeLine { end: [number, number]; } +/** User-selected transform applied to the calibration photo only. */ +export interface PhotoTransform { + tiltDeg: number; + zoom: number; + panX: number; + panY: 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; + photoTransform: PhotoTransform; } export interface Widths { @@ -146,6 +155,7 @@ export interface Draft { 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 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 558b714..76df5b9 100644 --- a/frontend/src/capture/session.calibrate.test.ts +++ b/frontend/src/capture/session.calibrate.test.ts @@ -445,7 +445,8 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as s.approveMask(); const tapeLine = { start: [2, 3] as [number, number], end: [35, 14] as [number, number] }; - s.setTapeLine(tapeLine); + 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,6 +455,7 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as 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); @@ -469,6 +471,7 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as 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 5249fa8..58f6731 100644 --- a/frontend/src/capture/session.ts +++ b/frontend/src/capture/session.ts @@ -1,16 +1,17 @@ import { writable } from 'svelte/store'; import type { - BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, WorkerIn, WorkerOut, + BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, 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 { initialTapeLine, rectAlongLine } from '../geometry/rect'; +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'; const DEBOUNCE_MS = 2000; @@ -41,7 +42,7 @@ export interface CaptureSession { setSensitivity(n: number): void; setSheet(s: Sheet): void; approveMask(): void; - setTapeLine(tapeLine: TapeLine): void; + setTapeLine(tapeLine: TapeLine, photoTransform?: PhotoTransform): void; /** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */ setCropFill(pctOrFraction: number): void; approveAxis(): void; @@ -213,6 +214,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tapeLine: state.axis.tapeLine, + photoTransform: state.axis.photoTransform, lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null, }; } @@ -224,6 +226,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { source: state.source, maskParams: state.approvedMask.params, mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tapeLine: state.axis.tapeLine, + photoTransform: state.axis.photoTransform, lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: state.results ? rest : null, inventoryPng: state.results?.inventoryPng ?? null, @@ -318,7 +321,13 @@ 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 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); @@ -346,9 +355,10 @@ 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, + bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine, photoTransform, } }); return; } @@ -361,6 +371,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { axis: { bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end), tapeLine: draftTapeLine(d), + photoTransform: draftPhotoTransform(d), }, lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths, results: d.results @@ -490,20 +501,23 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return; teardownWorker(); const overlay = state.overlay; - const tapeLine = initialTapeLine(state.source.widthPx, state.source.heightPx); + 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, + bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine, photoTransform, } }); void persist(); }, - setTapeLine(tapeLine: TapeLine) { + setTapeLine(tapeLine: TapeLine, nextTransform?: PhotoTransform) { 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 } } }); + set({ ...state, axis: { bbox, tapeLine: { start, end }, photoTransform } }); void persist(); }, @@ -516,7 +530,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, tapeLine: state.axis.tapeLine }, + axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine, photoTransform: state.axis.photoTransform }, lengthEighths: 0, thicknessEighths: 0, results: null, }); void persist(); @@ -550,7 +564,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { let pngBlob: Blob; let belowFloor = false; try { - const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill }); + const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill, rotationDeg: axis.photoTransform.tiltDeg }); pngBlob = r.blob; belowFloor = r.sourceBelowFloor; } catch { @@ -576,6 +590,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { lengthLabel: fmtEighths(lengthEighths), slabBounds: approvedMask.slabBounds, source, + fill: cropFill, + rotationDeg: axis.photoTransform.tiltDeg, }); } catch { // Deterministic tests may inject a non-decodable inventory blob; still @@ -654,7 +670,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession { 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, + bbox: state.axis.bbox, tapeLine: state.axis.tapeLine, photoTransform: state.axis.photoTransform, } }); } }, diff --git a/frontend/src/capture/tapeCalibration.test.ts b/frontend/src/capture/tapeCalibration.test.ts new file mode 100644 index 0000000..30746ec --- /dev/null +++ b/frontend/src/capture/tapeCalibration.test.ts @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..a2cbfd5 --- /dev/null +++ b/frontend/src/capture/tapeCalibration.ts @@ -0,0 +1,104 @@ +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/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte index a0fea13..c96a1fe 100644 --- a/frontend/src/create/settings/Settings.svelte +++ b/frontend/src/create/settings/Settings.svelte @@ -20,6 +20,8 @@ crop_fill: number; } + export let onCropFillChange: (pct: number) => void = () => {}; + interface PriceRule { id: number; species_id: number; @@ -246,6 +248,7 @@ }); if (res.ok) { settings = await res.json(); + onCropFillChange(settings.crop_fill ?? 90); message = 'Saved'; // refresh secret buffers from the (possibly unmasked) server state form.woo_app_password = secretValue(settings.woo_app_password); diff --git a/frontend/src/png/inventory.test.ts b/frontend/src/png/inventory.test.ts index 669c042..f015762 100644 --- a/frontend/src/png/inventory.test.ts +++ b/frontend/src/png/inventory.test.ts @@ -118,3 +118,30 @@ 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 8ab24d5..f61fc0c 100644 --- a/frontend/src/png/inventory.ts +++ b/frontend/src/png/inventory.ts @@ -8,6 +8,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, ): 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)); @@ -55,6 +125,41 @@ 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 }> { @@ -68,6 +173,7 @@ export async function encodeInventoryPng( mask, source, input.fill, + input.rotationDeg, ); 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 d3940d9..2d154b4 100644 --- a/frontend/src/png/overlay.test.ts +++ b/frontend/src/png/overlay.test.ts @@ -1117,3 +1117,18 @@ 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 d9c35ce..e374e88 100644 --- a/frontend/src/png/overlay.ts +++ b/frontend/src/png/overlay.ts @@ -1,6 +1,7 @@ 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,7 +488,21 @@ export function inventorySourceMap( outW: number, outH: number, fill: number = FILL_DEFAULT, + bitset?: Uint8Array, + rotationDeg = 0, ): 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 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))); @@ -514,6 +529,8 @@ export interface EncodeOverlayInput { lengthLabel: string; slabBounds: { x: number; y: number; w: number; h: number }; source: { widthPx: number; heightPx: number }; + fill?: number; + rotationDeg?: number; } /** @@ -528,7 +545,9 @@ 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); + const { mapToInventory } = inventorySourceMap( + input.slabBounds, input.source, outW, outH, input.fill, input.bitset, input.rotationDeg, + ); const plan = buildOverlayPlan({ bitset: input.bitset, widthPx: input.widthPx, diff --git a/tools/check_requirements.py b/tools/check_requirements.py index a3dd133..2faba01 100644 --- a/tools/check_requirements.py +++ b/tools/check_requirements.py @@ -50,7 +50,7 @@ CITING_ROLES = {"capability", "contract"} CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"} HEADING = re.compile(r"^(#{2,4})\s+(.*)$") -SCOPES = {"shipped", "M3", "later"} +SCOPES = {"shipped", "M3", "M4", "later"} PLACEHOLDER = {"", "—", "-", "*(none yet)*"}