Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f725e04ae6 | |||
| 8673ae0ecd | |||
| a756cf58bb | |||
| d50d55aea9 | |||
| 1882fa3842 | |||
| 7908a99ec3 | |||
| 6000ac2b0f | |||
| d49ff2fab5 | |||
| 4ca2ef57a7 | |||
| 6fb92362a4 | |||
| 818874e65c |
@@ -0,0 +1,192 @@
|
||||
# M4 — Goals
|
||||
|
||||
Role: goals for Milestone 4. Cites requirement IDs and Gitea issue numbers; defines none
|
||||
new until accepted. Source of truth for scope will be `docs/MILESTONES.md` (M4 ID subset)
|
||||
and `docs/REQUIREMENTS.md` (rows) after acceptance.
|
||||
|
||||
**Base:** builds on branch `m4` (commit `818874e`, `feat(m4): crop_fill setting + tape-line
|
||||
prototype (#99)`), which is 1 ahead of `origin/main`. #99 is in scope.
|
||||
|
||||
**Objective:** Ship a polished, stable M3 experience with the tape-line calibration model,
|
||||
a **human-driven taxonomy review** (Call 1 deferred; owner picks everything on a thumb-first
|
||||
UI), **refined Call 2 long descriptions**, and a verified real WooCommerce draft submission.
|
||||
|
||||
**Exit criterion:** A real `SLAB-UAT-*` Woo **draft** matching the reviewed ready set exists
|
||||
in the store, created end-to-end through the M4 UI (tape-line calibration → human taxonomy
|
||||
review → Call 2 long description → publish), with **no Call 1** in the path (inference
|
||||
settings need not be vision-enabled). Call 2 is exercised on a live call at least once.
|
||||
Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
|
||||
|
||||
## Goals (accepted direction)
|
||||
|
||||
1. **Stabilize + polish the M3 experience (A) — includes the #99 tape-line calibration (B).**
|
||||
- **Tape-line calibration (#99) — SHIPPED (as-built 2026-09-25, UAT signed off):** the
|
||||
axis-rectangle calibration is replaced by a **fixed** black centre line with two end
|
||||
ticks. The line is a stage fixture and is **never an owner input** (not draggable, not
|
||||
focusable, not keyboard-nudgeable). The **source photo/slab is the only thing that
|
||||
moves** — pan/zoom/tilt it until it lines up with the fixed line at the place the tape
|
||||
measurement was taken. Declared Length = the real inches read off the tape, and the
|
||||
fixed line represents that same length; scale = fixed-line pixel span / declared
|
||||
Length, no `maskSpanAlong` check. The crop slider alone sets the line ends
|
||||
(`crop_fill=90` ⇒ each end 5% from its edge). The earlier `?tape-prototype` sandbox,
|
||||
`TapePrototype.svelte`, `frontend/src/png/crop.ts` and the `sveltedrag` dependency are
|
||||
all superseded/removed. Requirement rows CAPTURE-170/175/75 carry the as-built; see
|
||||
`tasks.md` T2 for detail.
|
||||
- **UX polish:** photo-picker look/feel (#91), photo filenames on submit (#92), capture
|
||||
gated on a tested Woo connection (#93), review UI is always the human-pick flow (#94),
|
||||
debug mode (#95), required-photo lock glyph (#100). (#96 Call 1/2 prompt viewer drops
|
||||
with Call 1.)
|
||||
- Post-launch issue work + the tape-line feature; no other new feature surface.
|
||||
|
||||
2. **Human-driven taxonomy review + thumb-first UI (Call 1 deferred; Call 2 kept).**
|
||||
- **Call 1 (vision) → deferred to future work.** Not in the MVP: no "Suggest with AI"
|
||||
button, no auto-pre-fill, no confidence pre-fill, no AI markers on fields. The
|
||||
inference settings need not be vision-enabled.
|
||||
- **Owner picks everything Call 1 used to propose**, via a thumb-first, on-phone usable
|
||||
UI: species (1 leaf), wood categories (primary + optional secondaries), Edge, Figure
|
||||
(multi), Grade (owner judgment), Thickness band (derived, no pick), Moisture, and
|
||||
`feat-*` / `fig-*` tags. All from the synced Woo taxonomy (METADATA-5/10/15/30).
|
||||
- **Call 2 (text) stays** — writes the long description from the human-picked values +
|
||||
dimension fact block; brand/geo context, primary + secondary categories. (Refining its
|
||||
output quality is its own goal, #3 below.)
|
||||
- **Title + short description remain deterministic** from the primary wood category +
|
||||
dimension fact block (METADATA-80/85) — they depend only on the human's primary pick
|
||||
+ fact block, not on Call 1.
|
||||
- **All METADATA requirements remain in scope** — the human collects the same data
|
||||
Call 1 previously suggested, just by hand.
|
||||
- **Wireframe first (a sprint task in this milestone):** before any review-UI coding,
|
||||
produce a wireframe (2-3 HTML variants) to align on the overall flow — how the
|
||||
thumb-first taxonomy collection, primary/secondary selection, and Call 2 generate
|
||||
control compose on a phone. This is the design gate for the review work.
|
||||
**Starting point (saved, recent):** `docs/design/issue-99-tape-line-overlay.html`
|
||||
(approved #99 tape-line overlay wireframe, committed 2026-09-24 on
|
||||
`issue-99-tape-line-overlay`). Do **not** build on the older `pre-simplification`
|
||||
wireframes or the 2026-09-18 `ux-design.md` coverage matrix — those are stale.
|
||||
- *To be scoped:* the exact thumb-first interaction model (wizard vs single-scroll,
|
||||
chips vs pickers) — settled by the wireframe.
|
||||
|
||||
3. **Refine Call 2 so it delivers better long descriptions.**
|
||||
- Call 2 (text) stays in the MVP and is the inference work of this milestone: improve
|
||||
how it turns the human-picked taxonomy + dimension fact block + brand/geo context into
|
||||
a long description that reads well for the store.
|
||||
- Levers (task-level, settled during the sprint): prompt revision (METADATA-105/106),
|
||||
the dimension fact block as the only numbers it may print (METADATA-65/90/95),
|
||||
primary + secondary category weighting, brand voice / geo context settings
|
||||
(SETTINGS-30), and recoverable/unrecoverable failure (METADATA-115).
|
||||
- Success is judged on the exit-walk description quality (Ty reads it), not on a proxy
|
||||
metric. Raw evidence recorded.
|
||||
|
||||
4. **Exit: real Woo draft submission.**
|
||||
- PUBLISH-5 sequence, PUBLISH-10 (`SLAB-UAT-*` force draft), PUBLISH-25 (woo_product_id,
|
||||
purge photos, discard local row, clear device draft). QUALITY-110 exit evidence.
|
||||
|
||||
## Non-goals (excluded from M4)
|
||||
|
||||
- **Call 1 (vision) inference** — deferred to future work. No auto-suggest, no confidence
|
||||
pre-fill, no AI markers. METADATA-35/40/45/100 rows are out of the M4 active set.
|
||||
- TLS on LAN transports (#78) — production-readiness, deferred.
|
||||
- Backup/restore (OPS-30/35) — deferred ("Later").
|
||||
- Instrumentation: Prometheus metrics, alerting, log retention (#7/#16) — deferred.
|
||||
- Store-admin species-leaf creation (#13), shared validation module (#17), 409/422
|
||||
field-level recovery (#18) — post-launch designs, deferred.
|
||||
- Offline capture (CAPTURE-15), U2Net server matting (CAPTURE-165) — "Later".
|
||||
- Browser e2e on all five clients (QUALITY-115) — "Later".
|
||||
- Prompt-revision auth beyond trusted network (METADATA-107) — "Later".
|
||||
- App login (relies on trusted LAN/Tailscale + HTTPS, OPS-55) — out of scope.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- M4 does **not** add an app-level login; the trusted-network model (OPS-55) holds.
|
||||
- The `m4` branch is the working base; `main` is the merge target once M4 is accepted and green.
|
||||
- Inference endpoint is a remote, configurable endpoint (OPS-45); M4 refines prompts/calls,
|
||||
not the endpoint transport.
|
||||
- Real Woo for the exit walk; Woo mock is a dishonest proxy (QUALITY-105/110).
|
||||
- Existing M3 requirement rows that the tape-line model supersedes (CAPTURE-60, CAPTURE-75)
|
||||
are updated in place; new rows are added for the tape-line behavior. No row is deleted.
|
||||
|
||||
## Requirements delta (draft — rows written at execution, after acceptance)
|
||||
|
||||
- **New rows:** tape-line calibration behavior (overlay, draggable ticks,
|
||||
owner-declared length, tick-distance scale). ~1–3 rows under CAPTURE.
|
||||
- **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood
|
||||
categories (primary + secondary) / edge / figure / grade / moisture / tags by hand,
|
||||
thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW.
|
||||
- **Superseded/updated rows (draft):** CAPTURE-60 (length axis), CAPTURE-75 (scale model) —
|
||||
rewritten to the tape-line model.
|
||||
- **Deferred rows (out of M4 active set):** METADATA-35/40/45/100 (Call 1 vision
|
||||
suggestions + confidence gate) — moved to "Later"/future work. The rows stay in
|
||||
REQUIREMENTS.md; only their M4 scope drops.
|
||||
- **Refined rows (draft):** METADATA-55/60/65 (Call 2) — if the human-picked flow changes
|
||||
normative Call 2 inputs (it shouldn't materially; Call 2 already receives the metadata
|
||||
confirmed in review).
|
||||
- **Refined rows (draft):** REVIEW-15/20/25/40 (review-workflow usability) — field layout,
|
||||
nudge behavior, primary/secondary category interaction, generate-text control placement,
|
||||
if the usability changes alter normative review behavior. #94 (conditional review UI)
|
||||
now means: the review screen is **always** the human-pick flow (no inference on/off
|
||||
branch for Call 1).
|
||||
- **Unchanged (in M4 ID subset):** all PUBLISH, REVIEW, SETTINGS, APP, OPS-40, QUALITY rows
|
||||
that M3 already shipped and M4 re-uses for the exit; METADATA-5/10/15/30/85 (the data the
|
||||
human collects + the deterministic title/short-desc).
|
||||
|
||||
## Acceptance criteria (draft)
|
||||
|
||||
1. **Tape-line calibration + M3 polish ship together (Goal 1):** the tape-line calibration
|
||||
ships as a **fixed** centre line the owner pans/zooms/tilts the slab onto (line is never
|
||||
an owner input; crop slider sets its ends; declared length = the real tape inches, and
|
||||
the fixed line represents that same length; no `maskSpanAlong` check), and the
|
||||
M3 polish issues (#91–#100, excluding #96 which drops with Call 1) are closed with
|
||||
passing tests and raw runner evidence.
|
||||
2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick
|
||||
species, wood categories (primary + secondary), edge, figure, grade, moisture, and tags
|
||||
by hand on a phone, with no "Suggest with AI" surface and no AI pre-fill. The wireframe
|
||||
was accepted before coding (design gate).
|
||||
3. **Call 2 (text) is exercised on a live call** during the exit walk and delivers a
|
||||
description that reads well (Ty judges it); content-gen control visibility (METADATA-110),
|
||||
recoverable/unrecoverable classification (METADATA-115), and prompt-revision API
|
||||
(METADATA-105/106) all pass their tests.
|
||||
4. **A real `SLAB-UAT-*` Woo draft** exists in the store matching the reviewed ready set
|
||||
(QUALITY-110), with `woo_product_id` set, photos purged, local row discarded, device
|
||||
draft cleared (PUBLISH-25).
|
||||
5. **Gates pass:** `python tools/check_requirements.py` and `python tools/check_coverage.py`
|
||||
exit 0; full suite passes with inference **and** off (QUALITY-20) — now including the
|
||||
human-pick flow with Call 1 removed; raw evidence recorded.
|
||||
|
||||
## Verification strategy (smallest that proves the claim)
|
||||
|
||||
- **Deterministic core:** unit tests for tape-line geometry + `crop_fill` (existing
|
||||
`frontend/src/png/*` test surface).
|
||||
- **Integration:** Woo mock for the PUBLISH-5 sequence (success, duplicate SKU, stale field,
|
||||
taxonomy anchor) — QUALITY-35/50.
|
||||
- **Exit walk:** `deploy/uat_walk.sh` on the real store for the `SLAB-UAT-*` draft
|
||||
(QUALITY-105/110) — the only proof that counts for the submission claim.
|
||||
- **Ty owns gate exit** (QUALITY-30); agents record raw evidence, never declare green.
|
||||
|
||||
## Task-level decisions (settled during the sprint, not at planning)
|
||||
|
||||
The only plan-level open item is the exact thumb-first interaction model
|
||||
(wizard vs single-scroll, chips vs pickers) — settled by the wireframe.
|
||||
Settled at kickoff (not reopened here): full #91–#100 polish wave (only #96 drops
|
||||
with Call 1); #99 confined to the calibration step (CAPTURE-130 lineup untouched);
|
||||
exit walk is a single slab carrying multiple photos.
|
||||
|
||||
## Task breakdown detail bar
|
||||
|
||||
The task breakdown is not a list of goal names. Every task carries, at minimum:
|
||||
|
||||
- **Goal it serves** (G1–G4) and the requirement IDs / issue numbers it touches.
|
||||
- **Skills to load** for that task — named and ordered (e.g. UI task: `sketch` +
|
||||
`wireframe-review-iteration` → `omh-frontend` + `omh-design-quality-gate` →
|
||||
`omh-visual-qa` + `omh-accessibility-audit`).
|
||||
- **Starting point / references** — the specific saved artifact, file, or branch the task
|
||||
builds from (e.g. `docs/design/issue-99-tape-line-overlay.html`, the live
|
||||
`Review.svelte`, the M3 `MultiSelect.svelte`).
|
||||
- **Verification** — the smallest check that proves the task's claim (unit test paths,
|
||||
`tsc --noEmit`, Woo mock, `deploy/uat_walk.sh`, 375/768/1280 visual captures, keyboard /
|
||||
contrast audit). Ty owns gate exit.
|
||||
- **Sequence / dependencies** — what must land first (wireframe accepted before review-UI
|
||||
coding; requirements rows before the code that implements them; Call 2 refinement after
|
||||
the human-pick flow feeds it real data).
|
||||
- **Stop condition** — when the task is done (e.g. "wireframe accepted by Ty," "gates
|
||||
green + raw evidence recorded," "real `SLAB-UAT-*` draft matches the ready set").
|
||||
- **Evidence boundary** — what is prepared vs observed; no task claims done without the
|
||||
raw runner / capture / store evidence named above.
|
||||
@@ -0,0 +1,205 @@
|
||||
# M4 Task Breakdown
|
||||
|
||||
Companion to `goals.md` (accepted direction). Detail bar per task: goal, skills,
|
||||
starting point, verification, sequence, stop condition, evidence boundary.
|
||||
|
||||
Phase order: P0 (requirements first) → P1 (G1 polish + #99, parallel-safe) → P2 (G2
|
||||
review-UI, wireframe-gated) → P3 (G3 Call 2) → P4 (G4 exit walk). Ty owns every gate
|
||||
exit (QUALITY-30); agents record raw evidence, never declare green.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Requirements baseline (must land before code it enables)
|
||||
|
||||
### T1. Requirements delta for M4
|
||||
- **Goal:** G2/G4 (unblocks G3). **IDs:** REQS (whole doc), REQS-30. **Issues:** —.
|
||||
- **Skills:** `document-to-action-items` (extract obligations), `ocr-and-documents`
|
||||
(read current REQUIREMENTS.md), OMH `omh-routing` (evidence boundaries).
|
||||
- **Starting point:** `docs/REQUIREMENTS.md` (current), `goals.md` requirements-delta
|
||||
section.
|
||||
- **Work:** add rows for the human-pick flow (REVIEW-*, CAPTURE-*, METADATA-*), the
|
||||
Call 2 taxonomy-input row, and the `SLAB-UAT-*` exit-walk row; note the Call 1
|
||||
deferral (METADATA-40/45/55) and drop #96; keep the "draft only" PUBLISH-40 invariant;
|
||||
confirm #99 is tracked. Every new/changed row is the gate for the code that implements it.
|
||||
- **Verification:** `python tools/check_requirements.py` exit 0; coverage matrix
|
||||
consistent.
|
||||
- **Sequence:** first task of the sprint.
|
||||
- **Stop:** rows merged + gate green. **Evidence:** runner output.
|
||||
|
||||
### T2. #99 tape-line calibration (folded into G1) — AS-BUILT, shipped
|
||||
- **Goal:** G1. **IDs:** CAPTURE-170, CAPTURE-175, CAPTURE-75. **Issues:** #99 (issue
|
||||
body still describes the superseded draggable-tick model — this entry is the as-built).
|
||||
- **Skills:** `systematic-debugging`, `svelte-core-bestpractices`,
|
||||
`docker-uat-deploy` (isolate-and-verify the review preview).
|
||||
- **As-built:** a **fixed** black centre line with two end ticks, drawn on the stage
|
||||
fixture. The line is **never an owner input** — not draggable, not focusable, not
|
||||
keyboard-nudgeable, and not part of the published pose. The **source photo/slab is the
|
||||
only thing that moves**: drag to pan, wheel/pinch to zoom, the ↻ handle or arrow keys
|
||||
(Shift = fine step) to tilt. The owner aligns the *slab* to the fixed line at the place
|
||||
the tape measurement was taken. Typed Length is the real inches read off the tape, and
|
||||
the fixed line represents that same length. Scale = fixed-line pixel span / declared
|
||||
Length; `maskSpanAlong` is not the scale source. The crop slider is the **sole** control
|
||||
of the line ends: `crop_fill=90` puts each end 5% from its corresponding edge.
|
||||
Calibration rotation applies to the calibration clean PNG + overlay only; batch
|
||||
siblings stay unrotated.
|
||||
- **Superseded (do not restore):** draggable ticks; `TapePrototype.svelte` and the
|
||||
`?tape-prototype` sandbox (both deleted); `frontend/src/png/crop.ts` (removed in
|
||||
6000ac2 — crop geometry now lives in `frontend/src/capture/tapeLine.ts`
|
||||
`cropEnds(fillPercent)`); the `sveltedrag` dependency (404s on npm, never added).
|
||||
The wireframe `docs/design/issue-99-tape-line-overlay.html` lives on branch
|
||||
`issue-99-tape-line-overlay` (63b8879), not on `m4`.
|
||||
- **Verification:** unit tests in `frontend/src/capture/tapeLine.test.ts` (ends fixed by
|
||||
the crop slider, never an owner input) + `frontend/src/png/{inventory,overlay}.test.ts`
|
||||
(calibration rotation reaches the calibration PNG/overlay only); `tsc --noEmit`;
|
||||
`npm run build`; e2e `frontend/tests/e2e/tape-fixed-line.spec.ts` (no tick drag; a tick
|
||||
drag behaves identically to a pan; ends follow the crop slider).
|
||||
- **Sequence:** parallel with T3–T5; no dependency on T1.
|
||||
- **Stop:** SHIPPED. UAT-signed off by Ty 2026-09-25 on the M4 review preview
|
||||
(`http://192.168.1.202:10883/?noload=1`, build `a756cf5`, branch `m4`).
|
||||
- **Evidence:** `npm test` 102/102; requirements + coverage gates green; live e2e on the
|
||||
deployed preview (0°/20° rotation produces differing clean PNG + overlay bytes); render
|
||||
gate clean at 1440/768/375 with no overflow or clipped text; Ty's UAT sign-off.
|
||||
**Not covered:** `svelte-check` still reports its known 42-error baseline.
|
||||
|
||||
---
|
||||
|
||||
## P1 — M3 polish (G1), parallel-safe once T1 lands
|
||||
|
||||
### T3. Polish wave (full set #91–#100)
|
||||
- **Goal:** G1. **IDs:** UX (various). **Issues:** all of #91–#100 — #91 (photo-picker
|
||||
look/feel), #92 (photo filenames on submit), #93 (capture gated on tested Woo
|
||||
connection), #94 (review layout), #97 (settings), #98 (publish), #100 (UX general).
|
||||
**Excluded:** #96 (drops with Call 1). Ty confirmed: full polish wave, no triage.
|
||||
- **Skills:** `svelte-core-bestpractices`, `omh-frontend` (component states),
|
||||
`omh-visual-qa` (rendered evidence per screen).
|
||||
- **Starting point:** the live screens named in each issue; `frontend/src/create/*`.
|
||||
- **Work:** per-issue fixes. Each is a small, individually-verifiable unit; sequence
|
||||
by dependency (e.g. #93 gate before #98 publish flow touches it).
|
||||
- **Verification:** per-issue acceptance (capture at 375/768/1280, or the named
|
||||
behavior), full suite green.
|
||||
- **Sequence:** after T1; independent of T2.
|
||||
- **Stop:** each issue's acceptance captured. **Evidence:** captures + test run.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Human-driven taxonomy review + thumb-first UI (G2)
|
||||
|
||||
### T4. Wireframe (design gate)
|
||||
- **Goal:** G2. **IDs:** REVIEW-*, UX (phone). **Issues:** #100 (UX).
|
||||
- **Skills:** `sketch` (variants) + `wireframe-review-iteration` (paradigms, serve on
|
||||
LAN IP, iterate on Ty's feedback) → `omh-frontend` + `omh-design-quality-gate`
|
||||
(lock the chosen variant).
|
||||
- **Starting point:** `docs/design/issue-99-tape-line-overlay.html` (approved overlay
|
||||
wireframe, 2026-09-24) for the visual language + the interaction model.
|
||||
- **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms:
|
||||
*no tap-bubble chip clouds for long lists → collapsible searchable checklist*;
|
||||
leftmost-kept-is-primary for wood (primary + optional secondaries);
|
||||
no AI-assist surface (Call 1 decommissioned); every metadata requirement present (primary
|
||||
wood-category required, secondaries optional).
|
||||
- **Verification:** Ty reviews the served variants and accepts one (375/768/1280).
|
||||
- **Sequence:** before T5/T6. **Stop:** Ty accepts a variant.
|
||||
**Evidence:** the accepted `docs/design/*` file + Ty's acceptance.
|
||||
|
||||
### T5. Review-UI implementation
|
||||
- **Goal:** G2. **IDs:** REVIEW-*, CAPTURE-*. **Issues:** #100.
|
||||
- **Skills:** `svelte-core-bestpractices`, `svelte-code-writer` (Svelte 5 lookups),
|
||||
`omh-frontend`, `omh-visual-qa`, `omh-accessibility-audit` (keyboard, 44px targets,
|
||||
contrast, 320px reflow).
|
||||
- **Starting point:** accepted T4 wireframe; `frontend/src/create/review/Review.svelte`
|
||||
(current single-column form); the M3 `MultiSelect.svelte` pattern (reused, not the
|
||||
tap-bubble pattern).
|
||||
- **Work:** replace the review form with the accepted thumb-first flow; primary
|
||||
wood-category + optional secondaries; remove the "Suggest with AI" bar (#96 drops
|
||||
with Call 1); keep all metadata requirements present and editable.
|
||||
- **Verification:** `tsc --noEmit`; `omh-visual-qa` at 375/768/1280 (PASS);
|
||||
`omh-accessibility-audit` (keyboard-reachable, 44px, contrast, 320px reflow);
|
||||
full suite green with inference on **and** off (QUALITY-20).
|
||||
- **Sequence:** after T4 accepted. **Stop:** flow renders, all requirements present,
|
||||
audits pass. **Evidence:** captures + audit output.
|
||||
|
||||
### T6. Taxonomy → title/shortdesc (server-side)
|
||||
- **Goal:** G2. **IDs:** METADATA-*. **Issues:** —.
|
||||
- **Skills:** `systematic-debugging` (verify the title/shortdesc builders),
|
||||
`evidence-based-code-review`.
|
||||
- **Starting point:** the title/short description builders in `backend/` (review.py /
|
||||
publish.py path); the new human-pick fields from T5.
|
||||
- **Work:** ensure title + short description are built from the human-picked primary
|
||||
wood + form (not from the removed Call 1). Confirm the server_description
|
||||
(inference-off) path stays intact.
|
||||
- **Verification:** unit tests for title/shortdesc from human picks; inference-off
|
||||
path covered. **Sequence:** with/after T5. **Stop:** tests green.
|
||||
**Evidence:** test output.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Refine Call 2 long description (G3)
|
||||
|
||||
### T7. Wire human-picked taxonomy into the Call 2 prompt
|
||||
- **Goal:** G3. **IDs:** METADATA-*. **Issues:** —.
|
||||
- **Skills:** `systematic-debugging`, `evidence-based-code-review`,
|
||||
`local-llm-benchmarking` (endpoint behavior), `omh-ask` (prompt-engineering consult
|
||||
if the owner wants an external pass).
|
||||
- **Starting point:** `backend/inference.py` `call2` (line 467) — the prompt today is
|
||||
the **dimension fact block + brand voice + geo only; taxonomy is NOT in the prompt**.
|
||||
`CALL2_SEED` (line 112), `_active_prompt`, `brand_voice`/`geo_context` settings.
|
||||
- **Work:** add the human-picked primary/secondary wood + form to the Call 2 fact
|
||||
block so the long description is grounded in what the owner picked; keep the
|
||||
"invent no numbers" constraint; keep the dimension block exact.
|
||||
- **Verification:** `test_inference.py` updated for the new input; a recorded call2
|
||||
with a known taxonomy set shows the wood/form in the output. **Sequence:** after T5
|
||||
(needs the human-pick data shape). **Stop:** test passes + sample output grounded.
|
||||
**Evidence:** test output + a sample Call 2 response.
|
||||
|
||||
### T8. Exit-walk quality gate
|
||||
- **Goal:** G3. **IDs:** QUALITY-105/110. **Issues:** —.
|
||||
- **Skills:** `omh-verification-gate` (record the gate), `omh-visual-qa`.
|
||||
- **Starting point:** `deploy/uat_walk.sh`; the refined Call 2 from T7.
|
||||
- **Work:** Ty reads the Call 2 long description on the exit walk for 2–3 real slabs;
|
||||
pass = "sounds like a good product description." This is the only proof that counts
|
||||
for the Call 2 quality claim.
|
||||
- **Verification:** Ty's judgment on 2–3 outputs, recorded. **Sequence:** after T7.
|
||||
**Stop:** Ty passes the gate. **Evidence:** Ty's recorded judgment.
|
||||
|
||||
---
|
||||
|
||||
## P4 — Exit: real Woo draft (G4)
|
||||
|
||||
### T9. `SLAB-UAT-*` draft submission + walk
|
||||
- **Goal:** G4. **IDs:** PUBLISH-*, QUALITY-105/110. **Issues:** —.
|
||||
- **Skills:** `omh-deploy-and-monitor` (release checklist + monitor),
|
||||
`omh-verification-gate`, `docker-uat-deploy` (if the UAT host needs staging).
|
||||
- **Starting point:** `backend/publish.py` (draft create path), the Woo mock
|
||||
(`test_integration_woo_mock.py`) for the PUBLISH-5 sequence, `deploy/uat_walk.sh`.
|
||||
- **Work:** run the full create → review (human pick) → Call 2 → publish path on the
|
||||
real store, producing **one** `SLAB-UAT-*` draft; walk it (draft only, PUBLISH-40);
|
||||
confirm the draft matches the ready set. **Shape (Ty confirmed):** single slab, and
|
||||
the draft carries **multiple photos** of that slab — not a QUALITY-110 pair.
|
||||
- **Verification:** `deploy/uat_walk.sh` on the real store; draft present in Woo with
|
||||
the correct fields — including **multiple photos** on the single `SLAB-UAT-*` draft;
|
||||
full suite green inference on **and** off (QUALITY-20).
|
||||
- **Sequence:** last; after T3–T8. **Stop:** real draft matches the ready set + Ty
|
||||
passes the walk. **Evidence:** store screenshot + walk transcript + suite output.
|
||||
|
||||
---
|
||||
|
||||
## Dependency map
|
||||
|
||||
```
|
||||
T1 ──┬─ T3 (polish)
|
||||
├─ T4 ─ T5 ─┬─ T6
|
||||
│ └─ T7 ─ T8 ─ T9
|
||||
└─ T2 (tape-line, parallel)
|
||||
T9 is the milestone exit.
|
||||
```
|
||||
|
||||
- T2 is independent of T1 (prototype already on `m4`).
|
||||
- T4 must be accepted before T5.
|
||||
- T7 needs T5's data shape; T8/T9 need T7.
|
||||
- Nothing in P2/P3 blocks T2/T3 (polish + tape-line ship independently).
|
||||
|
||||
## Resolved at kickoff (deep-interview, 2026-09-24)
|
||||
- **Polish scope:** full wave — all of #91–#100, no triage (only #96 drops with Call 1).
|
||||
- **Tape-line scope:** confined to the calibration step; photos-ready lineup
|
||||
(CAPTURE-130) untouched.
|
||||
- **Exit-walk shape:** single slab; the `SLAB-UAT-*` draft carries multiple photos of
|
||||
that slab (not a QUALITY-110 pair).
|
||||
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
|
||||
"woo_create_status",
|
||||
"taxonomy_last_sync_at",
|
||||
"taxonomy_anchor",
|
||||
"crop_fill",
|
||||
}
|
||||
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] = {
|
||||
"inference_enabled": False,
|
||||
"content_llm_enabled": False,
|
||||
"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)
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
for k, v in payload.items():
|
||||
if k not in ALL_KEYS:
|
||||
|
||||
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
||||
body = resp.json()
|
||||
assert body["inference_api_key"] == {"configured": False}
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 90
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 75
|
||||
conn = db_mod.get_connection()
|
||||
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||
assert row["value"] == "75" and row["value_enc"] is None
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
for bad in (0, 101, -5, "abc", None):
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||
assert resp.json()["error"]["code"] == "validation"
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||
# boundaries are valid
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
@@ -117,3 +117,33 @@ Do not put a host name in the product spec.
|
||||
GET slab/taxonomy/settings 200, review 200.
|
||||
- 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.
|
||||
|
||||
### 2026-09-25 08:57 PDT — M4 review preview: #99 fixed tape line + calibration rotation
|
||||
|
||||
- Source: branch `m4`, commit **`a756cf5`** (`fix(m4): drop stale tick-nudge hint from tape page (#99)`),
|
||||
pushed and verified on `origin/m4`. `matches HEAD`: **yes**. Preceding M4 commits on the same
|
||||
branch: `1882fa3` (calibration tilt applied to the calibration PNG/overlay only) and
|
||||
`7908a99` (tape calibration collapsed onto one page).
|
||||
- Image `deploy-frontend:m4-a756cf5` (`sha256:26402a77…`) built on the 5060 from a clean release
|
||||
archive, **not** from the `~/slab-deploy` greenfield checkout. Container `slabuploader-m4-review`
|
||||
recreated on network `deploy_default`, host port `10883:80`, **zero mounts**.
|
||||
- URL: http://192.168.1.202:10883/?noload=1 (M4 review only). **Not** `10881` — that is the old
|
||||
`main` stack and has no Length page. `10882` is the API, not a UI.
|
||||
- Bundle: `assets/index-BI2cLnOA.js`. Served bundle has **zero** `Drag the ticks` /
|
||||
`nudge the last tick` strings and **does** contain the fixed-line hint.
|
||||
- Main stack untouched: `:10881` and `:10882/api/health` both 200 throughout.
|
||||
- Sample data preserved: **yes** — the review container is volume-free; the real DB was never
|
||||
rebuilt or re-seeded.
|
||||
- Live verification (deployed URL, real backend): the fixed line renders with both end ticks;
|
||||
a drag starting on a tick behaves identically to a pan elsewhere (line does not move); the
|
||||
crop slider moves the line ends; Length/Thickness → Calculate reaches Photos ready. A drag is
|
||||
still allowed (that is the intended pan).
|
||||
- Gates: `npm test` 102/102; `tsc --noEmit` clean; requirements + coverage gates green;
|
||||
render gate clean at 1440/768/375 (no overflow, no clipped text, no page/console/HTTP errors).
|
||||
- Regression guard proven to fail against pre-fix code, not just pass post-fix.
|
||||
- Ty UAT sign-off: **length e2e signed off 2026-09-25**; minor bugs to be filed separately.
|
||||
- Not done: `svelte-check` still reports its known 42-error baseline (no new error from this work);
|
||||
`Calculate` (42px) and `Fit` (41px) are marginally under the 44px touch reference — pre-existing,
|
||||
deliberately left for a separate decision; Gitea #99's **body** still describes the superseded
|
||||
draggable-tick model (the as-built is recorded in `tasks.md` T2 and requirements rows
|
||||
CAPTURE-170/175/75).
|
||||
|
||||
+29
-1
@@ -41,8 +41,36 @@ OPS-40,
|
||||
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105, QUALITY-110,
|
||||
QUALITY-120, QUALITY-125
|
||||
|
||||
## M4 — Tape-line, manual taxonomy, Call 2, and UAT exit
|
||||
|
||||
IDs:
|
||||
|
||||
CAPTURE-25, CAPTURE-30, CAPTURE-35, CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50,
|
||||
CAPTURE-55, CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-75, CAPTURE-80, CAPTURE-85,
|
||||
CAPTURE-90, CAPTURE-95, CAPTURE-100, CAPTURE-105, CAPTURE-110, CAPTURE-115,
|
||||
CAPTURE-120, CAPTURE-125, CAPTURE-130, CAPTURE-135, CAPTURE-140, CAPTURE-141,
|
||||
CAPTURE-145, CAPTURE-150, CAPTURE-155, CAPTURE-160, CAPTURE-170, CAPTURE-175,
|
||||
METADATA-5, METADATA-10, METADATA-15, METADATA-20, METADATA-25, METADATA-30,
|
||||
METADATA-50, METADATA-55, METADATA-60, METADATA-65, METADATA-66, METADATA-70,
|
||||
METADATA-75, METADATA-80, METADATA-85, METADATA-90, METADATA-95, METADATA-105,
|
||||
METADATA-106, METADATA-110, METADATA-115, METADATA-120,
|
||||
REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-35,
|
||||
REVIEW-40, REVIEW-45, REVIEW-50, REVIEW-55,
|
||||
PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-31,
|
||||
PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-40, PUBLISH-45,
|
||||
PUBLISH-50, PUBLISH-55, PUBLISH-60,
|
||||
SETTINGS-5, SETTINGS-10, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30,
|
||||
SETTINGS-35, SETTINGS-40, SETTINGS-45, SETTINGS-50, SETTINGS-51, SETTINGS-55,
|
||||
SETTINGS-60, SETTINGS-65, SETTINGS-70, SETTINGS-75, SETTINGS-80, SETTINGS-85,
|
||||
SETTINGS-90,
|
||||
APP-15, APP-20, APP-30, APP-35, APP-45, APP-50, APP-55, APP-60, APP-65,
|
||||
OPS-40,
|
||||
QUALITY-20, QUALITY-35, QUALITY-50, QUALITY-90, QUALITY-95, QUALITY-105,
|
||||
QUALITY-110, QUALITY-120, QUALITY-125, QUALITY-130
|
||||
|
||||
## Later
|
||||
|
||||
IDs:
|
||||
|
||||
CAPTURE-15, CAPTURE-165, METADATA-107, OPS-30, OPS-35, QUALITY-115
|
||||
CAPTURE-15, CAPTURE-165, METADATA-35, METADATA-40, METADATA-45, METADATA-100,
|
||||
METADATA-107, OPS-30, OPS-35, QUALITY-115
|
||||
|
||||
@@ -26,10 +26,12 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
|
||||
| CAPTURE-45 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts |
|
||||
| CAPTURE-50 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — |
|
||||
| CAPTURE-55 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — |
|
||||
| CAPTURE-60 | The length axis is the long side of the minimum-area bounding rectangle of the mask; the owner rotates it to the prevailing length and confirms. | shipped | frontend/src/geometry/rect.test.ts |
|
||||
| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
|
||||
| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
|
||||
| CAPTURE-75 | scale equals 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-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
||||
@@ -71,7 +73,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| METADATA-80 | The server assembles the name and short description deterministically from the primary wood category's patterns and the dimension fact block. The patterns use only synced taxonomy terms and capture facts. | M3 | — |
|
||||
| METADATA-90 | The dimension fact block supplies the exact strings the patterns interpolate and the only numbers Call 2 may print; there is no L by W by T rectangle assumption. | M3 | — |
|
||||
| METADATA-55 | Call 2 runs on demand and produces the long description. | M3 | — |
|
||||
| METADATA-60 | Call 2 receives the processed photos, the metadata confirmed or changed in review, the primary and secondary wood categories, the parent-category path, the dimension fact block, and brand/geo context. | M3 | — |
|
||||
| METADATA-60 | Call 2 receives the processed photos, the metadata the owner picked or changed in review, including primary and secondary wood categories and the human-selected taxonomy attributes and tags, the parent-category path, the dimension fact block, and brand/geo context. | M4 | — |
|
||||
| METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
|
||||
| METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — |
|
||||
| METADATA-70 | With inference off, the server produces the long description. | M3 | — |
|
||||
@@ -82,6 +84,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| METADATA-107 | Before the prompt revision interface is exposed beyond the trusted network it requires authentication. | later | — |
|
||||
| METADATA-110 | The Generate text control appears only when content generation is enabled and the inference endpoint is reachable, and is disabled when the text service is down. | M3 | — |
|
||||
| METADATA-115 | A runtime inference failure is classified recoverable or unrecoverable for the owner: recoverable offers Retry, unrecoverable gives a plain message explaining it. | M3 | — |
|
||||
| METADATA-120 | Call 2 is grounded in the owner's human-picked primary and secondary wood categories and taxonomy values from review; it does not receive or depend on Call 1 suggestions. | M4 | — |
|
||||
|
||||
## REVIEW — confirm, tweak, ready
|
||||
|
||||
@@ -97,6 +100,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| REVIEW-40 | The owner can edit any field, including generated title and description, until create, except the calculated measurement fields (widths, sqft, bdft), which are not user mutable. | M3 | — |
|
||||
| REVIEW-45 | SKU is 3 to 24 characters, alphanumeric and hyphen only. | M3 | — |
|
||||
| REVIEW-50 | Title and short_description warn past 60 and 160 characters and never exceed the store's hard limits; description has no maximum. | M3 | docs/contracts/WOO-API.md |
|
||||
| REVIEW-55 | Review always presents a thumb-first manual taxonomy flow: the owner selects species, primary and optional secondary wood categories, Edge, Figure, Grade, Moisture, and optional synced tags; no Call 1 suggestion, inference pre-fill, or AI marker is shown. | M4 | — |
|
||||
|
||||
## PUBLISH — store submission
|
||||
|
||||
@@ -113,7 +117,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| PUBLISH-36 | Every publish failure is classified for the owner as recoverable (he can act) or unrecoverable (outside his control), and the plain message states which it is and the next step. | M3 | — |
|
||||
| PUBLISH-37 | A recoverable failure points at the field to fix, or offers Retry while the draft and photos are retained. | M3 | — |
|
||||
| PUBLISH-38 | An unrecoverable failure says it is outside the owner's control and offers the available next step. | M3 | — |
|
||||
| PUBLISH-40 | Images upload as processed PNGs with alpha; the clean primary, the primary dimensioned copy, and every remaining batch photo the owner selected are uploaded, with no upper cap on the number of images. | M3 | — |
|
||||
| PUBLISH-40 | Images upload as processed PNGs with alpha; the clean primary, the primary dimensioned copy, and every remaining batch photo the owner selected are uploaded, with no upper cap on the number of images. | M4 | — |
|
||||
| PUBLISH-45 | The Woo image order is the photos-ready lineup: clean calibration, dimension-overlay copy, then the rest in capture order. | M3 | — |
|
||||
| PUBLISH-50 | Published means the store create succeeded, not that the product is live when status is draft. | M3 | — |
|
||||
| PUBLISH-55 | Only non-null fields are sent; the payload carries name, type simple, status, sku, slug, regular_price, description, short_description, categories, tags, images, attributes, virtual false, downloadable false, catalog_visibility visible, sold_individually true. The slug is the sanitized product name plus the lowercased SKU. | M3 | docs/contracts/WOO-API.md |
|
||||
@@ -206,3 +210,4 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| QUALITY-115 | Browser end-to-end capture through publish is verified on iOS Safari, Android Chrome, and desktop Chrome, Edge, and Firefox. | later | — |
|
||||
| QUALITY-120 | No raw status code, stack trace, or internal code reaches the owner in any tested path. | M3 | — |
|
||||
| QUALITY-125 | An encrypted credential stored by Settings decrypts back to the value it was given. | M3 | — |
|
||||
| QUALITY-130 | The M4 exit walk records raw runner evidence for a real `SLAB-UAT-*` WooCommerce draft created through the UI; the draft matches the reviewed ready set and is not published live. | M4 | — |
|
||||
|
||||
+3
-1
@@ -42,6 +42,8 @@ remain the point of record, and `.planning/` files cite requirement IDs without
|
||||
| `.planning/MILESTONES.md` | history |
|
||||
| `.planning/milestones/v0.5/M3/roadmap.md` | roadmap |
|
||||
| `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown |
|
||||
| `.planning/milestones/v0.5/M4/goals.md` | roadmap |
|
||||
| `.planning/milestones/v0.5/M4/tasks.md` | breakdown |
|
||||
|
||||
Roles:
|
||||
|
||||
@@ -87,7 +89,7 @@ Defined only in `docs/REQUIREMENTS.md`, as table rows:
|
||||
| CAPTURE-5 | Statement of one testable requirement | shipped | frontend/src/measure/measure.test.ts |
|
||||
```
|
||||
|
||||
- `Scope` is `shipped`, `M3`, or `later`.
|
||||
- `Scope` is `shipped`, a milestone scope (`M3`, `M4`, ...), or `later`.
|
||||
- `Test` is a repo-relative test path, a requirement ID, or `—`. A path given for a `shipped` requirement must exist; a given ID must resolve to a defined requirement.
|
||||
- IDs advance by 5 within each namespace (`5, 10, 15, ...`), resetting for each prefix. The gaps are intentional room for later insertions; never renumber to close a gap.
|
||||
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||||
rectangle (calibration length + crop)".
|
||||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||||
|
||||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||||
in from the top and the bottom — and never changes the aspect ratio.
|
||||
|
||||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||||
production workflow:
|
||||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||||
- the requirements-coverage board below the stage
|
||||
- the "was / now" pair of mini diagrams
|
||||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||||
- the window.__wf debug hook
|
||||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||||
semantics, and the single shared crop_fill.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||||
.panel{padding:10px 4px;color:var(--fg)}
|
||||
h1{font-size:1.3rem;margin:0 0 10px}
|
||||
h3{font-size:.95rem;margin:0 0 8px}
|
||||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||||
.row{display:flex;gap:10px;margin-top:16px}
|
||||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||||
/* ---- the stage: fixed line + movable slab ---- */
|
||||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||||
.tw.dragging svg{cursor:grabbing}
|
||||
.tickg{cursor:ns-resize}
|
||||
.rotg{cursor:ew-resize}
|
||||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||||
input[type=range]{flex:1}
|
||||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||||
.nums span:first-child{color:var(--mut)}
|
||||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||||
.nums .dim{color:#777}
|
||||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||||
.status.ok{background:#12210f;color:var(--ok)}
|
||||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||||
.plan code{color:#ddd;font-size:.72rem}
|
||||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||||
/* replacement two-up */
|
||||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||||
.swap .gone{color:#ff9d8a}
|
||||
/* scope lineup */
|
||||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||||
/* fields + settings */
|
||||
.fields{display:flex;gap:16px;margin-top:16px}
|
||||
.field{flex:1}
|
||||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||||
.settings .msg.ok{color:var(--ok)}
|
||||
/* requirement coverage board (outside the phone) */
|
||||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||||
.cov .where{color:#ddd}
|
||||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||||
@media (max-width:760px){
|
||||
.cov thead{display:none}
|
||||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||||
.cov tr:last-child{border-bottom:0}
|
||||
.cov td{border:0;padding:1px 0}
|
||||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||||
}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||||
<div class="flowbar" id="flowbar"></div>
|
||||
<div class="vp">
|
||||
<span>Viewport</span>
|
||||
<span id="vpval">412</span>
|
||||
<button data-w="390">390</button>
|
||||
<button data-w="412" class="on">412</button>
|
||||
<button data-w="720">720</button>
|
||||
<button data-w="1440">1440</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage"><div id="phone"></div></div>
|
||||
<div class="cov"><div class="cov-inner">
|
||||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||||
<table>
|
||||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
<script>
|
||||
// ===========================================================================
|
||||
// issue #99 — the tape line is a FIXED ENTITY.
|
||||
//
|
||||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||||
// its long axis lies on the line, tick to tick.
|
||||
// * The ticks belong to the line - they slide along it, never with the photo.
|
||||
// * L = the distance between the two ticks (the owner's tape reading).
|
||||
// ===========================================================================
|
||||
const S = {
|
||||
screen: "tape",
|
||||
// slab transform (the only movable entity)
|
||||
tilt: 14, // degrees
|
||||
zoom: 1,
|
||||
panX: 60, panY: 0,
|
||||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom - and the operator drags them onto their tape marks from there.
|
||||
tickA: 32,
|
||||
tickB: 608,
|
||||
focus: "A",
|
||||
fill: 90, // settings.crop_fill - global, default 90
|
||||
// declared values (entered on Measure, shown on both steps)
|
||||
L: "88",
|
||||
thickness: "2.125",
|
||||
savedMsg: "",
|
||||
};
|
||||
|
||||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||||
const VB = { w: 480, h: 640 };
|
||||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||||
// AXIS - the centreline running down the middle of the board, which is where
|
||||
// the tape is laid. (Not a physical side edge.)
|
||||
const SLAB = {
|
||||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||||
};
|
||||
const TICK_LEN = 26; // rifle-scope end tick, total
|
||||
const TICK_GAP = 40; // min separation between ticks
|
||||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||||
const HIT_R = 34;
|
||||
const ALIGN_TOL = 3; // stage units
|
||||
|
||||
const rad = (a) => a * Math.PI / 180;
|
||||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||||
// crop frame, which is where the crop centres the slab's long axis.
|
||||
const lineX = () => VB.w / 2;
|
||||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||||
|
||||
// slab transform: zoom + tilt about the stage centre, then pan
|
||||
function mapPoint(p) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||||
}
|
||||
function slabTransform() {
|
||||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||||
}
|
||||
// where the slab's long axis crosses a given stage y
|
||||
function axisXAt(y) {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||||
}
|
||||
const axisAngle = () => {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||||
};
|
||||
// the axis is vertical when its angle points straight down (or up) the screen
|
||||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||||
|
||||
// ---------------------------------------------------------------- the stage
|
||||
function slabSvg() {
|
||||
return `
|
||||
<g id="slab" transform="${slabTransform()}">
|
||||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||||
<g clip-path="url(#slabClip)" opacity=".8">
|
||||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
</g>
|
||||
</g>`;
|
||||
}
|
||||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||||
function fixtureSvg() {
|
||||
const x = lineX();
|
||||
const tick = (y, id) => {
|
||||
const on = S.focus === id;
|
||||
return `
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||||
};
|
||||
return `
|
||||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||||
}
|
||||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||||
function cropFrameSvg() {
|
||||
const m = cropInset();
|
||||
return `
|
||||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||||
}
|
||||
function rotateHandleSvg() {
|
||||
return `
|
||||
<g class="rotg" data-rot="1">
|
||||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||||
</g>`;
|
||||
}
|
||||
function stageSvg() {
|
||||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||||
${cropFrameSvg()}
|
||||
${slabSvg()}
|
||||
${fixtureSvg()}
|
||||
${rotateHandleSvg()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ screens
|
||||
const screens = {
|
||||
batch() {
|
||||
return `<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||||
</section>`;
|
||||
},
|
||||
mask() {
|
||||
return `<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||||
<div class="nums">
|
||||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-batch">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
tape() {
|
||||
const off = axisOffset(), ok = aligned();
|
||||
return `<section class="panel">
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<div class="tw" id="tw">${stageSvg()}</div>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||||
</div>
|
||||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||||
? "The board's long axis is on the line ✓"
|
||||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||||
</div>
|
||||
|
||||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||
|
||||
<div class="swap">
|
||||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-mask">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||||
<ul>
|
||||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
measure() {
|
||||
const mini = (tilt, marks) => `
|
||||
<svg viewBox="0 0 480 640">
|
||||
<rect width="480" height="640" fill="#16281c"/>
|
||||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||||
</g>
|
||||
</svg>`;
|
||||
return `<section class="panel">
|
||||
<h1>Photos ready</h1>
|
||||
<div class="fields">
|
||||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||||
</div>
|
||||
<div class="nums">
|
||||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||||
</div>
|
||||
<div class="plan">
|
||||
<h3>Rotation scope — req. 6</h3>
|
||||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||||
</div>
|
||||
<ol class="lineup">
|
||||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-tape">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
settings() {
|
||||
return `<section class="panel settings">
|
||||
<h1>Settings</h1>
|
||||
<div class="section">
|
||||
<h3>Crop — global</h3>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||||
<div class="croprow">
|
||||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||||
<button data-act="save-fill">Save Changes</button>
|
||||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Implementation notes</h3>
|
||||
<ul class="plan" style="border:0;padding:0;background:none">
|
||||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||||
</section>`;
|
||||
},
|
||||
};
|
||||
|
||||
// the two mini overlays for the was/now pair
|
||||
function oldOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||||
</svg>`;
|
||||
}
|
||||
function newOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- plumbing
|
||||
const phone = document.getElementById('phone');
|
||||
const flowbar = document.getElementById('flowbar');
|
||||
const STEPS = [
|
||||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||||
];
|
||||
function render() {
|
||||
phone.innerHTML = screens[S.screen]();
|
||||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||||
}
|
||||
flowbar.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-goto]');
|
||||
if (!b) return;
|
||||
S.screen = b.dataset.goto;
|
||||
render();
|
||||
});
|
||||
document.querySelector('.vp').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-w]');
|
||||
if (!b) return;
|
||||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||||
document.getElementById('vpval').textContent = b.dataset.w;
|
||||
});
|
||||
|
||||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||||
// (a full panel re-render would destroy the element under the pointer).
|
||||
function refreshStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
host.innerHTML = stageSvg();
|
||||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||
set('tiltVal', S.tilt + '\u00B0');
|
||||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||||
set('fillVal', S.fill + '%');
|
||||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||||
const st = document.getElementById('alignstatus');
|
||||
if (st) {
|
||||
const ok = aligned(), off = axisOffset();
|
||||
st.className = 'status' + (ok ? ' ok' : '');
|
||||
st.innerHTML = ok
|
||||
? "The board's long axis is on the line \u2713"
|
||||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||||
}
|
||||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||||
axisAngle: axisAngle(), aligned: aligned() };
|
||||
}
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||||
// twin, stepping about the stage centre so the slab stays where it is.
|
||||
function stepZoom(dir) {
|
||||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||||
refreshStage();
|
||||
}
|
||||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||||
function nudgeTick(dir, mult) {
|
||||
const step = (mult === undefined ? 1 : mult);
|
||||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||||
render();
|
||||
}
|
||||
|
||||
function bindStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
const toVB = (e) => {
|
||||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||||
};
|
||||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||||
function zoomAt(p, factor) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2;
|
||||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||||
if (z1 === z0) return;
|
||||
const qx = cx + (p.x - cx - S.panX) / z0;
|
||||
const qy = cy + (p.y - cy - S.panY) / z0;
|
||||
S.zoom = z1;
|
||||
S.panX = p.x - cx - z1 * (qx - cx);
|
||||
S.panY = p.y - cy - z1 * (qy - cy);
|
||||
refreshStage();
|
||||
}
|
||||
host.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||||
const dy = clamp(e.deltaY, -200, 200);
|
||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||||
}, { passive: false });
|
||||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||||
|
||||
let drag = null, tb = null, moved = false, downP = null;
|
||||
const off = { x: 0, y: 0 };
|
||||
host.addEventListener('pointerdown', (e) => {
|
||||
const tick = e.target.closest('[data-tick]');
|
||||
const rot = e.target.closest('[data-rot]');
|
||||
const p = toVB(e);
|
||||
moved = false; downP = p;
|
||||
if (tick) {
|
||||
S.focus = tick.dataset.tick;
|
||||
drag = { kind: "tick", id: S.focus };
|
||||
} else if (rot) {
|
||||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||||
} else {
|
||||
drag = { kind: "slab" };
|
||||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||||
}
|
||||
host.setPointerCapture(e.pointerId);
|
||||
host.classList.add('dragging');
|
||||
refreshStage();
|
||||
e.preventDefault();
|
||||
});
|
||||
host.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const p = toVB(e);
|
||||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||||
if (drag.kind === "tick") {
|
||||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||||
const y = clamp(p.y, 20, VB.h - 20);
|
||||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||||
} else if (drag.kind === "rot") {
|
||||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||||
} else {
|
||||
S.panX = clamp(p.x - off.x, -400, 400);
|
||||
S.panY = clamp(p.y - off.y, -400, 400);
|
||||
}
|
||||
refreshStage();
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag) return;
|
||||
const wasSlab = drag.kind === "slab";
|
||||
const startP = toVB(e);
|
||||
drag = null;
|
||||
host.classList.remove('dragging');
|
||||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||||
// dblclick both stop arriving), so track the taps ourselves.
|
||||
if (wasSlab && !moved) {
|
||||
const now = performance.now();
|
||||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||||
tb = { t: now, x: startP.x, y: startP.y };
|
||||
}
|
||||
refreshStage();
|
||||
};
|
||||
host.addEventListener('pointerup', end);
|
||||
host.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (S.screen !== "tape") return;
|
||||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!e.key.startsWith("Arrow")) return;
|
||||
const fine = e.shiftKey ? 0.1 : 1;
|
||||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
phone.addEventListener('click', (e) => {
|
||||
const t = e.target.closest('[data-act]');
|
||||
if (!t) return;
|
||||
const a = t.dataset.act;
|
||||
if (a === "tilt") tiltBy(+t.dataset.d);
|
||||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||||
else if (a === "fit") fitSlab();
|
||||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||||
});
|
||||
phone.addEventListener('input', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||||
else if (t.dataset.act === "fill-range") {
|
||||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||||
// rectangle's aspect never changes and the line's x never moves.
|
||||
S.fill = +t.value;
|
||||
const ct = cropTicks();
|
||||
S.tickA = ct.a; S.tickB = ct.b;
|
||||
refreshStage();
|
||||
}
|
||||
});
|
||||
phone.addEventListener('change', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+18
-13
@@ -18,7 +18,7 @@
|
||||
import {
|
||||
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||
} from './create/flow';
|
||||
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
|
||||
import { createSlab, uploadPhoto, getSettings, updateSettings, ApiError } from './api/slabs';
|
||||
import { clearDraft } from './draft/idb';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
@@ -26,7 +26,6 @@
|
||||
|
||||
let cap: Capture = { k: 'batch', photos: [] };
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
let showSettings = false;
|
||||
let showSlabs = false;
|
||||
|
||||
@@ -37,16 +36,20 @@
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
||||
});
|
||||
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||
|
||||
let cropFill = 90;
|
||||
|
||||
onMount(() => {
|
||||
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
|
||||
// device draft into IndexedDB after the origin exists, then reload without
|
||||
// ?noload to trigger the Resume prompt deterministically.
|
||||
const boot = new URLSearchParams(window.location.search);
|
||||
if (!boot.get('noload')) void session.resumeProbe();
|
||||
void getSettings().then((s) => {
|
||||
if (typeof s.crop_fill === 'number') {
|
||||
cropFill = s.crop_fill;
|
||||
session.setCropFill(s.crop_fill);
|
||||
}
|
||||
}).catch(() => {});
|
||||
});
|
||||
onDestroy(() => {
|
||||
unsubCap();
|
||||
@@ -216,18 +219,20 @@
|
||||
{:else if cap.k === 'axis'}
|
||||
<AxisPanel
|
||||
state={cap}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
approveAxis={session.approveAxis}
|
||||
{cropFill}
|
||||
setTapePose={session.setTapePose}
|
||||
setCropFill={(pct) => {
|
||||
cropFill = pct;
|
||||
session.setCropFill(pct);
|
||||
void updateSettings({ crop_fill: pct }).catch(() => {});
|
||||
}}
|
||||
calculateFromAxis={session.calculateFromAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else}
|
||||
{:else if cap.k === 'measure'}
|
||||
<MeasurePanel
|
||||
state={cap}
|
||||
setLength={session.setLength}
|
||||
setThickness={session.setThickness}
|
||||
calculate={session.calculate}
|
||||
back={session.back}
|
||||
{pngUrl}
|
||||
onContinue={saving ? undefined : () => void continueToReview()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -454,6 +454,8 @@ export interface SettingsView {
|
||||
woo_create_status: string;
|
||||
taxonomy_last_sync_at: 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> {
|
||||
|
||||
@@ -1,58 +1,385 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { onDestroy, onMount } from '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 setAxisAngle: (deg: number) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let cropFill = 90;
|
||||
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;
|
||||
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
const frame = CALIBRATION_FRAME;
|
||||
let stageEl: HTMLDivElement | undefined;
|
||||
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>
|
||||
|
||||
<svelte:window on:keydown={onKey} />
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</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>
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<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>
|
||||
<h1>Length</h1>
|
||||
<p class="hint">Move the slab until its long axis sits on the line, the way it lines up with the tape. The line is fixed in the middle; its ends are set by the crop slider and do not move.</p>
|
||||
<div class="stage" bind:this={stageEl} data-testid="tape-line">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel={onWheel}
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<p class="ctl">Tilt</p>
|
||||
<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="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>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.hint, .nudge { color: #aaa; font-size: 0.85rem; line-height: 1.4; margin: 0 0 12px; }
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.val { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.fit { margin-top: 8px; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.fields { display: flex; gap: 12px; margin-top: 8px; }
|
||||
.field { flex: 1; }
|
||||
.field input {
|
||||
box-sizing: border-box; width: 100%; min-height: 44px; padding: 10px; font-size: 1rem;
|
||||
border-radius: 3px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.primary {
|
||||
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;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
photosReadyPngAlt,
|
||||
@@ -11,22 +9,13 @@
|
||||
} from './photosReadyLineup';
|
||||
|
||||
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 pngUrl: string | null;
|
||||
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
||||
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 lineupItems: PhotosReadyLineupItem[] = [];
|
||||
|
||||
$: photosReady = !!state.results;
|
||||
$: {
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
lineupUrls = [];
|
||||
@@ -48,92 +37,17 @@
|
||||
onDestroy(() => {
|
||||
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>
|
||||
|
||||
<section class="panel">
|
||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</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>
|
||||
<h1>Photos ready</h1>
|
||||
|
||||
{#if !state.results}
|
||||
<p class="hint">Calculating…</p>
|
||||
<div class="row">
|
||||
<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>
|
||||
{:else if state.results}
|
||||
{:else}
|
||||
<div class="results" aria-label="Photos ready">
|
||||
<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>
|
||||
@@ -169,12 +83,6 @@
|
||||
{/each}
|
||||
</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 class="row">
|
||||
@@ -191,14 +99,6 @@
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
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; }
|
||||
.primary {
|
||||
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-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>
|
||||
|
||||
@@ -43,13 +43,25 @@ export interface Mask {
|
||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||
bbox: BBox;
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the 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
|
||||
// corners[3] → corners[0] (the long side).
|
||||
export interface PhotoTransform {
|
||||
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 {
|
||||
bbox: BBox;
|
||||
photoTransform: PhotoTransform;
|
||||
tickStartY: number;
|
||||
tickEndY: number;
|
||||
tapeStart: [number, number];
|
||||
tapeEnd: [number, number];
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
@@ -96,7 +108,9 @@ export type Capture =
|
||||
k: 'axis';
|
||||
source: Source;
|
||||
approvedMask: Mask; // frozen footprint
|
||||
axis: Axis; // live axis.bbox being nudged
|
||||
axis: Axis; // live tape line
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
}
|
||||
| {
|
||||
k: 'measure';
|
||||
@@ -138,6 +152,11 @@ export interface Draft {
|
||||
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||
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;
|
||||
thicknessEighths: Eighths;
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||
|
||||
@@ -6,11 +6,15 @@ import { test } from 'node:test';
|
||||
import { inToEighths } from './capture.types';
|
||||
|
||||
const panelSrc = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'AxisPanel.svelte'),
|
||||
'utf8',
|
||||
);
|
||||
const measureSrc = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
||||
'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) ?? [];
|
||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||
assert.ok(
|
||||
@@ -31,6 +35,11 @@ test('MeasurePanel exposes typed number inputs for Length L and Thickness', () =
|
||||
false,
|
||||
'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', () => {
|
||||
|
||||
@@ -252,6 +252,33 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
|
||||
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 () => {
|
||||
installFakes();
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
installFakeMaskWorker();
|
||||
(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();
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.match(app, /MaskPanel/);
|
||||
|
||||
+226
-121
@@ -1,16 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
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';
|
||||
import {
|
||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||
} from './capture.types';
|
||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||
import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { IDENTITY_TRANSFORM, cropEnds, tapeSegmentSource } from './tapeLine';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
@@ -40,13 +40,20 @@ export interface CaptureSession {
|
||||
setSensitivity(n: number): void;
|
||||
setSheet(s: Sheet): 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;
|
||||
/** 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;
|
||||
setThickness(e: Eighths): void;
|
||||
calculate(): Promise<void>;
|
||||
back(): void;
|
||||
getInventoryUrl(): string | null;
|
||||
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
||||
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
||||
dispose(): void;
|
||||
@@ -65,6 +72,19 @@ const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
|
||||
const batchPhotosOf = (d: Draft): 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.
|
||||
// Every transition to an approved branch persists; no explicit "save".
|
||||
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
@@ -81,8 +101,29 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let passStarted = 0;
|
||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pngUrl: string | null = null;
|
||||
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) => {
|
||||
state = next;
|
||||
@@ -103,10 +144,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
}
|
||||
worker = null;
|
||||
}
|
||||
if (pngUrl) {
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeWorker = (blob: Blob) => {
|
||||
@@ -207,7 +244,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
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') {
|
||||
@@ -217,6 +259,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
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,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
@@ -308,6 +355,92 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||
): 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;
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
@@ -338,16 +471,18 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
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;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
// A resultless legacy measure draft predates the merged tape page. Do not
|
||||
// open the result-only screen; return to tape calibration with its inputs.
|
||||
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({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d),
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? {
|
||||
@@ -377,7 +512,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
void persist();
|
||||
};
|
||||
|
||||
return {
|
||||
const session: CaptureSession = {
|
||||
capture,
|
||||
busy,
|
||||
|
||||
@@ -476,22 +611,39 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
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();
|
||||
},
|
||||
|
||||
setAxisAngle(deg: number) {
|
||||
setTapePose(pose) {
|
||||
if (state.k !== 'axis') return;
|
||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
||||
void persist();
|
||||
const sourceW = pose.sourceW > 0 ? pose.sourceW : state.source.widthPx;
|
||||
const sourceH = pose.sourceH > 0 ? pose.sourceH : state.source.heightPx;
|
||||
const source = sourceW > 0 && sourceH > 0
|
||||
? { ...state.source, widthPx: sourceW, heightPx: sourceH }
|
||||
: state.source;
|
||||
set({
|
||||
...state,
|
||||
source,
|
||||
axis: axisFrom(state.axis.bbox, sourceW, sourceH, pose),
|
||||
});
|
||||
if (poseTimer) clearTimeout(poseTimer);
|
||||
poseTimer = setTimeout(() => {
|
||||
poseTimer = null;
|
||||
void persist();
|
||||
}, 250);
|
||||
},
|
||||
|
||||
setCropFill(pct: number) {
|
||||
cropFill = Math.max(1, Math.min(100, Math.round(pct)));
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.axis.bbox },
|
||||
axis: state.axis,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
@@ -509,105 +661,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
void persist();
|
||||
},
|
||||
|
||||
// #62: Calculate the calibration photo, then apply its frozen mask params
|
||||
// to siblings. This stays in Measure; siblings never enter Mask controls.
|
||||
// Legacy compatibility path; the shipped tape page calls calculateFromAxis.
|
||||
async calculate() {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
);
|
||||
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 input: CalibrationInput = {
|
||||
source: state.source,
|
||||
approvedMask: state.approvedMask,
|
||||
axis: state.axis,
|
||||
lengthEighths: state.lengthEighths,
|
||||
thicknessEighths: state.thicknessEighths,
|
||||
};
|
||||
const params = approvedMask.params;
|
||||
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',
|
||||
}));
|
||||
const output = await runCalibration(input);
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, ...output });
|
||||
void persist();
|
||||
},
|
||||
|
||||
// Process serially so each worker owns exactly one sibling photo and a
|
||||
// failing decode/mask/encode cannot prevent later siblings from running.
|
||||
for (let index = 0; index < batch.length; index++) {
|
||||
if (index === calIndex) continue;
|
||||
const photo = batch[index];
|
||||
try {
|
||||
const derived = await rederiveMaskDetailed(photo, 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 });
|
||||
} 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 };
|
||||
}
|
||||
async calculateFromAxis(lengthEighths, thicknessEighths) {
|
||||
if (state.k !== 'axis') return;
|
||||
const length = Math.max(0, Math.round(lengthEighths));
|
||||
const thickness = Math.max(0, Math.round(thicknessEighths));
|
||||
if (length === 0 || thickness === 0) return;
|
||||
if (poseTimer) {
|
||||
clearTimeout(poseTimer);
|
||||
poseTimer = null;
|
||||
}
|
||||
|
||||
set({ ...state, results, batchApply });
|
||||
const input: CalibrationInput = {
|
||||
source: state.source,
|
||||
approvedMask: state.approvedMask,
|
||||
axis: state.axis,
|
||||
lengthEighths: length,
|
||||
thicknessEighths: thickness,
|
||||
};
|
||||
// Keep the tape page mounted while the PNG, overlay, and batch apply run.
|
||||
// The old measure form was a real screen; the result state is not entered
|
||||
// until there is something complete to show.
|
||||
set({ ...state, lengthEighths: length, thicknessEighths: thickness });
|
||||
const output = await runCalibration(input);
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
k: 'measure',
|
||||
source: input.source,
|
||||
approvedMask: input.approvedMask,
|
||||
axis: input.axis,
|
||||
lengthEighths: input.lengthEighths,
|
||||
thicknessEighths: input.thicknessEighths,
|
||||
results: output.results,
|
||||
batchApply: output.batchApply,
|
||||
});
|
||||
void persist();
|
||||
},
|
||||
|
||||
@@ -626,14 +727,17 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
||||
set({
|
||||
k: 'axis',
|
||||
source: state.source,
|
||||
approvedMask: state.approvedMask,
|
||||
axis: state.axis,
|
||||
lengthEighths: state.lengthEighths,
|
||||
thicknessEighths: state.thicknessEighths,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getInventoryUrl() {
|
||||
return pngUrl;
|
||||
},
|
||||
|
||||
currentMeasure() {
|
||||
return state.k === 'measure' ? state : null;
|
||||
},
|
||||
@@ -642,6 +746,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
teardownWorker();
|
||||
},
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
// Display helper: format eighths as inches with fractions.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CALIBRATION_FRAME,
|
||||
IDENTITY_TRANSFORM,
|
||||
calibrationScale,
|
||||
cropEnds,
|
||||
hitRadiusStage,
|
||||
lineX,
|
||||
sourceTickDistance,
|
||||
stageToPhoto,
|
||||
} from './tapeLine.ts';
|
||||
|
||||
const moved = { tiltDeg: 17, zoom: 1.8, panX: 40, panY: -25 };
|
||||
|
||||
test('the line stays in the middle of the frame when the photo moves', () => {
|
||||
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
|
||||
assert.equal(lineX(), 240);
|
||||
const a = stageToPhoto([lineX(), 32], 1000, 1500, IDENTITY_TRANSFORM);
|
||||
const b = stageToPhoto([lineX(), 32], 1000, 1500, moved);
|
||||
assert.equal(a[0] === b[0] && a[1] === b[1], false);
|
||||
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
|
||||
});
|
||||
|
||||
test('crop fill moves only the ends and never the 3:4 frame', () => {
|
||||
const at90 = cropEnds(90);
|
||||
assert.equal(at90.startY, 32);
|
||||
assert.equal(at90.endY, 608);
|
||||
const at50 = cropEnds(50);
|
||||
const at100 = cropEnds(100);
|
||||
assert.equal(CALIBRATION_FRAME.width / CALIBRATION_FRAME.height, 3 / 4);
|
||||
assert.equal(at50.startY, 160);
|
||||
assert.equal(at100.startY, 0);
|
||||
assert.equal(at100.endY, 640);
|
||||
assert.equal(lineX(), 240);
|
||||
});
|
||||
|
||||
test('tick stage positions are inputs, not a function of the photo transform', () => {
|
||||
const ends = cropEnds(90);
|
||||
const still = sourceTickDistance(ends.startY, ends.endY, 800, 1200, IDENTITY_TRANSFORM);
|
||||
const after = sourceTickDistance(ends.startY, ends.endY, 800, 1200, moved);
|
||||
assert.ok(still > 0);
|
||||
assert.notEqual(still, after);
|
||||
assert.equal(ends.startY, 32);
|
||||
assert.equal(ends.endY, 608);
|
||||
});
|
||||
|
||||
test('scale is source-pixel tick distance divided by the declared length', () => {
|
||||
const scale = calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 72);
|
||||
const distance = sourceTickDistance(32, 608, 480, 640, IDENTITY_TRANSFORM);
|
||||
assert.equal(scale, distance / 72);
|
||||
assert.equal(calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 0), 0);
|
||||
});
|
||||
|
||||
test('tick ends are fixed by the crop slider and are never an owner input (#99)', () => {
|
||||
// The line is a stage fixture: its two ends are the crop insets, set by
|
||||
// crop_fill alone. There is no owner drag that can move them.
|
||||
for (const fill of [1, 25, 50, 75, 90, 100]) {
|
||||
const ends = cropEnds(fill);
|
||||
assert.equal(ends.startY, cropEnds(fill).startY, 'ends are a pure function of fill');
|
||||
assert.equal(ends.endY, cropEnds(fill).endY, 'ends are a pure function of fill');
|
||||
assert.ok(ends.endY > ends.startY, 'line keeps a positive span at any fill');
|
||||
}
|
||||
// Ends stay symmetric about the frame centre, so the line is fixed in the middle.
|
||||
for (const fill of [1, 50, 90, 100]) {
|
||||
const ends = cropEnds(fill);
|
||||
const top = ends.startY;
|
||||
const bottom = CALIBRATION_FRAME.height - ends.endY;
|
||||
assert.equal(top, bottom, `fill ${fill}: top and bottom insets match`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the scale span is the fixed line span, not a mask span', () => {
|
||||
const ends = cropEnds(90);
|
||||
const distance = sourceTickDistance(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM);
|
||||
const scale = calibrationScale(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM, 72);
|
||||
assert.ok(distance > 0, 'line has a real source-pixel span');
|
||||
assert.equal(scale, distance / 72, 'scale is the line span over the tape reading');
|
||||
});
|
||||
|
||||
test('the tick hit target is at least 44px on a 320px-wide stage', () => {
|
||||
const radius = hitRadiusStage(320);
|
||||
const screen = radius * (320 / CALIBRATION_FRAME.width);
|
||||
assert.ok(screen >= 44, `screen hit ${screen}`);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Fixed tape line for calibration (#99).
|
||||
// The line is a stage fixture. The photo is the only transformed entity.
|
||||
// Tick Y values are stage coordinates and are never derived from the photo.
|
||||
|
||||
export const CALIBRATION_FRAME = { width: 480, height: 640 } as const;
|
||||
|
||||
export interface PhotoTransform {
|
||||
tiltDeg: number;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
export const IDENTITY_TRANSFORM: PhotoTransform = {
|
||||
tiltDeg: 0,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
};
|
||||
|
||||
export function lineX(): number {
|
||||
return CALIBRATION_FRAME.width / 2;
|
||||
}
|
||||
|
||||
/** (1 - fill) / 2 of the frame's long axis, from the top and the bottom. */
|
||||
export function cropEnds(fillPercent: number): { startY: number; endY: number } {
|
||||
const fill = Math.max(1, Math.min(100, Number.isFinite(fillPercent) ? fillPercent : 90));
|
||||
const inset = (CALIBRATION_FRAME.height * (100 - fill)) / 200;
|
||||
return { startY: inset, endY: CALIBRATION_FRAME.height - inset };
|
||||
}
|
||||
|
||||
export function fitScale(sourceW: number, sourceH: number, tiltDeg: number): number {
|
||||
const radians = (tiltDeg * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(radians));
|
||||
const sin = Math.abs(Math.sin(radians));
|
||||
const rotatedW = cos * sourceW + sin * sourceH;
|
||||
const rotatedH = sin * sourceW + cos * sourceH;
|
||||
return Math.min(
|
||||
CALIBRATION_FRAME.width / Math.max(rotatedW, 1),
|
||||
CALIBRATION_FRAME.height / Math.max(rotatedH, 1),
|
||||
);
|
||||
}
|
||||
|
||||
/** Inverse of the photo transform. Stage points on the fixed line become source pixels. */
|
||||
export function stageToPhoto(
|
||||
point: [number, number],
|
||||
sourceW: number,
|
||||
sourceH: number,
|
||||
transform: PhotoTransform,
|
||||
): [number, number] {
|
||||
const scale = fitScale(sourceW, sourceH, transform.tiltDeg) * transform.zoom;
|
||||
if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN];
|
||||
const radians = (-transform.tiltDeg * Math.PI) / 180;
|
||||
const dx = point[0] - lineX() - transform.panX;
|
||||
const dy = point[1] - CALIBRATION_FRAME.height / 2 - transform.panY;
|
||||
return [
|
||||
sourceW / 2 + (dx * Math.cos(radians) - dy * Math.sin(radians)) / scale,
|
||||
sourceH / 2 + (dx * Math.sin(radians) + dy * Math.cos(radians)) / scale,
|
||||
];
|
||||
}
|
||||
|
||||
export function sourceTickDistance(
|
||||
startY: number,
|
||||
endY: number,
|
||||
sourceW: number,
|
||||
sourceH: number,
|
||||
transform: PhotoTransform,
|
||||
): number {
|
||||
const a = stageToPhoto([lineX(), startY], sourceW, sourceH, transform);
|
||||
const b = stageToPhoto([lineX(), endY], sourceW, sourceH, transform);
|
||||
return Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
}
|
||||
|
||||
export function calibrationScale(
|
||||
startY: number,
|
||||
endY: number,
|
||||
sourceW: number,
|
||||
sourceH: number,
|
||||
transform: PhotoTransform,
|
||||
lengthIn: number,
|
||||
): number {
|
||||
if (!(lengthIn > 0)) return 0;
|
||||
const distance = sourceTickDistance(startY, endY, sourceW, sourceH, transform);
|
||||
if (!(distance > 0) || !Number.isFinite(distance)) return 0;
|
||||
return distance / lengthIn;
|
||||
}
|
||||
|
||||
/** Stage-unit radius whose on-screen size is at least 44px at the given stage width. */
|
||||
export function hitRadiusStage(renderedWidthPx: number): number {
|
||||
return (44 * CALIBRATION_FRAME.width) / Math.max(renderedWidthPx, 1);
|
||||
}
|
||||
|
||||
export function tapeSegmentSource(
|
||||
startY: number,
|
||||
endY: number,
|
||||
sourceW: number,
|
||||
sourceH: number,
|
||||
transform: PhotoTransform,
|
||||
): { start: [number, number]; end: [number, number] } {
|
||||
return {
|
||||
start: stageToPhoto([lineX(), startY], sourceW, sourceH, transform),
|
||||
end: stageToPhoto([lineX(), endY], sourceW, sourceH, transform),
|
||||
};
|
||||
}
|
||||
@@ -17,8 +17,11 @@
|
||||
brand_voice: string | null;
|
||||
geo_context: string | null;
|
||||
woo_create_status: string | null;
|
||||
crop_fill: number;
|
||||
}
|
||||
|
||||
export let onCropFillChange: (pct: number) => void = () => {};
|
||||
|
||||
interface PriceRule {
|
||||
id: number;
|
||||
species_id: number;
|
||||
@@ -205,6 +208,7 @@
|
||||
brand_voice: settings.brand_voice || '',
|
||||
geo_context: settings.geo_context || '',
|
||||
woo_create_status: settings.woo_create_status || 'draft',
|
||||
crop_fill: settings.crop_fill ?? 90,
|
||||
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
||||
woo_app_password: secretValue(settings.woo_app_password),
|
||||
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.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 (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
|
||||
// secrets only if provided (write-only)
|
||||
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;
|
||||
@@ -243,6 +248,7 @@
|
||||
});
|
||||
if (res.ok) {
|
||||
settings = await res.json();
|
||||
onCropFillChange(settings.crop_fill ?? 90);
|
||||
message = 'Saved';
|
||||
// refresh secret buffers from the (possibly unmasked) server state
|
||||
form.woo_app_password = secretValue(settings.woo_app_password);
|
||||
@@ -351,6 +357,12 @@
|
||||
</select>
|
||||
</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>
|
||||
{#if message}<p class="msg">{message}</p>{/if}
|
||||
|
||||
@@ -415,7 +427,10 @@
|
||||
<style>
|
||||
.settings { max-width: 600px; color: #eee; }
|
||||
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; }
|
||||
button { margin-top: 16px; }
|
||||
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||
|
||||
@@ -51,6 +51,13 @@ test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
|
||||
test('scale follows the tape ticks when they disagree with the mask span', () => {
|
||||
const { bitset, box, slabPx } = uniform(480, 100);
|
||||
const tape = { start: [0, 50] as [number, number], end: [240, 50] as [number, number] };
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, tape);
|
||||
assert.equal(m.scale, 240 / 48);
|
||||
});
|
||||
|
||||
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(100, 100);
|
||||
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
|
||||
|
||||
@@ -76,11 +76,20 @@ export function measure(
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
tape?: { start: [number, number]; end: [number, number] },
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
const tapeSpan = tape
|
||||
? Math.hypot(tape.end[0] - tape.start[0], tape.end[1] - tape.start[1])
|
||||
: 0;
|
||||
const scale = tapeSpan > 0
|
||||
? tapeSpan / lengthIn
|
||||
: (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
const axisStart = tapeSpan > 0 ? tape!.start : seg.start;
|
||||
const axisUx = tapeSpan > 0 ? (tape!.end[0] - tape!.start[0]) / tapeSpan : seg.ux;
|
||||
const axisUy = tapeSpan > 0 ? (tape!.end[1] - tape!.start[1]) / tapeSpan : seg.uy;
|
||||
const every6In = sampleWidths(bitset, w, h, axisStart, axisUx, axisUy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
const min = Math.min(...every6In);
|
||||
|
||||
@@ -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)`);
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
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_H = 4;
|
||||
|
||||
@@ -12,6 +11,10 @@ export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
/** Global crop fill percent. Default 90. */
|
||||
cropFill?: number;
|
||||
/** Calibration-photo rotation in degrees; batch siblings remain unrotated. */
|
||||
rotationDeg?: number;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
@@ -25,7 +28,12 @@ export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
fill = 0.9,
|
||||
rotationDeg = 0,
|
||||
): Frame {
|
||||
if (Number.isFinite(rotationDeg) && Math.abs(rotationDeg % 360) > 1e-8) {
|
||||
return composeRotatedInventoryFrame(src, mask, source, fill, rotationDeg);
|
||||
}
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
const y0 = Math.max(0, Math.round(b.y));
|
||||
@@ -36,7 +44,7 @@ export function composeInventoryFrame(
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
@@ -62,10 +70,131 @@ export function composeInventoryFrame(
|
||||
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(
|
||||
input: InventoryInput,
|
||||
): 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 srcCtx = srcCanvas.getContext('2d')!;
|
||||
srcCtx.drawImage(bitmap, 0, 0);
|
||||
@@ -74,6 +203,8 @@ export async function encodeInventoryPng(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
fill,
|
||||
rotationDeg,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
|
||||
@@ -299,7 +299,37 @@ test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#
|
||||
});
|
||||
|
||||
|
||||
// --- Length dim must not overshoot wood footprint (#69) ---
|
||||
test('inventorySourceMap follows calibration rotation for overlay geometry (#99)', () => {
|
||||
const W = 400, H = 400;
|
||||
const bitset = new Uint8Array(W * H).fill(1);
|
||||
const slabBounds = { x: 0, y: 0, w: W, h: H };
|
||||
const unrotated = inventorySourceMap(
|
||||
slabBounds,
|
||||
{ widthPx: W, heightPx: H },
|
||||
800,
|
||||
1067,
|
||||
0.9,
|
||||
);
|
||||
const rotated = inventorySourceMap(
|
||||
slabBounds,
|
||||
{ widthPx: W, heightPx: H },
|
||||
800,
|
||||
1067,
|
||||
0.9,
|
||||
bitset,
|
||||
90,
|
||||
);
|
||||
const horizontal = (map: ReturnType<typeof inventorySourceMap>) => {
|
||||
const a = map.mapToInventory(100, 200);
|
||||
const b = map.mapToInventory(300, 200);
|
||||
return [b[0] - a[0], b[1] - a[1]] as const;
|
||||
};
|
||||
const [unrotatedDx, unrotatedDy] = horizontal(unrotated);
|
||||
const [rotatedDx, rotatedDy] = horizontal(rotated);
|
||||
assert.ok(Math.abs(unrotatedDx) > Math.abs(unrotatedDy), 'unrotated source axis stays horizontal');
|
||||
assert.ok(Math.abs(rotatedDy) > Math.abs(rotatedDx), '90° calibration rotation maps it vertically');
|
||||
});
|
||||
|
||||
|
||||
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
|
||||
function tiltedSlabFixture() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
import { createMaskRotation } from './inventory';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
@@ -485,15 +486,31 @@ export function inventorySourceMap(
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
fill = 0.9,
|
||||
bitset?: Uint8Array,
|
||||
rotationDeg = 0,
|
||||
): 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 y0 = Math.max(0, Math.round(slabBounds.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const scale = 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 dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
@@ -517,6 +534,8 @@ export interface EncodeOverlayInput {
|
||||
lengthLabel: string;
|
||||
slabBounds: { x: number; y: number; w: number; h: 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 ctx = canvas.getContext('2d')!;
|
||||
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({
|
||||
bitset: input.bitset,
|
||||
widthPx: input.widthPx,
|
||||
|
||||
@@ -28,9 +28,15 @@ Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=
|
||||
| `01-batch.png` | Batch with fixtures loaded |
|
||||
| `02-pick-calibration.png` | Pick calibration |
|
||||
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
||||
| `04-axis.png` | Length axis |
|
||||
| `05-measure.png` | Measure with L / thickness filled |
|
||||
| `04-axis.png` | Tape calibration page |
|
||||
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
|
||||
| `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.
|
||||
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
|
||||
// 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
|
||||
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
|
||||
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
|
||||
|
||||
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
|
||||
};
|
||||
|
||||
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
test('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);
|
||||
expect(fs.existsSync(FACE_A), 'fixture face-a').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 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 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' });
|
||||
await expect(calc).toBeDisabled();
|
||||
await page.getByLabel('Length L').fill('48');
|
||||
await page.getByLabel('Thickness').fill('2');
|
||||
await expect(calc).toBeEnabled();
|
||||
await shot(page, '05-tape-calibration.png');
|
||||
await calc.click();
|
||||
|
||||
// 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).
|
||||
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 = [
|
||||
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
||||
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FACE_A = path.resolve(here, '../fixtures/set1-48x2/face-a.jpeg');
|
||||
const ARTIFACTS = path.resolve(here, 'artifacts');
|
||||
|
||||
const shot = async (page: Page, name: string) => {
|
||||
fs.mkdirSync(ARTIFACTS, { recursive: true });
|
||||
await page.screenshot({ path: path.join(ARTIFACTS, name), fullPage: true });
|
||||
};
|
||||
|
||||
const ensureFreshBatch = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
const resumeHeading = page.getByRole('heading', { name: /resume/i });
|
||||
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
|
||||
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
|
||||
if (await resumeHeading.isVisible().catch(() => false)) {
|
||||
await page.getByRole('button', { name: /start fresh/i }).click();
|
||||
}
|
||||
await expect(batchHeading).toBeVisible();
|
||||
};
|
||||
|
||||
const prepareMaskForApprove = async (page: Page) => {
|
||||
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Black', exact: true }).click();
|
||||
const approve = page.getByRole('button', { name: 'Approve mask' });
|
||||
try {
|
||||
await expect(approve).toBeEnabled({ timeout: 45_000 });
|
||||
return;
|
||||
} catch {
|
||||
/* sensitivity ladder */
|
||||
}
|
||||
for (const target of [60, 70, 40, 30, 80]) {
|
||||
await page.locator('input[type="range"]').fill(String(target));
|
||||
await page.waitForTimeout(2800);
|
||||
if (await approve.isEnabled()) return;
|
||||
}
|
||||
await expect(approve).toBeEnabled({ timeout: 15_000 });
|
||||
};
|
||||
|
||||
const toLengthPage = async (page: Page) => {
|
||||
await ensureFreshBatch(page);
|
||||
await page.locator('input[type="file"][multiple]').setInputFiles([FACE_A]);
|
||||
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
|
||||
await prepareMaskForApprove(page);
|
||||
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('#99 fixed tape line', () => {
|
||||
test('the line is not editable: no tick-drag copy and no tick drag', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||
await toLengthPage(page);
|
||||
|
||||
// The hint must never tell the owner to drag or nudge the ticks.
|
||||
await expect(page.getByText('Drag the ticks')).toHaveCount(0);
|
||||
await expect(page.getByText('nudge the last tick')).toHaveCount(0);
|
||||
await expect(page.getByText('The line is fixed in the middle')).toBeVisible();
|
||||
|
||||
const stage = page.getByTestId('tape-line');
|
||||
await expect(stage).toBeVisible();
|
||||
const box = (await stage.boundingBox())!;
|
||||
const canvas = stage.locator('canvas').first();
|
||||
|
||||
// A pan delta produces the same result wherever you grab the stage, because
|
||||
// pan is measured as a delta from the grab point. So: drag from a tick
|
||||
// position, then repeat the same delta from a plain part of the stage. If
|
||||
// the tick drag were still live it would move the line too, and the two
|
||||
// results would differ. Identical output ⇒ the line is fixed.
|
||||
const centerX = box.x + box.width / 2;
|
||||
const tickY = box.y + box.height * 0.05;
|
||||
const plainY = box.y + box.height * 0.62;
|
||||
const delta = box.height * 0.18;
|
||||
|
||||
const dragBy = async (from: number) => {
|
||||
await page.mouse.move(centerX, from);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX, from + delta, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
await dragBy(tickY);
|
||||
const fromTick = await canvas.screenshot();
|
||||
|
||||
// Reset the transform, then repeat the identical delta from a non-tick point.
|
||||
await toLengthPage(page);
|
||||
await dragBy(plainY);
|
||||
const fromPlain = await canvas.screenshot();
|
||||
|
||||
expect(
|
||||
Buffer.compare(fromTick, fromPlain),
|
||||
'a tick drag must behave exactly like a pan elsewhere (line stays fixed)',
|
||||
).toBe(0);
|
||||
|
||||
// Length and thickness live on this same screen; Calculate stays usable.
|
||||
await page.getByLabel('Length L').fill('48');
|
||||
await page.getByLabel('Thickness').fill('2');
|
||||
await expect(page.getByRole('button', { name: 'Calculate' })).toBeEnabled();
|
||||
await shot(page, 'tape-fixed-line.png');
|
||||
});
|
||||
|
||||
test('the crop slider sets the line ends and nothing else does', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await toLengthPage(page);
|
||||
|
||||
const canvas = page.getByTestId('tape-line').locator('canvas').first();
|
||||
const fill = page.getByLabel('Crop fill percent');
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Move the crop-fill slider and confirm the control really took the value,
|
||||
// so a no-op fill() can never masquerade as a passing assertion.
|
||||
await fill.fill('50');
|
||||
await expect(fill).toHaveValue('50');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after), 'crop fill must move the line ends').not.toBe(0);
|
||||
|
||||
// Arrow-key nudge on the line must not change the geometry either.
|
||||
// Blur the slider first: arrow keys on a focused range input legitimately
|
||||
// change that input, which would move the line ends for the wrong reason.
|
||||
await page.locator('h1').click();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.waitForTimeout(500);
|
||||
const afterKeys = await canvas.screenshot();
|
||||
expect(
|
||||
Buffer.compare(after, afterKeys),
|
||||
'arrow keys must not move the fixed line ends',
|
||||
).toBe(0);
|
||||
await shot(page, 'tape-fixed-line-crop50.png');
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ CITING_ROLES = {"capability", "contract"}
|
||||
CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"}
|
||||
HEADING = re.compile(r"^(#{2,4})\s+(.*)$")
|
||||
|
||||
SCOPES = {"shipped", "M3", "later"}
|
||||
SCOPES = {"shipped", "M3", "M4", "later"}
|
||||
PLACEHOLDER = {"", "—", "-", "*(none yet)*"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user