Compare commits
2 Commits
818874e65c
...
4ca2ef57a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ca2ef57a7 | |||
| 6fb92362a4 |
@@ -0,0 +1,185 @@
|
||||
# M4 — Goals (DRAFT — not accepted)
|
||||
|
||||
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):** replace the axis-rectangle calibration with a draggable
|
||||
tape-line (black line + two rifle-scope end ticks). Declared length = distance between
|
||||
ticks (owner-declared); `maskSpanAlong` demoted to an optional sanity check. New
|
||||
requirement row(s); supersedes the as-built length model in CAPTURE-60/75. The `m4`
|
||||
branch already carries `crop_fill` + `TapePrototype.svelte` (`?tape-prototype`); M4
|
||||
promotes it from prototype to shipped.
|
||||
- **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 (draft):** tape-line calibration behavior (overlay, draggable ticks,
|
||||
owner-declared length, sanity check). ~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-prototype`
|
||||
sandbox is replaced by the shipped calibration path (owner drags both ticks; declared
|
||||
length = tick distance; as-built length model no longer the source of truth), 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)
|
||||
|
||||
These are implementation details the task breakdown and wireframe settle — not plan-level:
|
||||
the exact thumb-first interaction model (wizard vs single-scroll, chips vs pickers), which
|
||||
of #91–#100 are M4-must vs nice, whether #99 touches the photos-ready lineup (CAPTURE-130),
|
||||
and the exit-walk shape (single slab vs QUALITY-110 pair; test-inference is text-only now
|
||||
that Call 1 is out).
|
||||
|
||||
## Task breakdown detail bar
|
||||
|
||||
The task breakdown is not a list of goal names. Every task carries, at minimum:
|
||||
|
||||
- **Goal it serves** (G1–G4) and the requirement IDs / issue numbers it touches.
|
||||
- **Skills to load** for that task — named and ordered (e.g. UI task: `sketch` +
|
||||
`wireframe-review-iteration` → `omh-frontend` + `omh-design-quality-gate` →
|
||||
`omh-visual-qa` + `omh-accessibility-audit`).
|
||||
- **Starting point / references** — the specific saved artifact, file, or branch the task
|
||||
builds from (e.g. `docs/design/issue-99-tape-line-overlay.html`, the live
|
||||
`Review.svelte`, the M3 `MultiSelect.svelte`).
|
||||
- **Verification** — the smallest check that proves the task's claim (unit test paths,
|
||||
`tsc --noEmit`, Woo mock, `deploy/uat_walk.sh`, 375/768/1280 visual captures, keyboard /
|
||||
contrast audit). Ty owns gate exit.
|
||||
- **Sequence / dependencies** — what must land first (wireframe accepted before review-UI
|
||||
coding; requirements rows before the code that implements them; Call 2 refinement after
|
||||
the human-pick flow feeds it real data).
|
||||
- **Stop condition** — when the task is done (e.g. "wireframe accepted by Ty," "gates
|
||||
green + raw evidence recorded," "real `SLAB-UAT-*` draft matches the ready set").
|
||||
- **Evidence boundary** — what is prepared vs observed; no task claims done without the
|
||||
raw runner / capture / store evidence named above.
|
||||
@@ -0,0 +1,188 @@
|
||||
# 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)
|
||||
- **Goal:** G1. **IDs:** CAPTURE (dimensions), REQS-50. **Issues:** #99.
|
||||
- **Skills:** `systematic-debugging` (verify the prototype assumption),
|
||||
`wireframe-review-iteration` (reference the overlay interaction), `svelte-core-bestpractices`.
|
||||
- **Starting point:** `frontend/src/capture/TapePrototype.svelte` + `?tape-prototype`
|
||||
sandbox on the `m4` branch; `docs/design/issue-99-tape-line-overlay.html` (approved
|
||||
wireframe); `frontend/src/png/crop.ts` (projection logic).
|
||||
- **Work:** promote the prototype to the shipped calibration path (owner drags both
|
||||
ticks), adopt the as-built length model (maskSpanAlong projection), delete the
|
||||
sandbox + query-param gating, keep `crop_fill` (already landed in 818874e).
|
||||
**Scope (Ty confirmed):** confined to the calibration step — the photos-ready
|
||||
lineup (CAPTURE-130) stays as-is; #99 does not touch it.
|
||||
- **Verification:** unit tests in `frontend/src/png/*` (tape geometry), `tsc --noEmit`,
|
||||
manual calibration on one slab at 375px.
|
||||
- **Sequence:** parallel with T3–T5; no dependency on T1.
|
||||
- **Stop:** prototype shipped, `?tape-prototype` removed, tests green.
|
||||
**Evidence:** test output + a 375px capture of the calibration drag.
|
||||
|
||||
---
|
||||
|
||||
## 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); AI-assist
|
||||
labeled as "suggested, confirm"; 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).
|
||||
+3
-24
@@ -12,10 +12,7 @@
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
// #99 PROTOTYPE: tape-line overlay + per-slab length input + crop setting.
|
||||
// Throwaway — driven by the `tape-prototype` query param. Delete this branch
|
||||
// and the component after the design is settled.
|
||||
import TapePrototype from './capture/TapePrototype.svelte';
|
||||
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
import Publish from './create/publish/Publish.svelte';
|
||||
@@ -26,10 +23,6 @@
|
||||
import { clearDraft } from './draft/idb';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
// #99 PROTOTYPE entry: `?tape-prototype` boots the tape-line design sandbox.
|
||||
const isTapeProto =
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).has('tape-prototype');
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
|
||||
let cap: Capture = { k: 'batch', photos: [] };
|
||||
@@ -157,15 +150,6 @@
|
||||
</p>
|
||||
<p class="detail">Missing: {gate}</p>
|
||||
</main>
|
||||
{:else if isTapeProto}
|
||||
<!-- #99 PROTOTYPE: tape-line overlay design sandbox (throwaway). -->
|
||||
<main class="app proto">
|
||||
<div class="proto-bar">
|
||||
<strong>#99 tape-line prototype</strong>
|
||||
<a href="?noload">exit →</a>
|
||||
</div>
|
||||
<TapePrototype />
|
||||
</main>
|
||||
{:else}
|
||||
<main class="app">
|
||||
{#if curFlow.phase === 'review'}
|
||||
@@ -238,7 +222,7 @@
|
||||
{:else if cap.k === 'axis'}
|
||||
<AxisPanel
|
||||
state={cap}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
setTapeLine={session.setTapeLine}
|
||||
approveAxis={session.approveAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
@@ -282,12 +266,7 @@
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
.proto-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 16px; background: #1d1d1d; color: #00e5ff; font-size: 0.85rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.proto-bar a { color: #aaa; text-decoration: none; }
|
||||
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
|
||||
@@ -1,58 +1,251 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Capture, TapeLine } from './capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { photoToScreen, screenToPhoto, type PhotoPoint, type PhotoViewTransform } from '../png/crop';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let setTapeLine: (tapeLine: TapeLine) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let sourceW = state.source.widthPx;
|
||||
let sourceH = state.source.heightPx;
|
||||
let stageW = 0;
|
||||
let stageH = 0;
|
||||
let tickA: PhotoPoint = [...state.axis.tapeLine.start];
|
||||
let tickB: PhotoPoint = [...state.axis.tapeLine.end];
|
||||
let angleDeg = 0;
|
||||
let zoom = 1;
|
||||
let panX = 0;
|
||||
let panY = 0;
|
||||
let dragging: 'start' | 'end' | 'pan' | null = null;
|
||||
let selected: 'start' | 'end' = 'start';
|
||||
let lastPointer: PhotoPoint = [0, 0];
|
||||
|
||||
$: tickDistance = Math.hypot(tickB[0] - tickA[0], tickB[1] - tickA[1]);
|
||||
|
||||
function fittedScale(): number {
|
||||
if (!(stageW > 0 && stageH > 0 && sourceW > 0 && sourceH > 0)) return 0;
|
||||
return Math.min(stageW / sourceW, stageH / sourceH);
|
||||
}
|
||||
|
||||
function view(): PhotoViewTransform {
|
||||
return {
|
||||
width: sourceW,
|
||||
height: sourceH,
|
||||
centerX: stageW / 2,
|
||||
centerY: stageH / 2,
|
||||
scale: fittedScale(),
|
||||
zoom,
|
||||
angleDeg,
|
||||
panX,
|
||||
panY,
|
||||
};
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!canvas || !stageW || !stageH) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(stageW * dpr);
|
||||
canvas.height = Math.round(stageH * dpr);
|
||||
canvas.style.width = `${stageW}px`;
|
||||
canvas.style.height = `${stageH}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = '#161616';
|
||||
ctx.fillRect(0, 0, stageW, stageH);
|
||||
const transform = view();
|
||||
if (bitmap && transform.scale > 0) {
|
||||
ctx.save();
|
||||
ctx.translate(transform.centerX + panX, transform.centerY + panY);
|
||||
ctx.rotate((angleDeg * Math.PI) / 180);
|
||||
ctx.scale(transform.scale * zoom, transform.scale * zoom);
|
||||
ctx.drawImage(bitmap, -sourceW / 2, -sourceH / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const a = photoToScreen(tickA, transform);
|
||||
const b = photoToScreen(tickB, transform);
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const nx = -dy / length;
|
||||
const ny = dx / length;
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
|
||||
for (const [point, which] of [[a, 'start'], [b, 'end']] as const) {
|
||||
if (which === selected) {
|
||||
ctx.strokeStyle = '#ffd600';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(point[0], point[1], 16, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(point[0] - nx * 20, point[1] - ny * 20); ctx.lineTo(point[0] + nx * 20, point[1] + ny * 20); ctx.stroke();
|
||||
ctx.fillStyle = which === 'start' ? '#ffd600' : '#ff3d71';
|
||||
ctx.beginPath(); ctx.arc(point[0], point[1], 5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pointerPosition(event: PointerEvent): PhotoPoint {
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
return [event.clientX - rect.left, event.clientY - rect.top];
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
const point = pointerPosition(event);
|
||||
const transform = view();
|
||||
const a = photoToScreen(tickA, transform);
|
||||
const b = photoToScreen(tickB, transform);
|
||||
const hitA = Math.hypot(point[0] - a[0], point[1] - a[1]) < 28;
|
||||
const hitB = Math.hypot(point[0] - b[0], point[1] - b[1]) < 28;
|
||||
dragging = hitA ? 'start' : hitB ? 'end' : 'pan';
|
||||
if (dragging !== 'pan') selected = dragging;
|
||||
lastPointer = point;
|
||||
canvas?.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (!dragging) return;
|
||||
const point = pointerPosition(event);
|
||||
if (dragging === 'pan') {
|
||||
panX += point[0] - lastPointer[0];
|
||||
panY += point[1] - lastPointer[1];
|
||||
} else {
|
||||
const sourcePoint = screenToPhoto(point, view());
|
||||
sourcePoint[0] = Math.max(0, Math.min(sourceW, sourcePoint[0]));
|
||||
sourcePoint[1] = Math.max(0, Math.min(sourceH, sourcePoint[1]));
|
||||
if (dragging === 'start') tickA = sourcePoint;
|
||||
else tickB = sourcePoint;
|
||||
}
|
||||
lastPointer = point;
|
||||
draw();
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
if (dragging === 'start' || dragging === 'end') {
|
||||
setTapeLine({ start: [...tickA], end: [...tickB] });
|
||||
}
|
||||
dragging = null;
|
||||
canvas?.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function zoomAt(x: number, y: number, factor: number): void {
|
||||
const before = screenToPhoto([x, y], view());
|
||||
zoom = Math.max(0.5, Math.min(5, zoom * factor));
|
||||
const after = photoToScreen(before, view());
|
||||
panX += x - after[0];
|
||||
panY += y - after[1];
|
||||
draw();
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
event.preventDefault();
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
zoomAt(event.clientX - rect.left, event.clientY - rect.top, Math.exp(-event.deltaY * 0.0015));
|
||||
}
|
||||
|
||||
function resetView(): void {
|
||||
angleDeg = 0;
|
||||
zoom = 1;
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
draw();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let alive = true;
|
||||
const resize = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
stageW = entry.contentRect.width;
|
||||
stageH = entry.contentRect.height;
|
||||
draw();
|
||||
});
|
||||
if (stage) resize.observe(stage);
|
||||
void decodeOrientedBitmap(state.source.blob).then((decoded) => {
|
||||
if (!alive) {
|
||||
decoded.close();
|
||||
return;
|
||||
}
|
||||
bitmap = decoded;
|
||||
sourceW = decoded.width;
|
||||
sourceH = decoded.height;
|
||||
draw();
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
resize.disconnect();
|
||||
bitmap?.close();
|
||||
bitmap = null;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="179" step="1"
|
||||
value={angleDeg}
|
||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
||||
<span class="val">{angleDeg}°</span>
|
||||
<h1>Tape-line calibration</h1>
|
||||
<p class="hint">Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.</p>
|
||||
<div class="stage" bind:this={stage}>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
aria-label="Calibration photo with draggable tape endpoint ticks"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel|nonpassive={onWheel}
|
||||
></canvas>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="photo-angle">Rotate photo</label>
|
||||
<input id="photo-angle" type="range" min="-180" max="180" step="0.1" value={angleDeg}
|
||||
on:input={(event) => { angleDeg = Number(event.currentTarget.value); draw(); }} />
|
||||
<span class="value">{angleDeg.toFixed(1)}°</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="photo-zoom">Zoom</label>
|
||||
<input id="photo-zoom" type="range" min="0.5" max="5" step="0.05" value={zoom}
|
||||
on:input={(event) => { zoom = Number(event.currentTarget.value); draw(); }} />
|
||||
<span class="value">{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" class="minor" on:click={resetView}>Reset view</button>
|
||||
</div>
|
||||
<p class="tick-distance">Tick distance in source photo: {tickDistance.toFixed(1)} px</p>
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
||||
<button type="button" class="primary" disabled={tickDistance < 4} on:click={approveAxis}>Use tape line</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.thumb {
|
||||
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: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.45; }
|
||||
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
|
||||
canvas { display: block; touch-action: none; cursor: grab; }
|
||||
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
|
||||
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
|
||||
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
||||
.minor { min-height: 40px; border: 1px solid #444; background: #1d1d1d; color: #ccc; padding: 8px 10px; }
|
||||
.tick-distance { margin: 10px 0 0; color: #aaa; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import { calibrationScale } from '../png/crop';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
@@ -54,6 +55,12 @@
|
||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||
};
|
||||
|
||||
$: tapeScale = calibrationScale(
|
||||
state.axis.tapeLine.start,
|
||||
state.axis.tapeLine.end,
|
||||
positiveInches(String(lengthIn)) ?? 0,
|
||||
);
|
||||
|
||||
const commitLength = () => {
|
||||
const inches = positiveInches(String(lengthIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
@@ -67,7 +74,7 @@
|
||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
|
||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
|
||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null && tapeScale > 0;
|
||||
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
@@ -93,7 +100,7 @@
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<span class="label">Tape length L (distance between ticks)</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Length L"
|
||||
@@ -121,6 +128,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="scale">Tape ticks: {Math.hypot(state.axis.tapeLine.end[0] - state.axis.tapeLine.start[0], state.axis.tapeLine.end[1] - state.axis.tapeLine.start[1]).toFixed(1)} source-photo px · Scale {tapeScale.toFixed(2)} px/in</p>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
@@ -194,6 +202,7 @@
|
||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||
.field { flex: 1; }
|
||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
||||
.scale { margin: 8px 0 0; color: #aaa; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
||||
.field input {
|
||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
||||
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||
|
||||
@@ -1,611 +0,0 @@
|
||||
<script lang="ts">
|
||||
// #99 PROTOTYPE — throwaway design sandbox for the tape-line overlay.
|
||||
// NOT production. Delete this file (and the App.svelte branch) once the design
|
||||
// is settled.
|
||||
//
|
||||
// Model per issue #99:
|
||||
// - The MEASURE LINE IS FIXED VERTICAL on screen: it sits at the position the
|
||||
// crop places the slab's long edge (derived from crop fill + the 3:4 aspect
|
||||
// constants — the crop line IS the reference line).
|
||||
// - The SLAB (the photo) is the only thing that pans / zooms / rotates.
|
||||
// - Two rifle-scope ticks slide ALONG the fixed line (1-D drag). The owner
|
||||
// rotates the photo until its taped edge sits on the line, then places the
|
||||
// ticks at the tape start/end.
|
||||
// - Declared L = source-space distance between the ticks ÷ px-per-inch —
|
||||
// NOT a projected mask span. Zooming in makes tick placement finer.
|
||||
// - crop % is a GLOBAL setting (default 90%) and moves the fixed line.
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// ---- synthetic slab, portrait (long edge ~vertical) --------------------
|
||||
const SW = 300; // source px width (slab width)
|
||||
const SH = 1040; // source px height (slab runs long along Y)
|
||||
let bitset = new Uint8Array(SW * SH);
|
||||
const rnd = (n: number): number => {
|
||||
const x = Math.sin(n * 127.1) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
function vnoise(x: number, y: number): number {
|
||||
const xi = Math.floor(x), yi = Math.floor(y);
|
||||
const xf = x - xi, yf = y - yi;
|
||||
const s = (a: number, b: number) => rnd(a * 374.13 + b * 217.91);
|
||||
const n00 = s(xi, yi), n10 = s(xi + 1, yi), n01 = s(xi, yi + 1), n11 = s(xi + 1, yi + 1);
|
||||
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
|
||||
const a = n00 * (1 - u) + n10 * u;
|
||||
const b = n01 * (1 - u) + n11 * u;
|
||||
return a * (1 - v) + b * v;
|
||||
}
|
||||
function buildSlab(): void {
|
||||
bitset = new Uint8Array(SW * SH);
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
const edge = Math.min(y, SH - y);
|
||||
const taper = 1 - 0.22 * Math.max(0, 1 - edge / 170);
|
||||
const wobble =
|
||||
1 + 0.05 * Math.sin(y / 53 + 0.4)
|
||||
+ 0.035 * Math.sin(y / 19 + 1.1)
|
||||
+ 0.02 * Math.sin(y / 8.1 + 2.2);
|
||||
const halfW = (SW / 2) * taper * wobble;
|
||||
const cx = SW / 2 + 4 * Math.sin(y / 71);
|
||||
if (Math.abs(x - cx) <= halfW) bitset[y * SW + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function woodRGB(x: number, y: number): [number, number, number] {
|
||||
const cx = SW / 2;
|
||||
const t = (x - cx) / (SW / 2);
|
||||
let r = 172 - Math.abs(t) * 48;
|
||||
let g = 122 - Math.abs(t) * 42;
|
||||
let b = 78 - Math.abs(t) * 32;
|
||||
const n = vnoise(x * 0.05, y * 0.02);
|
||||
r += (n - 0.5) * 28; g += (n - 0.5) * 24; b += (n - 0.5) * 17;
|
||||
const grain =
|
||||
Math.sin(y * 0.02 + x * 0.5 + Math.sin(x * 0.09) * 2.2) * 0.5 +
|
||||
vnoise(x * 0.4, y * 0.02) * 0.6 - 0.3;
|
||||
const streak = grain * 30;
|
||||
r += streak; g += streak * 0.85; b += streak * 0.6;
|
||||
for (const ky of [240, 560, 820]) {
|
||||
const kx = cx + (rnd(ky) - 0.5) * 34;
|
||||
const d = Math.hypot((x - kx) * 2.0, y - ky);
|
||||
if (d < 22) {
|
||||
const ring = Math.cos(d * 0.7) * 0.5 + 0.5;
|
||||
const f = Math.max(0, 1 - d / 22);
|
||||
r -= f * 60 * (0.4 + 0.6 * (1 - ring));
|
||||
g -= f * 58 * (0.4 + 0.6 * (1 - ring));
|
||||
b -= f * 44 * (0.4 + 0.6 * (1 - ring));
|
||||
}
|
||||
}
|
||||
return [
|
||||
Math.max(38, Math.min(214, Math.round(r))),
|
||||
Math.max(26, Math.min(162, Math.round(g))),
|
||||
Math.max(18, Math.min(112, Math.round(b))),
|
||||
];
|
||||
}
|
||||
// Silhouette path: left edge top→bottom, right edge bottom→top.
|
||||
function slabPath(ctx: CanvasRenderingContext2D, scale: number): void {
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
if (bitset[y * SW + x]) {
|
||||
if (!started) { ctx.moveTo(x * scale, y * scale); started = true; }
|
||||
else ctx.lineTo(x * scale, y * scale);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = SH - 1; y >= 0; y--) {
|
||||
for (let x = SW - 1; x >= 0; x--) {
|
||||
if (bitset[y * SW + x]) { ctx.lineTo(x * scale, y * scale); break; }
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
let slabCanvas: HTMLCanvasElement | null = null;
|
||||
function buildSlabCanvas(): void {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = SW; c.height = SH;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const img = ctx.createImageData(SW, SH);
|
||||
for (let y = 0; y < SH; y++) {
|
||||
for (let x = 0; x < SW; x++) {
|
||||
const i = (y * SW + x) * 4;
|
||||
if (bitset[y * SW + x]) {
|
||||
const [r, g, b] = woodRGB(x, y);
|
||||
img.data[i] = r; img.data[i+1] = g; img.data[i+2] = b; img.data[i+3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
ctx.save();
|
||||
slabPath(ctx, 1);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = 'rgba(38,22,11,0.85)';
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
slabCanvas = c;
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let parentW = 0, parentH = 0;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
|
||||
// ---- the photo transform (pan/zoom/rotate moves ONLY the slab) ---------
|
||||
let panX = 0, panY = 0; // screen-space offset of the slab
|
||||
let zoom = 1;
|
||||
let photoAngle = 0; // deg — rotate the photo until its taped edge hits the line
|
||||
// ---- global crop fill (settings.crop_fill, default 90) ------------------
|
||||
let cropFill = 90;
|
||||
// ---- ticks: pinned to SOURCE pixels on the photo. Their screen position
|
||||
// is wherever the photo transform puts that pixel — which is ON the fixed
|
||||
// line only when the photo is aligned, which is exactly the task. ----
|
||||
let tickAs = 40, tickBs = 1000; // source-space Y on the slab (tape start/end)
|
||||
let selectedTick: 'A' | 'B' = 'A'; // the tick arrow/Shift+arrow nudges
|
||||
let dragging: 'pan' | 'A' | 'B' | null = null;
|
||||
let lastPointer = { x: 0, y: 0 };
|
||||
// Active pointers, for mouse drag vs two-finger pinch (#99 control model).
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let pinchDist = 0;
|
||||
|
||||
// ---- layout: a fixed 3:4 crop frame; the line sits where the crop puts
|
||||
// the slab's long (left) edge at the current fill.
|
||||
interface Frame { x: number; y: number; w: number; h: number }
|
||||
function cropFrame(): Frame {
|
||||
const pad = 16;
|
||||
const availW = parentW - pad * 2, availH = parentH - pad * 2;
|
||||
const w = Math.min(availW, availH * (3 / 4));
|
||||
const h = w * (4 / 3);
|
||||
return { x: (parentW - w) / 2, y: (parentH - h) / 2, w, h };
|
||||
}
|
||||
// Base slab scale so the slab fits the frame at fill% (like composeInventoryFrame).
|
||||
function baseScale(fr: Frame): number {
|
||||
return ((fr.h * (cropFill / 100)) / SH);
|
||||
}
|
||||
// The FIXED measure line: screen X where the crop puts the slab's long edge.
|
||||
function refLineX(fr: Frame): number {
|
||||
const s = baseScale(fr);
|
||||
return fr.x + (fr.w - SW * s) / 2;
|
||||
}
|
||||
|
||||
// Source-space → screen (slab only; the line never uses this).
|
||||
function toScreen(sx: number, sy: number): [number, number] {
|
||||
const fr = cropFrame();
|
||||
const s = baseScale(fr) * zoom;
|
||||
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
|
||||
const rad = (photoAngle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const dx = (sx - SW / 2) * s, dy = (sy - SH / 2) * s;
|
||||
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||
}
|
||||
// Screen → source-space (inverse of toScreen).
|
||||
function toSource(px: number, py: number): [number, number] {
|
||||
const fr = cropFrame();
|
||||
const s = baseScale(fr) * zoom;
|
||||
const cx = fr.x + fr.w / 2 + panX, cy = fr.y + fr.h / 2 + panY;
|
||||
const rad = (-photoAngle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const dx = px - cx, dy = py - cy;
|
||||
return [SW / 2 + (dx * cos - dy * sin) / s, SH / 2 + (dx * sin + dy * cos) / s];
|
||||
}
|
||||
|
||||
// Synthetic tape scale: the slab spans SH px ≈ 88 inches.
|
||||
function pxPerIn(): number {
|
||||
const spanPx = maskSpanAlong(bitset, SW, SH, 0, 1);
|
||||
return spanPx / 88;
|
||||
}
|
||||
// L = distance between the two photo-pinned ticks in source px ÷ scale.
|
||||
// Exact for the photo's natural pixels — independent of on-screen zoom.
|
||||
let declaredLenIn = 0;
|
||||
function recompute(): void {
|
||||
declaredLenIn = Math.abs(tickBs - tickAs) / (pxPerIn() || 1);
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!canvas) return;
|
||||
if (!parentW || !parentH) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(parentW * dpr);
|
||||
canvas.height = Math.round(parentH * dpr);
|
||||
canvas.style.width = parentW + 'px';
|
||||
canvas.style.height = parentH + 'px';
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const fr = cropFrame();
|
||||
const lineX = refLineX(fr);
|
||||
|
||||
// 1) workshop backdrop
|
||||
const bg = ctx.createRadialGradient(parentW/2, parentH*0.4, parentW*0.1, parentW/2, parentH/2, parentW*0.75);
|
||||
bg.addColorStop(0, '#242424');
|
||||
bg.addColorStop(1, '#141414');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, parentW, parentH);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let gx = 40; gx < parentW; gx += 40) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, parentH); ctx.stroke(); }
|
||||
for (let gy = 40; gy < parentH; gy += 40) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(parentW, gy); ctx.stroke(); }
|
||||
|
||||
// 2) THE 3:4 CROP FRAME — a fixed screen fixture (where output lands).
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0,229,255,0.35)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([8, 6]);
|
||||
ctx.strokeRect(fr.x, fr.y, fr.w, fr.h);
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '600 12px system-ui';
|
||||
ctx.fillStyle = 'rgba(0,229,255,0.6)';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('3:4 crop frame', fr.x + fr.w - 6, Math.max(fr.y - 4, 14));
|
||||
ctx.restore();
|
||||
|
||||
// 3) THE SLAB + the tape + the ticks — all pinned to the photo, so they
|
||||
// pan / zoom / rotate together. The tape only lands on the fixed line
|
||||
// once the owner has rotated the photo into alignment.
|
||||
ctx.save();
|
||||
if (slabCanvas) {
|
||||
const s = baseScale(fr) * zoom;
|
||||
ctx.save();
|
||||
ctx.translate(fr.x + fr.w/2 + panX, fr.y + fr.h/2 + panY);
|
||||
ctx.rotate((photoAngle * Math.PI) / 180);
|
||||
ctx.scale(s, s);
|
||||
ctx.translate(-SW/2, -SH/2);
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
||||
ctx.shadowBlur = 30 / s;
|
||||
ctx.shadowOffsetY = 12 / s;
|
||||
ctx.drawImage(slabCanvas, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
// tape + ticks in source space → screen
|
||||
const [ax, ay] = toScreen(SW / 2, tickAs);
|
||||
const [bx, by] = toScreen(SW / 2, tickBs);
|
||||
const invZoom = 1 / (baseScale(fr) * zoom);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2.5; // "2 weight"
|
||||
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
||||
const tdx = bx - ax, tdy = by - ay, tl = Math.hypot(tdx, tdy) || 1;
|
||||
const px = -tdy / tl, py = tdx / tl, half = 18;
|
||||
for (const [ex, ey, id] of [[ax, ay, 'A'], [bx, by, 'B']] as const) {
|
||||
// The selected tick gets a highlight ring: it is what arrow/Shift+arrow
|
||||
// nudges for pixel-level tape-mark alignment.
|
||||
if (id === selectedTick) {
|
||||
ctx.strokeStyle = 'rgba(255,214,0,0.95)';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(ex, ey, half + 8, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(ex - px*half, ey - py*half); ctx.lineTo(ex + px*half, ey + py*half); ctx.stroke();
|
||||
ctx.fillStyle = id === selectedTick ? '#ffd600' : '#ff3d71';
|
||||
ctx.beginPath(); ctx.arc(ex, ey, 6, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
}
|
||||
void invZoom;
|
||||
ctx.restore();
|
||||
|
||||
// 4) THE FIXED MEASURE LINE — a screen fixture, drawn ON TOP of the photo,
|
||||
// never pans/zooms/rotates. crop fill moves it (it is where the crop
|
||||
// puts the slab's long edge). The goal: rotate the photo until the black
|
||||
// tape lines up with this line.
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.setLineDash([2, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, fr.y - 18);
|
||||
ctx.lineTo(lineX, fr.y + fr.h + 18);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, fr.y - 18);
|
||||
ctx.lineTo(lineX, fr.y + fr.h + 18);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = '600 12px system-ui';
|
||||
ctx.fillStyle = '#00e5ff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
// Clamp above the frame top so the label can never be clipped off-canvas.
|
||||
const lineLabelY = Math.max(fr.y - 22, 15);
|
||||
ctx.fillText(`fixed reference line · crop ${cropFill}%`, lineX, lineLabelY);
|
||||
ctx.restore();
|
||||
|
||||
// 5) length chip beside the fixed line, mid-height (a screen fixture)
|
||||
const my = (ay + by) / 2;
|
||||
const fs = 16;
|
||||
ctx.font = `700 ${fs}px system-ui, -apple-system, sans-serif`;
|
||||
const label = declaredLenIn.toFixed(1) + '\u2033';
|
||||
const tw = ctx.measureText(label).width;
|
||||
const padX = 10, padY = 7, chipX = lineX + 44;
|
||||
ctx.fillStyle = 'rgba(15,15,15,0.92)';
|
||||
ctx.strokeStyle = 'rgba(255,61,113,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
roundRect(ctx, chipX, my - fs/2 - padY, tw + padX*2, fs + padY*2, 8);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = '#ff6b8f';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, chipX + (tw + padX*2)/2, my);
|
||||
|
||||
// Prototype-only debug hook so the behavior tests read geometry directly.
|
||||
(window as any).__tp = {
|
||||
lineX, declaredLenIn, photoAngle, zoom, panX, panY,
|
||||
tickA: { ax, ay }, tickB: { bx, by }, cropFill, frame: fr,
|
||||
tickAs, tickBs, selectedTick, dragging, pointersSize: pointers.size,
|
||||
};
|
||||
}
|
||||
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function frame(): void { draw(); } // helper: re-render + re-measure
|
||||
|
||||
onMount(() => {
|
||||
buildSlab();
|
||||
buildSlabCanvas();
|
||||
if (stage) { parentW = stage.clientWidth; parentH = stage.clientHeight; }
|
||||
recompute();
|
||||
const ro = new ResizeObserver((es) => {
|
||||
const e = es[0];
|
||||
if (!e) return;
|
||||
parentW = e.contentRect.width;
|
||||
parentH = e.contentRect.height;
|
||||
recompute();
|
||||
draw();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
draw();
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {});
|
||||
|
||||
// ---- Pointer Events: pan/zoom the PHOTO; drag ticks ALONG THE LINE -----
|
||||
function pointerPos(e: PointerEvent): { x: number; y: number } {
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
const TICK_HIT = 24; // ≥44×44 effective hit area
|
||||
function tickScreen(): { ax: number; ay: number; bx: number; by: number } {
|
||||
const [ax, ay] = toScreen(SW / 2, tickAs);
|
||||
const [bx, by] = toScreen(SW / 2, tickBs);
|
||||
return { ax, ay, bx, by };
|
||||
}
|
||||
function hitTick(px: number, py: number): 'A' | 'B' | null {
|
||||
const t = tickScreen();
|
||||
if (Math.hypot(px - t.ax, py - t.ay) <= TICK_HIT) return 'A';
|
||||
if (Math.hypot(px - t.bx, py - t.by) <= TICK_HIT) return 'B';
|
||||
return null;
|
||||
}
|
||||
// Zoom about a screen point: the source pixel under the cursor stays put
|
||||
// (#99 control model — "zoom-to-cursor").
|
||||
function zoomAt(px: number, py: number, factor: number): void {
|
||||
const before = toSource(px, py);
|
||||
zoom = Math.max(0.4, Math.min(6, zoom * factor));
|
||||
const [nx, ny] = toScreen(before[0], before[1]);
|
||||
panX += px - nx;
|
||||
panY += py - ny;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
const pinch = () => {
|
||||
const pts = [...pointers.values()];
|
||||
if (pts.length < 2) return { d: 0, mx: 0, my: 0 };
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
return { d, mx: (pts[0].x + pts[1].x) / 2, my: (pts[0].y + pts[1].y) / 2 };
|
||||
};
|
||||
function onPointerDown(e: PointerEvent): void {
|
||||
const p = pointerPos(e);
|
||||
pointers.set(e.pointerId, p);
|
||||
if (pointers.size === 2) {
|
||||
const { d } = pinch();
|
||||
pinchDist = d;
|
||||
dragging = null; // two fingers = pinch, never a pan/tick drag
|
||||
return;
|
||||
}
|
||||
const t = hitTick(p.x, p.y);
|
||||
if (t) selectedTick = t; // arrow/Shift+arrow then nudges THIS tick
|
||||
dragging = t ?? 'pan';
|
||||
lastPointer = p;
|
||||
canvas?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent): void {
|
||||
const p = pointerPos(e);
|
||||
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p);
|
||||
if (pointers.size >= 2) {
|
||||
const { d, mx, my } = pinch();
|
||||
if (pinchDist > 0 && d > 0) zoomAt(mx, my, d / pinchDist);
|
||||
pinchDist = d;
|
||||
return;
|
||||
}
|
||||
if (!dragging) {
|
||||
canvas?.style.setProperty('cursor', hitTick(p.x, p.y) ? 'ns-resize' : 'grab');
|
||||
return;
|
||||
}
|
||||
if (dragging === 'pan') {
|
||||
panX += p.x - lastPointer.x;
|
||||
panY += p.y - lastPointer.y;
|
||||
} else {
|
||||
// The tick slides ALONG the taped edge — source-space Y on the photo.
|
||||
const [, sy] = toSource(p.x, p.y);
|
||||
const y = Math.max(0, Math.min(SH, sy));
|
||||
if (dragging === 'A') tickAs = Math.min(y, tickBs - 4);
|
||||
else tickBs = Math.max(y, tickAs + 4);
|
||||
}
|
||||
lastPointer = p;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function onPointerUp(e: PointerEvent): void {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) pinchDist = 0;
|
||||
if (pointers.size === 0) dragging = null;
|
||||
canvas?.releasePointerCapture(e.pointerId);
|
||||
canvas?.style.setProperty('cursor', 'grab');
|
||||
}
|
||||
function onWheel(e: WheelEvent): void {
|
||||
e.preventDefault();
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
// Trackpad pinch arrives as a wheel with ctrlKey set; make it zoom harder
|
||||
// per event than a plain mouse wheel.
|
||||
const f = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
|
||||
zoomAt(e.clientX - rect.left, e.clientY - rect.top, f);
|
||||
}
|
||||
// #99: arrow keys rotate the photo 1° (coarse) / 0.1° (Shift, fine);
|
||||
// with a tick selected they nudge that tick instead — pixel-level alignment.
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
const fine = e.shiftKey;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const d = (e.key === 'ArrowUp' ? -1 : 1) * (fine ? 0.1 : 1);
|
||||
if (selectedTick === 'A') tickAs = Math.max(0, Math.min(tickAs + d, tickBs - 4));
|
||||
else tickBs = Math.min(SH, Math.max(tickBs + d, tickAs + 4));
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const d = (e.key === 'ArrowLeft' ? -1 : 1) * (fine ? 0.1 : 1);
|
||||
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function pickTick(t: 'A' | 'B'): void {
|
||||
selectedTick = t;
|
||||
draw();
|
||||
}
|
||||
function nudgePhoto(d: number): void {
|
||||
photoAngle = ((photoAngle + d) % 360 + 360) % 360;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function setPhotoAngle(d: number): void {
|
||||
photoAngle = ((d % 360) + 360) % 360;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
function setCrop(d: number): void {
|
||||
cropFill = d;
|
||||
recompute();
|
||||
draw();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="tp">
|
||||
<h1>Length axis — tape line</h1>
|
||||
<p class="hint">
|
||||
The measure line is <strong>fixed</strong>. Rotate / pan / zoom the <strong>photo</strong>
|
||||
until its taped edge sits on the line, then drag the two ticks along it to where your
|
||||
tape started and ended. L = the distance between the ticks.
|
||||
</p>
|
||||
|
||||
<div class="stage" bind:this={stage} role="application" tabindex="0" on:keydown={onKey}
|
||||
aria-label="Tape-line calibration. Arrow keys rotate the photo; with a tick selected they nudge that tick.">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
style="cursor:grab"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel={onWheel}
|
||||
></canvas>
|
||||
</div>
|
||||
<p class="stage-hint">Drag each tick to where your tape started and ended.</p>
|
||||
<p class="stage-sub">Arrow keys rotate 1° (Shift 0.1°) · with a tick selected the arrows nudge it · wheel or pinch zooms at the cursor</p>
|
||||
|
||||
<!-- global crop fill (settings.crop_fill) — moves the fixed line -->
|
||||
<div class="crop">
|
||||
<label for="crop">Global crop fill</label>
|
||||
<input id="crop" type="range" min="50" max="100" step="1" value={cropFill}
|
||||
on:input={(e) => setCrop(Number(e.currentTarget.value))} />
|
||||
<span class="val">{cropFill}%</span>
|
||||
</div>
|
||||
<p class="sub">Default 90% · single setting replaces both duplicated FILL=0.9 · moves the fixed line + re-crops future slabs (non-retro).</p>
|
||||
|
||||
<!-- rotate/pan/zoom the PHOTO (coarse → fine, Adobe nudge model) -->
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate photo counter-clockwise"
|
||||
on:click={() => nudgePhoto(-1)}>−</button>
|
||||
<input type="range" min="0" max="359" step="1" value={Math.round(photoAngle % 360)}
|
||||
on:input={(e) => setPhotoAngle(Number(e.currentTarget.value))} />
|
||||
<button type="button" class="thumb" aria-label="Rotate photo clockwise"
|
||||
on:click={() => nudgePhoto(1)}>+</button>
|
||||
<span class="val">{Math.round(photoAngle % 360)}°</span>
|
||||
<button type="button" class="ghost" on:click={() => { panX = 0; panY = 0; zoom = 1; photoAngle = 0; recompute(); frame(); }}>reset</button>
|
||||
</div>
|
||||
|
||||
<!-- declared length = distance between the two ticks -->
|
||||
<div class="len">
|
||||
<div class="len-head">
|
||||
<span>Declared length (between ticks)</span>
|
||||
<span class="eff">{declaredLenIn.toFixed(1)}″</span>
|
||||
</div>
|
||||
<div class="ticks">
|
||||
<button type="button" class="tickbtn" class:sel={selectedTick === 'A'} on:click={() => pickTick('A')}>
|
||||
tick A {selectedTick === 'A' ? '· arrows nudge' : ''}
|
||||
</button>
|
||||
<button type="button" class="tickbtn" class:sel={selectedTick === 'B'} on:click={() => pickTick('B')}>
|
||||
tick B {selectedTick === 'B' ? '· arrows nudge' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tp { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.5; }
|
||||
.stage {
|
||||
position: relative; width: 100%; height: 56vh; min-height: 360px;
|
||||
background: #181818; border-radius: 10px; overflow: hidden;
|
||||
border: 1px solid #2c2c2c;
|
||||
}
|
||||
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||
.stage-hint {
|
||||
font-size: 0.78rem; color: #bbb; margin: 8px 0 0;
|
||||
}
|
||||
.stage-sub { font-size: 0.72rem; color: #777; margin: 4px 0 0; }
|
||||
.stage:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
|
||||
.ticks { display: flex; gap: 10px; margin-top: 8px; }
|
||||
.tickbtn {
|
||||
flex: 1; padding: 10px; font-size: 0.85rem; border-radius: 4px;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ddd;
|
||||
}
|
||||
.tickbtn.sel { border-color: #ffd600; color: #ffd600; }
|
||||
.crop, .angle { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.crop label { font-size: 0.9rem; color: #ccc; }
|
||||
input[type='range'] { flex: 1; accent-color: #00e5ff; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
||||
.ghost {
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ccc; font-size: 0.85rem;
|
||||
padding: 8px 12px; border-radius: 4px;
|
||||
}
|
||||
.sub { color: #888; font-size: 0.82rem; margin-top: 8px; }
|
||||
.thumb {
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.5rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
.len { margin-top: 18px; }
|
||||
.len-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
|
||||
.len-head span:first-child { font-size: 0.9rem; color: #aaa; }
|
||||
.eff { color: #ff6b8f; font-weight: 700; font-size: 1.3rem; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
@@ -46,10 +46,17 @@ export interface Mask {
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%)
|
||||
}
|
||||
|
||||
/** The owner-positioned start/end points of the tape in oriented source pixels. */
|
||||
export interface TapeLine {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
// corners[3] → corners[0] (the long side).
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
tapeLine: TapeLine;
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
@@ -138,6 +145,7 @@ 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
|
||||
tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||
|
||||
@@ -378,9 +378,10 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
||||
s.setSheet('black');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.setTapeLine({ start: [0, 10], end: [39, 10] });
|
||||
s.approveAxis();
|
||||
|
||||
// L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L
|
||||
// 39 source-photo px between ticks / 40 owner-declared inches = 0.975 px/in.
|
||||
s.setLength(320);
|
||||
s.setThickness(16);
|
||||
await s.calculate();
|
||||
@@ -393,7 +394,7 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
||||
const r = cap.results!;
|
||||
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
||||
assert.equal(typeof r.scale, 'number');
|
||||
assert.ok(r.scale > 0, 'scale px/in > 0');
|
||||
assert.equal(r.scale, 39 / 40, 'scale comes from the positioned tape endpoints');
|
||||
assert.equal(typeof r.sqft, 'number');
|
||||
assert.equal(typeof r.bdft, 'number');
|
||||
// CAPTURE.md: bdft = sqft × thickness_in
|
||||
@@ -443,6 +444,8 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
||||
assert.equal(before!.batch![2].blob, c);
|
||||
|
||||
s.approveMask();
|
||||
const tapeLine = { start: [2, 3] as [number, number], end: [35, 14] as [number, number] };
|
||||
s.setTapeLine(tapeLine);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const after = store.peek();
|
||||
@@ -450,6 +453,7 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
||||
assert.equal(after!.stage, 'axis');
|
||||
assert.equal(after!.calibrationIndex, 1);
|
||||
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
||||
assert.deepEqual(after!.tapeLine, tapeLine, 'draft persists the owner-positioned tape endpoints');
|
||||
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
|
||||
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
||||
assert.equal(after!.batch![1].blob, b);
|
||||
@@ -464,6 +468,7 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
||||
assert.equal(meas!.batch![0].blob, a);
|
||||
assert.equal(meas!.batch![2].blob, c);
|
||||
assert.equal(meas!.calibrationIndex, 1);
|
||||
assert.deepEqual(meas!.tapeLine, tapeLine, 'measure draft keeps the same calibration endpoints');
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type {
|
||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, 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 { initialTapeLine, rectAlongLine } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { FILL_DEFAULT, fillFromSetting } from '../png/crop';
|
||||
import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
@@ -41,7 +41,7 @@ export interface CaptureSession {
|
||||
setSensitivity(n: number): void;
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
setTapeLine(tapeLine: TapeLine): void;
|
||||
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
|
||||
setCropFill(pctOrFraction: number): void;
|
||||
approveAxis(): void;
|
||||
@@ -212,6 +212,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
tapeLine: state.axis.tapeLine,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
@@ -222,6 +223,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
tapeLine: state.axis.tapeLine,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
@@ -316,6 +318,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
// 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;
|
||||
const draftTapeLine = (d: Draft) => d.tapeLine ?? initialTapeLine(d.source.widthPx, d.source.heightPx);
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
batchPhotos = batchPhotosOf(d);
|
||||
@@ -343,7 +346,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
||||
const tapeLine = draftTapeLine(d);
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: {
|
||||
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine,
|
||||
} });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
@@ -352,7 +358,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
}
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
axis: {
|
||||
bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end),
|
||||
tapeLine: draftTapeLine(d),
|
||||
},
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? {
|
||||
@@ -481,14 +490,20 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
const overlay = state.overlay;
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
||||
const tapeLine = initialTapeLine(state.source.widthPx, state.source.heightPx);
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: {
|
||||
bbox: rectAlongLine(overlay.bbox, tapeLine.start, tapeLine.end), tapeLine,
|
||||
} });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setAxisAngle(deg: number) {
|
||||
setTapeLine(tapeLine: TapeLine) {
|
||||
if (state.k !== 'axis') return;
|
||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
||||
const start: [number, number] = [...tapeLine.start];
|
||||
const end: [number, number] = [...tapeLine.end];
|
||||
const bbox = rectAlongLine(state.axis.bbox, start, end);
|
||||
if (!(bbox.longSidePx > 0)) return;
|
||||
set({ ...state, axis: { bbox, tapeLine: { start, end } } });
|
||||
void persist();
|
||||
},
|
||||
|
||||
@@ -501,7 +516,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.axis.bbox },
|
||||
axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine },
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
@@ -525,10 +540,12 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const scale = calibrationScale(axis.tapeLine.start, axis.tapeLine.end, eighthsToIn(lengthEighths));
|
||||
if (!(scale > 0) || !Number.isFinite(scale)) return;
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
@@ -636,7 +653,9 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
|
||||
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine,
|
||||
} });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
|
||||
import { measure } from '../measure/measure.ts';
|
||||
import { minAreaRect, orientedRect, rectAlongLine } from './rect.ts';
|
||||
|
||||
// 100×20 horizontal slab
|
||||
const W = 100, H = 20;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
|
||||
|
||||
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
|
||||
const sx = maskSpanAlong(bitset, W, H, 1, 0);
|
||||
const sy = maskSpanAlong(bitset, W, H, 0, 1);
|
||||
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
|
||||
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
|
||||
});
|
||||
|
||||
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||
const prev = minAreaRect(bitset, W, H);
|
||||
assert.ok(prev, 'minAreaRect returned a box');
|
||||
@@ -29,23 +21,15 @@ test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||
});
|
||||
|
||||
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
||||
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
||||
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
||||
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
||||
const TW = 20, TH = 100;
|
||||
const thin = new Uint8Array(TW * TH).fill(1);
|
||||
const thinBox = minAreaRect(thin, TW, TH);
|
||||
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
||||
const rotated = orientedRect(thinBox, 0);
|
||||
const lengthEighths = 24; // 3 in
|
||||
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
|
||||
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
|
||||
// far from the old longSidePx/3 ≈ 33.
|
||||
const ex = rotated.corners[0][0] - rotated.corners[3][0];
|
||||
const ey = rotated.corners[0][1] - rotated.corners[3][1];
|
||||
const elen = Math.hypot(ex, ey);
|
||||
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
|
||||
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
|
||||
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
|
||||
test('rectAlongLine sets the approved axis from the owner-positioned tape endpoints', () => {
|
||||
const previous = minAreaRect(bitset, W, H)!;
|
||||
const start: [number, number] = [20, 30];
|
||||
const end: [number, number] = [80, 70];
|
||||
const calibrated = rectAlongLine(previous, start, end);
|
||||
const center: [number, number] = [50, 50];
|
||||
assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9);
|
||||
assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9);
|
||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9);
|
||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9);
|
||||
assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ABox, BBox } from '../capture/capture.types';
|
||||
import type { ABox, BBox, TapeLine } from '../capture/capture.types';
|
||||
|
||||
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
||||
@@ -113,18 +113,40 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
||||
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||
}
|
||||
|
||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||
// extent in px. 0 when the mask is empty.
|
||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < min) min = p;
|
||||
if (p > max) max = p;
|
||||
}
|
||||
/** Build the approved axis rectangle from the owner's two tape-line endpoints. */
|
||||
export function rectAlongLine(prev: BBox, start: [number, number], end: [number, number]): BBox {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > 0) || !Number.isFinite(length)) return prev;
|
||||
|
||||
const halfW = Math.hypot(
|
||||
prev.corners[0][0] - prev.corners[1][0],
|
||||
prev.corners[0][1] - prev.corners[1][1],
|
||||
) / 2;
|
||||
const cx = (start[0] + end[0]) / 2;
|
||||
const cy = (start[1] + end[1]) / 2;
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const vx = -uy;
|
||||
const vy = ux;
|
||||
const halfL = length / 2;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [along, across] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([
|
||||
cx + along * halfL * ux + across * halfW * vx,
|
||||
cy + along * halfL * uy + across * halfW * vy,
|
||||
]);
|
||||
}
|
||||
return max < min ? 0 : max - min;
|
||||
const angleDeg = ((Math.atan2(uy, ux) * 180) / Math.PI + 180) % 180;
|
||||
return { corners, longSidePx: length, angleDeg };
|
||||
}
|
||||
|
||||
/** Initial guide positions; the owner still places both ticks at the tape ends. */
|
||||
export function initialTapeLine(width: number, height: number): TapeLine {
|
||||
const w = Math.max(1, width);
|
||||
const h = Math.max(1, height);
|
||||
return w >= h
|
||||
? { start: [w * 0.1, h / 2], end: [w * 0.9, h / 2] }
|
||||
: { start: [w / 2, h * 0.1], end: [w / 2, h * 0.9] };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function tapered(W: number, H: number, lengthEighths: number, tipPx: number) {
|
||||
|
||||
test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
|
||||
const { bitset, box, slabPx, lengthEighths } = tapered(480, 100, 384, 20); // tips 2in, bulk 10in
|
||||
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx);
|
||||
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx, 10);
|
||||
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
|
||||
@@ -38,7 +38,7 @@ test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
|
||||
|
||||
test('uniform slab: interior stations keep the full width', () => {
|
||||
const { bitset, box, slabPx } = uniform(480, 100);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 10);
|
||||
assert.equal(m.widths.every6In.length, 7);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
|
||||
@@ -47,12 +47,19 @@ test('uniform slab: interior stations keep the full width', () => {
|
||||
|
||||
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(120, 100);
|
||||
const m = measure(bitset, 120, 100, box, 96, 8, slabPx);
|
||||
const m = measure(bitset, 120, 100, box, 96, 8, slabPx, 10);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
|
||||
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);
|
||||
const m = measure(bitset, 100, 100, box, 80, 8, slabPx, 10);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
|
||||
test('measurement scale is supplied by tape calibration, not inferred from mask span', () => {
|
||||
const { bitset, box, slabPx } = uniform(480, 100);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 20);
|
||||
assert.equal(m.scale, 20);
|
||||
assert.ok(m.sqft < 1, `area uses tape scale, got ${m.sqft}`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// Display / direction: corners[3] → corners[0] is the long side.
|
||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||
@@ -76,10 +75,13 @@ export function measure(
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
scale: number,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
if (!(scale > 0) || !Number.isFinite(scale)) {
|
||||
throw new RangeError('Calibration scale must be a positive finite value');
|
||||
}
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { calibrationScale, photoToScreen, screenToPhoto } from './crop.ts';
|
||||
|
||||
test('tick scale uses endpoint distance in source-photo pixels', () => {
|
||||
assert.equal(calibrationScale([12, 34], [312, 434], 25), 20);
|
||||
assert.equal(calibrationScale([12, 34], [12, 34], 25), 0);
|
||||
assert.equal(calibrationScale([12, 34], [312, 434], 0), 0);
|
||||
});
|
||||
|
||||
test('tick distance and scale survive pan, zoom, and rotation in the photo view', () => {
|
||||
const a: [number, number] = [830, 420];
|
||||
const b: [number, number] = [2890, 2510];
|
||||
const declaredLengthIn = 80;
|
||||
const expectedScale = Math.hypot(b[0] - a[0], b[1] - a[1]) / declaredLengthIn;
|
||||
const views = [
|
||||
{ width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 1, angleDeg: 0, panX: 0, panY: 0 },
|
||||
{ width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 2.4, angleDeg: 37, panX: 48, panY: -22 },
|
||||
{ width: 4032, height: 3024, centerX: 187.5, centerY: 240, scale: 0.08, zoom: 0.7, angleDeg: 121, panX: -70, panY: 35 },
|
||||
];
|
||||
|
||||
for (const view of views) {
|
||||
const screenA = photoToScreen(a, view);
|
||||
const screenB = photoToScreen(b, view);
|
||||
const sourceA = screenToPhoto(screenA, view);
|
||||
const sourceB = screenToPhoto(screenB, view);
|
||||
assert.ok(Math.hypot(sourceA[0] - a[0], sourceA[1] - a[1]) < 1e-8);
|
||||
assert.ok(Math.hypot(sourceB[0] - b[0], sourceB[1] - b[1]) < 1e-8);
|
||||
assert.ok(Math.abs(calibrationScale(sourceA, sourceB, declaredLengthIn) - expectedScale) < 1e-10);
|
||||
}
|
||||
});
|
||||
@@ -14,6 +14,57 @@ export const ASPECT_H = 4;
|
||||
/** Shorter side of the output frame, px. */
|
||||
export const FLOOR = 800;
|
||||
|
||||
export type PhotoPoint = [number, number];
|
||||
|
||||
export interface PhotoViewTransform {
|
||||
width: number;
|
||||
height: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
/** Source-photo pixels to CSS pixels before the user zoom factor. */
|
||||
scale: number;
|
||||
zoom: number;
|
||||
angleDeg: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
/** Map a point in oriented source-photo pixels into the transformed photo view. */
|
||||
export function photoToScreen(point: PhotoPoint, view: PhotoViewTransform): PhotoPoint {
|
||||
const scale = view.scale * view.zoom;
|
||||
const radians = (view.angleDeg * Math.PI) / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
const dx = (point[0] - view.width / 2) * scale;
|
||||
const dy = (point[1] - view.height / 2) * scale;
|
||||
return [
|
||||
view.centerX + view.panX + dx * cos - dy * sin,
|
||||
view.centerY + view.panY + dx * sin + dy * cos,
|
||||
];
|
||||
}
|
||||
|
||||
/** Invert {@link photoToScreen}; screen zoom/pan/rotation never alter source pixels. */
|
||||
export function screenToPhoto(point: PhotoPoint, view: PhotoViewTransform): PhotoPoint {
|
||||
const scale = view.scale * view.zoom;
|
||||
if (!(scale > 0) || !Number.isFinite(scale)) return [Number.NaN, Number.NaN];
|
||||
const radians = (-view.angleDeg * Math.PI) / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
const dx = point[0] - view.centerX - view.panX;
|
||||
const dy = point[1] - view.centerY - view.panY;
|
||||
return [
|
||||
view.width / 2 + (dx * cos - dy * sin) / scale,
|
||||
view.height / 2 + (dx * sin + dy * cos) / scale,
|
||||
];
|
||||
}
|
||||
|
||||
/** Calibrated source-photo pixels per inch from the two tape endpoints. */
|
||||
export function calibrationScale(start: PhotoPoint, end: PhotoPoint, lengthIn: number): number {
|
||||
if (!(lengthIn > 0) || !Number.isFinite(lengthIn)) return 0;
|
||||
if (![...start, ...end].every(Number.isFinite)) return 0;
|
||||
return Math.hypot(end[0] - start[0], end[1] - start[1]) / lengthIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a `settings.crop_fill` value into a 0 < fill <= 1 scale.
|
||||
* Accepts a percentage (90) or a fraction (0.9); anything unusable falls back
|
||||
|
||||
Reference in New Issue
Block a user