Compare commits
8 Commits
49c22c9385
...
m4
| Author | SHA1 | Date | |
|---|---|---|---|
| f725e04ae6 | |||
| 8673ae0ecd | |||
| a756cf58bb | |||
| d50d55aea9 | |||
| 1882fa3842 | |||
| 7908a99ec3 | |||
| 6000ac2b0f | |||
| d49ff2fab5 |
@@ -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
|
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)
|
new until accepted. Source of truth for scope will be `docs/MILESTONES.md` (M4 ID subset)
|
||||||
@@ -20,12 +20,18 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
|
|||||||
## Goals (accepted direction)
|
## Goals (accepted direction)
|
||||||
|
|
||||||
1. **Stabilize + polish the M3 experience (A) — includes the #99 tape-line calibration (B).**
|
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 calibration (#99) — SHIPPED (as-built 2026-09-25, UAT signed off):** the
|
||||||
tape-line (black line + two rifle-scope end ticks). Declared length = distance between
|
axis-rectangle calibration is replaced by a **fixed** black centre line with two end
|
||||||
ticks (owner-declared); `maskSpanAlong` demoted to an optional sanity check. New
|
ticks. The line is a stage fixture and is **never an owner input** (not draggable, not
|
||||||
requirement row(s); supersedes the as-built length model in CAPTURE-60/75. The `m4`
|
focusable, not keyboard-nudgeable). The **source photo/slab is the only thing that
|
||||||
branch already carries `crop_fill` + `TapePrototype.svelte` (`?tape-prototype`); M4
|
moves** — pan/zoom/tilt it until it lines up with the fixed line at the place the tape
|
||||||
promotes it from prototype to shipped.
|
measurement was taken. Declared Length = the real inches read off the tape, and the
|
||||||
|
fixed line represents that same length; scale = fixed-line pixel span / declared
|
||||||
|
Length, no `maskSpanAlong` check. The crop slider alone sets the line ends
|
||||||
|
(`crop_fill=90` ⇒ each end 5% from its edge). The earlier `?tape-prototype` sandbox,
|
||||||
|
`TapePrototype.svelte`, `frontend/src/png/crop.ts` and the `sveltedrag` dependency are
|
||||||
|
all superseded/removed. Requirement rows CAPTURE-170/175/75 carry the as-built; see
|
||||||
|
`tasks.md` T2 for detail.
|
||||||
- **UX polish:** photo-picker look/feel (#91), photo filenames on submit (#92), capture
|
- **UX polish:** photo-picker look/feel (#91), photo filenames on submit (#92), capture
|
||||||
gated on a tested Woo connection (#93), review UI is always the human-pick flow (#94),
|
gated on a tested Woo connection (#93), review UI is always the human-pick flow (#94),
|
||||||
debug mode (#95), required-photo lock glyph (#100). (#96 Call 1/2 prompt viewer drops
|
debug mode (#95), required-photo lock glyph (#100). (#96 Call 1/2 prompt viewer drops
|
||||||
@@ -100,8 +106,8 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
|
|||||||
|
|
||||||
## Requirements delta (draft — rows written at execution, after acceptance)
|
## Requirements delta (draft — rows written at execution, after acceptance)
|
||||||
|
|
||||||
- **New rows (draft):** tape-line calibration behavior (overlay, draggable ticks,
|
- **New rows:** tape-line calibration behavior (overlay, draggable ticks,
|
||||||
owner-declared length, sanity check). ~1–3 rows under CAPTURE.
|
owner-declared length, tick-distance scale). ~1–3 rows under CAPTURE.
|
||||||
- **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood
|
- **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood
|
||||||
categories (primary + secondary) / edge / figure / grade / moisture / tags by hand,
|
categories (primary + secondary) / edge / figure / grade / moisture / tags by hand,
|
||||||
thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW.
|
thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW.
|
||||||
@@ -124,9 +130,10 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
|
|||||||
|
|
||||||
## Acceptance criteria (draft)
|
## Acceptance criteria (draft)
|
||||||
|
|
||||||
1. **Tape-line calibration + M3 polish ship together (Goal 1):** the `?tape-prototype`
|
1. **Tape-line calibration + M3 polish ship together (Goal 1):** the tape-line calibration
|
||||||
sandbox is replaced by the shipped calibration path (owner drags both ticks; declared
|
ships as a **fixed** centre line the owner pans/zooms/tilts the slab onto (line is never
|
||||||
length = tick distance; as-built length model no longer the source of truth), and the
|
an owner input; crop slider sets its ends; declared length = the real tape inches, and
|
||||||
|
the fixed line represents that same length; no `maskSpanAlong` check), and the
|
||||||
M3 polish issues (#91–#100, excluding #96 which drops with Call 1) are closed with
|
M3 polish issues (#91–#100, excluding #96 which drops with Call 1) are closed with
|
||||||
passing tests and raw runner evidence.
|
passing tests and raw runner evidence.
|
||||||
2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick
|
2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick
|
||||||
@@ -156,11 +163,11 @@ Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
|
|||||||
|
|
||||||
## Task-level decisions (settled during the sprint, not at planning)
|
## Task-level decisions (settled during the sprint, not at planning)
|
||||||
|
|
||||||
These are implementation details the task breakdown and wireframe settle — not plan-level:
|
The only plan-level open item is the exact thumb-first interaction model
|
||||||
the exact thumb-first interaction model (wizard vs single-scroll, chips vs pickers), which
|
(wizard vs single-scroll, chips vs pickers) — settled by the wireframe.
|
||||||
of #91–#100 are M4-must vs nice, whether #99 touches the photos-ready lineup (CAPTURE-130),
|
Settled at kickoff (not reopened here): full #91–#100 polish wave (only #96 drops
|
||||||
and the exit-walk shape (single slab vs QUALITY-110 pair; test-inference is text-only now
|
with Call 1); #99 confined to the calibration step (CAPTURE-130 lineup untouched);
|
||||||
that Call 1 is out).
|
exit walk is a single slab carrying multiple photos.
|
||||||
|
|
||||||
## Task breakdown detail bar
|
## Task breakdown detail bar
|
||||||
|
|
||||||
|
|||||||
@@ -26,23 +26,40 @@ exit (QUALITY-30); agents record raw evidence, never declare green.
|
|||||||
- **Sequence:** first task of the sprint.
|
- **Sequence:** first task of the sprint.
|
||||||
- **Stop:** rows merged + gate green. **Evidence:** runner output.
|
- **Stop:** rows merged + gate green. **Evidence:** runner output.
|
||||||
|
|
||||||
### T2. #99 tape-line calibration (folded into G1)
|
### T2. #99 tape-line calibration (folded into G1) — AS-BUILT, shipped
|
||||||
- **Goal:** G1. **IDs:** CAPTURE (dimensions), REQS-50. **Issues:** #99.
|
- **Goal:** G1. **IDs:** CAPTURE-170, CAPTURE-175, CAPTURE-75. **Issues:** #99 (issue
|
||||||
- **Skills:** `systematic-debugging` (verify the prototype assumption),
|
body still describes the superseded draggable-tick model — this entry is the as-built).
|
||||||
`wireframe-review-iteration` (reference the overlay interaction), `svelte-core-bestpractices`.
|
- **Skills:** `systematic-debugging`, `svelte-core-bestpractices`,
|
||||||
- **Starting point:** `frontend/src/capture/TapePrototype.svelte` + `?tape-prototype`
|
`docker-uat-deploy` (isolate-and-verify the review preview).
|
||||||
sandbox on the `m4` branch; `docs/design/issue-99-tape-line-overlay.html` (approved
|
- **As-built:** a **fixed** black centre line with two end ticks, drawn on the stage
|
||||||
wireframe); `frontend/src/png/crop.ts` (projection logic).
|
fixture. The line is **never an owner input** — not draggable, not focusable, not
|
||||||
- **Work:** promote the prototype to the shipped calibration path (owner drags both
|
keyboard-nudgeable, and not part of the published pose. The **source photo/slab is the
|
||||||
ticks), adopt the as-built length model (maskSpanAlong projection), delete the
|
only thing that moves**: drag to pan, wheel/pinch to zoom, the ↻ handle or arrow keys
|
||||||
sandbox + query-param gating, keep `crop_fill` (already landed in 818874e).
|
(Shift = fine step) to tilt. The owner aligns the *slab* to the fixed line at the place
|
||||||
**Scope (Ty confirmed):** confined to the calibration step — the photos-ready
|
the tape measurement was taken. Typed Length is the real inches read off the tape, and
|
||||||
lineup (CAPTURE-130) stays as-is; #99 does not touch it.
|
the fixed line represents that same length. Scale = fixed-line pixel span / declared
|
||||||
- **Verification:** unit tests in `frontend/src/png/*` (tape geometry), `tsc --noEmit`,
|
Length; `maskSpanAlong` is not the scale source. The crop slider is the **sole** control
|
||||||
manual calibration on one slab at 375px.
|
of the line ends: `crop_fill=90` puts each end 5% from its corresponding edge.
|
||||||
|
Calibration rotation applies to the calibration clean PNG + overlay only; batch
|
||||||
|
siblings stay unrotated.
|
||||||
|
- **Superseded (do not restore):** draggable ticks; `TapePrototype.svelte` and the
|
||||||
|
`?tape-prototype` sandbox (both deleted); `frontend/src/png/crop.ts` (removed in
|
||||||
|
6000ac2 — crop geometry now lives in `frontend/src/capture/tapeLine.ts`
|
||||||
|
`cropEnds(fillPercent)`); the `sveltedrag` dependency (404s on npm, never added).
|
||||||
|
The wireframe `docs/design/issue-99-tape-line-overlay.html` lives on branch
|
||||||
|
`issue-99-tape-line-overlay` (63b8879), not on `m4`.
|
||||||
|
- **Verification:** unit tests in `frontend/src/capture/tapeLine.test.ts` (ends fixed by
|
||||||
|
the crop slider, never an owner input) + `frontend/src/png/{inventory,overlay}.test.ts`
|
||||||
|
(calibration rotation reaches the calibration PNG/overlay only); `tsc --noEmit`;
|
||||||
|
`npm run build`; e2e `frontend/tests/e2e/tape-fixed-line.spec.ts` (no tick drag; a tick
|
||||||
|
drag behaves identically to a pan; ends follow the crop slider).
|
||||||
- **Sequence:** parallel with T3–T5; no dependency on T1.
|
- **Sequence:** parallel with T3–T5; no dependency on T1.
|
||||||
- **Stop:** prototype shipped, `?tape-prototype` removed, tests green.
|
- **Stop:** SHIPPED. UAT-signed off by Ty 2026-09-25 on the M4 review preview
|
||||||
**Evidence:** test output + a 375px capture of the calibration drag.
|
(`http://192.168.1.202:10883/?noload=1`, build `a756cf5`, branch `m4`).
|
||||||
|
- **Evidence:** `npm test` 102/102; requirements + coverage gates green; live e2e on the
|
||||||
|
deployed preview (0°/20° rotation produces differing clean PNG + overlay bytes); render
|
||||||
|
gate clean at 1440/768/375 with no overflow or clipped text; Ty's UAT sign-off.
|
||||||
|
**Not covered:** `svelte-check` still reports its known 42-error baseline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,8 +93,8 @@ exit (QUALITY-30); agents record raw evidence, never declare green.
|
|||||||
wireframe, 2026-09-24) for the visual language + the interaction model.
|
wireframe, 2026-09-24) for the visual language + the interaction model.
|
||||||
- **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms:
|
- **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms:
|
||||||
*no tap-bubble chip clouds for long lists → collapsible searchable checklist*;
|
*no tap-bubble chip clouds for long lists → collapsible searchable checklist*;
|
||||||
leftmost-kept-is-primary for wood (primary + optional secondaries); AI-assist
|
leftmost-kept-is-primary for wood (primary + optional secondaries);
|
||||||
labeled as "suggested, confirm"; every metadata requirement present (primary
|
no AI-assist surface (Call 1 decommissioned); every metadata requirement present (primary
|
||||||
wood-category required, secondaries optional).
|
wood-category required, secondaries optional).
|
||||||
- **Verification:** Ty reviews the served variants and accepts one (375/768/1280).
|
- **Verification:** Ty reviews the served variants and accepts one (375/768/1280).
|
||||||
- **Sequence:** before T5/T6. **Stop:** Ty accepts a variant.
|
- **Sequence:** before T5/T6. **Stop:** Ty accepts a variant.
|
||||||
|
|||||||
@@ -117,3 +117,33 @@ Do not put a host name in the product spec.
|
|||||||
GET slab/taxonomy/settings 200, review 200.
|
GET slab/taxonomy/settings 200, review 200.
|
||||||
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
|
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
|
||||||
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
|
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
|
||||||
|
|
||||||
|
### 2026-09-25 08:57 PDT — M4 review preview: #99 fixed tape line + calibration rotation
|
||||||
|
|
||||||
|
- Source: branch `m4`, commit **`a756cf5`** (`fix(m4): drop stale tick-nudge hint from tape page (#99)`),
|
||||||
|
pushed and verified on `origin/m4`. `matches HEAD`: **yes**. Preceding M4 commits on the same
|
||||||
|
branch: `1882fa3` (calibration tilt applied to the calibration PNG/overlay only) and
|
||||||
|
`7908a99` (tape calibration collapsed onto one page).
|
||||||
|
- Image `deploy-frontend:m4-a756cf5` (`sha256:26402a77…`) built on the 5060 from a clean release
|
||||||
|
archive, **not** from the `~/slab-deploy` greenfield checkout. Container `slabuploader-m4-review`
|
||||||
|
recreated on network `deploy_default`, host port `10883:80`, **zero mounts**.
|
||||||
|
- URL: http://192.168.1.202:10883/?noload=1 (M4 review only). **Not** `10881` — that is the old
|
||||||
|
`main` stack and has no Length page. `10882` is the API, not a UI.
|
||||||
|
- Bundle: `assets/index-BI2cLnOA.js`. Served bundle has **zero** `Drag the ticks` /
|
||||||
|
`nudge the last tick` strings and **does** contain the fixed-line hint.
|
||||||
|
- Main stack untouched: `:10881` and `:10882/api/health` both 200 throughout.
|
||||||
|
- Sample data preserved: **yes** — the review container is volume-free; the real DB was never
|
||||||
|
rebuilt or re-seeded.
|
||||||
|
- Live verification (deployed URL, real backend): the fixed line renders with both end ticks;
|
||||||
|
a drag starting on a tick behaves identically to a pan elsewhere (line does not move); the
|
||||||
|
crop slider moves the line ends; Length/Thickness → Calculate reaches Photos ready. A drag is
|
||||||
|
still allowed (that is the intended pan).
|
||||||
|
- Gates: `npm test` 102/102; `tsc --noEmit` clean; requirements + coverage gates green;
|
||||||
|
render gate clean at 1440/768/375 (no overflow, no clipped text, no page/console/HTTP errors).
|
||||||
|
- Regression guard proven to fail against pre-fix code, not just pass post-fix.
|
||||||
|
- Ty UAT sign-off: **length e2e signed off 2026-09-25**; minor bugs to be filed separately.
|
||||||
|
- Not done: `svelte-check` still reports its known 42-error baseline (no new error from this work);
|
||||||
|
`Calculate` (42px) and `Fit` (41px) are marginally under the 44px touch reference — pre-existing,
|
||||||
|
deliberately left for a separate decision; Gitea #99's **body** still describes the superseded
|
||||||
|
draggable-tick model (the as-built is recorded in `tasks.md` T2 and requirements rows
|
||||||
|
CAPTURE-170/175/75).
|
||||||
|
|||||||
+29
-1
@@ -41,8 +41,36 @@ OPS-40,
|
|||||||
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105, QUALITY-110,
|
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105, QUALITY-110,
|
||||||
QUALITY-120, QUALITY-125
|
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
|
## Later
|
||||||
|
|
||||||
IDs:
|
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
|
||||||
|
|||||||
@@ -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-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-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-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/capture/tapeLine.test.ts |
|
||||||
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
|
| 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-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 ends of the fixed tape line 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 draws one black tape-line fixed in the middle of the 3:4 frame, with a tick at each end. The line is a stage fixture: it is not editable, and its ends are set by the crop-fill setting alone (at 90 percent each end sits 5 percent in from the frame edge). The owner pans, zooms, and rotates the slab until its long axis lies along the line. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||||
|
| CAPTURE-175 | The owner-declared length is the tape reading taken where the slab was aligned to the line; scale equals the fixed line's pixel distance between its two ends, in source-photo coordinates, divided by that declared length. Mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | 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-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-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 |
|
| 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-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-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-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-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-95 | The dimension-overlay image is never sent to the model. | M3 | — |
|
||||||
| METADATA-70 | With inference off, the server produces the long description. | 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-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-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-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
|
## 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-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-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-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
|
## 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-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-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-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-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-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 |
|
| 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-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-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-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 | — |
|
||||||
|
|||||||
+3
-1
@@ -42,6 +42,8 @@ remain the point of record, and `.planning/` files cite requirement IDs without
|
|||||||
| `.planning/MILESTONES.md` | history |
|
| `.planning/MILESTONES.md` | history |
|
||||||
| `.planning/milestones/v0.5/M3/roadmap.md` | roadmap |
|
| `.planning/milestones/v0.5/M3/roadmap.md` | roadmap |
|
||||||
| `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown |
|
| `.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:
|
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 |
|
| 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.
|
- `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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
Generated
-10
@@ -8,7 +8,6 @@
|
|||||||
"name": "slabuploader-frontend",
|
"name": "slabuploader-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/svelte": "2.3.3",
|
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
@@ -496,15 +495,6 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"test:e2e:headed": "playwright test --headed"
|
"test:e2e:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/svelte": "2.3.3",
|
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-20
@@ -12,14 +12,13 @@
|
|||||||
import AxisPanel from './capture/AxisPanel.svelte';
|
import AxisPanel from './capture/AxisPanel.svelte';
|
||||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||||
import Settings from './create/settings/Settings.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
|
|
||||||
import SlabList from './create/slabs/SlabList.svelte';
|
import SlabList from './create/slabs/SlabList.svelte';
|
||||||
import Review from './create/review/Review.svelte';
|
import Review from './create/review/Review.svelte';
|
||||||
import Publish from './create/publish/Publish.svelte';
|
import Publish from './create/publish/Publish.svelte';
|
||||||
import {
|
import {
|
||||||
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||||
} from './create/flow';
|
} 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';
|
import { clearDraft } from './draft/idb';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
@@ -27,7 +26,6 @@
|
|||||||
|
|
||||||
let cap: Capture = { k: 'batch', photos: [] };
|
let cap: Capture = { k: 'batch', photos: [] };
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let pngUrl: string | null = null;
|
|
||||||
let showSettings = false;
|
let showSettings = false;
|
||||||
let showSlabs = false;
|
let showSlabs = false;
|
||||||
|
|
||||||
@@ -38,21 +36,20 @@
|
|||||||
|
|
||||||
const unsubCap = session.capture.subscribe((c) => {
|
const unsubCap = session.capture.subscribe((c) => {
|
||||||
cap = c;
|
cap = c;
|
||||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
|
||||||
});
|
});
|
||||||
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||||
|
|
||||||
|
let cropFill = 90;
|
||||||
|
|
||||||
onMount(() => {
|
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);
|
const boot = new URLSearchParams(window.location.search);
|
||||||
if (!boot.get('noload')) void session.resumeProbe();
|
if (!boot.get('noload')) void session.resumeProbe();
|
||||||
// #99: the global crop fill drives both the reference line and the PNG crop.
|
void getSettings().then((s) => {
|
||||||
// Falls back to the 90% default when settings are unreachable.
|
if (typeof s.crop_fill === 'number') {
|
||||||
void getSettings()
|
cropFill = s.crop_fill;
|
||||||
.then((s) => session.setCropFill(s.crop_fill ?? 90))
|
session.setCropFill(s.crop_fill);
|
||||||
.catch(() => {});
|
}
|
||||||
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubCap();
|
unsubCap();
|
||||||
@@ -222,18 +219,20 @@
|
|||||||
{:else if cap.k === 'axis'}
|
{:else if cap.k === 'axis'}
|
||||||
<AxisPanel
|
<AxisPanel
|
||||||
state={cap}
|
state={cap}
|
||||||
setTapeLine={session.setTapeLine}
|
{cropFill}
|
||||||
approveAxis={session.approveAxis}
|
setTapePose={session.setTapePose}
|
||||||
|
setCropFill={(pct) => {
|
||||||
|
cropFill = pct;
|
||||||
|
session.setCropFill(pct);
|
||||||
|
void updateSettings({ crop_fill: pct }).catch(() => {});
|
||||||
|
}}
|
||||||
|
calculateFromAxis={session.calculateFromAxis}
|
||||||
back={session.back}
|
back={session.back}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else if cap.k === 'measure'}
|
||||||
<MeasurePanel
|
<MeasurePanel
|
||||||
state={cap}
|
state={cap}
|
||||||
setLength={session.setLength}
|
|
||||||
setThickness={session.setThickness}
|
|
||||||
calculate={session.calculate}
|
|
||||||
back={session.back}
|
back={session.back}
|
||||||
{pngUrl}
|
|
||||||
onContinue={saving ? undefined : () => void continueToReview()}
|
onContinue={saving ? undefined : () => void continueToReview()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -266,7 +265,6 @@
|
|||||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
}
|
}
|
||||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||||
|
|
||||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||||
.hardstop h1 { font-size: 1.6rem; }
|
.hardstop h1 { font-size: 1.6rem; }
|
||||||
.detail { color: #888; font-size: 0.85rem; }
|
.detail { color: #888; font-size: 0.85rem; }
|
||||||
|
|||||||
@@ -1,251 +1,385 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type { Capture, TapeLine } from './capture.types';
|
import type { Capture, PhotoTransform } from './capture.types';
|
||||||
|
import { eighthsToIn, inToEighths } from './capture.types';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
import { photoToScreen, screenToPhoto, type PhotoPoint, type PhotoViewTransform } from '../png/crop';
|
import {
|
||||||
|
CALIBRATION_FRAME,
|
||||||
|
IDENTITY_TRANSFORM,
|
||||||
|
cropEnds,
|
||||||
|
fitScale,
|
||||||
|
} from './tapeLine';
|
||||||
|
|
||||||
export let state: Extract<Capture, { k: 'axis' }>;
|
export let state: Extract<Capture, { k: 'axis' }>;
|
||||||
export let setTapeLine: (tapeLine: TapeLine) => void;
|
export let cropFill = 90;
|
||||||
export let approveAxis: () => void;
|
export let setTapePose: (pose: {
|
||||||
|
transform: PhotoTransform;
|
||||||
|
sourceW: number;
|
||||||
|
sourceH: number;
|
||||||
|
}) => void;
|
||||||
|
export let setCropFill: (pct: number) => void = () => {};
|
||||||
|
export let calculateFromAxis: (lengthEighths: number, thicknessEighths: number) => Promise<void>;
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
|
|
||||||
let stage: HTMLDivElement | undefined;
|
const frame = CALIBRATION_FRAME;
|
||||||
|
let stageEl: HTMLDivElement | undefined;
|
||||||
let canvas: HTMLCanvasElement | undefined;
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
let bitmap: ImageBitmap | null = null;
|
let bitmap: ImageBitmap | null = null;
|
||||||
|
let maskLayer: HTMLCanvasElement | null = null;
|
||||||
let sourceW = state.source.widthPx;
|
let sourceW = state.source.widthPx;
|
||||||
let sourceH = state.source.heightPx;
|
let sourceH = state.source.heightPx;
|
||||||
let stageW = 0;
|
let renderedW = 320;
|
||||||
let stageH = 0;
|
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? IDENTITY_TRANSFORM) };
|
||||||
let tickA: PhotoPoint = [...state.axis.tapeLine.start];
|
// The line is a stage fixture. Its two ends are the crop insets, so they are
|
||||||
let tickB: PhotoPoint = [...state.axis.tapeLine.end];
|
// a pure function of crop_fill and are never an owner input (#99).
|
||||||
let angleDeg = 0;
|
let fill = cropFill;
|
||||||
let zoom = 1;
|
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||||
let panX = 0;
|
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||||
let panY = 0;
|
let calcBusy = false;
|
||||||
let dragging: 'start' | 'end' | 'pan' | null = null;
|
let drag: 'tilt' | 'pan' | 'pinch' | null = null;
|
||||||
let selected: 'start' | 'end' = 'start';
|
let pointers = new Map<number, [number, number]>();
|
||||||
let lastPointer: PhotoPoint = [0, 0];
|
let pinch0 = 0;
|
||||||
|
let zoom0 = 1;
|
||||||
|
let panGrab: [number, number] = [0, 0];
|
||||||
|
let tilt0 = 0;
|
||||||
|
let tiltX0 = 0;
|
||||||
|
let moved = false;
|
||||||
|
let downAt: [number, number] = [0, 0];
|
||||||
|
let lastTap: { t: number; x: number; y: number } | null = null;
|
||||||
|
|
||||||
$: tickDistance = Math.hypot(tickB[0] - tickA[0], tickB[1] - tickA[1]);
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
function fittedScale(): number {
|
function publish() {
|
||||||
if (!(stageW > 0 && stageH > 0 && sourceW > 0 && sourceH > 0)) return 0;
|
setTapePose({
|
||||||
return Math.min(stageW / sourceW, stageH / sourceH);
|
transform: { ...transform },
|
||||||
|
sourceW,
|
||||||
|
sourceH,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function view(): PhotoViewTransform {
|
function toFrame(event: { clientX: number; clientY: number }): [number, number] {
|
||||||
return {
|
const rect = canvas!.getBoundingClientRect();
|
||||||
width: sourceW,
|
renderedW = rect.width;
|
||||||
height: sourceH,
|
return [
|
||||||
centerX: stageW / 2,
|
((event.clientX - rect.left) / rect.width) * frame.width,
|
||||||
centerY: stageH / 2,
|
((event.clientY - rect.top) / rect.height) * frame.height,
|
||||||
scale: fittedScale(),
|
];
|
||||||
zoom,
|
|
||||||
angleDeg,
|
|
||||||
panX,
|
|
||||||
panY,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw(): void {
|
function draw() {
|
||||||
if (!canvas || !stageW || !stageH) return;
|
if (!canvas || !stageEl) return;
|
||||||
|
const rect = stageEl.getBoundingClientRect();
|
||||||
|
renderedW = rect.width || renderedW;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
canvas.width = Math.round(stageW * dpr);
|
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||||
canvas.height = Math.round(stageH * dpr);
|
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||||
canvas.style.width = `${stageW}px`;
|
|
||||||
canvas.style.height = `${stageH}px`;
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform((dpr * rect.width) / frame.width, 0, 0, (dpr * rect.height) / frame.height, 0, 0);
|
||||||
ctx.fillStyle = '#161616';
|
ctx.fillStyle = '#111';
|
||||||
ctx.fillRect(0, 0, stageW, stageH);
|
ctx.fillRect(0, 0, frame.width, frame.height);
|
||||||
const transform = view();
|
if (bitmap && sourceW > 0 && sourceH > 0) {
|
||||||
if (bitmap && transform.scale > 0) {
|
const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(transform.centerX + panX, transform.centerY + panY);
|
ctx.translate(frame.width / 2 + transform.panX, frame.height / 2 + transform.panY);
|
||||||
ctx.rotate((angleDeg * Math.PI) / 180);
|
ctx.rotate((transform.tiltDeg * Math.PI) / 180);
|
||||||
ctx.scale(transform.scale * zoom, transform.scale * zoom);
|
ctx.scale(scale, scale);
|
||||||
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
|
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
|
||||||
|
if (maskLayer) {
|
||||||
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
|
ctx.drawImage(maskLayer, -sourceW / 2, -sourceH / 2);
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
const x = frame.width / 2;
|
||||||
const a = photoToScreen(tickA, transform);
|
const ends = cropEnds(fill);
|
||||||
const b = photoToScreen(tickB, transform);
|
|
||||||
const dx = b[0] - a[0];
|
|
||||||
const dy = b[1] - a[1];
|
|
||||||
const length = Math.hypot(dx, dy) || 1;
|
|
||||||
const nx = -dy / length;
|
|
||||||
const ny = dx / length;
|
|
||||||
ctx.save();
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
|
||||||
ctx.lineWidth = 6;
|
|
||||||
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
|
|
||||||
ctx.strokeStyle = '#000';
|
ctx.strokeStyle = '#000';
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
|
|
||||||
for (const [point, which] of [[a, 'start'], [b, 'end']] as const) {
|
|
||||||
if (which === selected) {
|
|
||||||
ctx.strokeStyle = '#ffd600';
|
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath(); ctx.arc(point[0], point[1], 16, 0, Math.PI * 2); ctx.stroke();
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, ends.startY);
|
||||||
|
ctx.lineTo(x, ends.endY);
|
||||||
|
ctx.stroke();
|
||||||
|
for (const y of [ends.startY, ends.endY]) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - 13, y);
|
||||||
|
ctx.lineTo(x + 13, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
}
|
}
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
|
ctx.fillStyle = '#eee';
|
||||||
ctx.lineWidth = 5;
|
ctx.beginPath();
|
||||||
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
|
ctx.arc(x, frame.height - 28, 16, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = '#000';
|
ctx.fill();
|
||||||
ctx.lineWidth = 2.5;
|
|
||||||
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
|
|
||||||
ctx.fillStyle = which === 'start' ? '#ffd600' : '#ff3d71';
|
|
||||||
ctx.beginPath(); ctx.arc(point[0], point[1], 5, 0, Math.PI * 2); ctx.fill();
|
|
||||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointerPosition(event: PointerEvent): PhotoPoint {
|
function reseat() {
|
||||||
const rect = canvas!.getBoundingClientRect();
|
// The line's ends follow crop_fill; nothing about them is owner-draggable.
|
||||||
return [event.clientX - rect.left, event.clientY - rect.top];
|
publish();
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerDown(event: PointerEvent): void {
|
|
||||||
const point = pointerPosition(event);
|
|
||||||
const transform = view();
|
|
||||||
const a = photoToScreen(tickA, transform);
|
|
||||||
const b = photoToScreen(tickB, transform);
|
|
||||||
const hitA = Math.hypot(point[0] - a[0], point[1] - a[1]) < 28;
|
|
||||||
const hitB = Math.hypot(point[0] - b[0], point[1] - b[1]) < 28;
|
|
||||||
dragging = hitA ? 'start' : hitB ? 'end' : 'pan';
|
|
||||||
if (dragging !== 'pan') selected = dragging;
|
|
||||||
lastPointer = point;
|
|
||||||
canvas?.setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMove(event: PointerEvent): void {
|
|
||||||
if (!dragging) return;
|
|
||||||
const point = pointerPosition(event);
|
|
||||||
if (dragging === 'pan') {
|
|
||||||
panX += point[0] - lastPointer[0];
|
|
||||||
panY += point[1] - lastPointer[1];
|
|
||||||
} else {
|
|
||||||
const sourcePoint = screenToPhoto(point, view());
|
|
||||||
sourcePoint[0] = Math.max(0, Math.min(sourceW, sourcePoint[0]));
|
|
||||||
sourcePoint[1] = Math.max(0, Math.min(sourceH, sourcePoint[1]));
|
|
||||||
if (dragging === 'start') tickA = sourcePoint;
|
|
||||||
else tickB = sourcePoint;
|
|
||||||
}
|
|
||||||
lastPointer = point;
|
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(event: PointerEvent): void {
|
function zoomAt(point: [number, number], factor: number) {
|
||||||
if (dragging === 'start' || dragging === 'end') {
|
const z1 = clamp(transform.zoom * factor, 0.5, 3);
|
||||||
setTapeLine({ start: [...tickA], end: [...tickB] });
|
if (z1 === transform.zoom) return;
|
||||||
}
|
const cx = frame.width / 2;
|
||||||
dragging = null;
|
const cy = frame.height / 2;
|
||||||
canvas?.releasePointerCapture(event.pointerId);
|
const qx = cx + (point[0] - cx - transform.panX) / transform.zoom;
|
||||||
}
|
const qy = cy + (point[1] - cy - transform.panY) / transform.zoom;
|
||||||
|
transform = {
|
||||||
function zoomAt(x: number, y: number, factor: number): void {
|
...transform,
|
||||||
const before = screenToPhoto([x, y], view());
|
zoom: z1,
|
||||||
zoom = Math.max(0.5, Math.min(5, zoom * factor));
|
panX: point[0] - cx - z1 * (qx - cx),
|
||||||
const after = photoToScreen(before, view());
|
panY: point[1] - cy - z1 * (qy - cy),
|
||||||
panX += x - after[0];
|
};
|
||||||
panY += y - after[1];
|
publish();
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWheel(event: WheelEvent): void {
|
function fit() {
|
||||||
|
transform = { ...IDENTITY_TRANSFORM, tiltDeg: transform.tiltDeg };
|
||||||
|
publish();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent) {
|
||||||
|
const p = toFrame(event);
|
||||||
|
pointers.set(event.pointerId, p);
|
||||||
|
canvas!.setPointerCapture(event.pointerId);
|
||||||
|
moved = false;
|
||||||
|
downAt = p;
|
||||||
|
if (pointers.size >= 2) {
|
||||||
|
const pts = [...pointers.values()];
|
||||||
|
pinch0 = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
|
||||||
|
zoom0 = transform.zoom;
|
||||||
|
drag = 'pinch';
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rect = canvas!.getBoundingClientRect();
|
|
||||||
zoomAt(event.clientX - rect.left, event.clientY - rect.top, Math.exp(-event.deltaY * 0.0015));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetView(): void {
|
|
||||||
angleDeg = 0;
|
|
||||||
zoom = 1;
|
|
||||||
panX = 0;
|
|
||||||
panY = 0;
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let alive = true;
|
|
||||||
const resize = new ResizeObserver((entries) => {
|
|
||||||
const entry = entries[0];
|
|
||||||
if (!entry) return;
|
|
||||||
stageW = entry.contentRect.width;
|
|
||||||
stageH = entry.contentRect.height;
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
if (stage) resize.observe(stage);
|
|
||||||
void decodeOrientedBitmap(state.source.blob).then((decoded) => {
|
|
||||||
if (!alive) {
|
|
||||||
decoded.close();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bitmap = decoded;
|
const x = frame.width / 2;
|
||||||
sourceW = decoded.width;
|
if (Math.hypot(p[0] - x, p[1] - (frame.height - 28)) <= 22) {
|
||||||
sourceH = decoded.height;
|
drag = 'tilt';
|
||||||
|
tilt0 = transform.tiltDeg;
|
||||||
|
tiltX0 = p[0];
|
||||||
|
} else {
|
||||||
|
drag = 'pan';
|
||||||
|
panGrab = [p[0] - transform.panX, p[1] - transform.panY];
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
const p = toFrame(event);
|
||||||
|
pointers.set(event.pointerId, p);
|
||||||
|
if (!drag) return;
|
||||||
|
if (Math.hypot(p[0] - downAt[0], p[1] - downAt[1]) > 3) moved = true;
|
||||||
|
if (drag === 'pinch' && pointers.size >= 2) {
|
||||||
|
const pts = [...pointers.values()];
|
||||||
|
const dist = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) || 1;
|
||||||
|
const mid: [number, number] = [(pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2];
|
||||||
|
transform = { ...transform, zoom: zoom0 };
|
||||||
|
zoomAt(mid, clamp(dist / pinch0, 0.8, 1.25));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (drag === 'tilt') {
|
||||||
|
transform = { ...transform, tiltDeg: tilt0 + (p[0] - tiltX0) * 0.6 };
|
||||||
|
} else if (drag === 'pan') {
|
||||||
|
transform = {
|
||||||
|
...transform,
|
||||||
|
panX: clamp(p[0] - panGrab[0], -400, 400),
|
||||||
|
panY: clamp(p[1] - panGrab[1], -400, 400),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
publish();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: PointerEvent) {
|
||||||
|
const p = toFrame(event);
|
||||||
|
pointers.delete(event.pointerId);
|
||||||
|
const wasPan = drag === 'pan';
|
||||||
|
drag = pointers.size ? drag : null;
|
||||||
|
if (wasPan && !moved) {
|
||||||
|
const now = performance.now();
|
||||||
|
if (lastTap && now - lastTap.t < 350 && Math.hypot(p[0] - lastTap.x, p[1] - lastTap.y) < 12) {
|
||||||
|
lastTap = null;
|
||||||
|
fit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTap = { t: now, x: p[0], y: p[1] };
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const dy = clamp(event.deltaY, -200, 200);
|
||||||
|
zoomAt(toFrame(event), clamp(Math.exp(-dy * 0.0015), 0.8, 1.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
const tag = (event.target as HTMLElement | null)?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
|
if (event.key === '+' || event.key === '=') {
|
||||||
|
zoomAt([frame.width / 2, frame.height / 2], 1.1);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === '-' || event.key === '_') {
|
||||||
|
zoomAt([frame.width / 2, frame.height / 2], 1 / 1.1);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||||
|
const step = event.shiftKey ? 0.1 : 1;
|
||||||
|
transform = { ...transform, tiltDeg: transform.tiltDeg + (event.key === 'ArrowLeft' ? -step : step) };
|
||||||
|
publish();
|
||||||
|
draw();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFill(event: Event) {
|
||||||
|
fill = Number((event.currentTarget as HTMLInputElement).value);
|
||||||
|
setCropFill(fill);
|
||||||
|
reseat();
|
||||||
|
}
|
||||||
|
|
||||||
|
const positiveInches = (raw: string): number | null => {
|
||||||
|
const inches = Number.parseFloat(raw);
|
||||||
|
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitInches = (raw: string): number => {
|
||||||
|
const inches = positiveInches(raw);
|
||||||
|
return inches === null ? 0 : inToEighths(inches);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onCalculate() {
|
||||||
|
const length = commitInches(String(lengthIn));
|
||||||
|
const thickness = commitInches(String(thicknessIn));
|
||||||
|
lengthIn = length === 0 ? '' : String(eighthsToIn(length));
|
||||||
|
thicknessIn = thickness === 0 ? '' : String(eighthsToIn(thickness));
|
||||||
|
if (length === 0 || thickness === 0) return;
|
||||||
|
calcBusy = true;
|
||||||
|
try {
|
||||||
|
publish();
|
||||||
|
await calculateFromAxis(length, thickness);
|
||||||
|
} finally {
|
||||||
|
calcBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const bmp = await decodeOrientedBitmap(state.source.blob);
|
||||||
|
bitmap = bmp;
|
||||||
|
sourceW = bmp.width;
|
||||||
|
sourceH = bmp.height;
|
||||||
|
const bitset = state.approvedMask.bitset;
|
||||||
|
if (bitset.length === sourceW * sourceH) {
|
||||||
|
maskLayer = document.createElement('canvas');
|
||||||
|
maskLayer.width = sourceW;
|
||||||
|
maskLayer.height = sourceH;
|
||||||
|
const mctx = maskLayer.getContext('2d');
|
||||||
|
if (mctx) {
|
||||||
|
const data = mctx.createImageData(sourceW, sourceH);
|
||||||
|
for (let i = 0; i < bitset.length; i++) {
|
||||||
|
if (bitset[i]) data.data[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
mctx.putImageData(data, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish();
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
return () => {
|
|
||||||
alive = false;
|
onDestroy(() => bitmap?.close());
|
||||||
resize.disconnect();
|
|
||||||
bitmap?.close();
|
|
||||||
bitmap = null;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKey} />
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Tape-line calibration</h1>
|
<h1>Length</h1>
|
||||||
<p class="hint">Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.</p>
|
<p class="hint">Move the slab until its long axis sits on the line, the way it lines up with the tape. The line is fixed in the middle; its ends are set by the crop slider and do not move.</p>
|
||||||
<div class="stage" bind:this={stage}>
|
<div class="stage" bind:this={stageEl} data-testid="tape-line">
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={canvas}
|
bind:this={canvas}
|
||||||
aria-label="Calibration photo with draggable tape endpoint ticks"
|
|
||||||
on:pointerdown={onPointerDown}
|
on:pointerdown={onPointerDown}
|
||||||
on:pointermove={onPointerMove}
|
on:pointermove={onPointerMove}
|
||||||
on:pointerup={onPointerUp}
|
on:pointerup={onPointerUp}
|
||||||
on:pointercancel={onPointerUp}
|
on:pointercancel={onPointerUp}
|
||||||
on:wheel|nonpassive={onWheel}
|
on:wheel={onWheel}
|
||||||
></canvas>
|
></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
|
||||||
<label for="photo-angle">Rotate photo</label>
|
<p class="ctl">Tilt</p>
|
||||||
<input id="photo-angle" type="range" min="-180" max="180" step="0.1" value={angleDeg}
|
|
||||||
on:input={(event) => { angleDeg = Number(event.currentTarget.value); draw(); }} />
|
|
||||||
<span class="value">{angleDeg.toFixed(1)}°</span>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<label for="photo-zoom">Zoom</label>
|
|
||||||
<input id="photo-zoom" type="range" min="0.5" max="5" step="0.05" value={zoom}
|
|
||||||
on:input={(event) => { zoom = Number(event.currentTarget.value); draw(); }} />
|
|
||||||
<span class="value">{Math.round(zoom * 100)}%</span>
|
|
||||||
<button type="button" class="minor" on:click={resetView}>Reset view</button>
|
|
||||||
</div>
|
|
||||||
<p class="tick-distance">Tick distance in source photo: {tickDistance.toFixed(1)} px</p>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<button type="button" class="thumb" aria-label="Tilt counter-clockwise" on:click={() => { transform = { ...transform, tiltDeg: transform.tiltDeg - 1 }; publish(); draw(); }}>−</button>
|
||||||
|
<input type="range" min="-180" max="180" step="0.1" aria-label="Tilt" value={transform.tiltDeg} on:input={(e) => { transform = { ...transform, tiltDeg: Number(e.currentTarget.value) }; publish(); draw(); }} />
|
||||||
|
<button type="button" class="thumb" aria-label="Tilt clockwise" on:click={() => { transform = { ...transform, tiltDeg: transform.tiltDeg + 1 }; publish(); draw(); }}>+</button>
|
||||||
|
<span class="val">{transform.tiltDeg.toFixed(1)}°</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="ctl">Zoom</p>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => zoomAt([frame.width / 2, frame.height / 2], 1 / 1.1)}>−</button>
|
||||||
|
<input type="range" min="50" max="300" step="1" aria-label="Zoom" value={Math.round(transform.zoom * 100)} on:input={(e) => { const z = Number(e.currentTarget.value) / 100; zoomAt([frame.width / 2, frame.height / 2], z / Math.max(transform.zoom, 0.01)); }} />
|
||||||
|
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => zoomAt([frame.width / 2, frame.height / 2], 1.1)}>+</button>
|
||||||
|
<span class="val">{Math.round(transform.zoom * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ghost fit" on:click={fit}>Fit</button>
|
||||||
|
<p class="nudge">Wheel or pinch zooms the slab, drag pans it, and the handle tilts it. Arrow keys tilt; Shift is a fine step. Double-tap the slab to fit.</p>
|
||||||
|
|
||||||
|
<p class="ctl">Crop fill</p>
|
||||||
|
<div class="row">
|
||||||
|
<input type="range" min="1" max="100" step="1" aria-label="Crop fill percent" value={fill} on:input={onFill} />
|
||||||
|
<span class="val">{fill}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fields">
|
||||||
|
<label class="field">
|
||||||
|
<span class="ctl">Length L (tape reading)</span>
|
||||||
|
<input type="number" aria-label="Length L" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={lengthIn} />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="ctl">Thickness</span>
|
||||||
|
<input type="number" aria-label="Thickness" inputmode="decimal" step="0.125" min="0" placeholder="0" bind:value={thicknessIn} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
<button type="button" class="primary" disabled={tickDistance < 4} on:click={approveAxis}>Use tape line</button>
|
<button type="button" class="primary" disabled={calcBusy || positiveInches(String(lengthIn)) === null || positiveInches(String(thicknessIn)) === null} on:click={onCalculate}>
|
||||||
|
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.panel { padding: 16px; color: #eee; }
|
.panel { padding: 16px; color: #eee; }
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.45; }
|
.hint, .nudge { color: #aaa; font-size: 0.85rem; line-height: 1.4; margin: 0 0 12px; }
|
||||||
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
|
.ctl { color: #aaa; font-size: 0.78rem; margin: 14px 0 6px; }
|
||||||
canvas { display: block; touch-action: none; cursor: grab; }
|
.stage { width: 100%; aspect-ratio: 3 / 4; background: #111; border: 1px solid #333; border-radius: 3px; overflow: hidden; touch-action: none; }
|
||||||
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
|
.row { display: flex; align-items: center; gap: 10px; }
|
||||||
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
|
.thumb {
|
||||||
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
width: 44px; height: 44px; border-radius: 3px; font-size: 1.3rem;
|
||||||
.minor { min-height: 40px; border: 1px solid #444; background: #1d1d1d; color: #ccc; padding: 8px 10px; }
|
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||||
.tick-distance { margin: 10px 0 0; color: #aaa; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
|
}
|
||||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
input[type='range'] { flex: 1; }
|
||||||
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.fit { margin-top: 8px; }
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.fields { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
|
.field { flex: 1; }
|
||||||
|
.field input {
|
||||||
|
box-sizing: border-box; width: 100%; min-height: 44px; padding: 10px; font-size: 1rem;
|
||||||
|
border-radius: 3px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||||
|
}
|
||||||
.primary:disabled { background: #333; color: #777; }
|
.primary:disabled { background: #333; color: #777; }
|
||||||
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
|
.primary {
|
||||||
|
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
|
||||||
|
background: #00e5ff; color: #002; font-weight: 700;
|
||||||
|
}
|
||||||
|
.ghost { padding: 12px 16px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import type { Capture } from './capture.types';
|
import type { Capture } from './capture.types';
|
||||||
import { inToEighths, eighthsToIn } from './capture.types';
|
|
||||||
import { fmtEighths } from './session';
|
import { fmtEighths } from './session';
|
||||||
import { calibrationScale } from '../png/crop';
|
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
|
||||||
import {
|
import {
|
||||||
buildPhotosReadyLineup,
|
buildPhotosReadyLineup,
|
||||||
photosReadyPngAlt,
|
photosReadyPngAlt,
|
||||||
@@ -12,22 +9,13 @@
|
|||||||
} from './photosReadyLineup';
|
} from './photosReadyLineup';
|
||||||
|
|
||||||
export let state: Extract<Capture, { k: 'measure' }>;
|
export let state: Extract<Capture, { k: 'measure' }>;
|
||||||
export let setLength: (e: number) => void;
|
|
||||||
export let setThickness: (e: number) => void;
|
|
||||||
export let calculate: () => Promise<void>;
|
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
export let pngUrl: string | null;
|
|
||||||
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
||||||
export let onContinue: (() => void) | undefined = undefined;
|
export let onContinue: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
let calcBusy = false;
|
|
||||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
|
||||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
|
||||||
|
|
||||||
let lineupUrls: string[] = [];
|
let lineupUrls: string[] = [];
|
||||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||||
|
|
||||||
$: photosReady = !!state.results;
|
|
||||||
$: {
|
$: {
|
||||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||||
lineupUrls = [];
|
lineupUrls = [];
|
||||||
@@ -49,99 +37,17 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||||
});
|
});
|
||||||
|
|
||||||
const positiveInches = (raw: string): number | null => {
|
|
||||||
const inches = Number.parseFloat(raw);
|
|
||||||
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);
|
|
||||||
setLength(eighths);
|
|
||||||
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
|
||||||
};
|
|
||||||
const commitThickness = () => {
|
|
||||||
const inches = positiveInches(String(thicknessIn));
|
|
||||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
|
||||||
setThickness(eighths);
|
|
||||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
|
||||||
};
|
|
||||||
|
|
||||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null && tapeScale > 0;
|
|
||||||
|
|
||||||
const doCalc = async () => {
|
|
||||||
calcBusy = true;
|
|
||||||
try {
|
|
||||||
commitLength();
|
|
||||||
commitThickness();
|
|
||||||
await calculate();
|
|
||||||
} finally { calcBusy = false; }
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
<h1>Photos ready</h1>
|
||||||
|
|
||||||
{#if !photosReady}
|
|
||||||
<PhotoStage
|
|
||||||
blob={state.source.blob}
|
|
||||||
bbox={state.axis.bbox}
|
|
||||||
maskBitset={state.approvedMask.bitset}
|
|
||||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
|
||||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="fields">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Tape length L (distance between ticks)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
aria-label="Length L"
|
|
||||||
inputmode="decimal"
|
|
||||||
step="0.125"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
bind:value={lengthIn}
|
|
||||||
on:blur={commitLength}
|
|
||||||
on:change={commitLength}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Thickness</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
aria-label="Thickness"
|
|
||||||
inputmode="decimal"
|
|
||||||
step="0.125"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
bind:value={thicknessIn}
|
|
||||||
on:blur={commitThickness}
|
|
||||||
on:change={commitThickness}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="scale">Tape ticks: {Math.hypot(state.axis.tapeLine.end[0] - state.axis.tapeLine.start[0], state.axis.tapeLine.end[1] - state.axis.tapeLine.start[1]).toFixed(1)} source-photo px · Scale {tapeScale.toFixed(2)} px/in</p>
|
|
||||||
|
|
||||||
|
{#if !state.results}
|
||||||
|
<p class="hint">Calculating…</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="primary"
|
|
||||||
disabled={calcBusy || !canCalc}
|
|
||||||
on:click={doCalc}
|
|
||||||
>
|
|
||||||
{calcBusy ? 'Calculating…' : 'Calculate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if state.results}
|
{:else}
|
||||||
<div class="results" aria-label="Photos ready">
|
<div class="results" aria-label="Photos ready">
|
||||||
<div class="nums">
|
<div class="nums">
|
||||||
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
||||||
@@ -177,12 +83,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
|
||||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
|
|
||||||
<div class="png-wrap">
|
|
||||||
<img src={pngUrl} alt="Inventory PNG" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -199,15 +99,6 @@
|
|||||||
<style>
|
<style>
|
||||||
.panel { padding: 16px; color: #eee; }
|
.panel { padding: 16px; color: #eee; }
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||||
.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;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||||
.primary {
|
.primary {
|
||||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||||
@@ -238,6 +129,4 @@
|
|||||||
}
|
}
|
||||||
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
|
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
|
||||||
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
|
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
|
||||||
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
|
|
||||||
.png-wrap img { max-width: 100%; border-radius: 4px; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -43,20 +43,25 @@ export interface Mask {
|
|||||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||||
bbox: BBox;
|
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; the PNG crop bounds (#99)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The owner-positioned start/end points of the tape in oriented source pixels. */
|
export interface PhotoTransform {
|
||||||
export interface TapeLine {
|
tiltDeg: number;
|
||||||
start: [number, number];
|
zoom: number;
|
||||||
end: [number, number];
|
panX: number;
|
||||||
|
panY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
// The tape line is a stage fixture. bbox is kept for the dimension overlay.
|
||||||
// corners[3] → corners[0] (the long side).
|
// Scale is the source-pixel distance between tapeStart and tapeEnd.
|
||||||
export interface Axis {
|
export interface Axis {
|
||||||
bbox: BBox;
|
bbox: BBox;
|
||||||
tapeLine: TapeLine;
|
photoTransform: PhotoTransform;
|
||||||
|
tickStartY: number;
|
||||||
|
tickEndY: number;
|
||||||
|
tapeStart: [number, number];
|
||||||
|
tapeEnd: [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Widths {
|
export interface Widths {
|
||||||
@@ -103,7 +108,9 @@ export type Capture =
|
|||||||
k: 'axis';
|
k: 'axis';
|
||||||
source: Source;
|
source: Source;
|
||||||
approvedMask: Mask; // frozen footprint
|
approvedMask: Mask; // frozen footprint
|
||||||
axis: Axis; // live axis.bbox being nudged
|
axis: Axis; // live tape line
|
||||||
|
lengthEighths: Eighths;
|
||||||
|
thicknessEighths: Eighths;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
k: 'measure';
|
k: 'measure';
|
||||||
@@ -145,7 +152,11 @@ export interface Draft {
|
|||||||
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
||||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||||
tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts
|
photoTransform?: PhotoTransform;
|
||||||
|
tickStartY?: number;
|
||||||
|
tickEndY?: number;
|
||||||
|
tapeStart?: [number, number];
|
||||||
|
tapeEnd?: [number, number];
|
||||||
lengthEighths: Eighths;
|
lengthEighths: Eighths;
|
||||||
thicknessEighths: Eighths;
|
thicknessEighths: Eighths;
|
||||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { test } from 'node:test';
|
|||||||
import { inToEighths } from './capture.types';
|
import { inToEighths } from './capture.types';
|
||||||
|
|
||||||
const panelSrc = readFileSync(
|
const panelSrc = readFileSync(
|
||||||
|
join(dirname(fileURLToPath(import.meta.url)), 'AxisPanel.svelte'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const measureSrc = readFileSync(
|
||||||
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
|
|
||||||
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
|
test('tape page exposes typed number inputs for Length L and Thickness', () => {
|
||||||
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
||||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -31,6 +35,11 @@ test('MeasurePanel exposes typed number inputs for Length L and Thickness', () =
|
|||||||
false,
|
false,
|
||||||
'Length must not be a read-only fmtEighths span',
|
'Length must not be a read-only fmtEighths span',
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
/aria-label="Length L"/.test(measureSrc),
|
||||||
|
false,
|
||||||
|
'the separate measure page must not collect length',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||||
|
|||||||
@@ -252,6 +252,33 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
|
|||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('#62: calibration tape tilt is not copied to sibling photos (#99)', async () => {
|
||||||
|
installFakes();
|
||||||
|
const rotations: (number | undefined)[] = [];
|
||||||
|
const s = createCaptureSession({
|
||||||
|
draft: store,
|
||||||
|
encodeInventory: async (input) => {
|
||||||
|
rotations.push(input.rotationDeg);
|
||||||
|
return { blob: new Blob(['ok-png'], { type: 'image/png' }), sourceBelowFloor: false };
|
||||||
|
},
|
||||||
|
} as Parameters<typeof createCaptureSession>[0]);
|
||||||
|
|
||||||
|
await driveToMeasure(s, [tinyJpeg('sibling-a'), tinyJpeg('calibration'), tinyJpeg('sibling-b')], 1, { sheet: 'black' });
|
||||||
|
s.back();
|
||||||
|
s.setTapePose({
|
||||||
|
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
|
||||||
|
sourceW: 1000,
|
||||||
|
sourceH: 900,
|
||||||
|
});
|
||||||
|
await s.calculateFromAxis(320, 16);
|
||||||
|
|
||||||
|
assert.equal(rotations.length, 3, 'one encoder call for calibration and each sibling');
|
||||||
|
assert.equal(rotations[0], 20, 'calibration receives the selected tape tilt');
|
||||||
|
assert.equal(rotations[1], undefined, 'sibling photos remain unrotated');
|
||||||
|
assert.equal(rotations[2], undefined, 'sibling photos remain unrotated');
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => {
|
test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => {
|
||||||
installFakes();
|
installFakes();
|
||||||
const a = tinyJpeg('a'); // will empty-mask
|
const a = tinyJpeg('a'); // will empty-mask
|
||||||
|
|||||||
@@ -358,6 +358,87 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
|
|||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('calculateFromAxis stays on the tape page until the calibrated result exists', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||||
|
async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap;
|
||||||
|
|
||||||
|
let releaseCalibration!: () => void;
|
||||||
|
const calibrationGate = new Promise<void>((resolve) => { releaseCalibration = resolve; });
|
||||||
|
const s = createCaptureSession({
|
||||||
|
draft: store,
|
||||||
|
encodeInventory: async () => {
|
||||||
|
await calibrationGate;
|
||||||
|
return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false };
|
||||||
|
},
|
||||||
|
encodeOverlay: async () => new Blob(['overlay'], { type: 'image/png' }),
|
||||||
|
});
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
await s.addToBatch(a);
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(0);
|
||||||
|
s.setSheet('black');
|
||||||
|
await settleOverlay(s);
|
||||||
|
s.approveMask();
|
||||||
|
assert.equal(get(s.capture).k, 'axis');
|
||||||
|
|
||||||
|
const calculating = s.calculateFromAxis(320, 16);
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
const during = get(s.capture);
|
||||||
|
assert.equal(during.k, 'axis', 'tape page stays mounted while Calculate runs');
|
||||||
|
if (during.k === 'axis') {
|
||||||
|
assert.equal(during.lengthEighths, 320);
|
||||||
|
assert.equal(during.thicknessEighths, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseCalibration();
|
||||||
|
await calculating;
|
||||||
|
const after = get(s.capture);
|
||||||
|
assert.equal(after.k, 'measure');
|
||||||
|
if (after.k === 'measure') {
|
||||||
|
assert.ok(after.results, 'Photos ready opens only with a completed result');
|
||||||
|
assert.equal(after.lengthEighths, 320);
|
||||||
|
assert.equal(after.thicknessEighths, 16);
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calculateFromAxis passes the tape tilt to the calibration PNG and overlay only', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||||
|
async () => ({ width: MASK_W, height: MASK_H, close() {} }) as ImageBitmap;
|
||||||
|
|
||||||
|
const inventoryTilts: (number | undefined)[] = [];
|
||||||
|
const overlayTilts: (number | undefined)[] = [];
|
||||||
|
const s = createCaptureSession({
|
||||||
|
draft: store,
|
||||||
|
encodeInventory: async (input) => {
|
||||||
|
inventoryTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg);
|
||||||
|
return { blob: new Blob(['clean'], { type: 'image/png' }), sourceBelowFloor: false };
|
||||||
|
},
|
||||||
|
encodeOverlay: async (input) => {
|
||||||
|
overlayTilts.push((input as typeof input & { rotationDeg?: number }).rotationDeg);
|
||||||
|
return new Blob(['overlay'], { type: 'image/png' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await s.addToBatch(tinyJpeg('cal'));
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(0);
|
||||||
|
s.setSheet('black');
|
||||||
|
await settleOverlay(s);
|
||||||
|
s.approveMask();
|
||||||
|
s.setTapePose({
|
||||||
|
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
|
||||||
|
sourceW: MASK_W,
|
||||||
|
sourceH: MASK_H,
|
||||||
|
});
|
||||||
|
await s.calculateFromAxis(320, 16);
|
||||||
|
|
||||||
|
assert.deepEqual(inventoryTilts, [20], 'calibration inventory must receive tape tilt');
|
||||||
|
assert.deepEqual(overlayTilts, [20], 'dimension overlay must receive tape tilt');
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
|
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
|
||||||
installFakeMaskWorker();
|
installFakeMaskWorker();
|
||||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||||
@@ -378,10 +459,9 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
|||||||
s.setSheet('black');
|
s.setSheet('black');
|
||||||
await settleOverlay(s);
|
await settleOverlay(s);
|
||||||
s.approveMask();
|
s.approveMask();
|
||||||
s.setTapeLine({ start: [0, 10], end: [39, 10] });
|
|
||||||
s.approveAxis();
|
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.setLength(320);
|
||||||
s.setThickness(16);
|
s.setThickness(16);
|
||||||
await s.calculate();
|
await s.calculate();
|
||||||
@@ -394,7 +474,7 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
|||||||
const r = cap.results!;
|
const r = cap.results!;
|
||||||
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
||||||
assert.equal(typeof r.scale, 'number');
|
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.sqft, 'number');
|
||||||
assert.equal(typeof r.bdft, 'number');
|
assert.equal(typeof r.bdft, 'number');
|
||||||
// CAPTURE.md: bdft = sqft × thickness_in
|
// CAPTURE.md: bdft = sqft × thickness_in
|
||||||
@@ -444,8 +524,6 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
|||||||
assert.equal(before!.batch![2].blob, c);
|
assert.equal(before!.batch![2].blob, c);
|
||||||
|
|
||||||
s.approveMask();
|
s.approveMask();
|
||||||
const tapeLine = { start: [2, 3] as [number, number], end: [35, 14] as [number, number] };
|
|
||||||
s.setTapeLine(tapeLine);
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
const after = store.peek();
|
const after = store.peek();
|
||||||
@@ -453,7 +531,6 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
|||||||
assert.equal(after!.stage, 'axis');
|
assert.equal(after!.stage, 'axis');
|
||||||
assert.equal(after!.calibrationIndex, 1);
|
assert.equal(after!.calibrationIndex, 1);
|
||||||
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
||||||
assert.deepEqual(after!.tapeLine, tapeLine, 'draft persists the owner-positioned tape endpoints');
|
|
||||||
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
|
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
|
||||||
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
||||||
assert.equal(after!.batch![1].blob, b);
|
assert.equal(after!.batch![1].blob, b);
|
||||||
@@ -468,7 +545,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![0].blob, a);
|
||||||
assert.equal(meas!.batch![2].blob, c);
|
assert.equal(meas!.batch![2].blob, c);
|
||||||
assert.equal(meas!.calibrationIndex, 1);
|
assert.equal(meas!.calibrationIndex, 1);
|
||||||
assert.deepEqual(meas!.tapeLine, tapeLine, 'measure draft keeps the same calibration endpoints');
|
|
||||||
|
|
||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
@@ -525,6 +601,53 @@ test('#61: draft resume through measure restores calibration source (not photos[
|
|||||||
s2.dispose();
|
s2.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('legacy measure draft without results returns to the tape calibration page', async () => {
|
||||||
|
installFakeMaskWorker();
|
||||||
|
const source = tinyJpeg('legacy');
|
||||||
|
store.saveDraft({
|
||||||
|
version: 1,
|
||||||
|
stage: 'measure',
|
||||||
|
batch: [source],
|
||||||
|
calibrationIndex: 0,
|
||||||
|
source,
|
||||||
|
maskParams: { sheet: 'black', sensitivity: 50 },
|
||||||
|
mask: {
|
||||||
|
slabPx: MASK_W * MASK_H,
|
||||||
|
bbox: {
|
||||||
|
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
|
||||||
|
longSidePx: MASK_W,
|
||||||
|
angleDeg: 0,
|
||||||
|
},
|
||||||
|
slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H },
|
||||||
|
},
|
||||||
|
axisBbox: {
|
||||||
|
corners: [[MASK_W, 0], [MASK_W, MASK_H], [0, MASK_H], [0, 0]],
|
||||||
|
longSidePx: MASK_W,
|
||||||
|
angleDeg: 0,
|
||||||
|
},
|
||||||
|
lengthEighths: 320,
|
||||||
|
thicknessEighths: 16,
|
||||||
|
results: null,
|
||||||
|
inventoryPng: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
await s.resumeProbe();
|
||||||
|
s.continueDraft();
|
||||||
|
const start = Date.now();
|
||||||
|
let cap = get(s.capture);
|
||||||
|
while (cap.k !== 'axis' && Date.now() - start < 1500) {
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
cap = get(s.capture);
|
||||||
|
}
|
||||||
|
assert.equal(cap.k, 'axis', 'legacy resultless measure draft must not open a dead result screen');
|
||||||
|
if (cap.k === 'axis') {
|
||||||
|
assert.equal(cap.lengthEighths, 320);
|
||||||
|
assert.equal(cap.thicknessEighths, 16);
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
||||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||||
assert.match(app, /MaskPanel/);
|
assert.match(app, /MaskPanel/);
|
||||||
|
|||||||
+222
-146
@@ -1,17 +1,16 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import type {
|
import type {
|
||||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, WorkerIn, WorkerOut,
|
Axis, BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, Sheet, Source, WorkerIn, WorkerOut,
|
||||||
} from './capture.types';
|
} from './capture.types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||||
} from './capture.types';
|
} from './capture.types';
|
||||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||||
import { measure } from '../measure/measure';
|
import { measure } from '../measure/measure';
|
||||||
import { initialTapeLine, rectAlongLine } from '../geometry/rect';
|
|
||||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||||
import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop';
|
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
import { IDENTITY_TRANSFORM, cropEnds, tapeSegmentSource } from './tapeLine';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
const BUSY_MS = 300;
|
const BUSY_MS = 300;
|
||||||
@@ -41,15 +40,20 @@ export interface CaptureSession {
|
|||||||
setSensitivity(n: number): void;
|
setSensitivity(n: number): void;
|
||||||
setSheet(s: Sheet): void;
|
setSheet(s: Sheet): void;
|
||||||
approveMask(): void;
|
approveMask(): void;
|
||||||
setTapeLine(tapeLine: TapeLine): void;
|
setTapePose(pose: {
|
||||||
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
|
transform: PhotoTransform;
|
||||||
setCropFill(pctOrFraction: number): void;
|
sourceW: number;
|
||||||
|
sourceH: number;
|
||||||
|
}): void;
|
||||||
|
setCropFill(pct: number): void;
|
||||||
|
/** Legacy resultless transition retained for old callers/tests; the shipped UI uses calculateFromAxis. */
|
||||||
approveAxis(): void;
|
approveAxis(): void;
|
||||||
|
/** Length and thickness are entered on the tape page. Calculate leaves that page for Photos ready. */
|
||||||
|
calculateFromAxis(lengthEighths: Eighths, thicknessEighths: Eighths): Promise<void>;
|
||||||
setLength(e: Eighths): void;
|
setLength(e: Eighths): void;
|
||||||
setThickness(e: Eighths): void;
|
setThickness(e: Eighths): void;
|
||||||
calculate(): Promise<void>;
|
calculate(): Promise<void>;
|
||||||
back(): void;
|
back(): void;
|
||||||
getInventoryUrl(): string | null;
|
|
||||||
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
||||||
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -68,6 +72,19 @@ const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
|
|||||||
const batchPhotosOf = (d: Draft): Source[] =>
|
const batchPhotosOf = (d: Draft): Source[] =>
|
||||||
d.batch && d.batch.length > 0 ? d.batch : [d.source];
|
d.batch && d.batch.length > 0 ? d.batch : [d.source];
|
||||||
|
|
||||||
|
type CalibrationInput = {
|
||||||
|
source: Source;
|
||||||
|
approvedMask: Mask;
|
||||||
|
axis: Axis;
|
||||||
|
lengthEighths: Eighths;
|
||||||
|
thicknessEighths: Eighths;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalibrationOutput = {
|
||||||
|
results: Measures;
|
||||||
|
batchApply: BatchPhotoResult[];
|
||||||
|
};
|
||||||
|
|
||||||
// The one writer. Owns the worker port, the single debounce timer, and the draft.
|
// The one writer. Owns the worker port, the single debounce timer, and the draft.
|
||||||
// Every transition to an approved branch persists; no explicit "save".
|
// Every transition to an approved branch persists; no explicit "save".
|
||||||
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||||
@@ -84,10 +101,29 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let passStarted = 0;
|
let passStarted = 0;
|
||||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pngUrl: string | null = null;
|
|
||||||
let resumed = false;
|
let resumed = false;
|
||||||
// #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise.
|
let cropFill = 90;
|
||||||
let cropFill = FILL_DEFAULT;
|
let poseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// The tape line is a stage fixture: the owner only moves the slab. Its ends are
|
||||||
|
// the crop insets, so a pose carries the photo transform and nothing else (#99).
|
||||||
|
const axisFrom = (
|
||||||
|
bbox: Axis['bbox'],
|
||||||
|
sourceW: number,
|
||||||
|
sourceH: number,
|
||||||
|
pose?: { transform?: PhotoTransform; tapeStart?: [number, number]; tapeEnd?: [number, number] },
|
||||||
|
): Axis => {
|
||||||
|
const ends = cropEnds(cropFill);
|
||||||
|
const transform = pose?.transform ?? { ...IDENTITY_TRANSFORM };
|
||||||
|
const tickStartY = ends.startY;
|
||||||
|
const 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) => {
|
const set = (next: Capture) => {
|
||||||
state = next;
|
state = next;
|
||||||
@@ -108,10 +144,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
}
|
}
|
||||||
worker = null;
|
worker = null;
|
||||||
}
|
}
|
||||||
if (pngUrl) {
|
|
||||||
URL.revokeObjectURL(pngUrl);
|
|
||||||
pngUrl = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeWorker = (blob: Blob) => {
|
const makeWorker = (blob: Blob) => {
|
||||||
@@ -212,8 +244,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||||
tapeLine: state.axis.tapeLine,
|
photoTransform: state.axis.photoTransform,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
tickStartY: state.axis.tickStartY,
|
||||||
|
tickEndY: state.axis.tickEndY,
|
||||||
|
tapeStart: state.axis.tapeStart,
|
||||||
|
tapeEnd: state.axis.tapeEnd,
|
||||||
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: null, inventoryPng: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (state.k === 'measure') {
|
if (state.k === 'measure') {
|
||||||
@@ -223,7 +259,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
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,
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||||
results: state.results ? rest : null,
|
results: state.results ? rest : null,
|
||||||
inventoryPng: state.results?.inventoryPng ?? null,
|
inventoryPng: state.results?.inventoryPng ?? null,
|
||||||
@@ -315,10 +355,95 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
stub: Omit<Mask, 'bitset' | 'params'>,
|
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||||
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
|
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
|
||||||
|
|
||||||
|
const runCalibration = async (input: CalibrationInput): Promise<CalibrationOutput> => {
|
||||||
|
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = input;
|
||||||
|
if (lengthEighths === 0 || thicknessEighths === 0) {
|
||||||
|
throw new Error('length and thickness are required');
|
||||||
|
}
|
||||||
|
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||||
|
let pngBlob: Blob;
|
||||||
|
let belowFloor = false;
|
||||||
|
try {
|
||||||
|
const base = measure(
|
||||||
|
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||||
|
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||||
|
{ start: axis.tapeStart, end: axis.tapeEnd },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const encoded = await encodeInventory({ bitmap, mask: approvedMask, source, cropFill, rotationDeg: axis.photoTransform.tiltDeg });
|
||||||
|
pngBlob = encoded.blob;
|
||||||
|
belowFloor = encoded.sourceBelowFloor;
|
||||||
|
} catch {
|
||||||
|
pngBlob = new Blob([], { type: 'image/png' });
|
||||||
|
}
|
||||||
|
const overlayBlob = await (async () => {
|
||||||
|
try {
|
||||||
|
return await encodeOverlay({
|
||||||
|
inventoryPng: pngBlob,
|
||||||
|
bitset: approvedMask.bitset,
|
||||||
|
widthPx: source.widthPx,
|
||||||
|
heightPx: source.heightPx,
|
||||||
|
bbox: axis.bbox,
|
||||||
|
scale: base.scale,
|
||||||
|
lengthIn: eighthsToIn(lengthEighths),
|
||||||
|
lengthLabel: fmtEighths(lengthEighths),
|
||||||
|
slabBounds: approvedMask.slabBounds,
|
||||||
|
source,
|
||||||
|
cropFill,
|
||||||
|
rotationDeg: axis.photoTransform.tiltDeg,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Blob(['overlay'], { type: 'image/png' });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const results: Measures = {
|
||||||
|
...base,
|
||||||
|
inventoryPng: pngBlob,
|
||||||
|
overlayPng: overlayBlob,
|
||||||
|
sourceBelowFloor: belowFloor,
|
||||||
|
};
|
||||||
|
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||||
|
? calibrationIndex
|
||||||
|
: 0;
|
||||||
|
const batch = batchPhotos.length ? batchPhotos : [source];
|
||||||
|
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Batch photo was not processed',
|
||||||
|
}));
|
||||||
|
for (let index = 0; index < batch.length; index++) {
|
||||||
|
if (index === calIndex) continue;
|
||||||
|
const photo = batch[index];
|
||||||
|
try {
|
||||||
|
const derived = await rederiveMaskDetailed(photo, approvedMask.params, {
|
||||||
|
slabPx: 0,
|
||||||
|
bbox: EMPTY_BBOX,
|
||||||
|
slabBounds: EMPTY_ABOX,
|
||||||
|
});
|
||||||
|
if (derived.mask.slabPx === 0) throw new Error('empty mask: no slab pixels detected');
|
||||||
|
const siblingSource: Source = { ...photo, widthPx: derived.widthPx, heightPx: derived.heightPx };
|
||||||
|
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||||
|
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||||
|
try {
|
||||||
|
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, cropFill });
|
||||||
|
} finally {
|
||||||
|
siblingBitmap.close();
|
||||||
|
}
|
||||||
|
if (encoded.sourceBelowFloor) throw new Error('PNG size floor: shorter side is below 800 px');
|
||||||
|
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||||
|
batchApply[index] = { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { results, batchApply };
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||||
// fall back to the mask's own bbox.
|
// fall back to the mask's own bbox.
|
||||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||||
const draftTapeLine = (d: Draft) => d.tapeLine ?? initialTapeLine(d.source.widthPx, d.source.heightPx);
|
|
||||||
|
|
||||||
const restoreFromDraft = async (d: Draft) => {
|
const restoreFromDraft = async (d: Draft) => {
|
||||||
batchPhotos = batchPhotosOf(d);
|
batchPhotos = batchPhotosOf(d);
|
||||||
@@ -346,22 +471,18 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
try {
|
try {
|
||||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||||
if (d.stage === 'axis') {
|
if (d.stage === 'axis') {
|
||||||
const tapeLine = draftTapeLine(d);
|
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
|
||||||
set({ k: 'axis', source, approvedMask: mask, axis: {
|
|
||||||
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine,
|
|
||||||
} });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.inventoryPng) {
|
// A resultless legacy measure draft predates the merged tape page. Do not
|
||||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
// open the result-only screen; return to tape calibration with its inputs.
|
||||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
if (d.stage === 'measure' && !d.results) {
|
||||||
|
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
k: 'measure', source, approvedMask: mask,
|
k: 'measure', source, approvedMask: mask,
|
||||||
axis: {
|
axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d),
|
||||||
bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end),
|
|
||||||
tapeLine: draftTapeLine(d),
|
|
||||||
},
|
|
||||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||||
results: d.results
|
results: d.results
|
||||||
? {
|
? {
|
||||||
@@ -391,7 +512,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
void persist();
|
void persist();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const session: CaptureSession = {
|
||||||
capture,
|
capture,
|
||||||
busy,
|
busy,
|
||||||
|
|
||||||
@@ -490,33 +611,39 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||||
teardownWorker();
|
teardownWorker();
|
||||||
const overlay = state.overlay;
|
const overlay = state.overlay;
|
||||||
const tapeLine = initialTapeLine(state.source.widthPx, state.source.heightPx);
|
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: {
|
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx), lengthEighths: 0, thicknessEighths: 0 });
|
||||||
bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine,
|
|
||||||
} });
|
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
setTapeLine(tapeLine: TapeLine) {
|
setTapePose(pose) {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
const start: [number, number] = [...tapeLine.start];
|
const sourceW = pose.sourceW > 0 ? pose.sourceW : state.source.widthPx;
|
||||||
const end: [number, number] = [...tapeLine.end];
|
const sourceH = pose.sourceH > 0 ? pose.sourceH : state.source.heightPx;
|
||||||
const bbox = rectAlongLine(state.axis.bbox, start, end);
|
const source = sourceW > 0 && sourceH > 0
|
||||||
if (!(bbox.longSidePx > 0)) return;
|
? { ...state.source, widthPx: sourceW, heightPx: sourceH }
|
||||||
set({ ...state, axis: { bbox, tapeLine: { start, end } } });
|
: state.source;
|
||||||
|
set({
|
||||||
|
...state,
|
||||||
|
source,
|
||||||
|
axis: axisFrom(state.axis.bbox, sourceW, sourceH, pose),
|
||||||
|
});
|
||||||
|
if (poseTimer) clearTimeout(poseTimer);
|
||||||
|
poseTimer = setTimeout(() => {
|
||||||
|
poseTimer = null;
|
||||||
void persist();
|
void persist();
|
||||||
|
}, 250);
|
||||||
},
|
},
|
||||||
|
|
||||||
setCropFill(pctOrFraction: number) {
|
setCropFill(pct: number) {
|
||||||
// #99: non-retro — only future encodes use this; stored PNGs are untouched.
|
cropFill = Math.max(1, Math.min(100, Math.round(pct)));
|
||||||
cropFill = fillFromSetting(pctOrFraction);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
approveAxis() {
|
approveAxis() {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
set({
|
set({
|
||||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||||
axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine },
|
axis: state.axis,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||||
});
|
});
|
||||||
void persist();
|
void persist();
|
||||||
@@ -534,107 +661,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
// #62: Calculate the calibration photo, then apply its frozen mask params
|
// Legacy compatibility path; the shipped tape page calls calculateFromAxis.
|
||||||
// to siblings. This stays in Measure; siblings never enter Mask controls.
|
|
||||||
async calculate() {
|
async calculate() {
|
||||||
if (state.k !== 'measure') return;
|
if (state.k !== 'measure') return;
|
||||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
const input: CalibrationInput = {
|
||||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
source: state.source,
|
||||||
const scale = calibrationScale(axis.tapeLine.start, axis.tapeLine.end, eighthsToIn(lengthEighths));
|
approvedMask: state.approvedMask,
|
||||||
if (!(scale > 0) || !Number.isFinite(scale)) return;
|
axis: state.axis,
|
||||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
lengthEighths: state.lengthEighths,
|
||||||
const base = measure(
|
thicknessEighths: state.thicknessEighths,
|
||||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
|
||||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale,
|
|
||||||
);
|
|
||||||
let pngBlob: Blob;
|
|
||||||
let belowFloor = false;
|
|
||||||
try {
|
|
||||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill });
|
|
||||||
pngBlob = r.blob;
|
|
||||||
belowFloor = r.sourceBelowFloor;
|
|
||||||
} catch {
|
|
||||||
// CAPTURE.md: inventory is image/png even when encode is unavailable.
|
|
||||||
pngBlob = new Blob([], { type: 'image/png' });
|
|
||||||
}
|
|
||||||
bitmap.close();
|
|
||||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
|
||||||
pngUrl = URL.createObjectURL(pngBlob);
|
|
||||||
|
|
||||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
|
||||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
|
||||||
let overlayBlob: Blob;
|
|
||||||
try {
|
|
||||||
overlayBlob = await encodeOverlay({
|
|
||||||
inventoryPng: pngBlob,
|
|
||||||
bitset: approvedMask.bitset,
|
|
||||||
widthPx: source.widthPx,
|
|
||||||
heightPx: source.heightPx,
|
|
||||||
bbox: axis.bbox,
|
|
||||||
scale: base.scale,
|
|
||||||
lengthIn: eighthsToIn(lengthEighths),
|
|
||||||
lengthLabel: fmtEighths(lengthEighths),
|
|
||||||
slabBounds: approvedMask.slabBounds,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
|
||||||
// return a distinct image/png so callers never confuse it with clean.
|
|
||||||
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
|
|
||||||
}
|
|
||||||
const results: Measures = {
|
|
||||||
...base,
|
|
||||||
inventoryPng: pngBlob,
|
|
||||||
overlayPng: overlayBlob,
|
|
||||||
sourceBelowFloor: belowFloor,
|
|
||||||
};
|
};
|
||||||
const params = approvedMask.params;
|
const output = await runCalibration(input);
|
||||||
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
if (state.k !== 'measure') return;
|
||||||
? calibrationIndex
|
set({ ...state, ...output });
|
||||||
: 0;
|
void persist();
|
||||||
const batch = batchPhotos.length ? batchPhotos : [source];
|
},
|
||||||
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
|
||||||
status: 'error',
|
|
||||||
message: 'Batch photo was not processed',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Process serially so each worker owns exactly one sibling photo and a
|
async calculateFromAxis(lengthEighths, thicknessEighths) {
|
||||||
// failing decode/mask/encode cannot prevent later siblings from running.
|
if (state.k !== 'axis') return;
|
||||||
for (let index = 0; index < batch.length; index++) {
|
const length = Math.max(0, Math.round(lengthEighths));
|
||||||
if (index === calIndex) continue;
|
const thickness = Math.max(0, Math.round(thicknessEighths));
|
||||||
const photo = batch[index];
|
if (length === 0 || thickness === 0) return;
|
||||||
try {
|
if (poseTimer) {
|
||||||
const derived = await rederiveMaskDetailed(photo, params, {
|
clearTimeout(poseTimer);
|
||||||
slabPx: 0,
|
poseTimer = null;
|
||||||
bbox: EMPTY_BBOX,
|
|
||||||
slabBounds: EMPTY_ABOX,
|
|
||||||
});
|
|
||||||
if (derived.mask.slabPx === 0) {
|
|
||||||
throw new Error('empty mask: no slab pixels detected');
|
|
||||||
}
|
}
|
||||||
const siblingSource: Source = {
|
const input: CalibrationInput = {
|
||||||
...photo,
|
source: state.source,
|
||||||
widthPx: derived.widthPx,
|
approvedMask: state.approvedMask,
|
||||||
heightPx: derived.heightPx,
|
axis: state.axis,
|
||||||
|
lengthEighths: length,
|
||||||
|
thicknessEighths: thickness,
|
||||||
};
|
};
|
||||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
// Keep the tape page mounted while the PNG, overlay, and batch apply run.
|
||||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
// The old measure form was a real screen; the result state is not entered
|
||||||
try {
|
// until there is something complete to show.
|
||||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill });
|
set({ ...state, lengthEighths: length, thicknessEighths: thickness });
|
||||||
} finally {
|
const output = await runCalibration(input);
|
||||||
siblingBitmap.close();
|
if (state.k !== 'axis') return;
|
||||||
}
|
set({
|
||||||
if (encoded.sourceBelowFloor) {
|
k: 'measure',
|
||||||
throw new Error('PNG size floor: shorter side is below 800 px');
|
source: input.source,
|
||||||
}
|
approvedMask: input.approvedMask,
|
||||||
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
axis: input.axis,
|
||||||
} catch (error) {
|
lengthEighths: input.lengthEighths,
|
||||||
const message = error instanceof Error ? error.message : 'unable to process photo';
|
thicknessEighths: input.thicknessEighths,
|
||||||
batchApply[index] = { status: 'error', message };
|
results: output.results,
|
||||||
}
|
batchApply: output.batchApply,
|
||||||
}
|
});
|
||||||
|
|
||||||
set({ ...state, results, batchApply });
|
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -653,16 +727,17 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
const m = state.approvedMask;
|
const m = state.approvedMask;
|
||||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||||
} else if (state.k === 'measure') {
|
} else if (state.k === 'measure') {
|
||||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
|
set({
|
||||||
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine,
|
k: 'axis',
|
||||||
} });
|
source: state.source,
|
||||||
|
approvedMask: state.approvedMask,
|
||||||
|
axis: state.axis,
|
||||||
|
lengthEighths: state.lengthEighths,
|
||||||
|
thicknessEighths: state.thicknessEighths,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getInventoryUrl() {
|
|
||||||
return pngUrl;
|
|
||||||
},
|
|
||||||
|
|
||||||
currentMeasure() {
|
currentMeasure() {
|
||||||
return state.k === 'measure' ? state : null;
|
return state.k === 'measure' ? state : null;
|
||||||
},
|
},
|
||||||
@@ -671,6 +746,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
teardownWorker();
|
teardownWorker();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display helper: format eighths as inches with fractions.
|
// Display helper: format eighths as inches with fractions.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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('tick ends are fixed by the crop slider and are never an owner input (#99)', () => {
|
||||||
|
// The line is a stage fixture: its two ends are the crop insets, set by
|
||||||
|
// crop_fill alone. There is no owner drag that can move them.
|
||||||
|
for (const fill of [1, 25, 50, 75, 90, 100]) {
|
||||||
|
const ends = cropEnds(fill);
|
||||||
|
assert.equal(ends.startY, cropEnds(fill).startY, 'ends are a pure function of fill');
|
||||||
|
assert.equal(ends.endY, cropEnds(fill).endY, 'ends are a pure function of fill');
|
||||||
|
assert.ok(ends.endY > ends.startY, 'line keeps a positive span at any fill');
|
||||||
|
}
|
||||||
|
// Ends stay symmetric about the frame centre, so the line is fixed in the middle.
|
||||||
|
for (const fill of [1, 50, 90, 100]) {
|
||||||
|
const ends = cropEnds(fill);
|
||||||
|
const top = ends.startY;
|
||||||
|
const bottom = CALIBRATION_FRAME.height - ends.endY;
|
||||||
|
assert.equal(top, bottom, `fill ${fill}: top and bottom insets match`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the scale span is the fixed line span, not a mask span', () => {
|
||||||
|
const ends = cropEnds(90);
|
||||||
|
const distance = sourceTickDistance(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM);
|
||||||
|
const scale = calibrationScale(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM, 72);
|
||||||
|
assert.ok(distance > 0, 'line has a real source-pixel span');
|
||||||
|
assert.equal(scale, distance / 72, 'scale is the line span over the tape reading');
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
crop_fill: number;
|
crop_fill: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export let onCropFillChange: (pct: number) => void = () => {};
|
||||||
|
|
||||||
interface PriceRule {
|
interface PriceRule {
|
||||||
id: number;
|
id: number;
|
||||||
species_id: number;
|
species_id: number;
|
||||||
@@ -246,6 +248,7 @@
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
settings = await res.json();
|
settings = await res.json();
|
||||||
|
onCropFillChange(settings.crop_fill ?? 90);
|
||||||
message = 'Saved';
|
message = 'Saved';
|
||||||
// refresh secret buffers from the (possibly unmasked) server state
|
// refresh secret buffers from the (possibly unmasked) server state
|
||||||
form.woo_app_password = secretValue(settings.woo_app_password);
|
form.woo_app_password = secretValue(settings.woo_app_password);
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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
|
// 100×20 horizontal slab
|
||||||
const W = 100, H = 20;
|
const W = 100, H = 20;
|
||||||
const bitset = new Uint8Array(W * H);
|
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;
|
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', () => {
|
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||||
const prev = minAreaRect(bitset, W, H);
|
const prev = minAreaRect(bitset, W, H);
|
||||||
assert.ok(prev, 'minAreaRect returned a box');
|
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);
|
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rectAlongLine sets the approved axis from the owner-positioned tape endpoints', () => {
|
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
||||||
const previous = minAreaRect(bitset, W, H)!;
|
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
||||||
const start: [number, number] = [20, 30];
|
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
||||||
const end: [number, number] = [80, 70];
|
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
||||||
const calibrated = rectAlongLine(previous, start, end);
|
const TW = 20, TH = 100;
|
||||||
const center: [number, number] = [50, 50];
|
const thin = new Uint8Array(TW * TH).fill(1);
|
||||||
assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9);
|
const thinBox = minAreaRect(thin, TW, TH);
|
||||||
assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9);
|
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
||||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9);
|
const rotated = orientedRect(thinBox, 0);
|
||||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9);
|
const lengthEighths = 24; // 3 in
|
||||||
assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle');
|
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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
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 };
|
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the approved axis rectangle from the owner's two tape-line endpoints. */
|
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||||
export function rectAlongLine(prev: BBox, start: [number, number], end: [number, number]): BBox {
|
// extent in px. 0 when the mask is empty.
|
||||||
const dx = end[0] - start[0];
|
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||||
const dy = end[1] - start[1];
|
let min = Infinity, max = -Infinity;
|
||||||
const length = Math.hypot(dx, dy);
|
for (let y = 0; y < h; y++) {
|
||||||
if (!(length > 0) || !Number.isFinite(length)) return prev;
|
const row = y * w;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
const halfW = Math.hypot(
|
if (!bitset[row + x]) continue;
|
||||||
prev.corners[0][0] - prev.corners[1][0],
|
const p = x * ux + y * uy;
|
||||||
prev.corners[0][1] - prev.corners[1][1],
|
if (p < min) min = p;
|
||||||
) / 2;
|
if (p > max) max = p;
|
||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
const angleDeg = ((Math.atan2(uy, ux) * 180) / Math.PI + 180) % 180;
|
}
|
||||||
return { corners, longSidePx: length, angleDeg };
|
return max < min ? 0 : max - min;
|
||||||
}
|
|
||||||
|
|
||||||
/** 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] };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
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 { 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)`);
|
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
|
||||||
for (const e of m.widths.every6In) {
|
for (const e of m.widths.every6In) {
|
||||||
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
|
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', () => {
|
test('uniform slab: interior stations keep the full width', () => {
|
||||||
const { bitset, box, slabPx } = uniform(480, 100);
|
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);
|
assert.equal(m.widths.every6In.length, 7);
|
||||||
for (const e of m.widths.every6In) {
|
for (const e of m.widths.every6In) {
|
||||||
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
|
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', () => {
|
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||||
const { bitset, box, slabPx } = uniform(120, 100);
|
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: [] });
|
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', () => {
|
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
||||||
const { bitset, box, slabPx } = uniform(100, 100);
|
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: [] });
|
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}`);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
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.
|
// Display / direction: corners[3] → corners[0] is the long side.
|
||||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||||
@@ -75,14 +76,20 @@ export function measure(
|
|||||||
bbox: BBox,
|
bbox: BBox,
|
||||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||||
slabPx: number,
|
slabPx: number,
|
||||||
scale: number,
|
tape?: { start: [number, number]; end: [number, number] },
|
||||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||||
const lengthIn = lengthEighths / 8;
|
const lengthIn = lengthEighths / 8;
|
||||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||||
if (!(scale > 0) || !Number.isFinite(scale)) {
|
const tapeSpan = tape
|
||||||
throw new RangeError('Calibration scale must be a positive finite value');
|
? Math.hypot(tape.end[0] - tape.start[0], tape.end[1] - tape.start[1])
|
||||||
}
|
: 0;
|
||||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
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;
|
let widths: Widths;
|
||||||
if (every6In.length > 0) {
|
if (every6In.length > 0) {
|
||||||
const min = Math.min(...every6In);
|
const min = Math.min(...every6In);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -107,6 +107,37 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
|||||||
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('calibration rotation turns a landscape slab into an upright crop extent (#99)', () => {
|
||||||
|
const width = 6;
|
||||||
|
const 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: Pick<Source, 'widthPx' | 'heightPx'> = { 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);
|
||||||
|
});
|
||||||
|
|
||||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Mask, Source } from '../capture/capture.types';
|
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<Mask, 'bitset' | 'slabBounds'>;
|
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||||
@@ -8,8 +11,10 @@ export interface InventoryInput {
|
|||||||
bitmap: ImageBitmap;
|
bitmap: ImageBitmap;
|
||||||
mask: MaskPick;
|
mask: MaskPick;
|
||||||
source: SourcePick;
|
source: SourcePick;
|
||||||
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
|
/** Global crop fill percent. Default 90. */
|
||||||
fill?: number;
|
cropFill?: number;
|
||||||
|
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
|
||||||
|
rotationDeg?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
@@ -23,8 +28,12 @@ export function composeInventoryFrame(
|
|||||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||||
mask: MaskPick,
|
mask: MaskPick,
|
||||||
source: SourcePick,
|
source: SourcePick,
|
||||||
fill: number = FILL_DEFAULT,
|
fill = 0.9,
|
||||||
|
rotationDeg = 0,
|
||||||
): Frame {
|
): Frame {
|
||||||
|
if (Number.isFinite(rotationDeg) && Math.abs(rotationDeg % 360) > 1e-8) {
|
||||||
|
return composeRotatedInventoryFrame(src, mask, source, fill, rotationDeg);
|
||||||
|
}
|
||||||
const b = mask.slabBounds;
|
const b = mask.slabBounds;
|
||||||
const x0 = Math.max(0, Math.round(b.x));
|
const x0 = Math.max(0, Math.round(b.x));
|
||||||
const y0 = Math.max(0, Math.round(b.y));
|
const y0 = Math.max(0, Math.round(b.y));
|
||||||
@@ -32,8 +41,14 @@ export function composeInventoryFrame(
|
|||||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||||
const sw = x1 - x0;
|
const sw = x1 - x0;
|
||||||
const sh = y1 - y0;
|
const sh = y1 - y0;
|
||||||
const { outW, outH, belowFloor } = cropFrameSize(sw, sh);
|
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||||
const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
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 rgba = new Uint8Array(outW * outH * 4);
|
||||||
const xEnd = ox + dw;
|
const xEnd = ox + dw;
|
||||||
const yEnd = oy + dh;
|
const yEnd = oy + dh;
|
||||||
@@ -55,10 +70,131 @@ export function composeInventoryFrame(
|
|||||||
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PixelBounds {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPixelBounds(bitset: Uint8Array, w: number, h: number): PixelBounds | null {
|
||||||
|
let minX = w, minY = h, maxX = -1, maxY = -1;
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
if (!bitset[y * w + x]) continue;
|
||||||
|
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxX < 0 ? null : { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatePoint(
|
||||||
|
x: number, y: number, bounds: PixelBounds, radians: number,
|
||||||
|
): [number, number] {
|
||||||
|
const cx = (bounds.minX + bounds.maxX) / 2;
|
||||||
|
const cy = (bounds.minY + bounds.maxY) / 2;
|
||||||
|
const dx = x - cx, dy = y - cy;
|
||||||
|
const cos = Math.cos(radians), sin = Math.sin(radians);
|
||||||
|
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskRotation {
|
||||||
|
bounds: { x: number; y: number; w: number; h: number };
|
||||||
|
toRotated: (x: number, y: number) => [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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotatedMaskBounds(
|
||||||
|
bitset: Uint8Array,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
rotationDeg: number,
|
||||||
|
) {
|
||||||
|
return createMaskRotation(bitset, w, h, rotationDeg).bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 belowFloor = Math.min(rotated.w, rotated.h) < FLOOR;
|
||||||
|
const outW = belowFloor ? Math.max(1, Math.min(rotated.w, FLOOR)) : FLOOR;
|
||||||
|
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||||
|
const scale = Math.min((outW * fill) / Math.max(rotated.w, 1), (outH * fill) / Math.max(rotated.h, 1));
|
||||||
|
const dw = rotated.w * scale;
|
||||||
|
const dh = rotated.h * scale;
|
||||||
|
const ox = (outW - dw) / 2;
|
||||||
|
const oy = (outH - dh) / 2;
|
||||||
|
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(
|
export async function encodeInventoryPng(
|
||||||
input: InventoryInput,
|
input: InventoryInput,
|
||||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||||
const { bitmap, mask, source } = input;
|
const { bitmap, mask, source, cropFill, rotationDeg } = input;
|
||||||
|
const fill = Math.max(1, Math.min(100, cropFill ?? 90)) / 100;
|
||||||
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||||
const srcCtx = srcCanvas.getContext('2d')!;
|
const srcCtx = srcCanvas.getContext('2d')!;
|
||||||
srcCtx.drawImage(bitmap, 0, 0);
|
srcCtx.drawImage(bitmap, 0, 0);
|
||||||
@@ -67,7 +203,8 @@ export async function encodeInventoryPng(
|
|||||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||||
mask,
|
mask,
|
||||||
source,
|
source,
|
||||||
input.fill,
|
fill,
|
||||||
|
rotationDeg,
|
||||||
);
|
);
|
||||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||||
const ctx = out.getContext('2d')!;
|
const ctx = out.getContext('2d')!;
|
||||||
|
|||||||
@@ -299,7 +299,37 @@ test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// --- Length dim must not overshoot wood footprint (#69) ---
|
test('inventorySourceMap follows calibration rotation for overlay geometry (#99)', () => {
|
||||||
|
const W = 400, H = 400;
|
||||||
|
const bitset = new Uint8Array(W * H).fill(1);
|
||||||
|
const slabBounds = { x: 0, y: 0, w: W, h: H };
|
||||||
|
const unrotated = inventorySourceMap(
|
||||||
|
slabBounds,
|
||||||
|
{ widthPx: W, heightPx: H },
|
||||||
|
800,
|
||||||
|
1067,
|
||||||
|
0.9,
|
||||||
|
);
|
||||||
|
const rotated = inventorySourceMap(
|
||||||
|
slabBounds,
|
||||||
|
{ widthPx: W, heightPx: H },
|
||||||
|
800,
|
||||||
|
1067,
|
||||||
|
0.9,
|
||||||
|
bitset,
|
||||||
|
90,
|
||||||
|
);
|
||||||
|
const horizontal = (map: ReturnType<typeof inventorySourceMap>) => {
|
||||||
|
const a = map.mapToInventory(100, 200);
|
||||||
|
const b = map.mapToInventory(300, 200);
|
||||||
|
return [b[0] - a[0], b[1] - a[1]] as const;
|
||||||
|
};
|
||||||
|
const [unrotatedDx, unrotatedDy] = horizontal(unrotated);
|
||||||
|
const [rotatedDx, rotatedDy] = horizontal(rotated);
|
||||||
|
assert.ok(Math.abs(unrotatedDx) > Math.abs(unrotatedDy), 'unrotated source axis stays horizontal');
|
||||||
|
assert.ok(Math.abs(rotatedDy) > Math.abs(rotatedDx), '90° calibration rotation maps it vertically');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
|
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
|
||||||
function tiltedSlabFixture() {
|
function tiltedSlabFixture() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BBox } from '../capture/capture.types';
|
import type { BBox } from '../capture/capture.types';
|
||||||
import { axisEndpoints } from '../measure/measure';
|
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). */
|
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||||
export function overlayWidthStations(lengthIn: number): number[] {
|
export function overlayWidthStations(lengthIn: number): number[] {
|
||||||
@@ -486,15 +486,35 @@ export function inventorySourceMap(
|
|||||||
source: { widthPx: number; heightPx: number },
|
source: { widthPx: number; heightPx: number },
|
||||||
outW: number,
|
outW: number,
|
||||||
outH: number,
|
outH: number,
|
||||||
fill: number = FILL_DEFAULT,
|
fill = 0.9,
|
||||||
|
bitset?: Uint8Array,
|
||||||
|
rotationDeg = 0,
|
||||||
): InventoryMap {
|
): InventoryMap {
|
||||||
|
if (bitset && Math.abs(rotationDeg % 360) > 1e-8) {
|
||||||
|
const rotation = createMaskRotation(bitset, source.widthPx, source.heightPx, rotationDeg);
|
||||||
|
const scale = Math.min((outW * fill) / Math.max(rotation.bounds.w, 1), (outH * fill) / Math.max(rotation.bounds.h, 1));
|
||||||
|
const ox = (outW - rotation.bounds.w * scale) / 2;
|
||||||
|
const oy = (outH - rotation.bounds.h * scale) / 2;
|
||||||
|
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 x0 = Math.max(0, Math.round(slabBounds.x));
|
||||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
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 y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||||
const sw = x1 - x0;
|
const sw = x1 - x0;
|
||||||
const sh = y1 - y0;
|
const sh = y1 - y0;
|
||||||
const { scale, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
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;
|
||||||
return {
|
return {
|
||||||
outW,
|
outW,
|
||||||
outH,
|
outH,
|
||||||
@@ -514,6 +534,8 @@ export interface EncodeOverlayInput {
|
|||||||
lengthLabel: string;
|
lengthLabel: string;
|
||||||
slabBounds: { x: number; y: number; w: number; h: number };
|
slabBounds: { x: number; y: number; w: number; h: number };
|
||||||
source: { widthPx: number; heightPx: number };
|
source: { widthPx: number; heightPx: number };
|
||||||
|
cropFill?: number;
|
||||||
|
rotationDeg?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -528,7 +550,16 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
|||||||
const canvas = new OffscreenCanvas(outW, outH);
|
const canvas = new OffscreenCanvas(outW, outH);
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
ctx.drawImage(bmp, 0, 0);
|
ctx.drawImage(bmp, 0, 0);
|
||||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
|
const fill = Math.max(1, Math.min(100, input.cropFill ?? 90)) / 100;
|
||||||
|
const { mapToInventory } = inventorySourceMap(
|
||||||
|
input.slabBounds,
|
||||||
|
input.source,
|
||||||
|
outW,
|
||||||
|
outH,
|
||||||
|
fill,
|
||||||
|
input.bitset,
|
||||||
|
input.rotationDeg,
|
||||||
|
);
|
||||||
const plan = buildOverlayPlan({
|
const plan = buildOverlayPlan({
|
||||||
bitset: input.bitset,
|
bitset: input.bitset,
|
||||||
widthPx: input.widthPx,
|
widthPx: input.widthPx,
|
||||||
|
|||||||
@@ -28,9 +28,15 @@ Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=
|
|||||||
| `01-batch.png` | Batch with fixtures loaded |
|
| `01-batch.png` | Batch with fixtures loaded |
|
||||||
| `02-pick-calibration.png` | Pick calibration |
|
| `02-pick-calibration.png` | Pick calibration |
|
||||||
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
||||||
| `04-axis.png` | Length axis |
|
| `04-axis.png` | Tape calibration page |
|
||||||
| `05-measure.png` | Measure with L / thickness filled |
|
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
|
||||||
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
||||||
|
| `tape-fixed-line.png` | #99 fixed tape line: no tick-drag copy, ticks follow the crop slider |
|
||||||
|
| `tape-fixed-line-crop50.png` | #99 fixed tape line at 50% crop fill (line ends pulled in) |
|
||||||
|
|
||||||
|
`tape-fixed-line.spec.ts` asserts the #99 contract: the line is a stage fixture,
|
||||||
|
its ends are a pure function of `crop_fill`, a drag starting on a tick behaves
|
||||||
|
exactly like a pan elsewhere, and arrow keys cannot move the ends.
|
||||||
|
|
||||||
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
||||||
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
|
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
|
||||||
// the Resume path restores straight to the Photos-ready Measure step — the
|
// the Resume path restores straight to the Photos-ready result step — the
|
||||||
// deterministic driver for the create→review→publish flow (no live camera/canvas
|
// deterministic driver for the create→review→publish flow (no live camera/canvas
|
||||||
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
|
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
|
||||||
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
|
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||||
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
|
test('batch → pick → mask → tape calibration → photos ready + screenshots', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||||
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
||||||
@@ -87,20 +87,16 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
|||||||
await shot(page, '03-mask.png');
|
await shot(page, '03-mask.png');
|
||||||
await page.getByRole('button', { name: 'Approve mask' }).click();
|
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Measure' })).toHaveCount(0);
|
||||||
await shot(page, '04-axis.png');
|
await shot(page, '04-axis.png');
|
||||||
await page.getByRole('button', { name: 'Approve axis' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
|
|
||||||
await page.getByLabel('Length L').fill('48');
|
|
||||||
await page.getByLabel('Thickness').fill('2');
|
|
||||||
// blur/change commits eighths into session before Calculate.
|
|
||||||
await page.getByLabel('Length L').blur();
|
|
||||||
await page.getByLabel('Thickness').blur();
|
|
||||||
await shot(page, '05-measure.png');
|
|
||||||
|
|
||||||
const calc = page.getByRole('button', { name: 'Calculate' });
|
const calc = page.getByRole('button', { name: 'Calculate' });
|
||||||
|
await expect(calc).toBeDisabled();
|
||||||
|
await page.getByLabel('Length L').fill('48');
|
||||||
|
await page.getByLabel('Thickness').fill('2');
|
||||||
await expect(calc).toBeEnabled();
|
await expect(calc).toBeEnabled();
|
||||||
|
await shot(page, '05-tape-calibration.png');
|
||||||
await calc.click();
|
await calc.click();
|
||||||
|
|
||||||
// Calculate encodes calibration PNG + overlay + sibling batch apply.
|
// Calculate encodes calibration PNG + overlay + sibling batch apply.
|
||||||
@@ -117,7 +113,7 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
|||||||
|
|
||||||
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
||||||
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
||||||
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
|
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-tape-calibration.png', '06-photos-ready.png'];
|
||||||
const lines = [
|
const lines = [
|
||||||
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
||||||
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FACE_A = path.resolve(here, '../fixtures/set1-48x2/face-a.jpeg');
|
||||||
|
const ARTIFACTS = path.resolve(here, 'artifacts');
|
||||||
|
|
||||||
|
const shot = async (page: Page, name: string) => {
|
||||||
|
fs.mkdirSync(ARTIFACTS, { recursive: true });
|
||||||
|
await page.screenshot({ path: path.join(ARTIFACTS, name), fullPage: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureFreshBatch = async (page: Page) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const resumeHeading = page.getByRole('heading', { name: /resume/i });
|
||||||
|
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
|
||||||
|
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
|
||||||
|
if (await resumeHeading.isVisible().catch(() => false)) {
|
||||||
|
await page.getByRole('button', { name: /start fresh/i }).click();
|
||||||
|
}
|
||||||
|
await expect(batchHeading).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareMaskForApprove = async (page: Page) => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Black', exact: true }).click();
|
||||||
|
const approve = page.getByRole('button', { name: 'Approve mask' });
|
||||||
|
try {
|
||||||
|
await expect(approve).toBeEnabled({ timeout: 45_000 });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* sensitivity ladder */
|
||||||
|
}
|
||||||
|
for (const target of [60, 70, 40, 30, 80]) {
|
||||||
|
await page.locator('input[type="range"]').fill(String(target));
|
||||||
|
await page.waitForTimeout(2800);
|
||||||
|
if (await approve.isEnabled()) return;
|
||||||
|
}
|
||||||
|
await expect(approve).toBeEnabled({ timeout: 15_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toLengthPage = async (page: Page) => {
|
||||||
|
await ensureFreshBatch(page);
|
||||||
|
await page.locator('input[type="file"][multiple]').setInputFiles([FACE_A]);
|
||||||
|
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
|
||||||
|
await prepareMaskForApprove(page);
|
||||||
|
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('#99 fixed tape line', () => {
|
||||||
|
test('the line is not editable: no tick-drag copy and no tick drag', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||||
|
await toLengthPage(page);
|
||||||
|
|
||||||
|
// The hint must never tell the owner to drag or nudge the ticks.
|
||||||
|
await expect(page.getByText('Drag the ticks')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('nudge the last tick')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('The line is fixed in the middle')).toBeVisible();
|
||||||
|
|
||||||
|
const stage = page.getByTestId('tape-line');
|
||||||
|
await expect(stage).toBeVisible();
|
||||||
|
const box = (await stage.boundingBox())!;
|
||||||
|
const canvas = stage.locator('canvas').first();
|
||||||
|
|
||||||
|
// A pan delta produces the same result wherever you grab the stage, because
|
||||||
|
// pan is measured as a delta from the grab point. So: drag from a tick
|
||||||
|
// position, then repeat the same delta from a plain part of the stage. If
|
||||||
|
// the tick drag were still live it would move the line too, and the two
|
||||||
|
// results would differ. Identical output ⇒ the line is fixed.
|
||||||
|
const centerX = box.x + box.width / 2;
|
||||||
|
const tickY = box.y + box.height * 0.05;
|
||||||
|
const plainY = box.y + box.height * 0.62;
|
||||||
|
const delta = box.height * 0.18;
|
||||||
|
|
||||||
|
const dragBy = async (from: number) => {
|
||||||
|
await page.mouse.move(centerX, from);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(centerX, from + delta, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
};
|
||||||
|
|
||||||
|
await dragBy(tickY);
|
||||||
|
const fromTick = await canvas.screenshot();
|
||||||
|
|
||||||
|
// Reset the transform, then repeat the identical delta from a non-tick point.
|
||||||
|
await toLengthPage(page);
|
||||||
|
await dragBy(plainY);
|
||||||
|
const fromPlain = await canvas.screenshot();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Buffer.compare(fromTick, fromPlain),
|
||||||
|
'a tick drag must behave exactly like a pan elsewhere (line stays fixed)',
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
// Length and thickness live on this same screen; Calculate stays usable.
|
||||||
|
await page.getByLabel('Length L').fill('48');
|
||||||
|
await page.getByLabel('Thickness').fill('2');
|
||||||
|
await expect(page.getByRole('button', { name: 'Calculate' })).toBeEnabled();
|
||||||
|
await shot(page, 'tape-fixed-line.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the crop slider sets the line ends and nothing else does', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await toLengthPage(page);
|
||||||
|
|
||||||
|
const canvas = page.getByTestId('tape-line').locator('canvas').first();
|
||||||
|
const fill = page.getByLabel('Crop fill percent');
|
||||||
|
const before = await canvas.screenshot();
|
||||||
|
|
||||||
|
// Move the crop-fill slider and confirm the control really took the value,
|
||||||
|
// so a no-op fill() can never masquerade as a passing assertion.
|
||||||
|
await fill.fill('50');
|
||||||
|
await expect(fill).toHaveValue('50');
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
const after = await canvas.screenshot();
|
||||||
|
expect(Buffer.compare(before, after), 'crop fill must move the line ends').not.toBe(0);
|
||||||
|
|
||||||
|
// Arrow-key nudge on the line must not change the geometry either.
|
||||||
|
// Blur the slider first: arrow keys on a focused range input legitimately
|
||||||
|
// change that input, which would move the line ends for the wrong reason.
|
||||||
|
await page.locator('h1').click();
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const afterKeys = await canvas.screenshot();
|
||||||
|
expect(
|
||||||
|
Buffer.compare(after, afterKeys),
|
||||||
|
'arrow keys must not move the fixed line ends',
|
||||||
|
).toBe(0);
|
||||||
|
await shot(page, 'tape-fixed-line-crop50.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,7 +50,7 @@ CITING_ROLES = {"capability", "contract"}
|
|||||||
CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"}
|
CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"}
|
||||||
HEADING = re.compile(r"^(#{2,4})\s+(.*)$")
|
HEADING = re.compile(r"^(#{2,4})\s+(.*)$")
|
||||||
|
|
||||||
SCOPES = {"shipped", "M3", "later"}
|
SCOPES = {"shipped", "M3", "M4", "later"}
|
||||||
PLACEHOLDER = {"", "—", "-", "*(none yet)*"}
|
PLACEHOLDER = {"", "—", "-", "*(none yet)*"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user