Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f725e04ae6 | |||
| 8673ae0ecd | |||
| a756cf58bb | |||
| d50d55aea9 | |||
| 1882fa3842 | |||
| 7908a99ec3 | |||
| 6000ac2b0f | |||
| d49ff2fab5 | |||
| 4ca2ef57a7 | |||
| 6fb92362a4 | |||
| 818874e65c |
@@ -0,0 +1,192 @@
|
|||||||
|
# M4 — Goals
|
||||||
|
|
||||||
|
Role: goals for Milestone 4. Cites requirement IDs and Gitea issue numbers; defines none
|
||||||
|
new until accepted. Source of truth for scope will be `docs/MILESTONES.md` (M4 ID subset)
|
||||||
|
and `docs/REQUIREMENTS.md` (rows) after acceptance.
|
||||||
|
|
||||||
|
**Base:** builds on branch `m4` (commit `818874e`, `feat(m4): crop_fill setting + tape-line
|
||||||
|
prototype (#99)`), which is 1 ahead of `origin/main`. #99 is in scope.
|
||||||
|
|
||||||
|
**Objective:** Ship a polished, stable M3 experience with the tape-line calibration model,
|
||||||
|
a **human-driven taxonomy review** (Call 1 deferred; owner picks everything on a thumb-first
|
||||||
|
UI), **refined Call 2 long descriptions**, and a verified real WooCommerce draft submission.
|
||||||
|
|
||||||
|
**Exit criterion:** A real `SLAB-UAT-*` Woo **draft** matching the reviewed ready set exists
|
||||||
|
in the store, created end-to-end through the M4 UI (tape-line calibration → human taxonomy
|
||||||
|
review → Call 2 long description → publish), with **no Call 1** in the path (inference
|
||||||
|
settings need not be vision-enabled). Call 2 is exercised on a live call at least once.
|
||||||
|
Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
|
||||||
|
|
||||||
|
## Goals (accepted direction)
|
||||||
|
|
||||||
|
1. **Stabilize + polish the M3 experience (A) — includes the #99 tape-line calibration (B).**
|
||||||
|
- **Tape-line calibration (#99) — SHIPPED (as-built 2026-09-25, UAT signed off):** the
|
||||||
|
axis-rectangle calibration is replaced by a **fixed** black centre line with two end
|
||||||
|
ticks. The line is a stage fixture and is **never an owner input** (not draggable, not
|
||||||
|
focusable, not keyboard-nudgeable). The **source photo/slab is the only thing that
|
||||||
|
moves** — pan/zoom/tilt it until it lines up with the fixed line at the place the tape
|
||||||
|
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
|
||||||
|
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
|
||||||
|
with Call 1.)
|
||||||
|
- Post-launch issue work + the tape-line feature; no other new feature surface.
|
||||||
|
|
||||||
|
2. **Human-driven taxonomy review + thumb-first UI (Call 1 deferred; Call 2 kept).**
|
||||||
|
- **Call 1 (vision) → deferred to future work.** Not in the MVP: no "Suggest with AI"
|
||||||
|
button, no auto-pre-fill, no confidence pre-fill, no AI markers on fields. The
|
||||||
|
inference settings need not be vision-enabled.
|
||||||
|
- **Owner picks everything Call 1 used to propose**, via a thumb-first, on-phone usable
|
||||||
|
UI: species (1 leaf), wood categories (primary + optional secondaries), Edge, Figure
|
||||||
|
(multi), Grade (owner judgment), Thickness band (derived, no pick), Moisture, and
|
||||||
|
`feat-*` / `fig-*` tags. All from the synced Woo taxonomy (METADATA-5/10/15/30).
|
||||||
|
- **Call 2 (text) stays** — writes the long description from the human-picked values +
|
||||||
|
dimension fact block; brand/geo context, primary + secondary categories. (Refining its
|
||||||
|
output quality is its own goal, #3 below.)
|
||||||
|
- **Title + short description remain deterministic** from the primary wood category +
|
||||||
|
dimension fact block (METADATA-80/85) — they depend only on the human's primary pick
|
||||||
|
+ fact block, not on Call 1.
|
||||||
|
- **All METADATA requirements remain in scope** — the human collects the same data
|
||||||
|
Call 1 previously suggested, just by hand.
|
||||||
|
- **Wireframe first (a sprint task in this milestone):** before any review-UI coding,
|
||||||
|
produce a wireframe (2-3 HTML variants) to align on the overall flow — how the
|
||||||
|
thumb-first taxonomy collection, primary/secondary selection, and Call 2 generate
|
||||||
|
control compose on a phone. This is the design gate for the review work.
|
||||||
|
**Starting point (saved, recent):** `docs/design/issue-99-tape-line-overlay.html`
|
||||||
|
(approved #99 tape-line overlay wireframe, committed 2026-09-24 on
|
||||||
|
`issue-99-tape-line-overlay`). Do **not** build on the older `pre-simplification`
|
||||||
|
wireframes or the 2026-09-18 `ux-design.md` coverage matrix — those are stale.
|
||||||
|
- *To be scoped:* the exact thumb-first interaction model (wizard vs single-scroll,
|
||||||
|
chips vs pickers) — settled by the wireframe.
|
||||||
|
|
||||||
|
3. **Refine Call 2 so it delivers better long descriptions.**
|
||||||
|
- Call 2 (text) stays in the MVP and is the inference work of this milestone: improve
|
||||||
|
how it turns the human-picked taxonomy + dimension fact block + brand/geo context into
|
||||||
|
a long description that reads well for the store.
|
||||||
|
- Levers (task-level, settled during the sprint): prompt revision (METADATA-105/106),
|
||||||
|
the dimension fact block as the only numbers it may print (METADATA-65/90/95),
|
||||||
|
primary + secondary category weighting, brand voice / geo context settings
|
||||||
|
(SETTINGS-30), and recoverable/unrecoverable failure (METADATA-115).
|
||||||
|
- Success is judged on the exit-walk description quality (Ty reads it), not on a proxy
|
||||||
|
metric. Raw evidence recorded.
|
||||||
|
|
||||||
|
4. **Exit: real Woo draft submission.**
|
||||||
|
- PUBLISH-5 sequence, PUBLISH-10 (`SLAB-UAT-*` force draft), PUBLISH-25 (woo_product_id,
|
||||||
|
purge photos, discard local row, clear device draft). QUALITY-110 exit evidence.
|
||||||
|
|
||||||
|
## Non-goals (excluded from M4)
|
||||||
|
|
||||||
|
- **Call 1 (vision) inference** — deferred to future work. No auto-suggest, no confidence
|
||||||
|
pre-fill, no AI markers. METADATA-35/40/45/100 rows are out of the M4 active set.
|
||||||
|
- TLS on LAN transports (#78) — production-readiness, deferred.
|
||||||
|
- Backup/restore (OPS-30/35) — deferred ("Later").
|
||||||
|
- Instrumentation: Prometheus metrics, alerting, log retention (#7/#16) — deferred.
|
||||||
|
- Store-admin species-leaf creation (#13), shared validation module (#17), 409/422
|
||||||
|
field-level recovery (#18) — post-launch designs, deferred.
|
||||||
|
- Offline capture (CAPTURE-15), U2Net server matting (CAPTURE-165) — "Later".
|
||||||
|
- Browser e2e on all five clients (QUALITY-115) — "Later".
|
||||||
|
- Prompt-revision auth beyond trusted network (METADATA-107) — "Later".
|
||||||
|
- App login (relies on trusted LAN/Tailscale + HTTPS, OPS-55) — out of scope.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- M4 does **not** add an app-level login; the trusted-network model (OPS-55) holds.
|
||||||
|
- The `m4` branch is the working base; `main` is the merge target once M4 is accepted and green.
|
||||||
|
- Inference endpoint is a remote, configurable endpoint (OPS-45); M4 refines prompts/calls,
|
||||||
|
not the endpoint transport.
|
||||||
|
- Real Woo for the exit walk; Woo mock is a dishonest proxy (QUALITY-105/110).
|
||||||
|
- Existing M3 requirement rows that the tape-line model supersedes (CAPTURE-60, CAPTURE-75)
|
||||||
|
are updated in place; new rows are added for the tape-line behavior. No row is deleted.
|
||||||
|
|
||||||
|
## Requirements delta (draft — rows written at execution, after acceptance)
|
||||||
|
|
||||||
|
- **New rows:** tape-line calibration behavior (overlay, draggable ticks,
|
||||||
|
owner-declared length, tick-distance scale). ~1–3 rows under CAPTURE.
|
||||||
|
- **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood
|
||||||
|
categories (primary + secondary) / edge / figure / grade / moisture / tags by hand,
|
||||||
|
thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW.
|
||||||
|
- **Superseded/updated rows (draft):** CAPTURE-60 (length axis), CAPTURE-75 (scale model) —
|
||||||
|
rewritten to the tape-line model.
|
||||||
|
- **Deferred rows (out of M4 active set):** METADATA-35/40/45/100 (Call 1 vision
|
||||||
|
suggestions + confidence gate) — moved to "Later"/future work. The rows stay in
|
||||||
|
REQUIREMENTS.md; only their M4 scope drops.
|
||||||
|
- **Refined rows (draft):** METADATA-55/60/65 (Call 2) — if the human-picked flow changes
|
||||||
|
normative Call 2 inputs (it shouldn't materially; Call 2 already receives the metadata
|
||||||
|
confirmed in review).
|
||||||
|
- **Refined rows (draft):** REVIEW-15/20/25/40 (review-workflow usability) — field layout,
|
||||||
|
nudge behavior, primary/secondary category interaction, generate-text control placement,
|
||||||
|
if the usability changes alter normative review behavior. #94 (conditional review UI)
|
||||||
|
now means: the review screen is **always** the human-pick flow (no inference on/off
|
||||||
|
branch for Call 1).
|
||||||
|
- **Unchanged (in M4 ID subset):** all PUBLISH, REVIEW, SETTINGS, APP, OPS-40, QUALITY rows
|
||||||
|
that M3 already shipped and M4 re-uses for the exit; METADATA-5/10/15/30/85 (the data the
|
||||||
|
human collects + the deterministic title/short-desc).
|
||||||
|
|
||||||
|
## Acceptance criteria (draft)
|
||||||
|
|
||||||
|
1. **Tape-line calibration + M3 polish ship together (Goal 1):** the tape-line calibration
|
||||||
|
ships as a **fixed** centre line the owner pans/zooms/tilts the slab onto (line is never
|
||||||
|
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
|
||||||
|
passing tests and raw runner evidence.
|
||||||
|
2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick
|
||||||
|
species, wood categories (primary + secondary), edge, figure, grade, moisture, and tags
|
||||||
|
by hand on a phone, with no "Suggest with AI" surface and no AI pre-fill. The wireframe
|
||||||
|
was accepted before coding (design gate).
|
||||||
|
3. **Call 2 (text) is exercised on a live call** during the exit walk and delivers a
|
||||||
|
description that reads well (Ty judges it); content-gen control visibility (METADATA-110),
|
||||||
|
recoverable/unrecoverable classification (METADATA-115), and prompt-revision API
|
||||||
|
(METADATA-105/106) all pass their tests.
|
||||||
|
4. **A real `SLAB-UAT-*` Woo draft** exists in the store matching the reviewed ready set
|
||||||
|
(QUALITY-110), with `woo_product_id` set, photos purged, local row discarded, device
|
||||||
|
draft cleared (PUBLISH-25).
|
||||||
|
5. **Gates pass:** `python tools/check_requirements.py` and `python tools/check_coverage.py`
|
||||||
|
exit 0; full suite passes with inference **and** off (QUALITY-20) — now including the
|
||||||
|
human-pick flow with Call 1 removed; raw evidence recorded.
|
||||||
|
|
||||||
|
## Verification strategy (smallest that proves the claim)
|
||||||
|
|
||||||
|
- **Deterministic core:** unit tests for tape-line geometry + `crop_fill` (existing
|
||||||
|
`frontend/src/png/*` test surface).
|
||||||
|
- **Integration:** Woo mock for the PUBLISH-5 sequence (success, duplicate SKU, stale field,
|
||||||
|
taxonomy anchor) — QUALITY-35/50.
|
||||||
|
- **Exit walk:** `deploy/uat_walk.sh` on the real store for the `SLAB-UAT-*` draft
|
||||||
|
(QUALITY-105/110) — the only proof that counts for the submission claim.
|
||||||
|
- **Ty owns gate exit** (QUALITY-30); agents record raw evidence, never declare green.
|
||||||
|
|
||||||
|
## Task-level decisions (settled during the sprint, not at planning)
|
||||||
|
|
||||||
|
The only plan-level open item is the exact thumb-first interaction model
|
||||||
|
(wizard vs single-scroll, chips vs pickers) — settled by the wireframe.
|
||||||
|
Settled at kickoff (not reopened here): full #91–#100 polish wave (only #96 drops
|
||||||
|
with Call 1); #99 confined to the calibration step (CAPTURE-130 lineup untouched);
|
||||||
|
exit walk is a single slab carrying multiple photos.
|
||||||
|
|
||||||
|
## Task breakdown detail bar
|
||||||
|
|
||||||
|
The task breakdown is not a list of goal names. Every task carries, at minimum:
|
||||||
|
|
||||||
|
- **Goal it serves** (G1–G4) and the requirement IDs / issue numbers it touches.
|
||||||
|
- **Skills to load** for that task — named and ordered (e.g. UI task: `sketch` +
|
||||||
|
`wireframe-review-iteration` → `omh-frontend` + `omh-design-quality-gate` →
|
||||||
|
`omh-visual-qa` + `omh-accessibility-audit`).
|
||||||
|
- **Starting point / references** — the specific saved artifact, file, or branch the task
|
||||||
|
builds from (e.g. `docs/design/issue-99-tape-line-overlay.html`, the live
|
||||||
|
`Review.svelte`, the M3 `MultiSelect.svelte`).
|
||||||
|
- **Verification** — the smallest check that proves the task's claim (unit test paths,
|
||||||
|
`tsc --noEmit`, Woo mock, `deploy/uat_walk.sh`, 375/768/1280 visual captures, keyboard /
|
||||||
|
contrast audit). Ty owns gate exit.
|
||||||
|
- **Sequence / dependencies** — what must land first (wireframe accepted before review-UI
|
||||||
|
coding; requirements rows before the code that implements them; Call 2 refinement after
|
||||||
|
the human-pick flow feeds it real data).
|
||||||
|
- **Stop condition** — when the task is done (e.g. "wireframe accepted by Ty," "gates
|
||||||
|
green + raw evidence recorded," "real `SLAB-UAT-*` draft matches the ready set").
|
||||||
|
- **Evidence boundary** — what is prepared vs observed; no task claims done without the
|
||||||
|
raw runner / capture / store evidence named above.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# M4 Task Breakdown
|
||||||
|
|
||||||
|
Companion to `goals.md` (accepted direction). Detail bar per task: goal, skills,
|
||||||
|
starting point, verification, sequence, stop condition, evidence boundary.
|
||||||
|
|
||||||
|
Phase order: P0 (requirements first) → P1 (G1 polish + #99, parallel-safe) → P2 (G2
|
||||||
|
review-UI, wireframe-gated) → P3 (G3 Call 2) → P4 (G4 exit walk). Ty owns every gate
|
||||||
|
exit (QUALITY-30); agents record raw evidence, never declare green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Requirements baseline (must land before code it enables)
|
||||||
|
|
||||||
|
### T1. Requirements delta for M4
|
||||||
|
- **Goal:** G2/G4 (unblocks G3). **IDs:** REQS (whole doc), REQS-30. **Issues:** —.
|
||||||
|
- **Skills:** `document-to-action-items` (extract obligations), `ocr-and-documents`
|
||||||
|
(read current REQUIREMENTS.md), OMH `omh-routing` (evidence boundaries).
|
||||||
|
- **Starting point:** `docs/REQUIREMENTS.md` (current), `goals.md` requirements-delta
|
||||||
|
section.
|
||||||
|
- **Work:** add rows for the human-pick flow (REVIEW-*, CAPTURE-*, METADATA-*), the
|
||||||
|
Call 2 taxonomy-input row, and the `SLAB-UAT-*` exit-walk row; note the Call 1
|
||||||
|
deferral (METADATA-40/45/55) and drop #96; keep the "draft only" PUBLISH-40 invariant;
|
||||||
|
confirm #99 is tracked. Every new/changed row is the gate for the code that implements it.
|
||||||
|
- **Verification:** `python tools/check_requirements.py` exit 0; coverage matrix
|
||||||
|
consistent.
|
||||||
|
- **Sequence:** first task of the sprint.
|
||||||
|
- **Stop:** rows merged + gate green. **Evidence:** runner output.
|
||||||
|
|
||||||
|
### T2. #99 tape-line calibration (folded into G1) — AS-BUILT, shipped
|
||||||
|
- **Goal:** G1. **IDs:** CAPTURE-170, CAPTURE-175, CAPTURE-75. **Issues:** #99 (issue
|
||||||
|
body still describes the superseded draggable-tick model — this entry is the as-built).
|
||||||
|
- **Skills:** `systematic-debugging`, `svelte-core-bestpractices`,
|
||||||
|
`docker-uat-deploy` (isolate-and-verify the review preview).
|
||||||
|
- **As-built:** a **fixed** black centre line with two end ticks, drawn on the stage
|
||||||
|
fixture. The line is **never an owner input** — not draggable, not focusable, not
|
||||||
|
keyboard-nudgeable, and not part of the published pose. The **source photo/slab is the
|
||||||
|
only thing that moves**: drag to pan, wheel/pinch to zoom, the ↻ handle or arrow keys
|
||||||
|
(Shift = fine step) to tilt. The owner aligns the *slab* to the fixed line at the place
|
||||||
|
the tape measurement was taken. Typed Length is the real inches read off the tape, and
|
||||||
|
the fixed line represents that same length. Scale = fixed-line pixel span / declared
|
||||||
|
Length; `maskSpanAlong` is not the scale source. The crop slider is the **sole** control
|
||||||
|
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.
|
||||||
|
- **Stop:** SHIPPED. UAT-signed off by Ty 2026-09-25 on the M4 review preview
|
||||||
|
(`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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — M3 polish (G1), parallel-safe once T1 lands
|
||||||
|
|
||||||
|
### T3. Polish wave (full set #91–#100)
|
||||||
|
- **Goal:** G1. **IDs:** UX (various). **Issues:** all of #91–#100 — #91 (photo-picker
|
||||||
|
look/feel), #92 (photo filenames on submit), #93 (capture gated on tested Woo
|
||||||
|
connection), #94 (review layout), #97 (settings), #98 (publish), #100 (UX general).
|
||||||
|
**Excluded:** #96 (drops with Call 1). Ty confirmed: full polish wave, no triage.
|
||||||
|
- **Skills:** `svelte-core-bestpractices`, `omh-frontend` (component states),
|
||||||
|
`omh-visual-qa` (rendered evidence per screen).
|
||||||
|
- **Starting point:** the live screens named in each issue; `frontend/src/create/*`.
|
||||||
|
- **Work:** per-issue fixes. Each is a small, individually-verifiable unit; sequence
|
||||||
|
by dependency (e.g. #93 gate before #98 publish flow touches it).
|
||||||
|
- **Verification:** per-issue acceptance (capture at 375/768/1280, or the named
|
||||||
|
behavior), full suite green.
|
||||||
|
- **Sequence:** after T1; independent of T2.
|
||||||
|
- **Stop:** each issue's acceptance captured. **Evidence:** captures + test run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — Human-driven taxonomy review + thumb-first UI (G2)
|
||||||
|
|
||||||
|
### T4. Wireframe (design gate)
|
||||||
|
- **Goal:** G2. **IDs:** REVIEW-*, UX (phone). **Issues:** #100 (UX).
|
||||||
|
- **Skills:** `sketch` (variants) + `wireframe-review-iteration` (paradigms, serve on
|
||||||
|
LAN IP, iterate on Ty's feedback) → `omh-frontend` + `omh-design-quality-gate`
|
||||||
|
(lock the chosen variant).
|
||||||
|
- **Starting point:** `docs/design/issue-99-tape-line-overlay.html` (approved overlay
|
||||||
|
wireframe, 2026-09-24) for the visual language + the interaction model.
|
||||||
|
- **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms:
|
||||||
|
*no tap-bubble chip clouds for long lists → collapsible searchable checklist*;
|
||||||
|
leftmost-kept-is-primary for wood (primary + optional secondaries);
|
||||||
|
no AI-assist surface (Call 1 decommissioned); every metadata requirement present (primary
|
||||||
|
wood-category required, secondaries optional).
|
||||||
|
- **Verification:** Ty reviews the served variants and accepts one (375/768/1280).
|
||||||
|
- **Sequence:** before T5/T6. **Stop:** Ty accepts a variant.
|
||||||
|
**Evidence:** the accepted `docs/design/*` file + Ty's acceptance.
|
||||||
|
|
||||||
|
### T5. Review-UI implementation
|
||||||
|
- **Goal:** G2. **IDs:** REVIEW-*, CAPTURE-*. **Issues:** #100.
|
||||||
|
- **Skills:** `svelte-core-bestpractices`, `svelte-code-writer` (Svelte 5 lookups),
|
||||||
|
`omh-frontend`, `omh-visual-qa`, `omh-accessibility-audit` (keyboard, 44px targets,
|
||||||
|
contrast, 320px reflow).
|
||||||
|
- **Starting point:** accepted T4 wireframe; `frontend/src/create/review/Review.svelte`
|
||||||
|
(current single-column form); the M3 `MultiSelect.svelte` pattern (reused, not the
|
||||||
|
tap-bubble pattern).
|
||||||
|
- **Work:** replace the review form with the accepted thumb-first flow; primary
|
||||||
|
wood-category + optional secondaries; remove the "Suggest with AI" bar (#96 drops
|
||||||
|
with Call 1); keep all metadata requirements present and editable.
|
||||||
|
- **Verification:** `tsc --noEmit`; `omh-visual-qa` at 375/768/1280 (PASS);
|
||||||
|
`omh-accessibility-audit` (keyboard-reachable, 44px, contrast, 320px reflow);
|
||||||
|
full suite green with inference on **and** off (QUALITY-20).
|
||||||
|
- **Sequence:** after T4 accepted. **Stop:** flow renders, all requirements present,
|
||||||
|
audits pass. **Evidence:** captures + audit output.
|
||||||
|
|
||||||
|
### T6. Taxonomy → title/shortdesc (server-side)
|
||||||
|
- **Goal:** G2. **IDs:** METADATA-*. **Issues:** —.
|
||||||
|
- **Skills:** `systematic-debugging` (verify the title/shortdesc builders),
|
||||||
|
`evidence-based-code-review`.
|
||||||
|
- **Starting point:** the title/short description builders in `backend/` (review.py /
|
||||||
|
publish.py path); the new human-pick fields from T5.
|
||||||
|
- **Work:** ensure title + short description are built from the human-picked primary
|
||||||
|
wood + form (not from the removed Call 1). Confirm the server_description
|
||||||
|
(inference-off) path stays intact.
|
||||||
|
- **Verification:** unit tests for title/shortdesc from human picks; inference-off
|
||||||
|
path covered. **Sequence:** with/after T5. **Stop:** tests green.
|
||||||
|
**Evidence:** test output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3 — Refine Call 2 long description (G3)
|
||||||
|
|
||||||
|
### T7. Wire human-picked taxonomy into the Call 2 prompt
|
||||||
|
- **Goal:** G3. **IDs:** METADATA-*. **Issues:** —.
|
||||||
|
- **Skills:** `systematic-debugging`, `evidence-based-code-review`,
|
||||||
|
`local-llm-benchmarking` (endpoint behavior), `omh-ask` (prompt-engineering consult
|
||||||
|
if the owner wants an external pass).
|
||||||
|
- **Starting point:** `backend/inference.py` `call2` (line 467) — the prompt today is
|
||||||
|
the **dimension fact block + brand voice + geo only; taxonomy is NOT in the prompt**.
|
||||||
|
`CALL2_SEED` (line 112), `_active_prompt`, `brand_voice`/`geo_context` settings.
|
||||||
|
- **Work:** add the human-picked primary/secondary wood + form to the Call 2 fact
|
||||||
|
block so the long description is grounded in what the owner picked; keep the
|
||||||
|
"invent no numbers" constraint; keep the dimension block exact.
|
||||||
|
- **Verification:** `test_inference.py` updated for the new input; a recorded call2
|
||||||
|
with a known taxonomy set shows the wood/form in the output. **Sequence:** after T5
|
||||||
|
(needs the human-pick data shape). **Stop:** test passes + sample output grounded.
|
||||||
|
**Evidence:** test output + a sample Call 2 response.
|
||||||
|
|
||||||
|
### T8. Exit-walk quality gate
|
||||||
|
- **Goal:** G3. **IDs:** QUALITY-105/110. **Issues:** —.
|
||||||
|
- **Skills:** `omh-verification-gate` (record the gate), `omh-visual-qa`.
|
||||||
|
- **Starting point:** `deploy/uat_walk.sh`; the refined Call 2 from T7.
|
||||||
|
- **Work:** Ty reads the Call 2 long description on the exit walk for 2–3 real slabs;
|
||||||
|
pass = "sounds like a good product description." This is the only proof that counts
|
||||||
|
for the Call 2 quality claim.
|
||||||
|
- **Verification:** Ty's judgment on 2–3 outputs, recorded. **Sequence:** after T7.
|
||||||
|
**Stop:** Ty passes the gate. **Evidence:** Ty's recorded judgment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P4 — Exit: real Woo draft (G4)
|
||||||
|
|
||||||
|
### T9. `SLAB-UAT-*` draft submission + walk
|
||||||
|
- **Goal:** G4. **IDs:** PUBLISH-*, QUALITY-105/110. **Issues:** —.
|
||||||
|
- **Skills:** `omh-deploy-and-monitor` (release checklist + monitor),
|
||||||
|
`omh-verification-gate`, `docker-uat-deploy` (if the UAT host needs staging).
|
||||||
|
- **Starting point:** `backend/publish.py` (draft create path), the Woo mock
|
||||||
|
(`test_integration_woo_mock.py`) for the PUBLISH-5 sequence, `deploy/uat_walk.sh`.
|
||||||
|
- **Work:** run the full create → review (human pick) → Call 2 → publish path on the
|
||||||
|
real store, producing **one** `SLAB-UAT-*` draft; walk it (draft only, PUBLISH-40);
|
||||||
|
confirm the draft matches the ready set. **Shape (Ty confirmed):** single slab, and
|
||||||
|
the draft carries **multiple photos** of that slab — not a QUALITY-110 pair.
|
||||||
|
- **Verification:** `deploy/uat_walk.sh` on the real store; draft present in Woo with
|
||||||
|
the correct fields — including **multiple photos** on the single `SLAB-UAT-*` draft;
|
||||||
|
full suite green inference on **and** off (QUALITY-20).
|
||||||
|
- **Sequence:** last; after T3–T8. **Stop:** real draft matches the ready set + Ty
|
||||||
|
passes the walk. **Evidence:** store screenshot + walk transcript + suite output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency map
|
||||||
|
|
||||||
|
```
|
||||||
|
T1 ──┬─ T3 (polish)
|
||||||
|
├─ T4 ─ T5 ─┬─ T6
|
||||||
|
│ └─ T7 ─ T8 ─ T9
|
||||||
|
└─ T2 (tape-line, parallel)
|
||||||
|
T9 is the milestone exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
- T2 is independent of T1 (prototype already on `m4`).
|
||||||
|
- T4 must be accepted before T5.
|
||||||
|
- T7 needs T5's data shape; T8/T9 need T7.
|
||||||
|
- Nothing in P2/P3 blocks T2/T3 (polish + tape-line ship independently).
|
||||||
|
|
||||||
|
## Resolved at kickoff (deep-interview, 2026-09-24)
|
||||||
|
- **Polish scope:** full wave — all of #91–#100, no triage (only #96 drops with Call 1).
|
||||||
|
- **Tape-line scope:** confined to the calibration step; photos-ready lineup
|
||||||
|
(CAPTURE-130) untouched.
|
||||||
|
- **Exit-walk shape:** single slab; the `SLAB-UAT-*` draft carries multiple photos of
|
||||||
|
that slab (not a QUALITY-110 pair).
|
||||||
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
|
|||||||
"woo_create_status",
|
"woo_create_status",
|
||||||
"taxonomy_last_sync_at",
|
"taxonomy_last_sync_at",
|
||||||
"taxonomy_anchor",
|
"taxonomy_anchor",
|
||||||
|
"crop_fill",
|
||||||
}
|
}
|
||||||
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
||||||
|
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
|
||||||
|
NUMERIC_KEYS: set[str] = {"crop_fill"}
|
||||||
|
|
||||||
DEFAULTS: dict[str, Any] = {
|
DEFAULTS: dict[str, Any] = {
|
||||||
"inference_enabled": False,
|
"inference_enabled": False,
|
||||||
"content_llm_enabled": False,
|
"content_llm_enabled": False,
|
||||||
"woo_create_status": "draft",
|
"woo_create_status": "draft",
|
||||||
|
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
|
||||||
|
"crop_fill": 90,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +96,12 @@ def get_settings() -> dict[str, Any]:
|
|||||||
result[k] = val if val is not None else DEFAULTS.get(k)
|
result[k] = val if val is not None else DEFAULTS.get(k)
|
||||||
else:
|
else:
|
||||||
result[k] = DEFAULTS.get(k)
|
result[k] = DEFAULTS.get(k)
|
||||||
|
if k in NUMERIC_KEYS:
|
||||||
|
try:
|
||||||
|
f = float(result[k])
|
||||||
|
result[k] = int(f) if f.is_integer() else f
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
result[k] = DEFAULTS.get(k)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -108,6 +119,25 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #99: rules live here in the 4xx layer, not in the DB (structural only).
|
||||||
|
if "crop_fill" in payload:
|
||||||
|
raw = payload["crop_fill"]
|
||||||
|
try:
|
||||||
|
pct = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pct = None
|
||||||
|
if pct is None or not (1 <= pct <= 100):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "validation",
|
||||||
|
"message": "crop_fill must be a number between 1 and 100 (percent)",
|
||||||
|
"detail": {"field": "crop_fill"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
for k, v in payload.items():
|
for k, v in payload.items():
|
||||||
if k not in ALL_KEYS:
|
if k not in ALL_KEYS:
|
||||||
|
|||||||
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["inference_api_key"] == {"configured": False}
|
assert body["inference_api_key"] == {"configured": False}
|
||||||
db_mod._reset_for_tests()
|
db_mod._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||||
|
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_setup_env(monkeypatch, tmp)
|
||||||
|
from backend.app import app
|
||||||
|
client = TestClient(app)
|
||||||
|
db_mod.init_db()
|
||||||
|
resp = client.get("/api/v1/settings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["crop_fill"] == 90
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||||
|
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_setup_env(monkeypatch, tmp)
|
||||||
|
from backend.app import app
|
||||||
|
client = TestClient(app)
|
||||||
|
db_mod.init_db()
|
||||||
|
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["crop_fill"] == 75
|
||||||
|
conn = db_mod.get_connection()
|
||||||
|
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||||
|
assert row["value"] == "75" and row["value_enc"] is None
|
||||||
|
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||||
|
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_setup_env(monkeypatch, tmp)
|
||||||
|
from backend.app import app
|
||||||
|
client = TestClient(app)
|
||||||
|
db_mod.init_db()
|
||||||
|
for bad in (0, 101, -5, "abc", None):
|
||||||
|
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||||
|
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||||
|
assert resp.json()["error"]["code"] == "validation"
|
||||||
|
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||||
|
# boundaries are valid
|
||||||
|
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||||
|
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+18
-13
@@ -18,7 +18,7 @@
|
|||||||
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';
|
||||||
@@ -26,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;
|
||||||
|
|
||||||
@@ -37,16 +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();
|
||||||
|
void getSettings().then((s) => {
|
||||||
|
if (typeof s.crop_fill === 'number') {
|
||||||
|
cropFill = s.crop_fill;
|
||||||
|
session.setCropFill(s.crop_fill);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubCap();
|
unsubCap();
|
||||||
@@ -216,18 +219,20 @@
|
|||||||
{:else if cap.k === 'axis'}
|
{:else if cap.k === 'axis'}
|
||||||
<AxisPanel
|
<AxisPanel
|
||||||
state={cap}
|
state={cap}
|
||||||
setAxisAngle={session.setAxisAngle}
|
{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}
|
||||||
|
|||||||
@@ -454,6 +454,8 @@ export interface SettingsView {
|
|||||||
woo_create_status: string;
|
woo_create_status: string;
|
||||||
taxonomy_last_sync_at: string | null;
|
taxonomy_last_sync_at: string | null;
|
||||||
taxonomy_anchor: string | null;
|
taxonomy_anchor: string | null;
|
||||||
|
/** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */
|
||||||
|
crop_fill: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSettings(): Promise<SettingsView> {
|
export function getSettings(): Promise<SettingsView> {
|
||||||
|
|||||||
@@ -1,58 +1,385 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Capture } from './capture.types';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
import type { Capture, PhotoTransform } from './capture.types';
|
||||||
|
import { eighthsToIn, inToEighths } from './capture.types';
|
||||||
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
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 setAxisAngle: (deg: number) => 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;
|
||||||
|
|
||||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
const frame = CALIBRATION_FRAME;
|
||||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
let stageEl: HTMLDivElement | undefined;
|
||||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
|
let bitmap: ImageBitmap | null = null;
|
||||||
|
let maskLayer: HTMLCanvasElement | null = null;
|
||||||
|
let sourceW = state.source.widthPx;
|
||||||
|
let sourceH = state.source.heightPx;
|
||||||
|
let renderedW = 320;
|
||||||
|
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? IDENTITY_TRANSFORM) };
|
||||||
|
// The line is a stage fixture. Its two ends are the crop insets, so they are
|
||||||
|
// a pure function of crop_fill and are never an owner input (#99).
|
||||||
|
let fill = cropFill;
|
||||||
|
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||||
|
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||||
|
let calcBusy = false;
|
||||||
|
let drag: 'tilt' | 'pan' | 'pinch' | null = null;
|
||||||
|
let pointers = new Map<number, [number, number]>();
|
||||||
|
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;
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
|
function publish() {
|
||||||
|
setTapePose({
|
||||||
|
transform: { ...transform },
|
||||||
|
sourceW,
|
||||||
|
sourceH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFrame(event: { clientX: number; clientY: number }): [number, number] {
|
||||||
|
const rect = canvas!.getBoundingClientRect();
|
||||||
|
renderedW = rect.width;
|
||||||
|
return [
|
||||||
|
((event.clientX - rect.left) / rect.width) * frame.width,
|
||||||
|
((event.clientY - rect.top) / rect.height) * frame.height,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (!canvas || !stageEl) return;
|
||||||
|
const rect = stageEl.getBoundingClientRect();
|
||||||
|
renderedW = rect.width || renderedW;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||||
|
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform((dpr * rect.width) / frame.width, 0, 0, (dpr * rect.height) / frame.height, 0, 0);
|
||||||
|
ctx.fillStyle = '#111';
|
||||||
|
ctx.fillRect(0, 0, frame.width, frame.height);
|
||||||
|
if (bitmap && sourceW > 0 && sourceH > 0) {
|
||||||
|
const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(frame.width / 2 + transform.panX, frame.height / 2 + transform.panY);
|
||||||
|
ctx.rotate((transform.tiltDeg * Math.PI) / 180);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
|
||||||
|
if (maskLayer) {
|
||||||
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
|
ctx.drawImage(maskLayer, -sourceW / 2, -sourceH / 2);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
const x = frame.width / 2;
|
||||||
|
const ends = cropEnds(fill);
|
||||||
|
ctx.strokeStyle = '#000';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
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.fillStyle = '#eee';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, frame.height - 28, 16, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reseat() {
|
||||||
|
// The line's ends follow crop_fill; nothing about them is owner-draggable.
|
||||||
|
publish();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAt(point: [number, number], factor: number) {
|
||||||
|
const z1 = clamp(transform.zoom * factor, 0.5, 3);
|
||||||
|
if (z1 === transform.zoom) return;
|
||||||
|
const cx = frame.width / 2;
|
||||||
|
const cy = frame.height / 2;
|
||||||
|
const qx = cx + (point[0] - cx - transform.panX) / transform.zoom;
|
||||||
|
const qy = cy + (point[1] - cy - transform.panY) / transform.zoom;
|
||||||
|
transform = {
|
||||||
|
...transform,
|
||||||
|
zoom: z1,
|
||||||
|
panX: point[0] - cx - z1 * (qx - cx),
|
||||||
|
panY: point[1] - cy - z1 * (qy - cy),
|
||||||
|
};
|
||||||
|
publish();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const x = frame.width / 2;
|
||||||
|
if (Math.hypot(p[0] - x, p[1] - (frame.height - 28)) <= 22) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => bitmap?.close());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKey} />
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Length axis</h1>
|
<h1>Length</h1>
|
||||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</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>
|
||||||
<PhotoStage
|
<div class="stage" bind:this={stageEl} data-testid="tape-line">
|
||||||
blob={state.source.blob}
|
<canvas
|
||||||
bbox={state.axis.bbox}
|
bind:this={canvas}
|
||||||
maskBitset={state.approvedMask.bitset}
|
on:pointerdown={onPointerDown}
|
||||||
{maskW}
|
on:pointermove={onPointerMove}
|
||||||
{maskH}
|
on:pointerup={onPointerUp}
|
||||||
/>
|
on:pointercancel={onPointerUp}
|
||||||
<div class="angle">
|
on:wheel={onWheel}
|
||||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
></canvas>
|
||||||
<input
|
|
||||||
type="range" min="0" max="179" step="1"
|
|
||||||
value={angleDeg}
|
|
||||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
|
||||||
/>
|
|
||||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
|
||||||
<span class="val">{angleDeg}°</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="ctl">Tilt</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" on:click={approveAxis}>Approve axis</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; }
|
.hint, .nudge { color: #aaa; font-size: 0.85rem; line-height: 1.4; margin: 0 0 12px; }
|
||||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
.ctl { color: #aaa; font-size: 0.78rem; margin: 14px 0 6px; }
|
||||||
|
.stage { width: 100%; aspect-ratio: 3 / 4; background: #111; border: 1px solid #333; border-radius: 3px; overflow: hidden; touch-action: none; }
|
||||||
|
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
|
.row { display: flex; align-items: center; gap: 10px; }
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
width: 44px; height: 44px; border-radius: 3px; font-size: 1.3rem;
|
||||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||||
}
|
}
|
||||||
input[type='range'] { flex: 1; }
|
input[type='range'] { flex: 1; }
|
||||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
.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 {
|
.primary {
|
||||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
|
||||||
background: #00e5ff; color: #002; font-weight: 700;
|
background: #00e5ff; color: #002; font-weight: 700;
|
||||||
}
|
}
|
||||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
.ghost { padding: 12px 16px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +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 PhotoStage from './PhotoStage.svelte';
|
|
||||||
import {
|
import {
|
||||||
buildPhotosReadyLineup,
|
buildPhotosReadyLineup,
|
||||||
photosReadyPngAlt,
|
photosReadyPngAlt,
|
||||||
@@ -11,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 = [];
|
||||||
@@ -48,92 +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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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">Length L</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>
|
|
||||||
|
|
||||||
|
{#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>
|
||||||
@@ -169,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">
|
||||||
@@ -191,14 +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; }
|
|
||||||
.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;
|
||||||
@@ -229,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,13 +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 80%-fill PNG crop
|
slabBounds: ABox; // axis-aligned extent of slab px; the PNG crop bounds (#99)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
export interface PhotoTransform {
|
||||||
// corners[3] → corners[0] (the long side).
|
tiltDeg: number;
|
||||||
|
zoom: number;
|
||||||
|
panX: number;
|
||||||
|
panY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tape line is a stage fixture. bbox is kept for the dimension overlay.
|
||||||
|
// Scale is the source-pixel distance between tapeStart and tapeEnd.
|
||||||
export interface Axis {
|
export interface Axis {
|
||||||
bbox: BBox;
|
bbox: BBox;
|
||||||
|
photoTransform: PhotoTransform;
|
||||||
|
tickStartY: number;
|
||||||
|
tickEndY: number;
|
||||||
|
tapeStart: [number, number];
|
||||||
|
tapeEnd: [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Widths {
|
export interface Widths {
|
||||||
@@ -96,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';
|
||||||
@@ -138,6 +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
|
||||||
|
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 =
|
||||||
@@ -520,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/);
|
||||||
|
|||||||
+224
-119
@@ -1,16 +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, 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 { orientedRect } 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 { 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;
|
||||||
@@ -40,13 +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;
|
||||||
setAxisAngle(deg: number): void;
|
setTapePose(pose: {
|
||||||
|
transform: PhotoTransform;
|
||||||
|
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;
|
||||||
@@ -65,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 {
|
||||||
@@ -81,8 +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;
|
||||||
|
let cropFill = 90;
|
||||||
|
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;
|
||||||
@@ -103,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) => {
|
||||||
@@ -207,7 +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,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
photoTransform: state.axis.photoTransform,
|
||||||
|
tickStartY: state.axis.tickStartY,
|
||||||
|
tickEndY: state.axis.tickEndY,
|
||||||
|
tapeStart: state.axis.tapeStart,
|
||||||
|
tapeEnd: state.axis.tapeEnd,
|
||||||
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths, results: null, inventoryPng: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (state.k === 'measure') {
|
if (state.k === 'measure') {
|
||||||
@@ -217,6 +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,
|
||||||
|
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,
|
||||||
@@ -308,6 +355,92 @@ 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;
|
||||||
@@ -338,16 +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') {
|
||||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
set({ k: 'axis', source, approvedMask: mask, axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
|
||||||
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: { bbox: draftAxisBbox(d) },
|
axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d),
|
||||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||||
results: d.results
|
results: d.results
|
||||||
? {
|
? {
|
||||||
@@ -377,7 +512,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
void persist();
|
void persist();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const session: CaptureSession = {
|
||||||
capture,
|
capture,
|
||||||
busy,
|
busy,
|
||||||
|
|
||||||
@@ -476,22 +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;
|
||||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
const bbox = { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg };
|
||||||
|
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: axisFrom(bbox, state.source.widthPx, state.source.heightPx), lengthEighths: 0, thicknessEighths: 0 });
|
||||||
void persist();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
setAxisAngle(deg: number) {
|
setTapePose(pose) {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
const sourceW = pose.sourceW > 0 ? pose.sourceW : state.source.widthPx;
|
||||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
const sourceH = pose.sourceH > 0 ? pose.sourceH : state.source.heightPx;
|
||||||
|
const source = sourceW > 0 && sourceH > 0
|
||||||
|
? { ...state.source, widthPx: sourceW, heightPx: sourceH }
|
||||||
|
: state.source;
|
||||||
|
set({
|
||||||
|
...state,
|
||||||
|
source,
|
||||||
|
axis: axisFrom(state.axis.bbox, sourceW, sourceH, pose),
|
||||||
|
});
|
||||||
|
if (poseTimer) clearTimeout(poseTimer);
|
||||||
|
poseTimer = setTimeout(() => {
|
||||||
|
poseTimer = null;
|
||||||
void persist();
|
void persist();
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCropFill(pct: number) {
|
||||||
|
cropFill = Math.max(1, Math.min(100, Math.round(pct)));
|
||||||
},
|
},
|
||||||
|
|
||||||
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 },
|
axis: state.axis,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||||
});
|
});
|
||||||
void persist();
|
void persist();
|
||||||
@@ -509,105 +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 bitmap = await decodeOrientedBitmap(source.blob);
|
approvedMask: state.approvedMask,
|
||||||
const base = measure(
|
axis: state.axis,
|
||||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
lengthEighths: state.lengthEighths,
|
||||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
thicknessEighths: state.thicknessEighths,
|
||||||
);
|
|
||||||
let pngBlob: Blob;
|
|
||||||
let belowFloor = false;
|
|
||||||
try {
|
|
||||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
|
||||||
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 });
|
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();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -626,14 +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: { bbox: state.axis.bbox } });
|
set({
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
@@ -642,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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,8 +17,11 @@
|
|||||||
brand_voice: string | null;
|
brand_voice: string | null;
|
||||||
geo_context: string | null;
|
geo_context: string | null;
|
||||||
woo_create_status: string | null;
|
woo_create_status: string | null;
|
||||||
|
crop_fill: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export let onCropFillChange: (pct: number) => void = () => {};
|
||||||
|
|
||||||
interface PriceRule {
|
interface PriceRule {
|
||||||
id: number;
|
id: number;
|
||||||
species_id: number;
|
species_id: number;
|
||||||
@@ -205,6 +208,7 @@
|
|||||||
brand_voice: settings.brand_voice || '',
|
brand_voice: settings.brand_voice || '',
|
||||||
geo_context: settings.geo_context || '',
|
geo_context: settings.geo_context || '',
|
||||||
woo_create_status: settings.woo_create_status || 'draft',
|
woo_create_status: settings.woo_create_status || 'draft',
|
||||||
|
crop_fill: settings.crop_fill ?? 90,
|
||||||
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
||||||
woo_app_password: secretValue(settings.woo_app_password),
|
woo_app_password: secretValue(settings.woo_app_password),
|
||||||
inference_base_url: secretValue(settings.inference_base_url),
|
inference_base_url: secretValue(settings.inference_base_url),
|
||||||
@@ -230,6 +234,7 @@
|
|||||||
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
||||||
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
||||||
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
||||||
|
if (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
|
||||||
// secrets only if provided (write-only)
|
// secrets only if provided (write-only)
|
||||||
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
||||||
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
||||||
@@ -243,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);
|
||||||
@@ -351,6 +357,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="crop-fill">Crop Fill % (default 90) — inventory PNG + reference line</label>
|
||||||
|
<input id="crop-fill" type="range" min="1" max="100" step="1" bind:value={form.crop_fill} />
|
||||||
|
<span>{form.crop_fill}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button on:click={save} disabled={saving}>Save Changes</button>
|
<button on:click={save} disabled={saving}>Save Changes</button>
|
||||||
{#if message}<p class="msg">{message}</p>{/if}
|
{#if message}<p class="msg">{message}</p>{/if}
|
||||||
|
|
||||||
@@ -415,7 +427,10 @@
|
|||||||
<style>
|
<style>
|
||||||
.settings { max-width: 600px; color: #eee; }
|
.settings { max-width: 600px; color: #eee; }
|
||||||
label { display: block; margin: 8px 0 4px; }
|
label { display: block; margin: 8px 0 4px; }
|
||||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; }
|
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; box-sizing: border-box; }
|
||||||
|
/* width:100% + the UA default input margins overflowed narrow viewports
|
||||||
|
(mobile Settings scrolled 16px sideways). Zero the horizontal margins. */
|
||||||
|
.settings input, .settings select { margin-left: 0; margin-right: 0; }
|
||||||
option { color: #eee; background: #1c1c1c; }
|
option { color: #eee; background: #1c1c1c; }
|
||||||
button { margin-top: 16px; }
|
button { margin-top: 16px; }
|
||||||
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
|||||||
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);
|
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
|
||||||
|
|||||||
@@ -76,11 +76,20 @@ export function measure(
|
|||||||
bbox: BBox,
|
bbox: BBox,
|
||||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||||
slabPx: number,
|
slabPx: 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);
|
||||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
const tapeSpan = tape
|
||||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
? Math.hypot(tape.end[0] - tape.start[0], tape.end[1] - tape.start[1])
|
||||||
|
: 0;
|
||||||
|
const scale = tapeSpan > 0
|
||||||
|
? tapeSpan / lengthIn
|
||||||
|
: (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||||
|
const axisStart = tapeSpan > 0 ? tape!.start : seg.start;
|
||||||
|
const axisUx = tapeSpan > 0 ? (tape!.end[0] - tape!.start[0]) / tapeSpan : seg.ux;
|
||||||
|
const axisUy = tapeSpan > 0 ? (tape!.end[1] - tape!.start[1]) / tapeSpan : seg.uy;
|
||||||
|
const every6In = sampleWidths(bitset, w, h, axisStart, axisUx, axisUy, scale, lengthIn);
|
||||||
let widths: Widths;
|
let widths: Widths;
|
||||||
if (every6In.length > 0) {
|
if (every6In.length > 0) {
|
||||||
const min = Math.min(...every6In);
|
const min = Math.min(...every6In);
|
||||||
|
|||||||
@@ -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,7 +1,6 @@
|
|||||||
import type { Mask, Source } from '../capture/capture.types';
|
import type { Mask, Source } from '../capture/capture.types';
|
||||||
|
|
||||||
const FLOOR = 800; // shorter side, px
|
const FLOOR = 800; // shorter side, px
|
||||||
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
|
|
||||||
const ASPECT_W = 3;
|
const ASPECT_W = 3;
|
||||||
const ASPECT_H = 4;
|
const ASPECT_H = 4;
|
||||||
|
|
||||||
@@ -12,6 +11,10 @@ export interface InventoryInput {
|
|||||||
bitmap: ImageBitmap;
|
bitmap: ImageBitmap;
|
||||||
mask: MaskPick;
|
mask: MaskPick;
|
||||||
source: SourcePick;
|
source: SourcePick;
|
||||||
|
/** Global crop fill percent. Default 90. */
|
||||||
|
cropFill?: number;
|
||||||
|
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
|
||||||
|
rotationDeg?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
@@ -25,7 +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 = 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));
|
||||||
@@ -36,7 +44,7 @@ export function composeInventoryFrame(
|
|||||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
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 scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
|
||||||
const dw = sw * scale;
|
const dw = sw * scale;
|
||||||
const dh = sh * scale;
|
const dh = sh * scale;
|
||||||
const ox = (outW - dw) / 2;
|
const ox = (outW - dw) / 2;
|
||||||
@@ -62,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);
|
||||||
@@ -74,6 +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,
|
||||||
|
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,5 +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 { 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[] {
|
||||||
@@ -485,15 +486,31 @@ export function inventorySourceMap(
|
|||||||
source: { widthPx: number; heightPx: number },
|
source: { widthPx: number; heightPx: number },
|
||||||
outW: number,
|
outW: number,
|
||||||
outH: number,
|
outH: number,
|
||||||
|
fill = 0.9,
|
||||||
|
bitset?: Uint8Array,
|
||||||
|
rotationDeg = 0,
|
||||||
): InventoryMap {
|
): InventoryMap {
|
||||||
const FILL = 0.9;
|
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 = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
|
||||||
const dw = sw * scale;
|
const dw = sw * scale;
|
||||||
const dh = sh * scale;
|
const dh = sh * scale;
|
||||||
const ox = (outW - dw) / 2;
|
const ox = (outW - dw) / 2;
|
||||||
@@ -517,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -531,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