1 Commits

Author SHA1 Message Date
Ty 63b8879720 docs(design): add approved #99 tape-line overlay wireframe
Owner-approved design proposal for #99 (tape-line overlay replaces the axis
rectangle). Wireframe only - no production code.

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 and L = tick to tick; crop % moves only the
overlay's ends ((1 - fill)/2 of the long axis in from the top and the bottom)
and never changes the aspect ratio.

The file header lists the wireframe scaffolding to scrub before this reaches the
production workflow (viewport switcher, coverage board, was/now pair, PROPOSAL
markers, in-stage tutorial labels, sample values, window.__wf debug hook).
2026-09-24 11:30:13 -07:00
32 changed files with 1023 additions and 1953 deletions
-192
View File
@@ -1,192 +0,0 @@
# M4 — Goals
Role: goals for Milestone 4. Cites requirement IDs and Gitea issue numbers; defines none
new until accepted. Source of truth for scope will be `docs/MILESTONES.md` (M4 ID subset)
and `docs/REQUIREMENTS.md` (rows) after acceptance.
**Base:** builds on branch `m4` (commit `818874e`, `feat(m4): crop_fill setting + tape-line
prototype (#99)`), which is 1 ahead of `origin/main`. #99 is in scope.
**Objective:** Ship a polished, stable M3 experience with the tape-line calibration model,
a **human-driven taxonomy review** (Call 1 deferred; owner picks everything on a thumb-first
UI), **refined Call 2 long descriptions**, and a verified real WooCommerce draft submission.
**Exit criterion:** A real `SLAB-UAT-*` Woo **draft** matching the reviewed ready set exists
in the store, created end-to-end through the M4 UI (tape-line calibration → human taxonomy
review → Call 2 long description → publish), with **no Call 1** in the path (inference
settings need not be vision-enabled). Call 2 is exercised on a live call at least once.
Raw runner evidence recorded; Ty owns gate exit (QUALITY-25/30).
## Goals (accepted direction)
1. **Stabilize + polish the M3 experience (A) — includes the #99 tape-line calibration (B).**
- **Tape-line calibration (#99) — SHIPPED (as-built 2026-09-25, UAT signed off):** the
axis-rectangle calibration is replaced by a **fixed** black centre line with two end
ticks. The line is a stage fixture and is **never an owner input** (not draggable, not
focusable, not keyboard-nudgeable). The **source photo/slab is the only thing that
moves** — pan/zoom/tilt it until it lines up with the fixed line at the place the tape
measurement was taken. Declared Length = the real inches read off the tape, and the
fixed line represents that same length; scale = fixed-line pixel span / declared
Length, no `maskSpanAlong` check. The crop slider alone sets the line ends
(`crop_fill=90` ⇒ each end 5% from its edge). The earlier `?tape-prototype` sandbox,
`TapePrototype.svelte`, `frontend/src/png/crop.ts` and the `sveltedrag` dependency are
all superseded/removed. Requirement rows CAPTURE-170/175/75 carry the as-built; see
`tasks.md` T2 for detail.
- **UX polish:** photo-picker look/feel (#91), photo filenames on submit (#92), capture
gated on a tested Woo connection (#93), review UI is always the human-pick flow (#94),
debug mode (#95), required-photo lock glyph (#100). (#96 Call 1/2 prompt viewer drops
with Call 1.)
- Post-launch issue work + the tape-line feature; no other new feature surface.
2. **Human-driven taxonomy review + thumb-first UI (Call 1 deferred; Call 2 kept).**
- **Call 1 (vision) → deferred to future work.** Not in the MVP: no "Suggest with AI"
button, no auto-pre-fill, no confidence pre-fill, no AI markers on fields. The
inference settings need not be vision-enabled.
- **Owner picks everything Call 1 used to propose**, via a thumb-first, on-phone usable
UI: species (1 leaf), wood categories (primary + optional secondaries), Edge, Figure
(multi), Grade (owner judgment), Thickness band (derived, no pick), Moisture, and
`feat-*` / `fig-*` tags. All from the synced Woo taxonomy (METADATA-5/10/15/30).
- **Call 2 (text) stays** — writes the long description from the human-picked values +
dimension fact block; brand/geo context, primary + secondary categories. (Refining its
output quality is its own goal, #3 below.)
- **Title + short description remain deterministic** from the primary wood category +
dimension fact block (METADATA-80/85) — they depend only on the human's primary pick
+ fact block, not on Call 1.
- **All METADATA requirements remain in scope** — the human collects the same data
Call 1 previously suggested, just by hand.
- **Wireframe first (a sprint task in this milestone):** before any review-UI coding,
produce a wireframe (2-3 HTML variants) to align on the overall flow — how the
thumb-first taxonomy collection, primary/secondary selection, and Call 2 generate
control compose on a phone. This is the design gate for the review work.
**Starting point (saved, recent):** `docs/design/issue-99-tape-line-overlay.html`
(approved #99 tape-line overlay wireframe, committed 2026-09-24 on
`issue-99-tape-line-overlay`). Do **not** build on the older `pre-simplification`
wireframes or the 2026-09-18 `ux-design.md` coverage matrix — those are stale.
- *To be scoped:* the exact thumb-first interaction model (wizard vs single-scroll,
chips vs pickers) — settled by the wireframe.
3. **Refine Call 2 so it delivers better long descriptions.**
- Call 2 (text) stays in the MVP and is the inference work of this milestone: improve
how it turns the human-picked taxonomy + dimension fact block + brand/geo context into
a long description that reads well for the store.
- Levers (task-level, settled during the sprint): prompt revision (METADATA-105/106),
the dimension fact block as the only numbers it may print (METADATA-65/90/95),
primary + secondary category weighting, brand voice / geo context settings
(SETTINGS-30), and recoverable/unrecoverable failure (METADATA-115).
- Success is judged on the exit-walk description quality (Ty reads it), not on a proxy
metric. Raw evidence recorded.
4. **Exit: real Woo draft submission.**
- PUBLISH-5 sequence, PUBLISH-10 (`SLAB-UAT-*` force draft), PUBLISH-25 (woo_product_id,
purge photos, discard local row, clear device draft). QUALITY-110 exit evidence.
## Non-goals (excluded from M4)
- **Call 1 (vision) inference** — deferred to future work. No auto-suggest, no confidence
pre-fill, no AI markers. METADATA-35/40/45/100 rows are out of the M4 active set.
- TLS on LAN transports (#78) — production-readiness, deferred.
- Backup/restore (OPS-30/35) — deferred ("Later").
- Instrumentation: Prometheus metrics, alerting, log retention (#7/#16) — deferred.
- Store-admin species-leaf creation (#13), shared validation module (#17), 409/422
field-level recovery (#18) — post-launch designs, deferred.
- Offline capture (CAPTURE-15), U2Net server matting (CAPTURE-165) — "Later".
- Browser e2e on all five clients (QUALITY-115) — "Later".
- Prompt-revision auth beyond trusted network (METADATA-107) — "Later".
- App login (relies on trusted LAN/Tailscale + HTTPS, OPS-55) — out of scope.
## Assumptions
- M4 does **not** add an app-level login; the trusted-network model (OPS-55) holds.
- The `m4` branch is the working base; `main` is the merge target once M4 is accepted and green.
- Inference endpoint is a remote, configurable endpoint (OPS-45); M4 refines prompts/calls,
not the endpoint transport.
- Real Woo for the exit walk; Woo mock is a dishonest proxy (QUALITY-105/110).
- Existing M3 requirement rows that the tape-line model supersedes (CAPTURE-60, CAPTURE-75)
are updated in place; new rows are added for the tape-line behavior. No row is deleted.
## Requirements delta (draft — rows written at execution, after acceptance)
- **New rows:** tape-line calibration behavior (overlay, draggable ticks,
owner-declared length, tick-distance scale). ~1–3 rows under CAPTURE.
- **New rows (draft):** the **manual taxonomy selection UI** — owner picks species / wood
categories (primary + secondary) / edge / figure / grade / moisture / tags by hand,
thumb-first, with no inference pre-fill. ~1 row under METADATA or REVIEW.
- **Superseded/updated rows (draft):** CAPTURE-60 (length axis), CAPTURE-75 (scale model) —
rewritten to the tape-line model.
- **Deferred rows (out of M4 active set):** METADATA-35/40/45/100 (Call 1 vision
suggestions + confidence gate) — moved to "Later"/future work. The rows stay in
REQUIREMENTS.md; only their M4 scope drops.
- **Refined rows (draft):** METADATA-55/60/65 (Call 2) — if the human-picked flow changes
normative Call 2 inputs (it shouldn't materially; Call 2 already receives the metadata
confirmed in review).
- **Refined rows (draft):** REVIEW-15/20/25/40 (review-workflow usability) — field layout,
nudge behavior, primary/secondary category interaction, generate-text control placement,
if the usability changes alter normative review behavior. #94 (conditional review UI)
now means: the review screen is **always** the human-pick flow (no inference on/off
branch for Call 1).
- **Unchanged (in M4 ID subset):** all PUBLISH, REVIEW, SETTINGS, APP, OPS-40, QUALITY rows
that M3 already shipped and M4 re-uses for the exit; METADATA-5/10/15/30/85 (the data the
human collects + the deterministic title/short-desc).
## Acceptance criteria (draft)
1. **Tape-line calibration + M3 polish ship together (Goal 1):** the tape-line calibration
ships as a **fixed** centre line the owner pans/zooms/tilts the slab onto (line is never
an owner input; crop slider sets its ends; declared length = the real tape inches, and
the fixed line represents that same length; no `maskSpanAlong` check), and the
M3 polish issues (#91–#100, excluding #96 which drops with Call 1) are closed with
passing tests and raw runner evidence.
2. **The review flow is human-driven, thumb-first, and Call 1-free:** the owner can pick
species, wood categories (primary + secondary), edge, figure, grade, moisture, and tags
by hand on a phone, with no "Suggest with AI" surface and no AI pre-fill. The wireframe
was accepted before coding (design gate).
3. **Call 2 (text) is exercised on a live call** during the exit walk and delivers a
description that reads well (Ty judges it); content-gen control visibility (METADATA-110),
recoverable/unrecoverable classification (METADATA-115), and prompt-revision API
(METADATA-105/106) all pass their tests.
4. **A real `SLAB-UAT-*` Woo draft** exists in the store matching the reviewed ready set
(QUALITY-110), with `woo_product_id` set, photos purged, local row discarded, device
draft cleared (PUBLISH-25).
5. **Gates pass:** `python tools/check_requirements.py` and `python tools/check_coverage.py`
exit 0; full suite passes with inference **and** off (QUALITY-20) — now including the
human-pick flow with Call 1 removed; raw evidence recorded.
## Verification strategy (smallest that proves the claim)
- **Deterministic core:** unit tests for tape-line geometry + `crop_fill` (existing
`frontend/src/png/*` test surface).
- **Integration:** Woo mock for the PUBLISH-5 sequence (success, duplicate SKU, stale field,
taxonomy anchor) — QUALITY-35/50.
- **Exit walk:** `deploy/uat_walk.sh` on the real store for the `SLAB-UAT-*` draft
(QUALITY-105/110) — the only proof that counts for the submission claim.
- **Ty owns gate exit** (QUALITY-30); agents record raw evidence, never declare green.
## Task-level decisions (settled during the sprint, not at planning)
The only plan-level open item is the exact thumb-first interaction model
(wizard vs single-scroll, chips vs pickers) — settled by the wireframe.
Settled at kickoff (not reopened here): full #91–#100 polish wave (only #96 drops
with Call 1); #99 confined to the calibration step (CAPTURE-130 lineup untouched);
exit walk is a single slab carrying multiple photos.
## Task breakdown detail bar
The task breakdown is not a list of goal names. Every task carries, at minimum:
- **Goal it serves** (G1–G4) and the requirement IDs / issue numbers it touches.
- **Skills to load** for that task — named and ordered (e.g. UI task: `sketch` +
`wireframe-review-iteration` → `omh-frontend` + `omh-design-quality-gate` →
`omh-visual-qa` + `omh-accessibility-audit`).
- **Starting point / references** — the specific saved artifact, file, or branch the task
builds from (e.g. `docs/design/issue-99-tape-line-overlay.html`, the live
`Review.svelte`, the M3 `MultiSelect.svelte`).
- **Verification** — the smallest check that proves the task's claim (unit test paths,
`tsc --noEmit`, Woo mock, `deploy/uat_walk.sh`, 375/768/1280 visual captures, keyboard /
contrast audit). Ty owns gate exit.
- **Sequence / dependencies** — what must land first (wireframe accepted before review-UI
coding; requirements rows before the code that implements them; Call 2 refinement after
the human-pick flow feeds it real data).
- **Stop condition** — when the task is done (e.g. "wireframe accepted by Ty," "gates
green + raw evidence recorded," "real `SLAB-UAT-*` draft matches the ready set").
- **Evidence boundary** — what is prepared vs observed; no task claims done without the
raw runner / capture / store evidence named above.
-205
View File
@@ -1,205 +0,0 @@
# M4 Task Breakdown
Companion to `goals.md` (accepted direction). Detail bar per task: goal, skills,
starting point, verification, sequence, stop condition, evidence boundary.
Phase order: P0 (requirements first) → P1 (G1 polish + #99, parallel-safe) → P2 (G2
review-UI, wireframe-gated) → P3 (G3 Call 2) → P4 (G4 exit walk). Ty owns every gate
exit (QUALITY-30); agents record raw evidence, never declare green.
---
## P0 — Requirements baseline (must land before code it enables)
### T1. Requirements delta for M4
- **Goal:** G2/G4 (unblocks G3). **IDs:** REQS (whole doc), REQS-30. **Issues:** —.
- **Skills:** `document-to-action-items` (extract obligations), `ocr-and-documents`
(read current REQUIREMENTS.md), OMH `omh-routing` (evidence boundaries).
- **Starting point:** `docs/REQUIREMENTS.md` (current), `goals.md` requirements-delta
section.
- **Work:** add rows for the human-pick flow (REVIEW-*, CAPTURE-*, METADATA-*), the
Call 2 taxonomy-input row, and the `SLAB-UAT-*` exit-walk row; note the Call 1
deferral (METADATA-40/45/55) and drop #96; keep the "draft only" PUBLISH-40 invariant;
confirm #99 is tracked. Every new/changed row is the gate for the code that implements it.
- **Verification:** `python tools/check_requirements.py` exit 0; coverage matrix
consistent.
- **Sequence:** first task of the sprint.
- **Stop:** rows merged + gate green. **Evidence:** runner output.
### T2. #99 tape-line calibration (folded into G1) — AS-BUILT, shipped
- **Goal:** G1. **IDs:** CAPTURE-170, CAPTURE-175, CAPTURE-75. **Issues:** #99 (issue
body still describes the superseded draggable-tick model — this entry is the as-built).
- **Skills:** `systematic-debugging`, `svelte-core-bestpractices`,
`docker-uat-deploy` (isolate-and-verify the review preview).
- **As-built:** a **fixed** black centre line with two end ticks, drawn on the stage
fixture. The line is **never an owner input** — not draggable, not focusable, not
keyboard-nudgeable, and not part of the published pose. The **source photo/slab is the
only thing that moves**: drag to pan, wheel/pinch to zoom, the ↻ handle or arrow keys
(Shift = fine step) to tilt. The owner aligns the *slab* to the fixed line at the place
the tape measurement was taken. Typed Length is the real inches read off the tape, and
the fixed line represents that same length. Scale = fixed-line pixel span / declared
Length; `maskSpanAlong` is not the scale source. The crop slider is the **sole** control
of the line ends: `crop_fill=90` puts each end 5% from its corresponding edge.
Calibration rotation applies to the calibration clean PNG + overlay only; batch
siblings stay unrotated.
- **Superseded (do not restore):** draggable ticks; `TapePrototype.svelte` and the
`?tape-prototype` sandbox (both deleted); `frontend/src/png/crop.ts` (removed in
6000ac2 — crop geometry now lives in `frontend/src/capture/tapeLine.ts`
`cropEnds(fillPercent)`); the `sveltedrag` dependency (404s on npm, never added).
The wireframe `docs/design/issue-99-tape-line-overlay.html` lives on branch
`issue-99-tape-line-overlay` (63b8879), not on `m4`.
- **Verification:** unit tests in `frontend/src/capture/tapeLine.test.ts` (ends fixed by
the crop slider, never an owner input) + `frontend/src/png/{inventory,overlay}.test.ts`
(calibration rotation reaches the calibration PNG/overlay only); `tsc --noEmit`;
`npm run build`; e2e `frontend/tests/e2e/tape-fixed-line.spec.ts` (no tick drag; a tick
drag behaves identically to a pan; ends follow the crop slider).
- **Sequence:** parallel with T3–T5; no dependency on T1.
- **Stop:** SHIPPED. UAT-signed off by Ty 2026-09-25 on the M4 review preview
(`http://192.168.1.202:10883/?noload=1`, build `a756cf5`, branch `m4`).
- **Evidence:** `npm test` 102/102; requirements + coverage gates green; live e2e on the
deployed preview (0°/20° rotation produces differing clean PNG + overlay bytes); render
gate clean at 1440/768/375 with no overflow or clipped text; Ty's UAT sign-off.
**Not covered:** `svelte-check` still reports its known 42-error baseline.
---
## P1 — M3 polish (G1), parallel-safe once T1 lands
### T3. Polish wave (full set #91–#100)
- **Goal:** G1. **IDs:** UX (various). **Issues:** all of #91–#100 — #91 (photo-picker
look/feel), #92 (photo filenames on submit), #93 (capture gated on tested Woo
connection), #94 (review layout), #97 (settings), #98 (publish), #100 (UX general).
**Excluded:** #96 (drops with Call 1). Ty confirmed: full polish wave, no triage.
- **Skills:** `svelte-core-bestpractices`, `omh-frontend` (component states),
`omh-visual-qa` (rendered evidence per screen).
- **Starting point:** the live screens named in each issue; `frontend/src/create/*`.
- **Work:** per-issue fixes. Each is a small, individually-verifiable unit; sequence
by dependency (e.g. #93 gate before #98 publish flow touches it).
- **Verification:** per-issue acceptance (capture at 375/768/1280, or the named
behavior), full suite green.
- **Sequence:** after T1; independent of T2.
- **Stop:** each issue's acceptance captured. **Evidence:** captures + test run.
---
## P2 — Human-driven taxonomy review + thumb-first UI (G2)
### T4. Wireframe (design gate)
- **Goal:** G2. **IDs:** REVIEW-*, UX (phone). **Issues:** #100 (UX).
- **Skills:** `sketch` (variants) + `wireframe-review-iteration` (paradigms, serve on
LAN IP, iterate on Ty's feedback) → `omh-frontend` + `omh-design-quality-gate`
(lock the chosen variant).
- **Starting point:** `docs/design/issue-99-tape-line-overlay.html` (approved overlay
wireframe, 2026-09-24) for the visual language + the interaction model.
- **Work:** 2–3 HTML variants of the thumb-first review flow. Standing paradigms:
*no tap-bubble chip clouds for long lists → collapsible searchable checklist*;
leftmost-kept-is-primary for wood (primary + optional secondaries);
no AI-assist surface (Call 1 decommissioned); every metadata requirement present (primary
wood-category required, secondaries optional).
- **Verification:** Ty reviews the served variants and accepts one (375/768/1280).
- **Sequence:** before T5/T6. **Stop:** Ty accepts a variant.
**Evidence:** the accepted `docs/design/*` file + Ty's acceptance.
### T5. Review-UI implementation
- **Goal:** G2. **IDs:** REVIEW-*, CAPTURE-*. **Issues:** #100.
- **Skills:** `svelte-core-bestpractices`, `svelte-code-writer` (Svelte 5 lookups),
`omh-frontend`, `omh-visual-qa`, `omh-accessibility-audit` (keyboard, 44px targets,
contrast, 320px reflow).
- **Starting point:** accepted T4 wireframe; `frontend/src/create/review/Review.svelte`
(current single-column form); the M3 `MultiSelect.svelte` pattern (reused, not the
tap-bubble pattern).
- **Work:** replace the review form with the accepted thumb-first flow; primary
wood-category + optional secondaries; remove the "Suggest with AI" bar (#96 drops
with Call 1); keep all metadata requirements present and editable.
- **Verification:** `tsc --noEmit`; `omh-visual-qa` at 375/768/1280 (PASS);
`omh-accessibility-audit` (keyboard-reachable, 44px, contrast, 320px reflow);
full suite green with inference on **and** off (QUALITY-20).
- **Sequence:** after T4 accepted. **Stop:** flow renders, all requirements present,
audits pass. **Evidence:** captures + audit output.
### T6. Taxonomy → title/shortdesc (server-side)
- **Goal:** G2. **IDs:** METADATA-*. **Issues:** —.
- **Skills:** `systematic-debugging` (verify the title/shortdesc builders),
`evidence-based-code-review`.
- **Starting point:** the title/short description builders in `backend/` (review.py /
publish.py path); the new human-pick fields from T5.
- **Work:** ensure title + short description are built from the human-picked primary
wood + form (not from the removed Call 1). Confirm the server_description
(inference-off) path stays intact.
- **Verification:** unit tests for title/shortdesc from human picks; inference-off
path covered. **Sequence:** with/after T5. **Stop:** tests green.
**Evidence:** test output.
---
## P3 — Refine Call 2 long description (G3)
### T7. Wire human-picked taxonomy into the Call 2 prompt
- **Goal:** G3. **IDs:** METADATA-*. **Issues:** —.
- **Skills:** `systematic-debugging`, `evidence-based-code-review`,
`local-llm-benchmarking` (endpoint behavior), `omh-ask` (prompt-engineering consult
if the owner wants an external pass).
- **Starting point:** `backend/inference.py` `call2` (line 467) — the prompt today is
the **dimension fact block + brand voice + geo only; taxonomy is NOT in the prompt**.
`CALL2_SEED` (line 112), `_active_prompt`, `brand_voice`/`geo_context` settings.
- **Work:** add the human-picked primary/secondary wood + form to the Call 2 fact
block so the long description is grounded in what the owner picked; keep the
"invent no numbers" constraint; keep the dimension block exact.
- **Verification:** `test_inference.py` updated for the new input; a recorded call2
with a known taxonomy set shows the wood/form in the output. **Sequence:** after T5
(needs the human-pick data shape). **Stop:** test passes + sample output grounded.
**Evidence:** test output + a sample Call 2 response.
### T8. Exit-walk quality gate
- **Goal:** G3. **IDs:** QUALITY-105/110. **Issues:** —.
- **Skills:** `omh-verification-gate` (record the gate), `omh-visual-qa`.
- **Starting point:** `deploy/uat_walk.sh`; the refined Call 2 from T7.
- **Work:** Ty reads the Call 2 long description on the exit walk for 2–3 real slabs;
pass = "sounds like a good product description." This is the only proof that counts
for the Call 2 quality claim.
- **Verification:** Ty's judgment on 2–3 outputs, recorded. **Sequence:** after T7.
**Stop:** Ty passes the gate. **Evidence:** Ty's recorded judgment.
---
## P4 — Exit: real Woo draft (G4)
### T9. `SLAB-UAT-*` draft submission + walk
- **Goal:** G4. **IDs:** PUBLISH-*, QUALITY-105/110. **Issues:** —.
- **Skills:** `omh-deploy-and-monitor` (release checklist + monitor),
`omh-verification-gate`, `docker-uat-deploy` (if the UAT host needs staging).
- **Starting point:** `backend/publish.py` (draft create path), the Woo mock
(`test_integration_woo_mock.py`) for the PUBLISH-5 sequence, `deploy/uat_walk.sh`.
- **Work:** run the full create → review (human pick) → Call 2 → publish path on the
real store, producing **one** `SLAB-UAT-*` draft; walk it (draft only, PUBLISH-40);
confirm the draft matches the ready set. **Shape (Ty confirmed):** single slab, and
the draft carries **multiple photos** of that slab — not a QUALITY-110 pair.
- **Verification:** `deploy/uat_walk.sh` on the real store; draft present in Woo with
the correct fields — including **multiple photos** on the single `SLAB-UAT-*` draft;
full suite green inference on **and** off (QUALITY-20).
- **Sequence:** last; after T3–T8. **Stop:** real draft matches the ready set + Ty
passes the walk. **Evidence:** store screenshot + walk transcript + suite output.
---
## Dependency map
```
T1 ──┬─ T3 (polish)
├─ T4 ─ T5 ─┬─ T6
│ └─ T7 ─ T8 ─ T9
└─ T2 (tape-line, parallel)
T9 is the milestone exit.
```
- T2 is independent of T1 (prototype already on `m4`).
- T4 must be accepted before T5.
- T7 needs T5's data shape; T8/T9 need T7.
- Nothing in P2/P3 blocks T2/T3 (polish + tape-line ship independently).
## Resolved at kickoff (deep-interview, 2026-09-24)
- **Polish scope:** full wave — all of #91–#100, no triage (only #96 drops with Call 1).
- **Tape-line scope:** confined to the calibration step; photos-ready lineup
(CAPTURE-130) untouched.
- **Exit-walk shape:** single slab; the `SLAB-UAT-*` draft carries multiple photos of
that slab (not a QUALITY-110 pair).
-30
View File
@@ -28,18 +28,13 @@ 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,
}
@@ -96,12 +91,6 @@ 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
@@ -119,25 +108,6 @@ 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:
-48
View File
@@ -138,51 +138,3 @@ 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()
-30
View File
@@ -117,33 +117,3 @@ 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).
+1 -29
View File
@@ -41,36 +41,8 @@ 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-35, METADATA-40, METADATA-45, METADATA-100,
METADATA-107, OPS-30, OPS-35, QUALITY-115
CAPTURE-15, CAPTURE-165, METADATA-107, OPS-30, OPS-35, QUALITY-115
+4 -9
View File
@@ -26,12 +26,10 @@ 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 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-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-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 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-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-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 |
@@ -73,7 +71,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 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-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-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 | — |
@@ -84,7 +82,6 @@ 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
@@ -100,7 +97,6 @@ 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
@@ -117,7 +113,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. | M4 | — |
| 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-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 |
@@ -210,4 +206,3 @@ 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 | — |
+1 -3
View File
@@ -42,8 +42,6 @@ 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:
@@ -89,7 +87,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`, a milestone scope (`M3`, `M4`, ...), or `later`.
- `Scope` is `shipped`, `M3`, 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.
+713
View File
@@ -0,0 +1,713 @@
<!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">&#8635;</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 &#8635; 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">&#8722;</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}&#176;</span>
</div>
<p class="nudge">&larr; &rarr; tilt 1&#176; &nbsp;·&nbsp; Shift + &larr; &rarr; tilt 0.1&#176; &nbsp;·&nbsp; or drag the &#8635; 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">&#8722;</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 &nbsp;·&nbsp; <b>+</b> / <b>&#8722;</b> keys step 10% &nbsp;·&nbsp; 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&#215;44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>&#8593; &#8595;</b> nudge 1px, Shift = 0.1px &mdash; 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}&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; 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 &#10003;"
: `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}&#176;`}.`}</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)}&#8243;</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}&#176; &nbsp;·&nbsp; zoom ${S.zoom.toFixed(2)}&#215; <span class="dim">(moving these never changes the line)</span></p>
<div class="swap">
<figure>${oldOverlayMini()}<figcaption class="gone">&#10007; 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}&#176;</span></li>
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}&#176;</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&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; 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", "&#9881; 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>
+13 -18
View File
@@ -18,7 +18,7 @@
import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow';
import { createSlab, uploadPhoto, getSettings, updateSettings, ApiError } from './api/slabs';
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
@@ -26,6 +26,7 @@
let cap: Capture = { k: 'batch', photos: [] };
let busy = false;
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
@@ -36,20 +37,16 @@
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();
@@ -219,20 +216,18 @@
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
{cropFill}
setTapePose={session.setTapePose}
setCropFill={(pct) => {
cropFill = pct;
session.setCropFill(pct);
void updateSettings({ crop_fill: pct }).catch(() => {});
}}
calculateFromAxis={session.calculateFromAxis}
setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis}
back={session.back}
/>
{:else if cap.k === 'measure'}
{:else}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
calculate={session.calculate}
back={session.back}
{pngUrl}
onContinue={saving ? undefined : () => void continueToReview()}
/>
{/if}
-2
View File
@@ -454,8 +454,6 @@ 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> {
+33 -360
View File
@@ -1,385 +1,58 @@
<script lang="ts">
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';
import type { Capture } from './capture.types';
import PhotoStage from './PhotoStage.svelte';
export let state: Extract<Capture, { k: 'axis' }>;
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 setAxisAngle: (deg: number) => void;
export let approveAxis: () => void;
export let back: () => void;
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());
$: 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);
</script>
<svelte:window on:keydown={onKey} />
<section class="panel">
<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>
<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>
</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" disabled={calcBusy || positiveInches(String(lengthIn)) === null || positiveInches(String(thicknessIn)) === null} on:click={onCalculate}>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.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; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.thumb {
width: 44px; height: 44px; border-radius: 3px; font-size: 1.3rem;
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.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; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 2; padding: 12px; font-size: 1rem; border-radius: 3px; border: 0;
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost { padding: 12px 16px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>
+106 -4
View File
@@ -1,7 +1,9 @@
<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,
@@ -9,13 +11,22 @@
} 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 = [];
@@ -37,17 +48,92 @@
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>Photos ready</h1>
<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>
{#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}
{:else if state.results}
<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>
@@ -83,6 +169,12 @@
{/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">
@@ -99,6 +191,14 @@
<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;
@@ -129,4 +229,6 @@
}
.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>
+4 -23
View File
@@ -43,25 +43,13 @@ 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; the PNG crop bounds (#99)
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
}
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.
// Direction only: an oriented rectangle over the slab. The arrow reads
// corners[3] → corners[0] (the long side).
export interface Axis {
bbox: BBox;
photoTransform: PhotoTransform;
tickStartY: number;
tickEndY: number;
tapeStart: [number, number];
tapeEnd: [number, number];
}
export interface Widths {
@@ -108,9 +96,7 @@ export type Capture =
k: 'axis';
source: Source;
approvedMask: Mask; // frozen footprint
axis: Axis; // live tape line
lengthEighths: Eighths;
thicknessEighths: Eighths;
axis: Axis; // live axis.bbox being nudged
}
| {
k: 'measure';
@@ -152,11 +138,6 @@ 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
+1 -10
View File
@@ -6,15 +6,11 @@ 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('tape page exposes typed number inputs for Length L and Thickness', () => {
test('MeasurePanel 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(
@@ -35,11 +31,6 @@ test('tape page 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,33 +252,6 @@ 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,87 +358,6 @@ 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 =
@@ -601,53 +520,6 @@ 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/);
+122 -227
View File
@@ -1,16 +1,16 @@
import { writable } from 'svelte/store';
import type {
Axis, BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, PhotoTransform, Sheet, Source, WorkerIn, WorkerOut,
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, 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,20 +40,13 @@ export interface CaptureSession {
setSensitivity(n: number): void;
setSheet(s: Sheet): void;
approveMask(): 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. */
setAxisAngle(deg: number): void;
approveAxis(): void;
/** Length and thickness are entered on the tape page. Calculate leaves that page for Photos ready. */
calculateFromAxis(lengthEighths: Eighths, thicknessEighths: Eighths): Promise<void>;
setLength(e: Eighths): void;
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;
@@ -72,19 +65,6 @@ 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 {
@@ -101,29 +81,8 @@ 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;
@@ -144,6 +103,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
}
worker = null;
}
if (pngUrl) {
URL.revokeObjectURL(pngUrl);
pngUrl = null;
}
};
const makeWorker = (blob: Blob) => {
@@ -244,12 +207,7 @@ 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: null, inventoryPng: null,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
if (state.k === 'measure') {
@@ -259,11 +217,6 @@ 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,
@@ -355,92 +308,6 @@ 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;
@@ -471,18 +338,16 @@ 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: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d), lengthEighths: d.lengthEighths ?? 0, thicknessEighths: d.thicknessEighths ?? 0 });
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
return;
}
// 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;
if (d.inventoryPng) {
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(d.inventoryPng);
}
set({
k: 'measure', source, approvedMask: mask,
axis: axisFrom(draftAxisBbox(d), source.widthPx, source.heightPx, d),
axis: { bbox: draftAxisBbox(d) },
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? {
@@ -512,7 +377,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
void persist();
};
const session: CaptureSession = {
return {
capture,
busy,
@@ -611,39 +476,22 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
const overlay = state.overlay;
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 });
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 } } });
void persist();
},
setTapePose(pose) {
setAxisAngle(deg: number) {
if (state.k !== 'axis') return;
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)));
const a = ((Math.round(deg) % 180) + 180) % 180;
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
void persist();
},
approveAxis() {
if (state.k !== 'axis') return;
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: state.axis,
axis: { bbox: state.axis.bbox },
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
@@ -661,54 +509,105 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
void persist();
},
// Legacy compatibility path; the shipped tape page calls calculateFromAxis.
// #62: Calculate the calibration photo, then apply its frozen mask params
// to siblings. This stays in Measure; siblings never enter Mask controls.
async calculate() {
if (state.k !== 'measure') return;
const input: CalibrationInput = {
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: state.lengthEighths,
thicknessEighths: state.thicknessEighths,
};
const output = await runCalibration(input);
if (state.k !== 'measure') return;
set({ ...state, ...output });
void persist();
},
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;
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' });
}
const input: CalibrationInput = {
source: state.source,
approvedMask: state.approvedMask,
axis: state.axis,
lengthEighths: length,
thicknessEighths: thickness,
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,
};
// 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,
});
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',
}));
// 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 };
}
}
set({ ...state, results, batchApply });
void persist();
},
@@ -727,17 +626,14 @@ 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: state.axis,
lengthEighths: state.lengthEighths,
thicknessEighths: state.thicknessEighths,
});
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
}
},
getInventoryUrl() {
return pngUrl;
},
currentMeasure() {
return state.k === 'measure' ? state : null;
},
@@ -746,7 +642,6 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
teardownWorker();
},
};
return session;
}
// Display helper: format eighths as inches with fractions.
-85
View File
@@ -1,85 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CALIBRATION_FRAME,
IDENTITY_TRANSFORM,
calibrationScale,
cropEnds,
hitRadiusStage,
lineX,
sourceTickDistance,
stageToPhoto,
} from './tapeLine.ts';
const moved = { tiltDeg: 17, zoom: 1.8, panX: 40, panY: -25 };
test('the line stays in the middle of the frame when the photo moves', () => {
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
assert.equal(lineX(), 240);
const a = stageToPhoto([lineX(), 32], 1000, 1500, IDENTITY_TRANSFORM);
const b = stageToPhoto([lineX(), 32], 1000, 1500, moved);
assert.equal(a[0] === b[0] && a[1] === b[1], false);
assert.equal(lineX(), CALIBRATION_FRAME.width / 2);
});
test('crop fill moves only the ends and never the 3:4 frame', () => {
const at90 = cropEnds(90);
assert.equal(at90.startY, 32);
assert.equal(at90.endY, 608);
const at50 = cropEnds(50);
const at100 = cropEnds(100);
assert.equal(CALIBRATION_FRAME.width / CALIBRATION_FRAME.height, 3 / 4);
assert.equal(at50.startY, 160);
assert.equal(at100.startY, 0);
assert.equal(at100.endY, 640);
assert.equal(lineX(), 240);
});
test('tick stage positions are inputs, not a function of the photo transform', () => {
const ends = cropEnds(90);
const still = sourceTickDistance(ends.startY, ends.endY, 800, 1200, IDENTITY_TRANSFORM);
const after = sourceTickDistance(ends.startY, ends.endY, 800, 1200, moved);
assert.ok(still > 0);
assert.notEqual(still, after);
assert.equal(ends.startY, 32);
assert.equal(ends.endY, 608);
});
test('scale is source-pixel tick distance divided by the declared length', () => {
const scale = calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 72);
const distance = sourceTickDistance(32, 608, 480, 640, IDENTITY_TRANSFORM);
assert.equal(scale, distance / 72);
assert.equal(calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 0), 0);
});
test('tick ends are fixed by the crop slider and are never an owner input (#99)', () => {
// The line is a stage fixture: its two ends are the crop insets, set by
// crop_fill alone. There is no owner drag that can move them.
for (const fill of [1, 25, 50, 75, 90, 100]) {
const ends = cropEnds(fill);
assert.equal(ends.startY, cropEnds(fill).startY, 'ends are a pure function of fill');
assert.equal(ends.endY, cropEnds(fill).endY, 'ends are a pure function of fill');
assert.ok(ends.endY > ends.startY, 'line keeps a positive span at any fill');
}
// Ends stay symmetric about the frame centre, so the line is fixed in the middle.
for (const fill of [1, 50, 90, 100]) {
const ends = cropEnds(fill);
const top = ends.startY;
const bottom = CALIBRATION_FRAME.height - ends.endY;
assert.equal(top, bottom, `fill ${fill}: top and bottom insets match`);
}
});
test('the scale span is the fixed line span, not a mask span', () => {
const ends = cropEnds(90);
const distance = sourceTickDistance(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM);
const scale = calibrationScale(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM, 72);
assert.ok(distance > 0, 'line has a real source-pixel span');
assert.equal(scale, distance / 72, 'scale is the line span over the tape reading');
});
test('the tick hit target is at least 44px on a 320px-wide stage', () => {
const radius = hitRadiusStage(320);
const screen = radius * (320 / CALIBRATION_FRAME.width);
assert.ok(screen >= 44, `screen hit ${screen}`);
});
-104
View File
@@ -1,104 +0,0 @@
// 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),
};
}
+1 -16
View File
@@ -17,11 +17,8 @@
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;
@@ -208,7 +205,6 @@
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),
@@ -234,7 +230,6 @@
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;
@@ -248,7 +243,6 @@
});
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);
@@ -357,12 +351,6 @@
</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}
@@ -427,10 +415,7 @@
<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; 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; }
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; }
option { color: #eee; background: #1c1c1c; }
button { margin-top: 16px; }
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
-7
View File
@@ -51,13 +51,6 @@ 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);
+2 -11
View File
@@ -76,20 +76,11 @@ 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 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);
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);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);
-31
View File
@@ -107,37 +107,6 @@ 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);
+3 -134
View File
@@ -1,6 +1,7 @@
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;
@@ -11,10 +12,6 @@ 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 {
@@ -28,12 +25,7 @@ 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));
@@ -44,7 +36,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;
@@ -70,131 +62,10 @@ 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, cropFill, rotationDeg } = input;
const fill = Math.max(1, Math.min(100, cropFill ?? 90)) / 100;
const { bitmap, mask, source } = input;
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
const srcCtx = srcCanvas.getContext('2d')!;
srcCtx.drawImage(bitmap, 0, 0);
@@ -203,8 +74,6 @@ 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')!;
+1 -31
View File
@@ -299,37 +299,7 @@ test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#
});
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');
});
// --- Length dim must not overshoot wood footprint (#69) ---
/** Tilted-axis slab: irregular mask + approved bbox long side ~10° from vertical. */
function tiltedSlabFixture() {
+3 -31
View File
@@ -1,6 +1,5 @@
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[] {
@@ -486,31 +485,15 @@ export function inventorySourceMap(
source: { widthPx: number; heightPx: number },
outW: number,
outH: number,
fill = 0.9,
bitset?: Uint8Array,
rotationDeg = 0,
): InventoryMap {
if (bitset && Math.abs(rotationDeg % 360) > 1e-8) {
const rotation = createMaskRotation(bitset, source.widthPx, source.heightPx, rotationDeg);
const scale = Math.min((outW * fill) / Math.max(rotation.bounds.w, 1), (outH * fill) / Math.max(rotation.bounds.h, 1));
const ox = (outW - rotation.bounds.w * scale) / 2;
const oy = (outH - rotation.bounds.h * scale) / 2;
return {
outW,
outH,
mapToInventory: (sx, sy) => {
const [rx, ry] = rotation.toRotated(sx, sy);
return [ox + (rx - rotation.bounds.x) * scale, oy + (ry - rotation.bounds.y) * scale];
},
};
}
const FILL = 0.9;
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;
@@ -534,8 +517,6 @@ export interface EncodeOverlayInput {
lengthLabel: string;
slabBounds: { x: number; y: number; w: number; h: number };
source: { widthPx: number; heightPx: number };
cropFill?: number;
rotationDeg?: number;
}
/**
@@ -550,16 +531,7 @@ 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 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 { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
const plan = buildOverlayPlan({
bitset: input.bitset,
widthPx: input.widthPx,
+2 -8
View File
@@ -28,15 +28,9 @@ 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` | Tape calibration page |
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
| `04-axis.png` | Length axis |
| `05-measure.png` | Measure with L / thickness filled |
| `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 -1
View File
@@ -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 result step — the
// the Resume path restores straight to the Photos-ready Measure 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` +
+11 -7
View File
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
};
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
test('batch → pick → mask → tape calibration → photos ready + screenshots', async ({ page }) => {
test('batch → pick → mask → axis → measure → 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,16 +87,20 @@ 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' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Measure' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
await shot(page, '04-axis.png');
await page.getByRole('button', { name: 'Approve axis' }).click();
const calc = page.getByRole('button', { name: 'Calculate' });
await expect(calc).toBeDisabled();
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).toBeEnabled();
await shot(page, '05-tape-calibration.png');
await calc.click();
// Calculate encodes calibration PNG + overlay + sibling batch apply.
@@ -113,7 +117,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-tape-calibration.png', '06-photos-ready.png'];
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.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('/')}`,
-141
View File
@@ -1,141 +0,0 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
const here = path.dirname(fileURLToPath(import.meta.url));
const FACE_A = path.resolve(here, '../fixtures/set1-48x2/face-a.jpeg');
const ARTIFACTS = path.resolve(here, 'artifacts');
const shot = async (page: Page, name: string) => {
fs.mkdirSync(ARTIFACTS, { recursive: true });
await page.screenshot({ path: path.join(ARTIFACTS, name), fullPage: true });
};
const ensureFreshBatch = async (page: Page) => {
await page.goto('/');
const resumeHeading = page.getByRole('heading', { name: /resume/i });
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
if (await resumeHeading.isVisible().catch(() => false)) {
await page.getByRole('button', { name: /start fresh/i }).click();
}
await expect(batchHeading).toBeVisible();
};
const prepareMaskForApprove = async (page: Page) => {
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
await page.getByRole('button', { name: 'Black', exact: true }).click();
const approve = page.getByRole('button', { name: 'Approve mask' });
try {
await expect(approve).toBeEnabled({ timeout: 45_000 });
return;
} catch {
/* sensitivity ladder */
}
for (const target of [60, 70, 40, 30, 80]) {
await page.locator('input[type="range"]').fill(String(target));
await page.waitForTimeout(2800);
if (await approve.isEnabled()) return;
}
await expect(approve).toBeEnabled({ timeout: 15_000 });
};
const toLengthPage = async (page: Page) => {
await ensureFreshBatch(page);
await page.locator('input[type="file"][multiple]').setInputFiles([FACE_A]);
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
await prepareMaskForApprove(page);
await page.getByRole('button', { name: 'Approve mask' }).click();
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
};
test.describe('#99 fixed tape line', () => {
test('the line is not editable: no tick-drag copy and no tick drag', async ({ page }) => {
test.setTimeout(180_000);
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
await toLengthPage(page);
// The hint must never tell the owner to drag or nudge the ticks.
await expect(page.getByText('Drag the ticks')).toHaveCount(0);
await expect(page.getByText('nudge the last tick')).toHaveCount(0);
await expect(page.getByText('The line is fixed in the middle')).toBeVisible();
const stage = page.getByTestId('tape-line');
await expect(stage).toBeVisible();
const box = (await stage.boundingBox())!;
const canvas = stage.locator('canvas').first();
// A pan delta produces the same result wherever you grab the stage, because
// pan is measured as a delta from the grab point. So: drag from a tick
// position, then repeat the same delta from a plain part of the stage. If
// the tick drag were still live it would move the line too, and the two
// results would differ. Identical output ⇒ the line is fixed.
const centerX = box.x + box.width / 2;
const tickY = box.y + box.height * 0.05;
const plainY = box.y + box.height * 0.62;
const delta = box.height * 0.18;
const dragBy = async (from: number) => {
await page.mouse.move(centerX, from);
await page.mouse.down();
await page.mouse.move(centerX, from + delta, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(300);
};
await dragBy(tickY);
const fromTick = await canvas.screenshot();
// Reset the transform, then repeat the identical delta from a non-tick point.
await toLengthPage(page);
await dragBy(plainY);
const fromPlain = await canvas.screenshot();
expect(
Buffer.compare(fromTick, fromPlain),
'a tick drag must behave exactly like a pan elsewhere (line stays fixed)',
).toBe(0);
// Length and thickness live on this same screen; Calculate stays usable.
await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2');
await expect(page.getByRole('button', { name: 'Calculate' })).toBeEnabled();
await shot(page, 'tape-fixed-line.png');
});
test('the crop slider sets the line ends and nothing else does', async ({ page }) => {
test.setTimeout(180_000);
await toLengthPage(page);
const canvas = page.getByTestId('tape-line').locator('canvas').first();
const fill = page.getByLabel('Crop fill percent');
const before = await canvas.screenshot();
// Move the crop-fill slider and confirm the control really took the value,
// so a no-op fill() can never masquerade as a passing assertion.
await fill.fill('50');
await expect(fill).toHaveValue('50');
await page.waitForTimeout(1200);
const after = await canvas.screenshot();
expect(Buffer.compare(before, after), 'crop fill must move the line ends').not.toBe(0);
// Arrow-key nudge on the line must not change the geometry either.
// Blur the slider first: arrow keys on a focused range input legitimately
// change that input, which would move the line ends for the wrong reason.
await page.locator('h1').click();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(500);
const afterKeys = await canvas.screenshot();
expect(
Buffer.compare(after, afterKeys),
'arrow keys must not move the fixed line ends',
).toBe(0);
await shot(page, 'tape-fixed-line-crop50.png');
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ CITING_ROLES = {"capability", "contract"}
CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"}
HEADING = re.compile(r"^(#{2,4})\s+(.*)$")
SCOPES = {"shipped", "M3", "M4", "later"}
SCOPES = {"shipped", "M3", "later"}
PLACEHOLDER = {"", "—", "-", "*(none yet)*"}