Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ca2ef57a7 | |||
| 6fb92362a4 | |||
| 818874e65c |
@@ -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).
|
||||||
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
|
|||||||
"woo_create_status",
|
"woo_create_status",
|
||||||
"taxonomy_last_sync_at",
|
"taxonomy_last_sync_at",
|
||||||
"taxonomy_anchor",
|
"taxonomy_anchor",
|
||||||
|
"crop_fill",
|
||||||
}
|
}
|
||||||
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
||||||
|
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
|
||||||
|
NUMERIC_KEYS: set[str] = {"crop_fill"}
|
||||||
|
|
||||||
DEFAULTS: dict[str, Any] = {
|
DEFAULTS: dict[str, Any] = {
|
||||||
"inference_enabled": False,
|
"inference_enabled": False,
|
||||||
"content_llm_enabled": False,
|
"content_llm_enabled": False,
|
||||||
"woo_create_status": "draft",
|
"woo_create_status": "draft",
|
||||||
|
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
|
||||||
|
"crop_fill": 90,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +96,12 @@ def get_settings() -> dict[str, Any]:
|
|||||||
result[k] = val if val is not None else DEFAULTS.get(k)
|
result[k] = val if val is not None else DEFAULTS.get(k)
|
||||||
else:
|
else:
|
||||||
result[k] = DEFAULTS.get(k)
|
result[k] = DEFAULTS.get(k)
|
||||||
|
if k in NUMERIC_KEYS:
|
||||||
|
try:
|
||||||
|
f = float(result[k])
|
||||||
|
result[k] = int(f) if f.is_integer() else f
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
result[k] = DEFAULTS.get(k)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -108,6 +119,25 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #99: rules live here in the 4xx layer, not in the DB (structural only).
|
||||||
|
if "crop_fill" in payload:
|
||||||
|
raw = payload["crop_fill"]
|
||||||
|
try:
|
||||||
|
pct = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pct = None
|
||||||
|
if pct is None or not (1 <= pct <= 100):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "validation",
|
||||||
|
"message": "crop_fill must be a number between 1 and 100 (percent)",
|
||||||
|
"detail": {"field": "crop_fill"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
for k, v in payload.items():
|
for k, v in payload.items():
|
||||||
if k not in ALL_KEYS:
|
if k not in ALL_KEYS:
|
||||||
|
|||||||
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["inference_api_key"] == {"configured": False}
|
assert body["inference_api_key"] == {"configured": False}
|
||||||
db_mod._reset_for_tests()
|
db_mod._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||||
|
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_setup_env(monkeypatch, tmp)
|
||||||
|
from backend.app import app
|
||||||
|
client = TestClient(app)
|
||||||
|
db_mod.init_db()
|
||||||
|
resp = client.get("/api/v1/settings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["crop_fill"] == 90
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||||
|
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_setup_env(monkeypatch, tmp)
|
||||||
|
from backend.app import app
|
||||||
|
client = TestClient(app)
|
||||||
|
db_mod.init_db()
|
||||||
|
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["crop_fill"] == 75
|
||||||
|
conn = db_mod.get_connection()
|
||||||
|
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||||
|
assert row["value"] == "75" and row["value_enc"] is None
|
||||||
|
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||||
|
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_setup_env(monkeypatch, tmp)
|
||||||
|
from backend.app import app
|
||||||
|
client = TestClient(app)
|
||||||
|
db_mod.init_db()
|
||||||
|
for bad in (0, 101, -5, "abc", None):
|
||||||
|
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||||
|
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||||
|
assert resp.json()["error"]["code"] == "validation"
|
||||||
|
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||||
|
# boundaries are valid
|
||||||
|
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||||
|
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
|||||||
@@ -1,713 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<!--
|
|
||||||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
|
||||||
rectangle (calibration length + crop)".
|
|
||||||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
|
||||||
|
|
||||||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
|
||||||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
|
||||||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
|
||||||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
|
||||||
in from the top and the bottom — and never changes the aspect ratio.
|
|
||||||
|
|
||||||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
|
||||||
production workflow:
|
|
||||||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
|
||||||
- the requirements-coverage board below the stage
|
|
||||||
- the "was / now" pair of mini diagrams
|
|
||||||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
|
||||||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
|
||||||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
|
||||||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
|
||||||
- the window.__wf debug hook
|
|
||||||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
|
||||||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
|
||||||
semantics, and the single shared crop_fill.
|
|
||||||
-->
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
|
||||||
<style>
|
|
||||||
:root{
|
|
||||||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
|
||||||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
|
||||||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
|
||||||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
|
||||||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
|
||||||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
|
||||||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
|
||||||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
|
||||||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
|
||||||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
|
||||||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
|
||||||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
|
||||||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
|
||||||
.panel{padding:10px 4px;color:var(--fg)}
|
|
||||||
h1{font-size:1.3rem;margin:0 0 10px}
|
|
||||||
h3{font-size:.95rem;margin:0 0 8px}
|
|
||||||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
|
||||||
.row{display:flex;gap:10px;margin-top:16px}
|
|
||||||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
|
||||||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
|
||||||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
|
||||||
/* ---- the stage: fixed line + movable slab ---- */
|
|
||||||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
|
||||||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
|
||||||
.tw.dragging svg{cursor:grabbing}
|
|
||||||
.tickg{cursor:ns-resize}
|
|
||||||
.rotg{cursor:ew-resize}
|
|
||||||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
|
||||||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
|
||||||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
|
||||||
input[type=range]{flex:1}
|
|
||||||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
|
||||||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
|
||||||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
|
||||||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
|
||||||
.nums span:first-child{color:var(--mut)}
|
|
||||||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
|
||||||
.nums .dim{color:#777}
|
|
||||||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
|
||||||
.status.ok{background:#12210f;color:var(--ok)}
|
|
||||||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
|
||||||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
|
||||||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
|
||||||
.plan code{color:#ddd;font-size:.72rem}
|
|
||||||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
|
||||||
/* replacement two-up */
|
|
||||||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
|
||||||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
|
||||||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
|
||||||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
|
||||||
.swap .gone{color:#ff9d8a}
|
|
||||||
/* scope lineup */
|
|
||||||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
|
||||||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
|
||||||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
|
||||||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
|
||||||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
|
||||||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
|
||||||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
|
||||||
/* fields + settings */
|
|
||||||
.fields{display:flex;gap:16px;margin-top:16px}
|
|
||||||
.field{flex:1}
|
|
||||||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
|
||||||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
|
||||||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
|
||||||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
|
||||||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
|
||||||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
|
||||||
.settings .msg.ok{color:var(--ok)}
|
|
||||||
/* requirement coverage board (outside the phone) */
|
|
||||||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
|
||||||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
|
||||||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
|
||||||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
|
||||||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
|
||||||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
|
||||||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
|
||||||
.cov .where{color:#ddd}
|
|
||||||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
|
||||||
@media (max-width:760px){
|
|
||||||
.cov thead{display:none}
|
|
||||||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
|
||||||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
|
||||||
.cov tr:last-child{border-bottom:0}
|
|
||||||
.cov td{border:0;padding:1px 0}
|
|
||||||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
|
||||||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
|
||||||
}
|
|
||||||
.hidden{display:none}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="top">
|
|
||||||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
|
||||||
<div class="flowbar" id="flowbar"></div>
|
|
||||||
<div class="vp">
|
|
||||||
<span>Viewport</span>
|
|
||||||
<span id="vpval">412</span>
|
|
||||||
<button data-w="390">390</button>
|
|
||||||
<button data-w="412" class="on">412</button>
|
|
||||||
<button data-w="720">720</button>
|
|
||||||
<button data-w="1440">1440</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stage"><div id="phone"></div></div>
|
|
||||||
<div class="cov"><div class="cov-inner">
|
|
||||||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
|
||||||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
|
||||||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
|
||||||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
|
||||||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
|
||||||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
|
||||||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div></div>
|
|
||||||
<script>
|
|
||||||
// ===========================================================================
|
|
||||||
// issue #99 — the tape line is a FIXED ENTITY.
|
|
||||||
//
|
|
||||||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
|
||||||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
|
||||||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
|
||||||
// its long axis lies on the line, tick to tick.
|
|
||||||
// * The ticks belong to the line - they slide along it, never with the photo.
|
|
||||||
// * L = the distance between the two ticks (the owner's tape reading).
|
|
||||||
// ===========================================================================
|
|
||||||
const S = {
|
|
||||||
screen: "tape",
|
|
||||||
// slab transform (the only movable entity)
|
|
||||||
tilt: 14, // degrees
|
|
||||||
zoom: 1,
|
|
||||||
panX: 60, panY: 0,
|
|
||||||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
|
||||||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
|
||||||
// bottom - and the operator drags them onto their tape marks from there.
|
|
||||||
tickA: 32,
|
|
||||||
tickB: 608,
|
|
||||||
focus: "A",
|
|
||||||
fill: 90, // settings.crop_fill - global, default 90
|
|
||||||
// declared values (entered on Measure, shown on both steps)
|
|
||||||
L: "88",
|
|
||||||
thickness: "2.125",
|
|
||||||
savedMsg: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
|
||||||
const VB = { w: 480, h: 640 };
|
|
||||||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
|
||||||
// AXIS - the centreline running down the middle of the board, which is where
|
|
||||||
// the tape is laid. (Not a physical side edge.)
|
|
||||||
const SLAB = {
|
|
||||||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
|
||||||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
|
||||||
};
|
|
||||||
const TICK_LEN = 26; // rifle-scope end tick, total
|
|
||||||
const TICK_GAP = 40; // min separation between ticks
|
|
||||||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
|
||||||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
|
||||||
const HIT_R = 34;
|
|
||||||
const ALIGN_TOL = 3; // stage units
|
|
||||||
|
|
||||||
const rad = (a) => a * Math.PI / 180;
|
|
||||||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
|
||||||
// crop frame, which is where the crop centres the slab's long axis.
|
|
||||||
const lineX = () => VB.w / 2;
|
|
||||||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
|
||||||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
|
||||||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
|
||||||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
|
||||||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
|
||||||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
|
||||||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
|
||||||
|
|
||||||
// slab transform: zoom + tilt about the stage centre, then pan
|
|
||||||
function mapPoint(p) {
|
|
||||||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
|
||||||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
|
||||||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
|
||||||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
|
||||||
}
|
|
||||||
function slabTransform() {
|
|
||||||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
|
||||||
}
|
|
||||||
// where the slab's long axis crosses a given stage y
|
|
||||||
function axisXAt(y) {
|
|
||||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
|
||||||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
|
||||||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
|
||||||
}
|
|
||||||
const axisAngle = () => {
|
|
||||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
|
||||||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
|
||||||
};
|
|
||||||
// the axis is vertical when its angle points straight down (or up) the screen
|
|
||||||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
|
||||||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
|
||||||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- the stage
|
|
||||||
function slabSvg() {
|
|
||||||
return `
|
|
||||||
<g id="slab" transform="${slabTransform()}">
|
|
||||||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
|
||||||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
|
||||||
<g clip-path="url(#slabClip)" opacity=".8">
|
|
||||||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
|
||||||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
|
||||||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
|
||||||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
|
||||||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
|
||||||
</g>
|
|
||||||
</g>`;
|
|
||||||
}
|
|
||||||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
|
||||||
function fixtureSvg() {
|
|
||||||
const x = lineX();
|
|
||||||
const tick = (y, id) => {
|
|
||||||
const on = S.focus === id;
|
|
||||||
return `
|
|
||||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
|
||||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
|
||||||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
|
||||||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
|
||||||
};
|
|
||||||
return `
|
|
||||||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
|
||||||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
|
||||||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
|
||||||
}
|
|
||||||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
|
||||||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
|
||||||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
|
||||||
function cropFrameSvg() {
|
|
||||||
const m = cropInset();
|
|
||||||
return `
|
|
||||||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
|
||||||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
|
||||||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
|
||||||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
|
||||||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
|
||||||
}
|
|
||||||
function rotateHandleSvg() {
|
|
||||||
return `
|
|
||||||
<g class="rotg" data-rot="1">
|
|
||||||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
|
||||||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
|
||||||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
|
||||||
</g>`;
|
|
||||||
}
|
|
||||||
function stageSvg() {
|
|
||||||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
|
||||||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
|
||||||
${cropFrameSvg()}
|
|
||||||
${slabSvg()}
|
|
||||||
${fixtureSvg()}
|
|
||||||
${rotateHandleSvg()}
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ screens
|
|
||||||
const screens = {
|
|
||||||
batch() {
|
|
||||||
return `<section class="panel">
|
|
||||||
<h1>Batch photos</h1>
|
|
||||||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
|
||||||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
|
||||||
</section>`;
|
|
||||||
},
|
|
||||||
mask() {
|
|
||||||
return `<section class="panel">
|
|
||||||
<h1>Mask</h1>
|
|
||||||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
|
||||||
<div class="nums">
|
|
||||||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
|
||||||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<button class="ghost" data-act="to-batch">Back</button>
|
|
||||||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
|
||||||
</div>
|
|
||||||
</section>`;
|
|
||||||
},
|
|
||||||
tape() {
|
|
||||||
const off = axisOffset(), ok = aligned();
|
|
||||||
return `<section class="panel">
|
|
||||||
<h1>Tape line</h1>
|
|
||||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
|
||||||
<div class="tw" id="tw">${stageSvg()}</div>
|
|
||||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
|
||||||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<div>
|
|
||||||
<p class="ctl-label">Tilt the slab</p>
|
|
||||||
<div class="angle">
|
|
||||||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
|
||||||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
|
||||||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
|
||||||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
|
||||||
</div>
|
|
||||||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
|
||||||
<div class="angle">
|
|
||||||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
|
||||||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
|
||||||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
|
||||||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
|
||||||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
|
||||||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
|
||||||
<div class="croprow">
|
|
||||||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
|
||||||
<span class="val" id="fillVal">${S.fill}%</span>
|
|
||||||
</div>
|
|
||||||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
|
||||||
? "The board's long axis is on the line ✓"
|
|
||||||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
|
||||||
|
|
||||||
<div class="nums">
|
|
||||||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
|
||||||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
|
||||||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
|
||||||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
|
||||||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
|
||||||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
|
||||||
|
|
||||||
<div class="swap">
|
|
||||||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
|
||||||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<button class="ghost" data-act="to-mask">Back</button>
|
|
||||||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="plan">
|
|
||||||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
|
||||||
<ul>
|
|
||||||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
|
||||||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>`;
|
|
||||||
},
|
|
||||||
measure() {
|
|
||||||
const mini = (tilt, marks) => `
|
|
||||||
<svg viewBox="0 0 480 640">
|
|
||||||
<rect width="480" height="640" fill="#16281c"/>
|
|
||||||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
|
||||||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
|
||||||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
|
||||||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
|
||||||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
|
||||||
</g>
|
|
||||||
</svg>`;
|
|
||||||
return `<section class="panel">
|
|
||||||
<h1>Photos ready</h1>
|
|
||||||
<div class="fields">
|
|
||||||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
|
||||||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
|
||||||
</div>
|
|
||||||
<div class="nums">
|
|
||||||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
|
||||||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
|
||||||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="plan">
|
|
||||||
<h3>Rotation scope — req. 6</h3>
|
|
||||||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
|
||||||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
|
||||||
</div>
|
|
||||||
<ol class="lineup">
|
|
||||||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
|
||||||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
|
||||||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
|
||||||
</ol>
|
|
||||||
<div class="row">
|
|
||||||
<button class="ghost" data-act="to-tape">Back</button>
|
|
||||||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
|
||||||
</div>
|
|
||||||
</section>`;
|
|
||||||
},
|
|
||||||
settings() {
|
|
||||||
return `<section class="panel settings">
|
|
||||||
<h1>Settings</h1>
|
|
||||||
<div class="section">
|
|
||||||
<h3>Crop — global</h3>
|
|
||||||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
|
||||||
<div class="croprow">
|
|
||||||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
|
||||||
<span class="val" id="fillVal">${S.fill}%</span>
|
|
||||||
</div>
|
|
||||||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
|
||||||
<button data-act="save-fill">Save Changes</button>
|
|
||||||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<h3>Implementation notes</h3>
|
|
||||||
<ul class="plan" style="border:0;padding:0;background:none">
|
|
||||||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
|
||||||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
|
||||||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
|
||||||
</section>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// the two mini overlays for the was/now pair
|
|
||||||
function oldOverlayMini() {
|
|
||||||
return `<svg viewBox="0 0 120 160">
|
|
||||||
<rect width="120" height="160" fill="#141414"/>
|
|
||||||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
|
||||||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
|
||||||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
|
||||||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
function newOverlayMini() {
|
|
||||||
return `<svg viewBox="0 0 120 160">
|
|
||||||
<rect width="120" height="160" fill="#141414"/>
|
|
||||||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
|
||||||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
|
||||||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
|
||||||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------- plumbing
|
|
||||||
const phone = document.getElementById('phone');
|
|
||||||
const flowbar = document.getElementById('flowbar');
|
|
||||||
const STEPS = [
|
|
||||||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
|
||||||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
|
||||||
];
|
|
||||||
function render() {
|
|
||||||
phone.innerHTML = screens[S.screen]();
|
|
||||||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
|
||||||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
|
||||||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
|
||||||
}
|
|
||||||
flowbar.addEventListener('click', (e) => {
|
|
||||||
const b = e.target.closest('[data-goto]');
|
|
||||||
if (!b) return;
|
|
||||||
S.screen = b.dataset.goto;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
document.querySelector('.vp').addEventListener('click', (e) => {
|
|
||||||
const b = e.target.closest('[data-w]');
|
|
||||||
if (!b) return;
|
|
||||||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
|
||||||
b.classList.add('on');
|
|
||||||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
|
||||||
document.getElementById('vpval').textContent = b.dataset.w;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
|
||||||
// (a full panel re-render would destroy the element under the pointer).
|
|
||||||
function refreshStage() {
|
|
||||||
const host = document.getElementById('tw');
|
|
||||||
if (!host) return;
|
|
||||||
host.innerHTML = stageSvg();
|
|
||||||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
|
||||||
set('tiltVal', S.tilt + '\u00B0');
|
|
||||||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
|
||||||
set('fillVal', S.fill + '%');
|
|
||||||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
|
||||||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
|
||||||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
|
||||||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
|
||||||
const st = document.getElementById('alignstatus');
|
|
||||||
if (st) {
|
|
||||||
const ok = aligned(), off = axisOffset();
|
|
||||||
st.className = 'status' + (ok ? ' ok' : '');
|
|
||||||
st.innerHTML = ok
|
|
||||||
? "The board's long axis is on the line \u2713"
|
|
||||||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
|
||||||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
|
||||||
}
|
|
||||||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
|
||||||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
|
||||||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
|
||||||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
|
||||||
axisAngle: axisAngle(), aligned: aligned() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
||||||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
|
||||||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
|
||||||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
|
||||||
// twin, stepping about the stage centre so the slab stays where it is.
|
|
||||||
function stepZoom(dir) {
|
|
||||||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
|
||||||
refreshStage();
|
|
||||||
}
|
|
||||||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
|
||||||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
|
||||||
function nudgeTick(dir, mult) {
|
|
||||||
const step = (mult === undefined ? 1 : mult);
|
|
||||||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
|
||||||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindStage() {
|
|
||||||
const host = document.getElementById('tw');
|
|
||||||
if (!host) return;
|
|
||||||
const toVB = (e) => {
|
|
||||||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
|
||||||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
|
||||||
};
|
|
||||||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
|
||||||
function zoomAt(p, factor) {
|
|
||||||
const cx = VB.w / 2, cy = VB.h / 2;
|
|
||||||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
|
||||||
if (z1 === z0) return;
|
|
||||||
const qx = cx + (p.x - cx - S.panX) / z0;
|
|
||||||
const qy = cy + (p.y - cy - S.panY) / z0;
|
|
||||||
S.zoom = z1;
|
|
||||||
S.panX = p.x - cx - z1 * (qx - cx);
|
|
||||||
S.panY = p.y - cy - z1 * (qy - cy);
|
|
||||||
refreshStage();
|
|
||||||
}
|
|
||||||
host.addEventListener('wheel', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
|
||||||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
|
||||||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
|
||||||
const dy = clamp(e.deltaY, -200, 200);
|
|
||||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
|
||||||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
|
||||||
}, { passive: false });
|
|
||||||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
|
||||||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
|
||||||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
|
||||||
|
|
||||||
let drag = null, tb = null, moved = false, downP = null;
|
|
||||||
const off = { x: 0, y: 0 };
|
|
||||||
host.addEventListener('pointerdown', (e) => {
|
|
||||||
const tick = e.target.closest('[data-tick]');
|
|
||||||
const rot = e.target.closest('[data-rot]');
|
|
||||||
const p = toVB(e);
|
|
||||||
moved = false; downP = p;
|
|
||||||
if (tick) {
|
|
||||||
S.focus = tick.dataset.tick;
|
|
||||||
drag = { kind: "tick", id: S.focus };
|
|
||||||
} else if (rot) {
|
|
||||||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
|
||||||
} else {
|
|
||||||
drag = { kind: "slab" };
|
|
||||||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
|
||||||
}
|
|
||||||
host.setPointerCapture(e.pointerId);
|
|
||||||
host.classList.add('dragging');
|
|
||||||
refreshStage();
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
host.addEventListener('pointermove', (e) => {
|
|
||||||
if (!drag) return;
|
|
||||||
const p = toVB(e);
|
|
||||||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
|
||||||
if (drag.kind === "tick") {
|
|
||||||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
|
||||||
const y = clamp(p.y, 20, VB.h - 20);
|
|
||||||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
|
||||||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
|
||||||
} else if (drag.kind === "rot") {
|
|
||||||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
|
||||||
} else {
|
|
||||||
S.panX = clamp(p.x - off.x, -400, 400);
|
|
||||||
S.panY = clamp(p.y - off.y, -400, 400);
|
|
||||||
}
|
|
||||||
refreshStage();
|
|
||||||
});
|
|
||||||
const end = (e) => {
|
|
||||||
if (!drag) return;
|
|
||||||
const wasSlab = drag.kind === "slab";
|
|
||||||
const startP = toVB(e);
|
|
||||||
drag = null;
|
|
||||||
host.classList.remove('dragging');
|
|
||||||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
|
||||||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
|
||||||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
|
||||||
// dblclick both stop arriving), so track the taps ourselves.
|
|
||||||
if (wasSlab && !moved) {
|
|
||||||
const now = performance.now();
|
|
||||||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
|
||||||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
|
||||||
tb = { t: now, x: startP.x, y: startP.y };
|
|
||||||
}
|
|
||||||
refreshStage();
|
|
||||||
};
|
|
||||||
host.addEventListener('pointerup', end);
|
|
||||||
host.addEventListener('pointercancel', end);
|
|
||||||
}
|
|
||||||
|
|
||||||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (S.screen !== "tape") return;
|
|
||||||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
|
||||||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!e.key.startsWith("Arrow")) return;
|
|
||||||
const fine = e.shiftKey ? 0.1 : 1;
|
|
||||||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
|
||||||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
|
||||||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
|
||||||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
|
||||||
else return;
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
phone.addEventListener('click', (e) => {
|
|
||||||
const t = e.target.closest('[data-act]');
|
|
||||||
if (!t) return;
|
|
||||||
const a = t.dataset.act;
|
|
||||||
if (a === "tilt") tiltBy(+t.dataset.d);
|
|
||||||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
|
||||||
else if (a === "fit") fitSlab();
|
|
||||||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
|
||||||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
|
||||||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
|
||||||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
|
||||||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
|
||||||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
|
||||||
});
|
|
||||||
phone.addEventListener('input', (e) => {
|
|
||||||
const t = e.target;
|
|
||||||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
|
||||||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
|
||||||
else if (t.dataset.act === "fill-range") {
|
|
||||||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
|
||||||
// rectangle's aspect never changes and the line's x never moves.
|
|
||||||
S.fill = +t.value;
|
|
||||||
const ct = cropTicks();
|
|
||||||
S.tickA = ct.a; S.tickB = ct.b;
|
|
||||||
refreshStage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
phone.addEventListener('change', (e) => {
|
|
||||||
const t = e.target;
|
|
||||||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
|
||||||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
render();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "slabuploader-frontend",
|
"name": "slabuploader-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@neodrag/svelte": "2.3.3",
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
@@ -495,6 +496,15 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@neodrag/svelte": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@neodrag/svelte/-/svelte-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-avXzhrilsBsnMFljhVAQ7h+6hbSIrvRCJ61GCiGbGISkC1QOhjDCNvPZo2+7KVwiYrnUBx4NRH0kTIqrcxv9Lg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test:e2e:headed": "playwright test --headed"
|
"test:e2e:headed": "playwright test --headed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@neodrag/svelte": "2.3.3",
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"svelte": "^4.2.20"
|
"svelte": "^4.2.20"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import AxisPanel from './capture/AxisPanel.svelte';
|
import AxisPanel from './capture/AxisPanel.svelte';
|
||||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||||
import Settings from './create/settings/Settings.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
|
|
||||||
import SlabList from './create/slabs/SlabList.svelte';
|
import SlabList from './create/slabs/SlabList.svelte';
|
||||||
import Review from './create/review/Review.svelte';
|
import Review from './create/review/Review.svelte';
|
||||||
import Publish from './create/publish/Publish.svelte';
|
import Publish from './create/publish/Publish.svelte';
|
||||||
@@ -47,6 +48,11 @@
|
|||||||
// ?noload to trigger the Resume prompt deterministically.
|
// ?noload to trigger the Resume prompt deterministically.
|
||||||
const boot = new URLSearchParams(window.location.search);
|
const boot = new URLSearchParams(window.location.search);
|
||||||
if (!boot.get('noload')) void session.resumeProbe();
|
if (!boot.get('noload')) void session.resumeProbe();
|
||||||
|
// #99: the global crop fill drives both the reference line and the PNG crop.
|
||||||
|
// Falls back to the 90% default when settings are unreachable.
|
||||||
|
void getSettings()
|
||||||
|
.then((s) => session.setCropFill(s.crop_fill ?? 90))
|
||||||
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubCap();
|
unsubCap();
|
||||||
@@ -216,7 +222,7 @@
|
|||||||
{:else if cap.k === 'axis'}
|
{:else if cap.k === 'axis'}
|
||||||
<AxisPanel
|
<AxisPanel
|
||||||
state={cap}
|
state={cap}
|
||||||
setAxisAngle={session.setAxisAngle}
|
setTapeLine={session.setTapeLine}
|
||||||
approveAxis={session.approveAxis}
|
approveAxis={session.approveAxis}
|
||||||
back={session.back}
|
back={session.back}
|
||||||
/>
|
/>
|
||||||
@@ -260,6 +266,7 @@
|
|||||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
}
|
}
|
||||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||||
|
|
||||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||||
.hardstop h1 { font-size: 1.6rem; }
|
.hardstop h1 { font-size: 1.6rem; }
|
||||||
.detail { color: #888; font-size: 0.85rem; }
|
.detail { color: #888; font-size: 0.85rem; }
|
||||||
|
|||||||
@@ -454,6 +454,8 @@ export interface SettingsView {
|
|||||||
woo_create_status: string;
|
woo_create_status: string;
|
||||||
taxonomy_last_sync_at: string | null;
|
taxonomy_last_sync_at: string | null;
|
||||||
taxonomy_anchor: string | null;
|
taxonomy_anchor: string | null;
|
||||||
|
/** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */
|
||||||
|
crop_fill: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSettings(): Promise<SettingsView> {
|
export function getSettings(): Promise<SettingsView> {
|
||||||
|
|||||||
@@ -1,58 +1,251 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Capture } from './capture.types';
|
import { onMount } from 'svelte';
|
||||||
import PhotoStage from './PhotoStage.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 state: Extract<Capture, { k: 'axis' }>;
|
||||||
export let setAxisAngle: (deg: number) => void;
|
export let setTapeLine: (tapeLine: TapeLine) => void;
|
||||||
export let approveAxis: () => void;
|
export let approveAxis: () => void;
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
|
|
||||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
let stage: HTMLDivElement | undefined;
|
||||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Length axis</h1>
|
<h1>Tape-line calibration</h1>
|
||||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
<p class="hint">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>
|
||||||
<PhotoStage
|
<div class="stage" bind:this={stage}>
|
||||||
blob={state.source.blob}
|
<canvas
|
||||||
bbox={state.axis.bbox}
|
bind:this={canvas}
|
||||||
maskBitset={state.approvedMask.bitset}
|
aria-label="Calibration photo with draggable tape endpoint ticks"
|
||||||
{maskW}
|
on:pointerdown={onPointerDown}
|
||||||
{maskH}
|
on:pointermove={onPointerMove}
|
||||||
/>
|
on:pointerup={onPointerUp}
|
||||||
<div class="angle">
|
on:pointercancel={onPointerUp}
|
||||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
on:wheel|nonpassive={onWheel}
|
||||||
<input
|
></canvas>
|
||||||
type="range" min="0" max="179" step="1"
|
|
||||||
value={angleDeg}
|
|
||||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
|
||||||
/>
|
|
||||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
|
||||||
<span class="val">{angleDeg}°</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
<button type="button" class="primary" disabled={tickDistance < 4} on:click={approveAxis}>Use tape line</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.panel { padding: 16px; color: #eee; }
|
.panel { padding: 16px; color: #eee; }
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.45; }
|
||||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
|
||||||
.thumb {
|
canvas { display: block; touch-action: none; cursor: grab; }
|
||||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
|
||||||
}
|
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
|
||||||
input[type='range'] { flex: 1; }
|
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
||||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
.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; }
|
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
.primary {
|
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
||||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
.primary:disabled { background: #333; color: #777; }
|
||||||
background: #00e5ff; color: #002; font-weight: 700;
|
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||||
}
|
|
||||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { Capture } from './capture.types';
|
import type { Capture } from './capture.types';
|
||||||
import { inToEighths, eighthsToIn } from './capture.types';
|
import { inToEighths, eighthsToIn } from './capture.types';
|
||||||
import { fmtEighths } from './session';
|
import { fmtEighths } from './session';
|
||||||
|
import { calibrationScale } from '../png/crop';
|
||||||
import PhotoStage from './PhotoStage.svelte';
|
import PhotoStage from './PhotoStage.svelte';
|
||||||
import {
|
import {
|
||||||
buildPhotosReadyLineup,
|
buildPhotosReadyLineup,
|
||||||
@@ -54,6 +55,12 @@
|
|||||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
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 commitLength = () => {
|
||||||
const inches = positiveInches(String(lengthIn));
|
const inches = positiveInches(String(lengthIn));
|
||||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||||
@@ -67,7 +74,7 @@
|
|||||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
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 () => {
|
const doCalc = async () => {
|
||||||
calcBusy = true;
|
calcBusy = true;
|
||||||
@@ -93,7 +100,7 @@
|
|||||||
|
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Length L</span>
|
<span class="label">Tape length L (distance between ticks)</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
aria-label="Length L"
|
aria-label="Length L"
|
||||||
@@ -121,6 +128,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
@@ -194,6 +202,7 @@
|
|||||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||||
.field { flex: 1; }
|
.field { flex: 1; }
|
||||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
.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 {
|
.field input {
|
||||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
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;
|
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||||
|
|||||||
@@ -43,13 +43,20 @@ export interface Mask {
|
|||||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||||
bbox: BBox;
|
bbox: BBox;
|
||||||
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
|
slabBounds: ABox; // axis-aligned extent of slab px, 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
|
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||||
// corners[3] → corners[0] (the long side).
|
// corners[3] → corners[0] (the long side).
|
||||||
export interface Axis {
|
export interface Axis {
|
||||||
bbox: BBox;
|
bbox: BBox;
|
||||||
|
tapeLine: TapeLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Widths {
|
export interface Widths {
|
||||||
@@ -138,6 +145,7 @@ export interface Draft {
|
|||||||
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
||||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||||
|
tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts
|
||||||
lengthEighths: Eighths;
|
lengthEighths: Eighths;
|
||||||
thicknessEighths: Eighths;
|
thicknessEighths: Eighths;
|
||||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||||
|
|||||||
@@ -378,9 +378,10 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
|||||||
s.setSheet('black');
|
s.setSheet('black');
|
||||||
await settleOverlay(s);
|
await settleOverlay(s);
|
||||||
s.approveMask();
|
s.approveMask();
|
||||||
|
s.setTapeLine({ start: [0, 10], end: [39, 10] });
|
||||||
s.approveAxis();
|
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.setLength(320);
|
||||||
s.setThickness(16);
|
s.setThickness(16);
|
||||||
await s.calculate();
|
await s.calculate();
|
||||||
@@ -393,7 +394,7 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
|||||||
const r = cap.results!;
|
const r = cap.results!;
|
||||||
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
||||||
assert.equal(typeof r.scale, 'number');
|
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.sqft, 'number');
|
||||||
assert.equal(typeof r.bdft, 'number');
|
assert.equal(typeof r.bdft, 'number');
|
||||||
// CAPTURE.md: bdft = sqft × thickness_in
|
// 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);
|
assert.equal(before!.batch![2].blob, c);
|
||||||
|
|
||||||
s.approveMask();
|
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));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
const after = store.peek();
|
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!.stage, 'axis');
|
||||||
assert.equal(after!.calibrationIndex, 1);
|
assert.equal(after!.calibrationIndex, 1);
|
||||||
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
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 ?? []).length, 3, 'full batch retained');
|
||||||
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
||||||
assert.equal(after!.batch![1].blob, b);
|
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![0].blob, a);
|
||||||
assert.equal(meas!.batch![2].blob, c);
|
assert.equal(meas!.batch![2].blob, c);
|
||||||
assert.equal(meas!.calibrationIndex, 1);
|
assert.equal(meas!.calibrationIndex, 1);
|
||||||
|
assert.deepEqual(meas!.tapeLine, tapeLine, 'measure draft keeps the same calibration endpoints');
|
||||||
|
|
||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import type {
|
import type {
|
||||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, WorkerIn, WorkerOut,
|
||||||
} from './capture.types';
|
} from './capture.types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||||
} from './capture.types';
|
} from './capture.types';
|
||||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||||
import { measure } from '../measure/measure';
|
import { measure } from '../measure/measure';
|
||||||
import { orientedRect } from '../geometry/rect';
|
import { initialTapeLine, rectAlongLine } from '../geometry/rect';
|
||||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||||
|
import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop';
|
||||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
@@ -40,7 +41,9 @@ export interface CaptureSession {
|
|||||||
setSensitivity(n: number): void;
|
setSensitivity(n: number): void;
|
||||||
setSheet(s: Sheet): void;
|
setSheet(s: Sheet): void;
|
||||||
approveMask(): void;
|
approveMask(): void;
|
||||||
setAxisAngle(deg: number): void;
|
setTapeLine(tapeLine: TapeLine): void;
|
||||||
|
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
|
||||||
|
setCropFill(pctOrFraction: number): void;
|
||||||
approveAxis(): void;
|
approveAxis(): void;
|
||||||
setLength(e: Eighths): void;
|
setLength(e: Eighths): void;
|
||||||
setThickness(e: Eighths): void;
|
setThickness(e: Eighths): void;
|
||||||
@@ -83,6 +86,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pngUrl: string | null = null;
|
let pngUrl: string | null = null;
|
||||||
let resumed = false;
|
let resumed = false;
|
||||||
|
// #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise.
|
||||||
|
let cropFill = FILL_DEFAULT;
|
||||||
|
|
||||||
const set = (next: Capture) => {
|
const set = (next: Capture) => {
|
||||||
state = next;
|
state = next;
|
||||||
@@ -207,6 +212,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||||
|
tapeLine: state.axis.tapeLine,
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -217,6 +223,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
calibrationIndex: calIdx,
|
calibrationIndex: calIdx,
|
||||||
source: state.source, maskParams: state.approvedMask.params,
|
source: state.source, maskParams: state.approvedMask.params,
|
||||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||||
|
tapeLine: state.axis.tapeLine,
|
||||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||||
results: state.results ? rest : null,
|
results: state.results ? rest : null,
|
||||||
inventoryPng: state.results?.inventoryPng ?? null,
|
inventoryPng: state.results?.inventoryPng ?? null,
|
||||||
@@ -311,6 +318,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||||
// fall back to the mask's own bbox.
|
// fall back to the mask's own bbox.
|
||||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||||
|
const draftTapeLine = (d: Draft) => d.tapeLine ?? initialTapeLine(d.source.widthPx, d.source.heightPx);
|
||||||
|
|
||||||
const restoreFromDraft = async (d: Draft) => {
|
const restoreFromDraft = async (d: Draft) => {
|
||||||
batchPhotos = batchPhotosOf(d);
|
batchPhotos = batchPhotosOf(d);
|
||||||
@@ -338,7 +346,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
try {
|
try {
|
||||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||||
if (d.stage === 'axis') {
|
if (d.stage === 'axis') {
|
||||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
const tapeLine = draftTapeLine(d);
|
||||||
|
set({ k: 'axis', source, approvedMask: mask, axis: {
|
||||||
|
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine,
|
||||||
|
} });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.inventoryPng) {
|
if (d.inventoryPng) {
|
||||||
@@ -347,7 +358,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
k: 'measure', source, approvedMask: mask,
|
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,
|
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||||
results: d.results
|
results: d.results
|
||||||
? {
|
? {
|
||||||
@@ -476,22 +490,33 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||||
teardownWorker();
|
teardownWorker();
|
||||||
const overlay = state.overlay;
|
const overlay = state.overlay;
|
||||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
const 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();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
setAxisAngle(deg: number) {
|
setTapeLine(tapeLine: TapeLine) {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
const start: [number, number] = [...tapeLine.start];
|
||||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
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();
|
void persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCropFill(pctOrFraction: number) {
|
||||||
|
// #99: non-retro — only future encodes use this; stored PNGs are untouched.
|
||||||
|
cropFill = fillFromSetting(pctOrFraction);
|
||||||
|
},
|
||||||
|
|
||||||
approveAxis() {
|
approveAxis() {
|
||||||
if (state.k !== 'axis') return;
|
if (state.k !== 'axis') return;
|
||||||
set({
|
set({
|
||||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||||
axis: { bbox: state.axis.bbox },
|
axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine },
|
||||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||||
});
|
});
|
||||||
void persist();
|
void persist();
|
||||||
@@ -515,15 +540,17 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
if (state.k !== 'measure') return;
|
if (state.k !== 'measure') return;
|
||||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
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 bitmap = await decodeOrientedBitmap(source.blob);
|
||||||
const base = measure(
|
const base = measure(
|
||||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale,
|
||||||
);
|
);
|
||||||
let pngBlob: Blob;
|
let pngBlob: Blob;
|
||||||
let belowFloor = false;
|
let belowFloor = false;
|
||||||
try {
|
try {
|
||||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill });
|
||||||
pngBlob = r.blob;
|
pngBlob = r.blob;
|
||||||
belowFloor = r.sourceBelowFloor;
|
belowFloor = r.sourceBelowFloor;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -593,7 +620,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||||
try {
|
try {
|
||||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill });
|
||||||
} finally {
|
} finally {
|
||||||
siblingBitmap.close();
|
siblingBitmap.close();
|
||||||
}
|
}
|
||||||
@@ -626,7 +653,9 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
const m = state.approvedMask;
|
const m = state.approvedMask;
|
||||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||||
} else if (state.k === 'measure') {
|
} else if (state.k === 'measure') {
|
||||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
|
||||||
|
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine,
|
||||||
|
} });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
brand_voice: string | null;
|
brand_voice: string | null;
|
||||||
geo_context: string | null;
|
geo_context: string | null;
|
||||||
woo_create_status: string | null;
|
woo_create_status: string | null;
|
||||||
|
crop_fill: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PriceRule {
|
interface PriceRule {
|
||||||
@@ -205,6 +206,7 @@
|
|||||||
brand_voice: settings.brand_voice || '',
|
brand_voice: settings.brand_voice || '',
|
||||||
geo_context: settings.geo_context || '',
|
geo_context: settings.geo_context || '',
|
||||||
woo_create_status: settings.woo_create_status || 'draft',
|
woo_create_status: settings.woo_create_status || 'draft',
|
||||||
|
crop_fill: settings.crop_fill ?? 90,
|
||||||
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
||||||
woo_app_password: secretValue(settings.woo_app_password),
|
woo_app_password: secretValue(settings.woo_app_password),
|
||||||
inference_base_url: secretValue(settings.inference_base_url),
|
inference_base_url: secretValue(settings.inference_base_url),
|
||||||
@@ -230,6 +232,7 @@
|
|||||||
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
||||||
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
||||||
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
||||||
|
if (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
|
||||||
// secrets only if provided (write-only)
|
// secrets only if provided (write-only)
|
||||||
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
||||||
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
||||||
@@ -351,6 +354,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="crop-fill">Crop Fill % (default 90) — inventory PNG + reference line</label>
|
||||||
|
<input id="crop-fill" type="range" min="1" max="100" step="1" bind:value={form.crop_fill} />
|
||||||
|
<span>{form.crop_fill}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button on:click={save} disabled={saving}>Save Changes</button>
|
<button on:click={save} disabled={saving}>Save Changes</button>
|
||||||
{#if message}<p class="msg">{message}</p>{/if}
|
{#if message}<p class="msg">{message}</p>{/if}
|
||||||
|
|
||||||
@@ -415,7 +424,10 @@
|
|||||||
<style>
|
<style>
|
||||||
.settings { max-width: 600px; color: #eee; }
|
.settings { max-width: 600px; color: #eee; }
|
||||||
label { display: block; margin: 8px 0 4px; }
|
label { display: block; margin: 8px 0 4px; }
|
||||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; }
|
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; box-sizing: border-box; }
|
||||||
|
/* width:100% + the UA default input margins overflowed narrow viewports
|
||||||
|
(mobile Settings scrolled 16px sideways). Zero the horizontal margins. */
|
||||||
|
.settings input, .settings select { margin-left: 0; margin-right: 0; }
|
||||||
option { color: #eee; background: #1c1c1c; }
|
option { color: #eee; background: #1c1c1c; }
|
||||||
button { margin-top: 16px; }
|
button { margin-top: 16px; }
|
||||||
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
|
import { minAreaRect, orientedRect, rectAlongLine } from './rect.ts';
|
||||||
import { measure } from '../measure/measure.ts';
|
|
||||||
|
|
||||||
// 100×20 horizontal slab
|
// 100×20 horizontal slab
|
||||||
const W = 100, H = 20;
|
const W = 100, H = 20;
|
||||||
const bitset = new Uint8Array(W * H);
|
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;
|
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', () => {
|
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||||
const prev = minAreaRect(bitset, W, H);
|
const prev = minAreaRect(bitset, W, H);
|
||||||
assert.ok(prev, 'minAreaRect returned a box');
|
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);
|
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
test('rectAlongLine sets the approved axis from the owner-positioned tape endpoints', () => {
|
||||||
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
const previous = minAreaRect(bitset, W, H)!;
|
||||||
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
const start: [number, number] = [20, 30];
|
||||||
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
const end: [number, number] = [80, 70];
|
||||||
const TW = 20, TH = 100;
|
const calibrated = rectAlongLine(previous, start, end);
|
||||||
const thin = new Uint8Array(TW * TH).fill(1);
|
const center: [number, number] = [50, 50];
|
||||||
const thinBox = minAreaRect(thin, TW, TH);
|
assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9);
|
||||||
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9);
|
||||||
const rotated = orientedRect(thinBox, 0);
|
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9);
|
||||||
const lengthEighths = 24; // 3 in
|
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9);
|
||||||
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
|
assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle');
|
||||||
// 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}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
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 };
|
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
/** Build the approved axis rectangle from the owner's two tape-line endpoints. */
|
||||||
// extent in px. 0 when the mask is empty.
|
export function rectAlongLine(prev: BBox, start: [number, number], end: [number, number]): BBox {
|
||||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
const dx = end[0] - start[0];
|
||||||
let min = Infinity, max = -Infinity;
|
const dy = end[1] - start[1];
|
||||||
for (let y = 0; y < h; y++) {
|
const length = Math.hypot(dx, dy);
|
||||||
const row = y * w;
|
if (!(length > 0) || !Number.isFinite(length)) return prev;
|
||||||
for (let x = 0; x < w; x++) {
|
|
||||||
if (!bitset[row + x]) continue;
|
const halfW = Math.hypot(
|
||||||
const p = x * ux + y * uy;
|
prev.corners[0][0] - prev.corners[1][0],
|
||||||
if (p < min) min = p;
|
prev.corners[0][1] - prev.corners[1][1],
|
||||||
if (p > max) max = p;
|
) / 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', () => {
|
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 { 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)`);
|
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
|
||||||
for (const e of m.widths.every6In) {
|
for (const e of m.widths.every6In) {
|
||||||
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
|
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', () => {
|
test('uniform slab: interior stations keep the full width', () => {
|
||||||
const { bitset, box, slabPx } = uniform(480, 100);
|
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);
|
assert.equal(m.widths.every6In.length, 7);
|
||||||
for (const e of m.widths.every6In) {
|
for (const e of m.widths.every6In) {
|
||||||
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
|
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', () => {
|
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||||
const { bitset, box, slabPx } = uniform(120, 100);
|
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: [] });
|
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
||||||
const { bitset, box, slabPx } = uniform(100, 100);
|
const { bitset, box, slabPx } = uniform(100, 100);
|
||||||
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
|
const m = measure(bitset, 100, 100, box, 80, 8, slabPx, 10);
|
||||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
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 type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||||
import { maskSpanAlong } from '../geometry/rect';
|
|
||||||
|
|
||||||
// Display / direction: corners[3] → corners[0] is the long side.
|
// Display / direction: corners[3] → corners[0] is the long side.
|
||||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||||
@@ -76,10 +75,13 @@ export function measure(
|
|||||||
bbox: BBox,
|
bbox: BBox,
|
||||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||||
slabPx: number,
|
slabPx: number,
|
||||||
|
scale: number,
|
||||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||||
const lengthIn = lengthEighths / 8;
|
const lengthIn = lengthEighths / 8;
|
||||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
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);
|
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||||
let widths: Widths;
|
let widths: Widths;
|
||||||
if (every6In.length > 0) {
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for the inventory crop geometry (issue #99, req. 4).
|
||||||
|
*
|
||||||
|
* The 90% fill and the 3:4 aspect used to be duplicated as `const FILL = 0.9`
|
||||||
|
* in `png/inventory.ts` and `png/overlay.ts`. Both now read from here, and the
|
||||||
|
* live fill comes from the global `settings.crop_fill` (default 90%) so the
|
||||||
|
* reference line and the PNG crop can never disagree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Default fill (90%) — mirrors the `settings.crop_fill` default. */
|
||||||
|
export const FILL_DEFAULT = 0.9;
|
||||||
|
export const ASPECT_W = 3;
|
||||||
|
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
|
||||||
|
* to {@link FILL_DEFAULT} rather than silently cropping to zero.
|
||||||
|
*/
|
||||||
|
export function fillFromSetting(v: unknown): number {
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return FILL_DEFAULT;
|
||||||
|
return Math.min(1, n > 1 ? n / 100 : n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output frame size: shorter side clamped to {@link FLOOR}. */
|
||||||
|
export function cropFrameSize(sw: number, sh: number): { outW: number; outH: number; belowFloor: boolean } {
|
||||||
|
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||||
|
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||||
|
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||||
|
return { outW, outH, belowFloor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CropGeometry {
|
||||||
|
scale: number;
|
||||||
|
dw: number;
|
||||||
|
dh: number;
|
||||||
|
ox: number;
|
||||||
|
oy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scale + centring offsets of a `sw × sh` slab inside an `outW × outH` frame. */
|
||||||
|
export function cropOffsets(
|
||||||
|
sw: number,
|
||||||
|
sh: number,
|
||||||
|
outW: number,
|
||||||
|
outH: number,
|
||||||
|
fill: number = FILL_DEFAULT,
|
||||||
|
): CropGeometry {
|
||||||
|
const scale = Math.min((outW * fill) / Math.max(sw, 1), (outH * fill) / Math.max(sh, 1));
|
||||||
|
const dw = sw * scale;
|
||||||
|
const dh = sh * scale;
|
||||||
|
return { scale, dw, dh, ox: (outW - dw) / 2, oy: (outH - dh) / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraction of the frame's LONG axis taken by the crop margin (issue #99, req. 3):
|
||||||
|
* the slab's long edge sits at `oy` (or `ox`) = `outH * (1 - fill) / 2`, so the
|
||||||
|
* fixed reference line is that same fraction in from the frame edge.
|
||||||
|
*/
|
||||||
|
export function cropMarginFraction(fill: number = FILL_DEFAULT): number {
|
||||||
|
return (1 - fill) / 2;
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { Mask, Source } from '../capture/capture.types';
|
import type { Mask, Source } from '../capture/capture.types';
|
||||||
|
import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop';
|
||||||
const FLOOR = 800; // shorter side, px
|
|
||||||
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
|
|
||||||
const ASPECT_W = 3;
|
|
||||||
const ASPECT_H = 4;
|
|
||||||
|
|
||||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||||
@@ -12,6 +8,8 @@ export interface InventoryInput {
|
|||||||
bitmap: ImageBitmap;
|
bitmap: ImageBitmap;
|
||||||
mask: MaskPick;
|
mask: MaskPick;
|
||||||
source: SourcePick;
|
source: SourcePick;
|
||||||
|
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
|
||||||
|
fill?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
@@ -25,6 +23,7 @@ export function composeInventoryFrame(
|
|||||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||||
mask: MaskPick,
|
mask: MaskPick,
|
||||||
source: SourcePick,
|
source: SourcePick,
|
||||||
|
fill: number = FILL_DEFAULT,
|
||||||
): Frame {
|
): Frame {
|
||||||
const b = mask.slabBounds;
|
const b = mask.slabBounds;
|
||||||
const x0 = Math.max(0, Math.round(b.x));
|
const x0 = Math.max(0, Math.round(b.x));
|
||||||
@@ -33,14 +32,8 @@ export function composeInventoryFrame(
|
|||||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||||
const sw = x1 - x0;
|
const sw = x1 - x0;
|
||||||
const sh = y1 - y0;
|
const sh = y1 - y0;
|
||||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
const { outW, outH, belowFloor } = cropFrameSize(sw, sh);
|
||||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
|
||||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
|
||||||
const dw = sw * scale;
|
|
||||||
const dh = sh * scale;
|
|
||||||
const ox = (outW - dw) / 2;
|
|
||||||
const oy = (outH - dh) / 2;
|
|
||||||
const rgba = new Uint8Array(outW * outH * 4);
|
const rgba = new Uint8Array(outW * outH * 4);
|
||||||
const xEnd = ox + dw;
|
const xEnd = ox + dw;
|
||||||
const yEnd = oy + dh;
|
const yEnd = oy + dh;
|
||||||
@@ -74,6 +67,7 @@ export async function encodeInventoryPng(
|
|||||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||||
mask,
|
mask,
|
||||||
source,
|
source,
|
||||||
|
input.fill,
|
||||||
);
|
);
|
||||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||||
const ctx = out.getContext('2d')!;
|
const ctx = out.getContext('2d')!;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BBox } from '../capture/capture.types';
|
import type { BBox } from '../capture/capture.types';
|
||||||
import { axisEndpoints } from '../measure/measure';
|
import { axisEndpoints } from '../measure/measure';
|
||||||
|
import { FILL_DEFAULT, cropOffsets } from './crop';
|
||||||
|
|
||||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||||
export function overlayWidthStations(lengthIn: number): number[] {
|
export function overlayWidthStations(lengthIn: number): number[] {
|
||||||
@@ -485,19 +486,15 @@ export function inventorySourceMap(
|
|||||||
source: { widthPx: number; heightPx: number },
|
source: { widthPx: number; heightPx: number },
|
||||||
outW: number,
|
outW: number,
|
||||||
outH: number,
|
outH: number,
|
||||||
|
fill: number = FILL_DEFAULT,
|
||||||
): InventoryMap {
|
): InventoryMap {
|
||||||
const FILL = 0.9;
|
|
||||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||||
const sw = x1 - x0;
|
const sw = x1 - x0;
|
||||||
const sh = y1 - y0;
|
const sh = y1 - y0;
|
||||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
const { scale, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||||
const dw = sw * scale;
|
|
||||||
const dh = sh * scale;
|
|
||||||
const ox = (outW - dw) / 2;
|
|
||||||
const oy = (outH - dh) / 2;
|
|
||||||
return {
|
return {
|
||||||
outW,
|
outW,
|
||||||
outH,
|
outH,
|
||||||
|
|||||||
Reference in New Issue
Block a user