11 Commits

Author SHA1 Message Date
Ty f725e04ae6 docs(m4): correct stale 80%-fill crop comment (#99 §7) 2026-09-25 09:21:07 -07:00
Ty 8673ae0ecd docs(m4): record #99 as-built and sweep stale draggable-tick refs (#99) 2026-09-25 09:17:23 -07:00
Ty a756cf58bb fix(m4): drop stale tick-nudge hint from tape page (#99) 2026-09-25 08:56:57 -07:00
Ty d50d55aea9 fix(m4): tape line is a fixed fixture, not draggable (#99) 2026-09-25 08:53:38 -07:00
Ty 1882fa3842 fix(m4): apply tape tilt to calibration output (#99) 2026-09-25 07:31:13 -07:00
Ty 7908a99ec3 fix(m4): keep tape calibration on one page (#99) 2026-09-24 22:33:23 -07:00
Ty 6000ac2b0f feat(m4): fixed tape line for length calibration (#99)
The photo pans, zooms, and tilts under a line that stays in the middle
of the frame. Scale is the source-pixel distance between the ticks
divided by the declared length, not the mask span.
2026-09-24 17:56:21 -07:00
Ty d49ff2fab5 fix(capture): align masked tape calibration with #99 2026-09-24 16:49:17 -07:00
Ty 4ca2ef57a7 feat(capture): ship tape-line calibration (#99) 2026-09-24 14:47:13 -07:00
Ty 6fb92362a4 docs(m4): M4 goals + task breakdown
Goals: polish M3 + #99 tape-line, human-driven taxonomy review
(thumb-first UI, Call 1 deferred), Call 2 inference refinement,
real SLAB-UAT-* Woo draft exit.

Tasks: 9-task / 5-phase breakdown with detail bar (skills, starting
point, verification, sequence, stop condition, evidence boundary per
task). Kickoff decisions folded in: full polish wave (#91-#100 minus
#96), tape-line confined to calibration step, single-slab multi-photo
exit walk.
2026-09-24 13:44:53 -07:00
Ty 818874e65c feat(m4): crop_fill setting + tape-line prototype (#99)
Add global crop_fill percent (default 90, 1-100) as a settings key with
FastAPI 4xx validation. Wires the value through to the inventory PNG
encode (non-retro: stored PNGs untouched). Adds a throwaway
TapePrototype.svelte behind ?tape-prototype for the #99 design sandbox.

Gates: check_requirements PASS, check_coverage PASS, settings tests 8/8.
2026-09-24 12:01:18 -07:00
31 changed files with 1953 additions and 310 deletions
+192
View File
@@ -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.
+205
View File
@@ -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).
+30
View File
@@ -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:
+48
View File
@@ -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()
+30
View File
@@ -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
View File
@@ -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
+9 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2
View File
@@ -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> {
+360 -33
View File
@@ -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>
+4 -106
View File
@@ -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>
+23 -4
View File
@@ -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
+10 -1
View File
@@ -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
View File
@@ -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.
+85
View File
@@ -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}`);
});
+104
View File
@@ -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),
};
}
+16 -1
View File
@@ -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; }
+7
View File
@@ -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);
+11 -2
View File
@@ -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);
+31
View File
@@ -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);
+134 -3
View File
@@ -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')!;
+31 -1
View File
@@ -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() {
+31 -3
View File
@@ -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,
+8 -2
View File
@@ -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 -1
View File
@@ -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` +
+8 -12
View File
@@ -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('/')}`,
+141
View File
@@ -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');
});
});
+1 -1
View File
@@ -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)*"}