Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ca2ef57a7 | |||
| 6fb92362a4 | |||
| 818874e65c |
@@ -43,6 +43,3 @@ frontend/tests/e2e/test-results/
|
||||
frontend/tests/e2e/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
|
||||
.uat-data/
|
||||
.venv-overnight/
|
||||
|
||||
@@ -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).
|
||||
+78
-474
@@ -1,14 +1,10 @@
|
||||
"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2
|
||||
content prose, test-inference, inference-off server description, inference_log.
|
||||
Cites METADATA-35..115, SETTINGS-30/45/90.
|
||||
|
||||
overnight-whole-app: Call 1 POST is hard-disabled (410 call1_disabled). Call 2 stays.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
@@ -108,49 +104,10 @@ CALL1_SEED = (
|
||||
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
|
||||
)
|
||||
CALL2_SEED = (
|
||||
"Write listing copy for this slab for woodworkers choosing stock for a project. "
|
||||
"Audience: makers matching wood to a tabletop, bar, bench, or similar build — practical, "
|
||||
"material/figure/spec-forward. No lifestyle or marketing hype "
|
||||
"(avoid empty phrases like elevate, stunning, dramatic visual contrast).\n"
|
||||
"You write BOTH the short description and the long description.\n"
|
||||
"\n"
|
||||
"PLAIN TEXT ONLY (short AND long):\n"
|
||||
"- Output plain text only. No HTML tags (<p>, <ul>, <li>, <strong>, <br>, etc.). "
|
||||
"No markdown bold/headers that look like markup (**bold**, # headers).\n"
|
||||
"- Structure the long description with newlines and \"- \" bullets when helpful.\n"
|
||||
"\n"
|
||||
"Rules that always apply:\n"
|
||||
"- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.\n"
|
||||
"- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade, "
|
||||
"moisture, or features.\n"
|
||||
"- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay "
|
||||
"neutral. Never invent return, warranty, or shipping policies.\n"
|
||||
"- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.\n"
|
||||
"- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece "
|
||||
"suits (tabletop, bar, etc.) without ad-copy fluff.\n"
|
||||
"- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED "
|
||||
"ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description "
|
||||
"(no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no \"Full specs\" "
|
||||
"section, no \"Maple Slab\" attribute-header block). You may weave 1–2 key traits into "
|
||||
"prose or benefit bullets naturally.\n"
|
||||
"\n"
|
||||
"SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):\n"
|
||||
"1. Lead with a practical outcome for a maker (what they can build / how the stock helps), "
|
||||
"not lifestyle hype and not a bare species list.\n"
|
||||
"2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).\n"
|
||||
"3. Close with a trust signal or concrete reason to choose this piece.\n"
|
||||
"\n"
|
||||
"LONG DESCRIPTION (plain text only — newlines and \"- \" bullets; no HTML, no markdown "
|
||||
"markup). Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute "
|
||||
"list / product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK:\n"
|
||||
"1. Hook — one sentence on what the piece suits and its standout usable trait.\n"
|
||||
"2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail. "
|
||||
"Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.\n"
|
||||
"3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present; "
|
||||
"otherwise a short neutral close with no invented policies.\n"
|
||||
"\n"
|
||||
"Return ONE JSON object only (no prose outside JSON) with exactly these keys:\n"
|
||||
'{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}'
|
||||
"Write the long product description for the slab from the supplied photos and listing "
|
||||
"facts. The name and short description are already built, so do not repeat them. Use the "
|
||||
"brand voice and geo context given. The dimensions in the fact block are exact: use those "
|
||||
"strings and invent no numbers. Write prose a premium lumber buyer would read."
|
||||
)
|
||||
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
|
||||
_THRESHOLD = 0.7
|
||||
@@ -338,21 +295,51 @@ def _enabled(conn, key: str) -> bool:
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call1")
|
||||
async def call1(slab_id: str) -> dict:
|
||||
"""Call 1 OUT (overnight-whole-app): hard-disabled. Call 2 stays.
|
||||
|
||||
Route retained so clients get an explicit 410 instead of a half-wired path.
|
||||
Prompt seed / revision APIs for call1 remain for docs & future re-enable.
|
||||
"""
|
||||
_ = slab_id # unused while disabled
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=_error(
|
||||
"call1_disabled",
|
||||
"Call 1 (vision classification) is disabled on this branch. Use Call 2 for description text.",
|
||||
{"call": 1, "disabled": True},
|
||||
),
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if not _enabled(conn, "inference_enabled"):
|
||||
return JSONResponse(status_code=409, content=_error("inference_disabled", "Inference is off"))
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||
client = InferenceClient(base, key, model)
|
||||
_seed_prompts(conn)
|
||||
conn.commit()
|
||||
act = _active_prompt(conn, "call1")
|
||||
prompt_text = act[1] if act else CALL1_SEED
|
||||
# Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy.
|
||||
photos = conn.execute(
|
||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts = []
|
||||
photo_meta: list[dict] = []
|
||||
for p in photos:
|
||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||
image_parts.append(_image_part_from_processed(blob))
|
||||
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
||||
tax = _taxonomy_lists(conn)
|
||||
text = (f"{prompt_text}\n\nLISTS:\n{json.dumps(tax, ensure_ascii=False)}\n\n"
|
||||
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
|
||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = client.chat(messages)
|
||||
except InferenceConnectionError as e:
|
||||
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
|
||||
except InferenceError as e:
|
||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
parsed = _parse_json(raw)
|
||||
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
||||
# Apply confidence gate (METADATA-45).
|
||||
suggestions = _gate(parsed)
|
||||
return {"ok": True, "suggestions": suggestions}
|
||||
|
||||
|
||||
def _feat_display_name(name: str) -> str:
|
||||
@@ -477,409 +464,39 @@ def _gate(parsed: dict) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _json_list(val: Any) -> list:
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _term_name(terms: dict, term_id: Any) -> str:
|
||||
if not term_id:
|
||||
return ""
|
||||
t = terms.get(int(term_id) if not isinstance(term_id, int) and str(term_id).isdigit() else term_id) or {}
|
||||
try:
|
||||
from .taxonomy import _decode_name
|
||||
return _decode_name(t.get("name") or "")
|
||||
except Exception:
|
||||
return str(t.get("name") or "")
|
||||
|
||||
|
||||
def _cat_name(cats: dict, cat_id: Any) -> str:
|
||||
if not cat_id:
|
||||
return ""
|
||||
c = cats.get(int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id) or {}
|
||||
try:
|
||||
from .taxonomy import _decode_name
|
||||
return _decode_name(c.get("name") or "")
|
||||
except Exception:
|
||||
return str(c.get("name") or "")
|
||||
|
||||
|
||||
def _parent_path(cats: dict, cat_id: Any) -> str:
|
||||
"""Parent-category path for a wood-category leaf (METADATA-60)."""
|
||||
if not cat_id:
|
||||
return ""
|
||||
cid = int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id
|
||||
leaf = cats.get(cid) or {}
|
||||
parts: list[str] = []
|
||||
pid = leaf.get("parent_id")
|
||||
seen = set()
|
||||
while pid and pid not in seen:
|
||||
seen.add(pid)
|
||||
parent = cats.get(pid) or {}
|
||||
name = _cat_name(cats, pid)
|
||||
if name:
|
||||
parts.append(name)
|
||||
pid = parent.get("parent_id")
|
||||
parts.reverse()
|
||||
leaf_name = _cat_name(cats, cid)
|
||||
if leaf_name:
|
||||
parts.append(leaf_name)
|
||||
return " > ".join(parts)
|
||||
|
||||
|
||||
def _feat_names(conn, feat_ids: list) -> list[str]:
|
||||
if not feat_ids:
|
||||
return []
|
||||
q = ",".join("?" * len(feat_ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, name FROM woo_tags WHERE id IN ({q})", feat_ids
|
||||
).fetchall()
|
||||
by_id = {r[0]: _feat_display_name(r[1]) for r in rows}
|
||||
return [by_id[i] for i in feat_ids if i in by_id]
|
||||
|
||||
|
||||
def build_declared_attributes(conn, slab: dict) -> str:
|
||||
"""Structured DECLARED ATTRIBUTES from the slab for Call 2 (METADATA-60).
|
||||
|
||||
Names only — no hardcoded Curl≠Curly bridges. Primary = first wood category
|
||||
when not otherwise known; secondary = the rest.
|
||||
"""
|
||||
cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()}
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
wood_ids = _json_list(slab.get("wood_category_ids"))
|
||||
primary_id = wood_ids[0] if wood_ids else None
|
||||
secondary_ids = wood_ids[1:] if len(wood_ids) > 1 else []
|
||||
figure_ids = _json_list(slab.get("figure_term_ids"))
|
||||
feat_ids = _json_list(slab.get("feat_tag_ids"))
|
||||
lines = [
|
||||
"DECLARED ATTRIBUTES (confirmed in review — use these names; do not invent taxonomy):",
|
||||
f"species: {_cat_name(cats, slab.get('species_id')) or '(none)'}",
|
||||
f"primary wood category: {_cat_name(cats, primary_id) or '(none)'}",
|
||||
f"primary path: {_parent_path(cats, primary_id) or '(none)'}",
|
||||
f"secondary wood categories: {', '.join(_cat_name(cats, i) for i in secondary_ids) or '(none)'}",
|
||||
f"edge: {_term_name(terms, slab.get('edge_type_term_id')) or '(none)'}",
|
||||
f"figure: {', '.join(_term_name(terms, i) for i in figure_ids) or '(none)'}",
|
||||
f"grade: {_term_name(terms, slab.get('grade_term_id')) or '(none)'}",
|
||||
f"moisture: {_term_name(terms, slab.get('moisture_term_id')) or '(none)'}",
|
||||
f"features: {', '.join(_feat_names(conn, feat_ids)) or '(none)'}",
|
||||
]
|
||||
title = (slab.get("title") or "").strip()
|
||||
if title:
|
||||
lines.append(f"title: {title}")
|
||||
# short_description is Call 2 output — do not feed a prior value back into the prompt.
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_dimension_fact_block(slab: dict) -> str:
|
||||
"""Exact dimension strings — invent no numbers (METADATA-65/90)."""
|
||||
return (
|
||||
"DIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
||||
f"length={slab.get('length_in')}in thickness={slab.get('thickness_in')}in "
|
||||
f"avg_width={slab.get('width_avg_in')}in width_min={slab.get('width_min_in')}in "
|
||||
f"width_max={slab.get('width_max_in')}in bdft={slab.get('bdft')}"
|
||||
)
|
||||
|
||||
|
||||
def assemble_call2_text(conn, slab: dict, prompt_text: str | None = None) -> str:
|
||||
"""Assemble the Call 2 text payload: seed + declared attrs + dims + brand/geo.
|
||||
|
||||
Shared by View-prompt GET and POST .../inference/call2 so both show the same text.
|
||||
"""
|
||||
seed = prompt_text
|
||||
if seed is None:
|
||||
act = _active_prompt(conn, "call2")
|
||||
seed = act[1] if act else CALL2_SEED
|
||||
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
||||
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
||||
brand_s = brand[0] if brand and brand[0] else ""
|
||||
geo_s = geo[0] if geo and geo[0] else ""
|
||||
return (
|
||||
f"{seed}\n\n"
|
||||
f"{build_declared_attributes(conn, slab)}\n\n"
|
||||
f"{build_dimension_fact_block(slab)}\n\n"
|
||||
f"Brand voice: {brand_s}\n"
|
||||
f"Geo: {geo_s}"
|
||||
)
|
||||
|
||||
|
||||
def assemble_call2_photo_slots(conn, slab_id: str, *, for_wire: bool = False) -> tuple[list[dict], list[dict]]:
|
||||
"""Up to 8 processed PNGs for Call 2.
|
||||
|
||||
Returns (image_parts for the model, photo_slots without base64 for View prompt / log).
|
||||
Slot list: seq, id, bytes, long_edge_px of the *fitted* infer payload.
|
||||
"""
|
||||
photos = conn.execute(
|
||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts: list[dict] = []
|
||||
photo_slots: list[dict] = []
|
||||
for p in photos:
|
||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||
fitted = fit_long_edge_png(blob)
|
||||
if for_wire:
|
||||
image_parts.append(_image_part_from_processed(blob))
|
||||
meta = _photo_meta_row(pid, seq, fitted)
|
||||
photo_slots.append({
|
||||
"seq": meta["seq"],
|
||||
"id": meta["photo_id"],
|
||||
"bytes": meta["bytes"],
|
||||
"long_edge_px": meta["infer_long_edge_px"],
|
||||
})
|
||||
return image_parts, photo_slots
|
||||
|
||||
|
||||
def assemble_call2(conn, slab_id: str) -> dict[str, Any] | None:
|
||||
"""Full Call 2 assembly used by View prompt and the live POST.
|
||||
|
||||
Returns None if the slab is missing. Does not call the model.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
slab = dict(row)
|
||||
for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"):
|
||||
if slab.get(f) is not None and isinstance(slab[f], str):
|
||||
try:
|
||||
slab[f] = json.loads(slab[f])
|
||||
except (ValueError, TypeError):
|
||||
slab[f] = []
|
||||
act = _active_prompt(conn, "call2")
|
||||
prompt_text = act[1] if act else CALL2_SEED
|
||||
rev_id = act[0] if act else None
|
||||
text = assemble_call2_text(conn, slab, prompt_text)
|
||||
image_parts, photo_slots = assemble_call2_photo_slots(conn, slab_id, for_wire=True)
|
||||
# photo_meta for the inference log (same order as image_parts)
|
||||
photo_meta = [
|
||||
{
|
||||
"photo_id": s["id"],
|
||||
"seq": s["seq"],
|
||||
"bytes": s["bytes"],
|
||||
"infer_long_edge_px": s["long_edge_px"],
|
||||
"label": f"photo seq={s['seq']} id={s['id']}",
|
||||
}
|
||||
for s in photo_slots
|
||||
]
|
||||
return {
|
||||
"text": text,
|
||||
"photos": photo_slots,
|
||||
"image_parts": image_parts,
|
||||
"photo_meta": photo_meta,
|
||||
"prompt_revision_id": rev_id,
|
||||
"slab": slab,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/slabs/{slab_id}/inference/call2/prompt")
|
||||
async def call2_prompt(slab_id: str) -> dict:
|
||||
"""View prompt: exact Call 2 text + photo slot list (no base64, no model call)."""
|
||||
conn = get_connection()
|
||||
assembled = assemble_call2(conn, slab_id)
|
||||
if assembled is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
return {
|
||||
"text": assembled["text"],
|
||||
"photos": assembled["photos"],
|
||||
"prompt_revision_id": assembled["prompt_revision_id"],
|
||||
}
|
||||
|
||||
|
||||
# Labels that mark a Woo/taxonomy/dimension metadata dump when used as "- Label: …".
|
||||
_ATTR_DUMP_LABELS = (
|
||||
"Category",
|
||||
"Primary",
|
||||
"Secondary",
|
||||
"Species",
|
||||
"Figure",
|
||||
"Edge",
|
||||
"Grade",
|
||||
"Moisture",
|
||||
"Features",
|
||||
"Feature",
|
||||
"Title",
|
||||
"Length",
|
||||
"Thickness",
|
||||
"Width",
|
||||
"Avg Width",
|
||||
"Average Width",
|
||||
"Width Min",
|
||||
"Width Max",
|
||||
"Min Width",
|
||||
"Max Width",
|
||||
"Board Feet",
|
||||
"Bdft",
|
||||
"BF",
|
||||
"Dimensions",
|
||||
"Dims",
|
||||
)
|
||||
_ATTR_DUMP_LABEL_RE = re.compile(
|
||||
r"(?i)^\s*-\s*(?:"
|
||||
+ "|".join(re.escape(l) for l in _ATTR_DUMP_LABELS)
|
||||
+ r")\s*:",
|
||||
)
|
||||
_ATTR_DUMP_HEADER_RE = re.compile(
|
||||
r"(?i)^\s*(?:Full\s+specs?|Specifications?|Attributes?|Attribute\s+list|"
|
||||
r"[A-Za-z][A-Za-z0-9 /&-]{0,40}\s+Slab)\s*$",
|
||||
)
|
||||
|
||||
|
||||
def _strip_attribute_dump(value: str) -> str:
|
||||
"""Remove a labeled Category:/Length:/Board Feet: metadata dump from long desc.
|
||||
|
||||
Specs live in taxonomy/dims on the product; a trailing (or mid-body) block that
|
||||
re-lists them as "- Label: value" lines drifts from Woo fields. Keeps Hook +
|
||||
Benefits (+ trust close) prose around the dump.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
lines = value.split("\n")
|
||||
n = len(lines)
|
||||
is_attr = [_ATTR_DUMP_LABEL_RE.match(ln) is not None for ln in lines]
|
||||
|
||||
# Find contiguous runs of ≥3 attribute bullets (optionally preceded by a header).
|
||||
runs: list[tuple[int, int]] = []
|
||||
i = 0
|
||||
while i < n:
|
||||
if not is_attr[i]:
|
||||
i += 1
|
||||
continue
|
||||
start = i
|
||||
while i < n and is_attr[i]:
|
||||
i += 1
|
||||
end = i # exclusive
|
||||
if end - start >= 3:
|
||||
# Absorb a single preceding header / "Maple Slab" title line.
|
||||
hdr = start - 1
|
||||
if hdr >= 0 and lines[hdr].strip() and _ATTR_DUMP_HEADER_RE.match(lines[hdr]):
|
||||
# Do not swallow a benefits bullet that happens to match poorly.
|
||||
if not is_attr[hdr]:
|
||||
start = hdr
|
||||
# Absorb blank lines immediately before the block.
|
||||
while start > 0 and not lines[start - 1].strip():
|
||||
start -= 1
|
||||
runs.append((start, end))
|
||||
|
||||
if not runs:
|
||||
return value.strip()
|
||||
|
||||
# Prefer the last run (typical trailing dump); drop all qualifying runs.
|
||||
keep = [True] * n
|
||||
for start, end in runs:
|
||||
for j in range(start, end):
|
||||
keep[j] = False
|
||||
out_lines = [ln for j, ln in enumerate(lines) if keep[j]]
|
||||
text = "\n".join(out_lines)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _html_to_plain(value: str) -> str:
|
||||
"""Defensive: strip HTML/markup so Review fields never show raw tags.
|
||||
|
||||
Converts common block tags to newlines and <li> to "- " bullets, then removes
|
||||
remaining tags and unescapes entities. Already-plain text is returned cleaned.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
t = value
|
||||
# Normalize common break / list / paragraph boundaries before dropping tags.
|
||||
t = re.sub(r"(?is)<br\s*/?>", "\n", t)
|
||||
t = re.sub(r"(?is)</\s*(p|div|h[1-6]|tr|table)\s*>", "\n", t)
|
||||
t = re.sub(r"(?is)<\s*li[^>]*>", "- ", t)
|
||||
t = re.sub(r"(?is)</\s*(ul|ol)\s*>", "\n", t)
|
||||
t = re.sub(r"(?is)<[^>]+>", "", t)
|
||||
t = html.unescape(t)
|
||||
# Drop leftover markdown-ish bold/headers that look like markup in Review.
|
||||
t = re.sub(r"(?m)^#{1,6}\s+", "", t)
|
||||
t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t)
|
||||
t = re.sub(r"[ \t]+\n", "\n", t)
|
||||
t = re.sub(r"\n{3,}", "\n\n", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
def _plain_long_desc(value: str) -> str:
|
||||
"""HTML→plain then drop labeled attribute/metadata dumps from long description."""
|
||||
return _strip_attribute_dump(_html_to_plain(value))
|
||||
|
||||
|
||||
def parse_call2_response(raw: str) -> tuple[str | None, str]:
|
||||
"""Split Call 2 model output into (short_description, description).
|
||||
|
||||
Preferred shape is a JSON object with short_description + description.
|
||||
Both fields are forced to plain text (HTML stripped) before return.
|
||||
Long description also drops a trailing/mid labeled attribute dump
|
||||
(Category:/Length:/Board Feet: …) so specs stay on Woo fields.
|
||||
If JSON is missing or incomplete, treat the whole body as description
|
||||
(stripped) and leave short_description None (caller keeps any prior short).
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return None, ""
|
||||
# Strip common markdown fences around JSON.
|
||||
body = text
|
||||
if body.startswith("```"):
|
||||
lines = body.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
body = "\n".join(lines).strip()
|
||||
try:
|
||||
obj = json.loads(body)
|
||||
except (ValueError, TypeError):
|
||||
# Try to find a JSON object embedded in prose.
|
||||
start = body.find("{")
|
||||
end = body.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
obj = json.loads(body[start : end + 1])
|
||||
except (ValueError, TypeError):
|
||||
return None, _plain_long_desc(text)
|
||||
else:
|
||||
return None, _plain_long_desc(text)
|
||||
if not isinstance(obj, dict):
|
||||
return None, _plain_long_desc(text)
|
||||
short = obj.get("short_description")
|
||||
desc = obj.get("description")
|
||||
if isinstance(short, str):
|
||||
short = _html_to_plain(short) or None
|
||||
else:
|
||||
short = None
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
return short, _plain_long_desc(desc)
|
||||
# JSON without a usable description — fall back to raw (stripped).
|
||||
return short, _plain_long_desc(text)
|
||||
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call2")
|
||||
async def call2(slab_id: str) -> dict:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if not _enabled(conn, "content_llm_enabled"):
|
||||
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||
assembled = assemble_call2(conn, slab_id)
|
||||
if assembled is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
client = InferenceClient(base, key, model)
|
||||
text = assembled["text"]
|
||||
image_parts = assembled["image_parts"]
|
||||
photo_meta = assembled["photo_meta"]
|
||||
act = _active_prompt(conn, "call2")
|
||||
prompt_text = act[1] if act else CALL2_SEED
|
||||
photos = conn.execute(
|
||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts = []
|
||||
photo_meta: list[dict] = []
|
||||
for p in photos:
|
||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||
image_parts.append(_image_part_from_processed(blob))
|
||||
photo_meta.append(_photo_meta_row(pid, seq, blob))
|
||||
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
||||
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
||||
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
||||
f"length={row['length_in']}in thickness={row['thickness_in']}in "
|
||||
f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in "
|
||||
f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}")
|
||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -889,25 +506,12 @@ async def call2(slab_id: str) -> dict:
|
||||
except InferenceError as e:
|
||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
_log_call(
|
||||
conn, slab_id, 2, assembled["prompt_revision_id"], model, messages, raw, latency, photo_meta,
|
||||
)
|
||||
short, description = parse_call2_response(raw)
|
||||
if short:
|
||||
conn.execute(
|
||||
"UPDATE slabs SET short_description=?, description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||
(short, description, _now(), slab_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||
(description, _now(), slab_id),
|
||||
)
|
||||
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency, photo_meta)
|
||||
# Store as description, mark content_source=llm.
|
||||
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||
(raw.strip(), _now(), slab_id))
|
||||
conn.commit()
|
||||
out: dict[str, Any] = {"ok": True, "description": description}
|
||||
if short:
|
||||
out["short_description"] = short
|
||||
return out
|
||||
return {"ok": True, "description": raw.strip()}
|
||||
|
||||
|
||||
def server_description(slab: dict) -> str:
|
||||
|
||||
@@ -35,7 +35,6 @@ _REQUIRED = [
|
||||
"sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||
"width_min_in", "width_max_in", "width_avg_in",
|
||||
"species_id", "wood_category_ids", "edge_type_term_id",
|
||||
"figure_term_ids", # UX requires ≥1 figure (Create-desc gate + ready)
|
||||
"grade_term_id", "thickness_term_id", "moisture_term_id",
|
||||
"price", "price_source", "title", "short_description", "description",
|
||||
"content_source",
|
||||
|
||||
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
|
||||
"woo_create_status",
|
||||
"taxonomy_last_sync_at",
|
||||
"taxonomy_anchor",
|
||||
"crop_fill",
|
||||
}
|
||||
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
|
||||
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
|
||||
NUMERIC_KEYS: set[str] = {"crop_fill"}
|
||||
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"inference_enabled": False,
|
||||
"content_llm_enabled": False,
|
||||
"woo_create_status": "draft",
|
||||
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
|
||||
"crop_fill": 90,
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +96,12 @@ def get_settings() -> dict[str, Any]:
|
||||
result[k] = val if val is not None else DEFAULTS.get(k)
|
||||
else:
|
||||
result[k] = DEFAULTS.get(k)
|
||||
if k in NUMERIC_KEYS:
|
||||
try:
|
||||
f = float(result[k])
|
||||
result[k] = int(f) if f.is_integer() else f
|
||||
except (TypeError, ValueError):
|
||||
result[k] = DEFAULTS.get(k)
|
||||
|
||||
return result
|
||||
|
||||
@@ -108,6 +119,25 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
},
|
||||
)
|
||||
|
||||
# #99: rules live here in the 4xx layer, not in the DB (structural only).
|
||||
if "crop_fill" in payload:
|
||||
raw = payload["crop_fill"]
|
||||
try:
|
||||
pct = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
pct = None
|
||||
if pct is None or not (1 <= pct <= 100):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "validation",
|
||||
"message": "crop_fill must be a number between 1 and 100 (percent)",
|
||||
"detail": {"field": "crop_fill"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
for k, v in payload.items():
|
||||
if k not in ALL_KEYS:
|
||||
|
||||
+7
-25
@@ -1,14 +1,11 @@
|
||||
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
|
||||
|
||||
Rebuild happens in one transaction; on any failure the prior cache is kept.
|
||||
Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen
|
||||
(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored
|
||||
subset, recorded only when it changes. Display names are HTML-unescaped so
|
||||
Woo entities like ``&`` store as ``&``.
|
||||
Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a
|
||||
stable hash of the stored subset, recorded only when it changes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -19,23 +16,8 @@ def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _decode_name(name: str | None) -> str:
|
||||
"""Decode HTML entities Woo puts in taxonomy display names."""
|
||||
return html.unescape(name or "")
|
||||
|
||||
|
||||
def _scoped_tags(tags: list[dict]) -> list[dict]:
|
||||
"""Keep product tags whose slug starts with fig- or feat- (hyphen required).
|
||||
|
||||
Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl.
|
||||
Filtering by name prefix kept 0 live tags; slug is authoritative.
|
||||
"""
|
||||
out = []
|
||||
for t in tags:
|
||||
slug = t.get("slug") or ""
|
||||
if slug.startswith("fig-") or slug.startswith("feat-"):
|
||||
out.append(t)
|
||||
return out
|
||||
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
|
||||
|
||||
|
||||
def _normalize_category(c: dict) -> tuple:
|
||||
@@ -52,7 +34,7 @@ def _normalize_category(c: dict) -> tuple:
|
||||
woo_id = c.get("woo_id", c.get("id"))
|
||||
if woo_id is not None:
|
||||
woo_id = int(woo_id)
|
||||
return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id)
|
||||
return (c["id"], c["name"], c["slug"], parent_id, woo_id)
|
||||
|
||||
|
||||
def _subset_hash(categories: list[dict], attributes: list[dict],
|
||||
@@ -108,16 +90,16 @@ def sync(client: WooClient, conn) -> dict:
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||
[(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes],
|
||||
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
|
||||
)
|
||||
for attr_id, terms in terms_by_attr.items():
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
|
||||
[(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms],
|
||||
[(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||
[(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags],
|
||||
[(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags],
|
||||
)
|
||||
|
||||
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
|
||||
|
||||
+89
-313
@@ -77,42 +77,6 @@ def test_call1_seed_is_locked_expert_prompt(env):
|
||||
assert "character\":" not in CALL1_SEED.replace(" ", "")
|
||||
|
||||
|
||||
def test_call2_seed_has_short_long_structure(env):
|
||||
"""Call 2 seed: plain-text short+long, woodworker tone, JSON dual output (not brand voice)."""
|
||||
client, _conn = env
|
||||
from backend.inference import CALL2_SEED
|
||||
|
||||
body = client.get("/api/v1/prompts/call2").json()
|
||||
assert body["active_content"] == CALL2_SEED
|
||||
assert "SHORT DESCRIPTION" in CALL2_SEED
|
||||
assert "LONG DESCRIPTION" in CALL2_SEED
|
||||
assert "PLAIN TEXT ONLY" in CALL2_SEED
|
||||
assert "plain text only" in CALL2_SEED.lower()
|
||||
assert "No HTML tags" in CALL2_SEED
|
||||
assert "woodworkers choosing stock" in CALL2_SEED
|
||||
assert "elevate" in CALL2_SEED # banned-phrase guidance
|
||||
assert "stunning" in CALL2_SEED
|
||||
assert "Lead with a practical outcome" in CALL2_SEED
|
||||
assert "Hook" in CALL2_SEED
|
||||
assert "Brand voice and Geography sections when provided" in CALL2_SEED
|
||||
assert "Whidbey" not in CALL2_SEED
|
||||
assert "Pacific Northwest" not in CALL2_SEED
|
||||
assert '"short_description"' in CALL2_SEED
|
||||
assert '"description"' in CALL2_SEED
|
||||
assert "<HTML long description>" not in CALL2_SEED
|
||||
assert "plain text long description" in CALL2_SEED
|
||||
# No Full-specs / attribute metadata dump in long desc (specs live on Woo fields).
|
||||
assert "Hook + Benefits only" in CALL2_SEED
|
||||
assert "Do NOT restate DECLARED" in CALL2_SEED
|
||||
assert 'no "Full specs"' in CALL2_SEED or "Do NOT add a Full specs" in CALL2_SEED
|
||||
assert "Category:/Figure:/Edge:" in CALL2_SEED
|
||||
assert "1–2 key traits" in CALL2_SEED
|
||||
assert "labeled catalog" in CALL2_SEED or "labeled metadata list" in CALL2_SEED
|
||||
# Must not instruct a Full-specs numbered part (old 4-part structure).
|
||||
assert "3. Full specs" not in CALL2_SEED
|
||||
assert "Four parts, in order" not in CALL2_SEED
|
||||
|
||||
|
||||
def test_taxonomy_lists_call1_closed_shape():
|
||||
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
|
||||
import tempfile, os, shutil
|
||||
@@ -189,75 +153,48 @@ def test_gate_feature_tag_ids_not_free_text():
|
||||
assert "feature_tag_ids" not in _gate(low)
|
||||
|
||||
|
||||
def test_call1_hard_disabled_410(env, monkeypatch):
|
||||
"""overnight-whole-app: Call 1 OUT — POST always 410, never chats the model."""
|
||||
def test_call1_confidence_gate(env, monkeypatch):
|
||||
client, conn = env
|
||||
# enable inference
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
called["n"] += 1
|
||||
return "{}"
|
||||
|
||||
# Capture list header for #86 shape
|
||||
text = messages[0]["content"][-1]["text"]
|
||||
assert "LISTS:" in text
|
||||
assert "grade_terms" not in text
|
||||
assert "species_leaves" not in text
|
||||
return json.dumps({
|
||||
"species_id": 1, "species_confidence": 0.9,
|
||||
"wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold
|
||||
"grade_term_id": 7, "grade_confidence": 0.99, # never suggested
|
||||
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
|
||||
"feature_tag_ids": [9], "feature_tag_confidence": 0.9,
|
||||
"character": ["x"], "inclusions": ["y"],
|
||||
})
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 410, r.text
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "call1_disabled"
|
||||
assert called["n"] == 0
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_stores_short_and_description(env, monkeypatch):
|
||||
"""Call 2 persists both fields as plain text (HTML from model is stripped)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','old short','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
import json as _json
|
||||
|
||||
payload = _json.dumps({
|
||||
"short_description": "Kiln-dried walnut live-edge stock for a dining table — clear midspan, usable width. Ready to finish.",
|
||||
"description": "<p><strong>Dining table stock.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>",
|
||||
})
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: payload)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "Dining table stock" in body["description"]
|
||||
assert "kiln dried" in body["description"]
|
||||
assert "<p>" not in body["description"]
|
||||
assert "<strong>" not in body["description"]
|
||||
assert body["short_description"].startswith("Kiln-dried walnut")
|
||||
row = conn.execute(
|
||||
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
|
||||
).fetchone()
|
||||
assert row["short_description"].startswith("Kiln-dried walnut")
|
||||
assert "<p>" not in row["description"]
|
||||
assert "- Stable:" in row["description"] or "Stable:" in row["description"]
|
||||
assert row["content_source"] == "llm"
|
||||
s = r.json()["suggestions"]
|
||||
assert s.get("species_id") == 1
|
||||
assert s.get("figure_term_ids") == [2]
|
||||
assert s.get("feature_tag_ids") == [9]
|
||||
assert "wood_category_ids" not in s # gated out
|
||||
assert "grade_term_id" not in s # never
|
||||
assert "character" not in s
|
||||
# inference_log written with call=1
|
||||
assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1
|
||||
|
||||
|
||||
def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
|
||||
"""Non-JSON Call 2 body becomes plain-text description; prior short is left alone."""
|
||||
def test_call2_stores_description(env, monkeypatch):
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','keep me','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','ready',1,1,'t','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
@@ -265,120 +202,18 @@ def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>A fine walnut slab.</p>")
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["description"] == "A fine walnut slab."
|
||||
assert "short_description" not in r.json()
|
||||
row = conn.execute(
|
||||
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
|
||||
).fetchone()
|
||||
assert row["short_description"] == "keep me"
|
||||
assert row["description"] == "A fine walnut slab."
|
||||
assert r.json()["description"] == "<p>A fine walnut slab.</p>"
|
||||
row = conn.execute("SELECT description, content_source FROM slabs WHERE id='s1'").fetchone()
|
||||
assert row["description"] == "<p>A fine walnut slab.</p>"
|
||||
assert row["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_parse_call2_response_json_and_fenced():
|
||||
from backend.inference import parse_call2_response, _html_to_plain
|
||||
import json as _json
|
||||
|
||||
short, desc = parse_call2_response(
|
||||
_json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "})
|
||||
)
|
||||
assert short == "Benefit first."
|
||||
assert desc == "Long"
|
||||
fenced = "```json\n" + _json.dumps({"short_description": "S", "description": "<p>D</p>"}) + "\n```"
|
||||
short2, desc2 = parse_call2_response(fenced)
|
||||
assert short2 == "S"
|
||||
assert desc2 == "D"
|
||||
short3, desc3 = parse_call2_response("<p>only long</p>")
|
||||
assert short3 is None
|
||||
assert desc3 == "only long"
|
||||
|
||||
# Defensive strip: lists become "- " bullets; entities unescaped; no tags left.
|
||||
plain = _html_to_plain(
|
||||
"<p>Hook.</p><ul><li><strong>Stable:</strong> kiln dried</li>"
|
||||
"<li>Live edge</li></ul><p>Trust & ship.</p>"
|
||||
)
|
||||
assert "<" not in plain
|
||||
assert "Hook." in plain
|
||||
assert "- Stable: kiln dried" in plain
|
||||
assert "- Live edge" in plain
|
||||
assert "Trust & ship." in plain
|
||||
# Already-plain text is unchanged aside from trim.
|
||||
assert _html_to_plain(" Kiln-dried walnut for a bar top. ") == "Kiln-dried walnut for a bar top."
|
||||
|
||||
|
||||
def test_strip_attribute_dump_trailing_and_header():
|
||||
"""Defensive: drop labeled Category:/Length: dumps from long description."""
|
||||
from backend.inference import _strip_attribute_dump, parse_call2_response
|
||||
import json as _json
|
||||
|
||||
hook_benefits = (
|
||||
"Kiln-dried maple suited to a compact bar top.\n"
|
||||
"- Stable stock: kiln dried, ready to mill.\n"
|
||||
"- Live edge on one side for a natural apron.\n"
|
||||
"- Character grade with usable burl figure."
|
||||
)
|
||||
dump = (
|
||||
"Maple Slab\n"
|
||||
"- Category: Live edge wood\n"
|
||||
"- Figure: Burl, Curl, Figured\n"
|
||||
"- Edge: Live Edge 1 side\n"
|
||||
"- Grade: Character\n"
|
||||
"- Moisture: Kiln dried\n"
|
||||
"- Length: 45.0in\n"
|
||||
"- Board Feet: 12.4"
|
||||
)
|
||||
trust = "Ships from the mill; ask about returns in Brand voice."
|
||||
full = f"{hook_benefits}\n\n{dump}\n\n{trust}"
|
||||
cleaned = _strip_attribute_dump(full)
|
||||
assert "Category:" not in cleaned
|
||||
assert "Board Feet:" not in cleaned
|
||||
assert "Maple Slab" not in cleaned
|
||||
assert "Kiln-dried maple" in cleaned
|
||||
assert "Live edge on one side" in cleaned
|
||||
assert "Ships from the mill" in cleaned
|
||||
|
||||
# Mid-body Full specs header also stripped; benefits bullets without Label: kept.
|
||||
mid = (
|
||||
"Hook line.\n"
|
||||
"- Good for a bench seat.\n"
|
||||
"\n"
|
||||
"Full specs\n"
|
||||
"- Species: Maple\n"
|
||||
"- Length: 45.0in\n"
|
||||
"- Thickness: 2.0in\n"
|
||||
"\n"
|
||||
"Neutral trust close."
|
||||
)
|
||||
mid_clean = _strip_attribute_dump(mid)
|
||||
assert "Full specs" not in mid_clean
|
||||
assert "Species:" not in mid_clean
|
||||
assert "Good for a bench seat" in mid_clean
|
||||
assert "Neutral trust close" in mid_clean
|
||||
|
||||
# Fewer than 3 attr lines: leave alone (not a dump).
|
||||
short_list = "Hook.\n- Length: 45.0in\n- Thickness: 2in\nTrust."
|
||||
assert _strip_attribute_dump(short_list) == short_list.strip()
|
||||
|
||||
# parse_call2_response applies strip on description (not short).
|
||||
payload = _json.dumps({
|
||||
"short_description": "Short with - Length: 1in - Width: 2in - Thickness: 3in ok",
|
||||
"description": full,
|
||||
})
|
||||
short, desc = parse_call2_response(payload)
|
||||
assert short and "Length: 1in" in short # short untouched by attr-dump strip
|
||||
assert "Category:" not in desc
|
||||
assert "Board Feet:" not in desc
|
||||
assert "Kiln-dried maple" in desc
|
||||
|
||||
|
||||
def test_call1_disabled_even_when_inference_off(env):
|
||||
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
|
||||
def test_inference_off_call1_409(env):
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01','calibrated',1,1,'t','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
assert r.status_code == 409 # inference_enabled default off
|
||||
|
||||
|
||||
def test_server_description_inference_off(env):
|
||||
@@ -471,18 +306,50 @@ def test_redact_request_for_log_strips_data_urls_keeps_text():
|
||||
assert "LISTS:" in parts[2]["text"]
|
||||
|
||||
|
||||
def test_call1_log_skipped_while_disabled(env):
|
||||
"""#87 Call 1 log path dormant while Call 1 is hard-disabled (overnight-whole-app)."""
|
||||
def test_call1_log_has_text_and_image_refs_no_base64(env, monkeypatch):
|
||||
"""#87 METADATA-66: inference_log stores text+refs, not image bytes."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||
)
|
||||
png = _rgba_png(400, 300)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('photo-abc','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
def fake_chat(self, messages):
|
||||
# Wire path still has data-URL for the model
|
||||
content = messages[0]["content"]
|
||||
assert any(
|
||||
isinstance(p, dict) and p.get("type") == "image_url"
|
||||
and str(p.get("image_url", {}).get("url", "")).startswith("data:image")
|
||||
for p in content
|
||||
)
|
||||
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
assert r.status_code == 200, r.text
|
||||
row = conn.execute(
|
||||
"SELECT request_payload, length(request_payload) AS n FROM inference_log ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
payload = row["request_payload"]
|
||||
assert row["n"] < 50000
|
||||
assert "data:image" not in payload
|
||||
assert "LISTS:" in payload
|
||||
assert "image_ref" in payload
|
||||
assert "photo-abc" in payload
|
||||
assert '"seq": 1' in payload or '"seq":1' in payload
|
||||
logged = json.loads(payload)
|
||||
refs = [p for p in logged[0]["content"] if p.get("type") == "image_ref"]
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["photo_id"] == "photo-abc"
|
||||
assert refs[0]["bytes"] == len(png)
|
||||
|
||||
|
||||
def test_call2_log_redacts_images(env, monkeypatch):
|
||||
@@ -513,129 +380,38 @@ def test_call2_log_redacts_images(env, monkeypatch):
|
||||
assert "DIMENSION FACT BLOCK" in payload
|
||||
|
||||
|
||||
def test_call1_sends_long_edge_dormant_while_disabled(env):
|
||||
"""METADATA-35 Call 1 sizing path dormant while Call 1 is hard-disabled."""
|
||||
client, _conn = env
|
||||
r = client.post("/api/v1/slabs/missing/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
|
||||
|
||||
def test_assemble_call2_includes_declared_attrs_and_dims(env):
|
||||
"""Call 2 text = seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; shared assembler."""
|
||||
def test_call1_sends_long_edge_at_most_1024(env, monkeypatch):
|
||||
"""METADATA-35 end-to-end: messages to the model use <=1024 long edge."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES "
|
||||
"(10,'Species','species',NULL,10),"
|
||||
"(11,'Black Walnut','black-walnut',10,11),"
|
||||
"(20,'Wcat','wcat',NULL,20),"
|
||||
"(21,'Slabs & Lumber','snl',20,21),"
|
||||
"(22,'Live edge wood','liveedge',21,22)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES "
|
||||
"(6,'Edge type','pa_etype',6),(8,'Figure','pa_fig',8),"
|
||||
"(9,'Grade','pa_gr',9),(7,'Moisture','pa_mst',7)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES "
|
||||
"(321,6,'Live Edge 1 side','le1',321),"
|
||||
"(328,8,'Curl','curl',328),"
|
||||
"(342,9,'Fancy','fnc',342),"
|
||||
"(326,7,'Kiln dried','kd',326)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
|
||||
"('brand_voice','Warm and precise.',NULL),"
|
||||
"('geo_context','Pacific Northwest mill.',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,moisture_term_id,"
|
||||
"feat_tag_ids,title,short_description,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',114.0,1.75,24.3,18.0,24.3,18.4,11,?,321,?,342,326,?,"
|
||||
"'Figured Black Walnut Live Edge Slab','short line','ready',1,1,'t','t')",
|
||||
(json.dumps([22]), json.dumps([328]), json.dumps([1])),
|
||||
)
|
||||
png = _rgba_png(200, 200)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p9','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import assemble_call2, assemble_call2_text, CALL2_SEED
|
||||
|
||||
assembled = assemble_call2(conn, "s1")
|
||||
assert assembled is not None
|
||||
text = assembled["text"]
|
||||
assert "DECLARED ATTRIBUTES" in text
|
||||
assert "species: Black Walnut" in text
|
||||
assert "primary wood category: Live edge wood" in text
|
||||
assert "edge: Live Edge 1 side" in text
|
||||
assert "figure: Curl" in text
|
||||
assert "grade: Fancy" in text
|
||||
assert "moisture: Kiln dried" in text
|
||||
assert "features: knot" in text
|
||||
assert "DIMENSION FACT BLOCK" in text
|
||||
assert "length=114.0in" in text
|
||||
assert "Brand voice: Warm and precise." in text
|
||||
assert "Geo: Pacific Northwest mill." in text
|
||||
assert CALL2_SEED.split("\n")[0] in text
|
||||
assert "SHORT DESCRIPTION" in text
|
||||
assert "LONG DESCRIPTION" in text
|
||||
assert "short_description" in CALL2_SEED
|
||||
assert '"description"' in CALL2_SEED or "\"description\"" in CALL2_SEED
|
||||
# Prior short must not be fed back into the prompt (Call 2 writes it).
|
||||
assert "short_description: short line" not in text
|
||||
assert "Write BOTH" in CALL2_SEED or "BOTH the short" in CALL2_SEED
|
||||
# No raw base64 in View-prompt shape
|
||||
assert "photos" in assembled
|
||||
assert assembled["photos"][0]["id"] == "p9"
|
||||
assert assembled["photos"][0]["seq"] == 1
|
||||
assert assembled["photos"][0]["bytes"] > 0
|
||||
assert "base64" not in json.dumps(assembled["photos"])
|
||||
# View prompt GET mirrors assembler (no model)
|
||||
r = client.get("/api/v1/slabs/s1/inference/call2/prompt")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["text"] == text
|
||||
assert body["photos"][0]["id"] == "p9"
|
||||
assert "data:image" not in json.dumps(body)
|
||||
|
||||
|
||||
def test_call2_post_uses_same_assembler_text(env, monkeypatch):
|
||||
"""POST call2 sends the same assembled text View prompt returns."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
|
||||
"('brand_voice','voice',NULL),('geo_context','geo',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||
"title,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,22,18,'Walnut Slab','ready',1,1,'t','t')"
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||
)
|
||||
big = _rgba_png(800, 1067)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
|
||||
(_rgba_png(64, 64),),
|
||||
(big,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient, assemble_call2
|
||||
from backend.inference import InferenceClient
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
expected = assemble_call2(conn, "s1")["text"]
|
||||
captured = {}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
captured["text"] = messages[0]["content"][-1]["text"]
|
||||
return "<p>ok</p>"
|
||||
captured["messages"] = messages
|
||||
return json.dumps({"species_id": 1, "species_confidence": 0.9})
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
assert captured["text"] == expected
|
||||
assert "DECLARED ATTRIBUTES" in captured["text"]
|
||||
assert "DIMENSION FACT BLOCK" in captured["text"]
|
||||
content = captured["messages"][0]["content"]
|
||||
imgs = [p for p in content if isinstance(p, dict) and p.get("type") == "image_url"]
|
||||
assert len(imgs) == 1
|
||||
url = imgs[0]["image_url"]["url"]
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
raw = base64.b64decode(url.split(",", 1)[1])
|
||||
im = Image.open(io.BytesIO(raw))
|
||||
assert max(im.size) <= 1024
|
||||
|
||||
@@ -194,29 +194,3 @@ def test_review_persists_owner_overrides(env):
|
||||
assert got["sku"] == "SLAB-99"
|
||||
# server_rev was incremented (the UPDATE fired).
|
||||
assert got["server_rev"] == 2
|
||||
|
||||
|
||||
def test_ready_requires_figure_term_ids(env):
|
||||
"""UX requires ≥1 figure — figure_term_ids is in _REQUIRED."""
|
||||
from backend.review import missing_required, compute_ready
|
||||
|
||||
slab = _slab(figure_term_ids=[])
|
||||
miss = missing_required(slab)
|
||||
assert "figure_term_ids" in miss
|
||||
ready, missing = compute_ready({**slab, "price": 1.0, "price_source": "manual",
|
||||
"title": "T", "short_description": "S",
|
||||
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||
"thickness_term_id": 1, "moisture_term_id": 1})
|
||||
assert ready is False
|
||||
assert "figure_term_ids" in missing
|
||||
|
||||
ready2, missing2 = compute_ready({
|
||||
**slab,
|
||||
"figure_term_ids": [1],
|
||||
"price": 1.0, "price_source": "manual",
|
||||
"title": "T", "short_description": "S",
|
||||
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||
"thickness_term_id": 1, "moisture_term_id": 1,
|
||||
})
|
||||
# May still miss other fields depending on fixture; figure must not be listed
|
||||
assert "figure_term_ids" not in missing2
|
||||
|
||||
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
|
||||
body = resp.json()
|
||||
assert body["inference_api_key"] == {"configured": False}
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_defaults_to_90(monkeypatch):
|
||||
"""Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 90
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
|
||||
"""Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": 75})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["crop_fill"] == 75
|
||||
conn = db_mod.get_connection()
|
||||
row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
|
||||
assert row["value"] == "75" and row["value_enc"] is None
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 75
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
|
||||
"""Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_setup_env(monkeypatch, tmp)
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
db_mod.init_db()
|
||||
for bad in (0, 101, -5, "abc", None):
|
||||
resp = client.put("/api/v1/settings", json={"crop_fill": bad})
|
||||
assert resp.status_code == 400, f"{bad!r} should be rejected"
|
||||
assert resp.json()["error"]["code"] == "validation"
|
||||
assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
|
||||
# boundaries are valid
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
|
||||
assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
@@ -200,30 +200,6 @@ def test_get_not_found(client, db):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
|
||||
def test_update_omitting_species_id_preserves_it(client, db):
|
||||
"""Review PUTs omit species_id after Measure owns it — must not null the column."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = {
|
||||
"id": slab_id, "sku": "", "length_in": 48, "thickness_in": 2, "sqft": 6.25, "bdft": 12.5,
|
||||
"width_min_in": 10, "width_max_in": 12, "width_avg_in": 11, "species_id": 1,
|
||||
}
|
||||
r = client.post("/api/v1/slabs", json=create)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["species_id"] == 1
|
||||
rev = r.json()["server_rev"]
|
||||
# PUT like Review commitReview: wood cats only, no species_id key.
|
||||
r2 = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": rev,
|
||||
"wood_category_ids": [2],
|
||||
"title": None,
|
||||
"short_description": None,
|
||||
})
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["species_id"] == 1
|
||||
assert r2.json()["wood_category_ids"] == [2]
|
||||
|
||||
|
||||
def test_update_success(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
|
||||
@@ -95,29 +95,12 @@ def test_401_raises_auth_error(db_dir):
|
||||
c.get_categories()
|
||||
|
||||
|
||||
def test_scoped_tags_by_slug_not_name():
|
||||
"""SETTINGS-51: keep fig-/feat- by slug prefix with hyphen; human names stay."""
|
||||
tags = [
|
||||
{"id": 1, "name": "Curl", "slug": "fig-curl"},
|
||||
{"id": 2, "name": "Knot", "slug": "feat-knot"},
|
||||
{"id": 3, "name": "fig-looks-like", "slug": "other"}, # name prefix alone — drop
|
||||
{"id": 4, "name": "Figured", "slug": "figured"}, # no hyphen — drop
|
||||
{"id": 5, "name": "other", "slug": "other-tag"},
|
||||
]
|
||||
kept = taxonomy_mod._scoped_tags(tags)
|
||||
assert {t["id"] for t in kept} == {1, 2}
|
||||
|
||||
|
||||
def test_sync_populates_scoped(db_dir):
|
||||
# Live Woo: human names + fig-/feat- slugs. Name-prefix filter would keep 0.
|
||||
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||
{"id": 1, "name": "Cookies & Burl Slices", "slug": "cookies-burl", "parent_id": None, "woo_id": 10},
|
||||
]).encode())
|
||||
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
|
||||
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||
{"id": 2, "name": "Curl", "slug": "fig-curl", "woo_id": 20},
|
||||
{"id": 3, "name": "Knot", "slug": "feat-knot", "woo_id": 21},
|
||||
{"id": 4, "name": "fig-fake", "slug": "other-tag", "woo_id": 22}, # name looks scoped; slug not — drop
|
||||
{"id": 5, "name": "other-tag", "slug": "other-tag-2", "woo_id": 23},
|
||||
{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20},
|
||||
{"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21},
|
||||
{"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped
|
||||
]).encode())
|
||||
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode())
|
||||
terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode())
|
||||
@@ -125,11 +108,8 @@ def test_sync_populates_scoped(db_dir):
|
||||
result = taxonomy_mod.sync(c, db_mod.get_connection())
|
||||
conn = db_mod.get_connection()
|
||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1]
|
||||
assert conn.execute("SELECT name FROM woo_categories WHERE id=1").fetchone()[0] == "Cookies & Burl Slices"
|
||||
tag_rows = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT id, name, slug FROM woo_tags")}
|
||||
assert set(tag_rows) == {2, 3} # fig- + feat- by slug; name-only fig-fake dropped
|
||||
assert tag_rows[2] == ("Curl", "fig-curl")
|
||||
assert tag_rows[3] == ("Knot", "feat-knot")
|
||||
tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")}
|
||||
assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped
|
||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6]
|
||||
assert result["taxonomy_anchor"]
|
||||
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
|
||||
|
||||
@@ -27,114 +27,6 @@ Do not put a host name in the product spec.
|
||||
|
||||
## Deploy records
|
||||
|
||||
### 2026-09-26 ~12:01 PT — Measure Zoom/Pan vs Rotate + drop dead ↻ on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`f505be0`** (`fix(capture): Zoom/Pan vs Rotate gesture modes; drop dead ↻ overlay`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-CsENRLHF.js`
|
||||
- UX: Measure stage segmented **Zoom / Pan** (default) vs **Rotate**; pinch/pan exclusive of two-finger twist; removed bottom-center ↻ that overlapped tick B
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~11:47 PT — Publish preview before Woo draft POST on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`73e2818`** (`feat(publish): Publish preview before Woo draft POST`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-Co2F8tsa.js`
|
||||
- UX: Review → Continue to Publish → glanceable preview (thumbs, SKU/title/price, taxonomy/dims, short+long desc) → Publish / Back to Review
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~11:08 PT — Review UI nits (margins, buttons, moisture, live validation) on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`5651a38`** (`fix(review): UI nits — margins, buttons, moisture, live validation`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-C1jF-uuB.js`
|
||||
- Nits: post-publish no Back; removed white Slabs footer control; Review/App padding; unified `.btn-sec`; moisture kd + live input validation
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~10:12 PT — Review View prompt + Call 2 declared attrs / Create-desc gate on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`1ff082f`** (`feat(review): View prompt + Call 2 declared attrs, Create-desc gate`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **fastapi + frontend** rebuilt/recreated
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-BJxrob4c.js`
|
||||
- Call 1 still **410** `call1_disabled`; `GET .../inference/call2/prompt` live (View prompt)
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~09:42 PT — Review metadata: slug tags, decode names, no fig picker / AI labels on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`85786f1`** (`fix(review): slug-scoped tags, decode names, drop fig picker & AI labels`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **fastapi + frontend** rebuilt/recreated
|
||||
- Taxonomy re-sync: `POST /api/v1/admin/taxonomy/sync` → 14 tags (11 `fig-*`, 3 `feat-*`); cats decode `&` → `&`
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-DA24IrWT.js`
|
||||
- Call 1 still **410** `call1_disabled`
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
|
||||
|
||||
### 2026-09-26 ~09:12 PT — PNG size floor warn 300–799 / block <300 on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`1b5ad11`** (`fix(capture): warn 300–799px PNG floor; hard-block only below 300`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend only** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-Cgc1wxKP.js`
|
||||
- Call 1 still **410** `call1_disabled`
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
|
||||
|
||||
### 2026-09-26 ~08:58 PT — fix Create title species carry-forward on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`76f3026`** (`fix(review): carry Measure species into Create title/short-desc`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend only** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; Tailscale `100.64.0.9:10890` → ok; root `/` → 200; asset `index-4mcBthul.js`
|
||||
- Call 1 still **410** `call1_disabled`
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
|
||||
### 2026-09-25 ~22:06 PT — overnight-whole-app SEPARATE stack on :10890/:10891
|
||||
|
||||
- **Intentional ports**: frontend **10890**, fastapi **10891** (1089x range; avoids fighting 10881/10882).
|
||||
- Host: `ubudual5060` / LAN `192.168.1.202` / Tailscale `100.64.0.9`
|
||||
- Checkout: `~/slab-deploy` @ **`6caa4f6`** branch `overnight-whole-app`
|
||||
- Compose: host-local untracked `deploy/override-overnight.yml` as **standalone** file (not merged with base); project **`-p slab-overnight`**
|
||||
- Containers: `slab-overnight-frontend-1` / `slab-overnight-fastapi-1`
|
||||
- Binds: `0.0.0.0:10890→80`, `0.0.0.0:10891→8000` (+ loopback 18090/18091)
|
||||
- Volumes: **dedicated** `slab-overnight_slab_overnight_db` / `_images` (did not reuse `deploy_slab_*`)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` and Tailscale → `{"status":"ok","version":"0.5.0-m3","woo_reachable":false}` (fresh DB; Woo app password not yet in Settings). Root `/` → 200.
|
||||
- Call 1: `POST /api/v1/slabs/{id}/inference/call1` → **410** `call1_disabled`
|
||||
- URLs for Ty: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
- Note: an earlier mistaken recreate put overnight bits onto `deploy-*` :10881/:10882; that stack was then **left running untouched**. Canonical overnight prop is **10890/10891**.
|
||||
- Box Cloudflare quick tunnel stopped; box local :18181/:18182 left running.
|
||||
|
||||
### 2026-09-25 ~22:05 PT — overnight-whole-app on ubudual5060 :10881/:10882
|
||||
|
||||
- Host: `ubudual5060` / LAN `192.168.1.202` / Tailscale `100.64.0.9`
|
||||
- Checkout: `~/slab-deploy` branch `overnight-whole-app` @ **`6caa4f6`** (`fix(uat): allow Cloudflare quick-tunnel Host on vite preview`)
|
||||
- Compose project: `deploy` → containers `deploy-frontend-1` / `deploy-fastapi-1` only
|
||||
- Ports: `0.0.0.0:10881→80`, `0.0.0.0:10882→8000` (+ loopback 18082/18083 from host `.env`); **did not** touch 10880 / `slabuploader-m1` (18080) / greenfield 10884–10885 / m4-review 10883
|
||||
- Build: on UAT host (`docker compose -f deploy/docker-compose.yml --env-file deploy/.env build` then `up -d`, **no `-v`**); volumes `deploy_slab_db` / `deploy_slab_images` preserved
|
||||
- Health: `GET /api/health` → `{"status":"ok","version":"0.5.0-m3","woo_reachable":true}` from 127.0.0.1 and from Tailscale `100.64.0.9`; root `/` → 200
|
||||
- Call 1: `POST /api/v1/slabs/{id}/inference/call1` → **410** `call1_disabled`
|
||||
- URLs for Ty: Tailscale `http://100.64.0.9:10881` · LAN `http://192.168.1.202:10881`
|
||||
- Box Cloudflare quick tunnel (`cloudflared tunnel --url http://127.0.0.1:18181`) stopped; box local :18181/:18182 left running
|
||||
|
||||
### 2026-09-25 ~17:50 PT — overnight-whole-app box UAT (Grok Bot computer)
|
||||
|
||||
- Branch: `overnight-whole-app` (main @ 0c00d25 + cherry-picked #99 tape-line `b44e231`→`323f29b`→`be2ea6c` + Call 1 OUT).
|
||||
- **Frontend**: vite preview `0.0.0.0:18181` (proxy `/api` → FastAPI). Never touch `:10880`.
|
||||
- **Backend**: uvicorn `0.0.0.0:18182` (`backend.app:app`), local SQLite under `.uat-data/` (gitignored).
|
||||
- Env: `WOO_BASE_URL` from `~/.config/slabuploader/woo.env` (password not logged); `SLAB_AES_KEY` from local file (not in repo).
|
||||
- Call 1: `POST .../inference/call1` → **410 call1_disabled**. Call 2 path retained.
|
||||
- Health: `/api/health` ok; `woo_reachable` may be false from this box (network) — draft-only smoke.
|
||||
- Partner Woo UAT containers: **not touched**.
|
||||
|
||||
|
||||
### 2026-09-21 02:41 PDT — #90 wood primary category + derived thickness band
|
||||
|
||||
- Source ref: `0329052` on `issue-89-metadata-lock` (pushed to Gitea; pulled into `~/slab-deploy`).
|
||||
|
||||
@@ -35,7 +35,7 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
|
||||
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-95 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-100 | Crop is 3:4 portrait, slab centered, 90 percent fill at the widest and tallest extremes, on a transparent background. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-105 | After crop the shorter side targets 800 px when the source allows; warn (still encode/include) when 300–799 px; hard-block below 300 px; never upscale. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-105 | After crop the shorter side is at least 800 px when the source allows; otherwise warn and require a new photo, and never upscale. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.batchApply.test.ts |
|
||||
| CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.batchApply.test.ts |
|
||||
| CAPTURE-120 | The dimension overlay is black and oriented to the approved length axis, with the length graphic beside the wood on the right in axis space. | shipped | frontend/src/png/overlay.test.ts |
|
||||
@@ -133,7 +133,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — |
|
||||
| SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — |
|
||||
| SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — |
|
||||
| SETTINGS-51 | Taxonomy sync keeps only product tags whose slug starts with fig- or feat- (hyphen required); all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | backend/tests/test_woo.py |
|
||||
| SETTINGS-51 | Taxonomy sync keeps only the store's figure and feature product-tag families; all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | — |
|
||||
| SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — |
|
||||
| SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — |
|
||||
| SETTINGS-65 | woo_create_status defaults to draft. | M3 | — |
|
||||
|
||||
@@ -123,7 +123,7 @@ On Calculate, the app builds the inventory image from the approved mask:
|
||||
1. Keep slab pixels from the source photo. Removed pixels are transparent.
|
||||
2. Place the slab in a 3:4 portrait frame, centered.
|
||||
3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
|
||||
4. After that crop, shorter side targets ≥ 800px when the source can supply it. If the slab bbox shorter side is 300–799 px, warn but still encode and include the photo. Below 300 px is a hard block (exclude / retake). No upscale from an undersized source.
|
||||
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
||||
5. Encode as PNG with alpha.
|
||||
|
||||
## Device draft
|
||||
@@ -147,6 +147,6 @@ The calibration photo is the measurement reference (`CAPTURE-25`, `CAPTURE-115`)
|
||||
| Alpha | Kept through encode and upload |
|
||||
| Aspect | 3:4 portrait |
|
||||
| Layout | Slab centered, 90% fill at the widest and tallest points |
|
||||
| Size | After crop, shorter side targets ≥ 800px when the source can supply it |
|
||||
| Size | After crop, shorter side ≥ 800px when the source can supply it |
|
||||
|
||||
Shorter side 300–799 px: warn but still encode/include. Below 300 px: hard block (exclude / retake). No upscale from an undersized source.
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
|
||||
@@ -78,7 +78,7 @@ default off (`SETTINGS-90`). Species rates are one flat value per synced leaf (`
|
||||
test-woo returns ok and detail, fails closed on missing credentials or non-HTTPS, and always
|
||||
resyncs taxonomy on success (`SETTINGS-80`, `SETTINGS-40`). test-inference returns ok, mode
|
||||
(stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only
|
||||
(`SETTINGS-45`). Taxonomy sync keeps only tags whose **slug** starts with `fig-` or `feat-` (`SETTINGS-51`), rebuilds
|
||||
(`SETTINGS-45`). Taxonomy sync keeps only fig-* and feat-* tag families (`SETTINGS-51`), rebuilds
|
||||
in one transaction, keeps the previous cache on failure (`SETTINGS-50`), and bumps the anchor only
|
||||
on a real stored-subset change (`SETTINGS-55`). The client re-pulls when the anchor advances, and
|
||||
publish refreshes when the last sync is over an hour old (`SETTINGS-60`).
|
||||
|
||||
@@ -48,7 +48,7 @@ For each non-calibration photo:
|
||||
|
||||
- Reuse the approved Mask sheet (Auto / Green / Black) and sensitivity from the calibration photo. Do not re-open Mask controls for that photo in this milestone.
|
||||
- Run background removal with those params; compose inventory PNG with the same crop rules as Milestone 1 Calculate.
|
||||
- If a photo yields an empty mask or fails the PNG size block floor (<300 px shorter side), keep it out of the ready set and show a clear per-photo error; other photos still proceed. A warn-band size (300–799 px) still includes the photo with a clear per-photo warn.
|
||||
- If a photo yields an empty mask or fails the PNG size floor, keep it out of the ready set and show a clear per-photo error; other photos still proceed.
|
||||
|
||||
Scale, widths, sqft, and bdft stay tied to the calibration photo and user `L` / thickness. Non-calibration photos are visual inventory only.
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLIS
|
||||
## 8. Taxonomy
|
||||
|
||||
- `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus
|
||||
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only tags whose slug starts with `fig-` or `feat-` are kept.
|
||||
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only fig-* and feat-* tag families are kept.
|
||||
These lists are the only legal picker options.
|
||||
- `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets
|
||||
`taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and
|
||||
|
||||
@@ -109,7 +109,7 @@ Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55.
|
||||
- `woo_categories` — species and wood-category leaves only; parents group and are never assigned.
|
||||
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
|
||||
Grade, Thickness, Moisture, with their terms.
|
||||
- `woo_tags` — only tags whose slug starts with `fig-` or `feat-`; other store tags are ignored.
|
||||
- `woo_tags` — only the fig-* and feat-* families; other store tags are ignored.
|
||||
|
||||
Rebuild is one transaction; on failure the previous cache is kept.
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Prompts contract
|
||||
|
||||
> **overnight-whole-app / Call 1 OUT:** Call 1 (vision classification) is hard-disabled.
|
||||
> `POST /api/v1/slabs/{id}/inference/call1` returns **410 call1_disabled**. UI triggers
|
||||
> (Review "Suggest with AI", GenerateText classify) are removed. Call 2
|
||||
> (`POST .../inference/call2`, Review "Generate description") stays invokable.
|
||||
> Call 1 seed text and prompt-revision APIs remain for documentation / future re-enable.
|
||||
|
||||
Status: SPEC · 2026-09-18.
|
||||
Owns prompt storage, prompt inputs, and the two inference calls.
|
||||
|
||||
@@ -96,14 +90,9 @@ No provider thread id is used, and Call 1's raw prompt and result are not sent (
|
||||
fact block (`METADATA-65`).
|
||||
- The dimension-overlay image is never sent (`METADATA-95`).
|
||||
|
||||
**Output.** Call 2 writes **both** `short_description` (plain text, ~50 words) and
|
||||
`description` (HTML). The seed carries short/long writing structure (benefit→diff→trust short;
|
||||
Hook→Benefits→Specs→Trust long) and requires a JSON object
|
||||
`{"short_description":"...","description":"..."}`. Brand voice and geo stay Settings-injected
|
||||
at assemble time — not hardcoded in the seed. The server still assembles `name`
|
||||
deterministically (`METADATA-55`, `METADATA-80`); short may also be owner-edited or rebuilt via
|
||||
Create short desc. The primary wood category selects the specialized prompt (`METADATA-85`).
|
||||
The owner can edit the result (`REVIEW-40`).
|
||||
**Output.** The long `description` prose. The server assembles `name` and `short_description`
|
||||
(`METADATA-55`, `METADATA-80`). The primary wood category selects the specialized prompt
|
||||
(`METADATA-85`). The owner can edit the result (`REVIEW-40`).
|
||||
|
||||
## 5. Fallback and templates
|
||||
|
||||
@@ -166,56 +155,10 @@ Return one JSON object with exactly these keys:
|
||||
|
||||
Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos.
|
||||
|
||||
**`call2` seed.** Shipped text in `backend/inference.py` (`CALL2_SEED`), mirrored here for review.
|
||||
|
||||
Write listing copy for this slab for woodworkers choosing stock for a project. Audience:
|
||||
makers matching wood to a tabletop, bar, bench, or similar build — practical,
|
||||
material/figure/spec-forward. No lifestyle or marketing hype (avoid empty phrases like
|
||||
elevate, stunning, dramatic visual contrast).
|
||||
You write BOTH the short description and the long description.
|
||||
|
||||
PLAIN TEXT ONLY (short AND long):
|
||||
- Output plain text only. No HTML tags (`<p>`, `<ul>`, `<li>`, `<strong>`, `<br>`, etc.).
|
||||
No markdown bold/headers that look like markup (`**bold**`, `#` headers).
|
||||
- Structure the long description with newlines and `- ` bullets when helpful.
|
||||
|
||||
Rules that always apply:
|
||||
- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.
|
||||
- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade,
|
||||
moisture, or features.
|
||||
- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay
|
||||
neutral. Never invent return, warranty, or shipping policies.
|
||||
- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.
|
||||
- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece
|
||||
suits (tabletop, bar, etc.) without ad-copy fluff.
|
||||
- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED
|
||||
ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description
|
||||
(no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no "Full specs"
|
||||
section, no "Maple Slab" attribute-header block). You may weave 1–2 key traits into
|
||||
prose or benefit bullets naturally.
|
||||
|
||||
SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):
|
||||
1. Lead with a practical outcome for a maker (what they can build / how the stock helps),
|
||||
not lifestyle hype and not a bare species list.
|
||||
2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).
|
||||
3. Close with a trust signal or concrete reason to choose this piece.
|
||||
|
||||
LONG DESCRIPTION (plain text only — newlines and `- ` bullets; no HTML, no markdown markup).
|
||||
Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute list /
|
||||
product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK:
|
||||
1. Hook — one sentence on what the piece suits and its standout usable trait.
|
||||
2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail.
|
||||
Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.
|
||||
3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present;
|
||||
otherwise a short neutral close with no invented policies.
|
||||
|
||||
Return ONE JSON object only (no prose outside JSON) with exactly these keys:
|
||||
`{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}`
|
||||
|
||||
Runtime appends DECLARED ATTRIBUTES (title + taxonomy; not a prior short), DIMENSION FACT
|
||||
BLOCK, and `Brand voice:` / `Geo:` from Settings (`SETTINGS-30`). The POST handler parses the
|
||||
JSON, strips any residual HTML to plain text, drops a labeled Category:/Length:/Board Feet:
|
||||
attribute dump if the model still emits one, and persists both fields.
|
||||
**`call2` seed.** Write the long product description for the slab from the supplied photos and
|
||||
listing facts. The name and short description are already built, so do not repeat them. Use the
|
||||
brand voice and geo context given. The dimensions in the fact block are exact: use those strings
|
||||
and invent no numbers. Write prose a premium lumber buyer would read.
|
||||
|
||||
## 7. Prompt management
|
||||
|
||||
|
||||
@@ -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",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@neodrag/svelte": "2.3.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
@@ -495,6 +496,15 @@
|
||||
"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": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neodrag/svelte": "2.3.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
|
||||
+31
-26
@@ -9,8 +9,11 @@
|
||||
import BatchPhotos from './capture/BatchPhotos.svelte';
|
||||
import PickCalibration from './capture/PickCalibration.svelte';
|
||||
import MaskPanel from './capture/MaskPanel.svelte';
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
import Publish from './create/publish/Publish.svelte';
|
||||
import {
|
||||
@@ -26,6 +29,7 @@
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
let showSettings = false;
|
||||
let showSlabs = false;
|
||||
|
||||
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
||||
$: curFlow = $flow;
|
||||
@@ -44,6 +48,11 @@
|
||||
// ?noload to trigger the Resume prompt deterministically.
|
||||
const boot = new URLSearchParams(window.location.search);
|
||||
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(() => {
|
||||
unsubCap();
|
||||
@@ -69,20 +78,17 @@
|
||||
const capture: CalibratedCapture = {
|
||||
lengthIn: eighthsToIn(m.lengthEighths),
|
||||
thicknessIn: eighthsToIn(m.thicknessEighths),
|
||||
speciesId: m.speciesId,
|
||||
sqft: r.sqft,
|
||||
bdft: r.bdft,
|
||||
widthMinIn: eighthsToIn(r.widths.min),
|
||||
widthMaxIn: eighthsToIn(r.widths.max),
|
||||
widthAvgIn: eighthsToIn(r.widths.avg),
|
||||
sourceBelowBlock: r.sourceBelowBlock,
|
||||
sourceBelowWarn: r.sourceBelowWarn,
|
||||
sourceBelowFloor: r.sourceBelowFloor,
|
||||
lineup: buildPhotosReadyLineup({
|
||||
inventoryPng: r.inventoryPng,
|
||||
overlayPng: r.overlayPng,
|
||||
batchApply: m.batchApply,
|
||||
sourceBelowBlock: r.sourceBelowBlock,
|
||||
sourceBelowWarn: r.sourceBelowWarn,
|
||||
sourceBelowFloor: r.sourceBelowFloor,
|
||||
}),
|
||||
};
|
||||
saving = true;
|
||||
@@ -100,7 +106,6 @@
|
||||
width_min_in: capture.widthMinIn,
|
||||
width_max_in: capture.widthMaxIn,
|
||||
width_avg_in: capture.widthAvgIn,
|
||||
species_id: capture.speciesId,
|
||||
price: null,
|
||||
price_source: 'recommendation',
|
||||
content_source: 'manual',
|
||||
@@ -214,14 +219,18 @@
|
||||
retakes={session.retakes}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else if cap.k === 'measure'}
|
||||
{:else if cap.k === 'axis'}
|
||||
<AxisPanel
|
||||
state={cap}
|
||||
setTapeLine={session.setTapeLine}
|
||||
approveAxis={session.approveAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else}
|
||||
<MeasurePanel
|
||||
state={cap}
|
||||
setLength={session.setLength}
|
||||
setThickness={session.setThickness}
|
||||
setSpecies={session.setSpecies}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
setTape={session.setTape}
|
||||
calculate={session.calculate}
|
||||
back={session.back}
|
||||
{pngUrl}
|
||||
@@ -236,15 +245,17 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Settings only (dev Slabs list removed — unused white control). Hidden after publish. -->
|
||||
{#if curFlow.phase !== 'done'}
|
||||
<div class="app-tools">
|
||||
<button type="button" class="tool" on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
|
||||
<!-- Separate pages, never in the flow (UX.md §1). -->
|
||||
<div style="margin-top:1rem;display:flex;gap:8px">
|
||||
<button on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
|
||||
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showSettings && curFlow.phase !== 'done'}
|
||||
{#if showSettings}
|
||||
<Settings />
|
||||
{/if}
|
||||
{#if showSlabs}
|
||||
<SlabList />
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -254,24 +265,18 @@
|
||||
background: #121212;
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; }
|
||||
.app { padding: 12px 16px 24px; }
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
.panel { padding: 4px 0 16px; color: #eee; }
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
.done .ok { color: #7fd37f; font-weight: 600; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0;
|
||||
flex: 1; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.busy { color: #aaa; }
|
||||
.save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; }
|
||||
.app-tools { margin-top: 1rem; display: flex; gap: 8px; }
|
||||
.tool {
|
||||
box-sizing: border-box; padding: 8px 12px; border-radius: 3px; border: 1px solid #444;
|
||||
background: #1d1d1d; color: #ccc; font-size: .82rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.tool:hover { background: #242424; }
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed]
|
||||
// review.py POST /api/v1/slabs/{id}/review
|
||||
// publish.py POST /api/v1/slabs/{id}/publish
|
||||
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
|
||||
// inference.py POST /api/v1/slabs/{id}/inference/call1|call2
|
||||
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
||||
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||
@@ -45,9 +45,6 @@ export interface Slab {
|
||||
inference_status: string | null;
|
||||
calibration_photo_id: string | null;
|
||||
woo_product_id: number | null;
|
||||
/** Create-time optimistic token; updates compare against server_rev, not this. */
|
||||
client_rev: number;
|
||||
/** Optimistic concurrency token; send as client_rev on PUT. */
|
||||
server_rev: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -138,7 +135,7 @@ export function getSlab(id: string): Promise<Slab> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
||||
}
|
||||
|
||||
function putSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
|
||||
export function updateSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -146,37 +143,6 @@ function putSlab(id: string, patch: SlabInput & { client_rev: number }): Promise
|
||||
}).then((r) => parse<Slab>(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT a slab patch. `client_rev` must equal the slab's current `server_rev`.
|
||||
* On 409 revision_conflict (stale rev — e.g. review POST bumped server_rev, or
|
||||
* a concurrent save), refetch once, adopt the latest server_rev, and retry.
|
||||
* A second 409 surfaces a clear reload message.
|
||||
*/
|
||||
export async function updateSlab(
|
||||
id: string,
|
||||
patch: SlabInput & { client_rev: number },
|
||||
): Promise<Slab> {
|
||||
try {
|
||||
return await putSlab(id, patch);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError) || e.status !== 409) throw e;
|
||||
const latest = await getSlab(id);
|
||||
try {
|
||||
return await putSlab(id, { ...patch, client_rev: latest.server_rev });
|
||||
} catch (e2) {
|
||||
if (e2 instanceof ApiError && e2.status === 409) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
e2.code ?? 'revision_conflict',
|
||||
'This slab changed elsewhere. Reload the page and try again.',
|
||||
e2.detail,
|
||||
);
|
||||
}
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSlab(id: string): Promise<void> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) =>
|
||||
parse<void>(r),
|
||||
@@ -364,11 +330,8 @@ export interface Call1Result {
|
||||
export interface Call2Result {
|
||||
ok: boolean;
|
||||
description: string;
|
||||
/** Present when Call 2 returned a parsed short_description. */
|
||||
short_description?: string;
|
||||
}
|
||||
|
||||
/** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */
|
||||
export function inferTaxon(slabId: string): Promise<Call1Result> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
|
||||
method: 'POST',
|
||||
@@ -381,26 +344,6 @@ export function inferContent(slabId: string): Promise<Call2Result> {
|
||||
}).then((r) => parse<Call2Result>(r));
|
||||
}
|
||||
|
||||
/** View prompt: assembled Call 2 text + photo slots (no base64, no model call). */
|
||||
export interface Call2PromptPhoto {
|
||||
seq: number;
|
||||
id: string;
|
||||
bytes: number;
|
||||
long_edge_px: number | null;
|
||||
}
|
||||
|
||||
export interface Call2Prompt {
|
||||
text: string;
|
||||
photos: Call2PromptPhoto[];
|
||||
prompt_revision_id: number | null;
|
||||
}
|
||||
|
||||
export function getCall2Prompt(slabId: string): Promise<Call2Prompt> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2/prompt`).then((r) =>
|
||||
parse<Call2Prompt>(r),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Taxonomy (METADATA-5/15/30; only legal picker options)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -511,6 +454,8 @@ export interface SettingsView {
|
||||
woo_create_status: string;
|
||||
taxonomy_last_sync_at: string | null;
|
||||
taxonomy_anchor: string | null;
|
||||
/** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */
|
||||
crop_fill: number;
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<SettingsView> {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, test } from 'node:test';
|
||||
import { ApiError, updateSlab, type Slab } from './slabs';
|
||||
|
||||
const SLAB_ID = 'slab-rev-1';
|
||||
|
||||
function baseSlab(server_rev: number): Slab {
|
||||
return {
|
||||
id: SLAB_ID,
|
||||
sku: 'SLAB-01',
|
||||
status: 'calibrated',
|
||||
price: 100,
|
||||
price_source: 'recommendation',
|
||||
length_in: 48,
|
||||
thickness_in: 2,
|
||||
sqft: 2.4,
|
||||
bdft: 4.8,
|
||||
width_min_in: 6,
|
||||
width_max_in: 9,
|
||||
width_avg_in: 8,
|
||||
species_id: 1,
|
||||
wood_category_ids: [2],
|
||||
edge_type_term_id: 3,
|
||||
figure_term_ids: [],
|
||||
grade_term_id: 4,
|
||||
thickness_term_id: 5,
|
||||
moisture_term_id: 6,
|
||||
fig_tag_ids: [],
|
||||
feat_tag_ids: [],
|
||||
title: 'Maple Slab',
|
||||
short_description: 'short',
|
||||
description: null,
|
||||
content_source: 'manual',
|
||||
species_confidence: null,
|
||||
figure_confidence: null,
|
||||
grade_confidence: null,
|
||||
inference_status: null,
|
||||
calibration_photo_id: null,
|
||||
woo_product_id: null,
|
||||
client_rev: 1,
|
||||
server_rev,
|
||||
created_at: 't',
|
||||
updated_at: 't',
|
||||
};
|
||||
}
|
||||
|
||||
type Call = { method: string; url: string; body: unknown };
|
||||
const calls: Call[] = [];
|
||||
let handler: (method: string, url: string, body: unknown) => { status: number; body: unknown };
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
function mockFetch() {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
calls.push({ method, url, body });
|
||||
const { status, body: resp } = handler(method, url, body);
|
||||
return new Response(JSON.stringify(resp), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
test('updateSlab succeeds on first PUT when client_rev matches', async () => {
|
||||
mockFetch();
|
||||
handler = (method, _url, body) => {
|
||||
assert.equal(method, 'PUT');
|
||||
const patch = body as { client_rev: number };
|
||||
assert.equal(patch.client_rev, 3);
|
||||
return { status: 200, body: baseSlab(4) };
|
||||
};
|
||||
const out = await updateSlab(SLAB_ID, { client_rev: 3, title: 'Maple Slab' });
|
||||
assert.equal(out.server_rev, 4);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('updateSlab on 409 refetches, adopts server_rev, retries once', async () => {
|
||||
mockFetch();
|
||||
let puts = 0;
|
||||
handler = (method, url, body) => {
|
||||
if (method === 'PUT') {
|
||||
puts += 1;
|
||||
const patch = body as { client_rev: number; title?: string };
|
||||
if (puts === 1) {
|
||||
assert.equal(patch.client_rev, 9); // stale (e.g. review POST bumped)
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: {
|
||||
code: 'revision_conflict',
|
||||
message: 'client_rev mismatch',
|
||||
detail: { current_server_rev: 10 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
assert.equal(patch.client_rev, 10); // adopted from GET
|
||||
assert.equal(patch.title, 'Maple Slab');
|
||||
return { status: 200, body: baseSlab(11) };
|
||||
}
|
||||
if (method === 'GET' && url.includes(SLAB_ID)) {
|
||||
return { status: 200, body: baseSlab(10) };
|
||||
}
|
||||
throw new Error(`unexpected ${method} ${url}`);
|
||||
};
|
||||
const out = await updateSlab(SLAB_ID, { client_rev: 9, title: 'Maple Slab' });
|
||||
assert.equal(out.server_rev, 11);
|
||||
assert.equal(puts, 2);
|
||||
assert.equal(calls.map((c) => c.method).join(','), 'PUT,GET,PUT');
|
||||
});
|
||||
|
||||
test('updateSlab second 409 surfaces a reload message', async () => {
|
||||
mockFetch();
|
||||
handler = (method) => {
|
||||
if (method === 'PUT') {
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: {
|
||||
code: 'revision_conflict',
|
||||
message: 'client_rev mismatch',
|
||||
detail: { current_server_rev: 99 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { status: 200, body: baseSlab(99) };
|
||||
};
|
||||
await assert.rejects(
|
||||
() => updateSlab(SLAB_ID, { client_rev: 1, sku: 'X' }),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof ApiError);
|
||||
assert.equal(e.status, 409);
|
||||
assert.match(e.message, /Reload the page/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,156 +1,251 @@
|
||||
<!-- DEPRECATED: tape orientation merged into MeasurePanel (overnight-whole-app). Kept for reference. -->
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Capture, TapeLine } from './capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { photoToScreen, screenToPhoto, type PhotoPoint, type PhotoViewTransform } from '../png/crop';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let setTape: (tape: TapeTransform) => void;
|
||||
export let setTapeLine: (tapeLine: TapeLine) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
let cropFill = getCropFill();
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: tape = state.axis.tape;
|
||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let sourceW = state.source.widthPx;
|
||||
let sourceH = state.source.heightPx;
|
||||
let stageW = 0;
|
||||
let stageH = 0;
|
||||
let tickA: PhotoPoint = [...state.axis.tapeLine.start];
|
||||
let tickB: PhotoPoint = [...state.axis.tapeLine.end];
|
||||
let angleDeg = 0;
|
||||
let zoom = 1;
|
||||
let panX = 0;
|
||||
let panY = 0;
|
||||
let dragging: 'start' | 'end' | 'pan' | null = null;
|
||||
let selected: 'start' | 'end' = 'start';
|
||||
let lastPointer: PhotoPoint = [0, 0];
|
||||
|
||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||
$: tickDistance = Math.hypot(tickB[0] - tickA[0], tickB[1] - tickA[1]);
|
||||
|
||||
const nudgeTilt = (delta: number) => {
|
||||
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const setTilt = (deg: number) => {
|
||||
const clamped = clampTiltDeg(deg);
|
||||
setTape({ ...tape, tiltDeg: clamped });
|
||||
// Keep legacy path warm for any callers still using setAxisAngle.
|
||||
setAxisAngle(clamped);
|
||||
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;
|
||||
};
|
||||
|
||||
const nudgeZoom = (dir: number) => {
|
||||
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
|
||||
setTape({ ...tape, zoom: z });
|
||||
};
|
||||
|
||||
const onFill = (pct: number) => {
|
||||
const fill = Math.min(1, Math.max(0.01, pct / 100));
|
||||
cropFill = setCropFill(fill);
|
||||
const ticks = defaultTicks(cropFill);
|
||||
setTape({ ...tape, ...ticks });
|
||||
};
|
||||
|
||||
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<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>
|
||||
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
blob={state.source.blob}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
{tape}
|
||||
{cropFill}
|
||||
onTapeChange={onTapeChange}
|
||||
/>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="-90" max="90" step="0.1"
|
||||
value={clampTiltDeg(tiltShown)}
|
||||
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||
<span class="val">{tiltShown}°</span>
|
||||
<h1>Tape-line calibration</h1>
|
||||
<p class="hint">Place both rifle-scope ticks on the actual start and end marks of your tape. Drag the photo to pan, use the zoom slider or wheel to zoom, and rotate until the tape follows the slab's length.</p>
|
||||
<div class="stage" bind:this={stage}>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
aria-label="Calibration photo with draggable tape endpoint ticks"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel|nonpassive={onWheel}
|
||||
></canvas>
|
||||
</div>
|
||||
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist (Rotate gesture mode)</p>
|
||||
<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>
|
||||
<p class="ctl-label">Zoom the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="50" max="300" step="1"
|
||||
value={Math.round(tape.zoom * 100)}
|
||||
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
|
||||
<span class="val">{Math.round(tape.zoom * 100)}%</span>
|
||||
<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>
|
||||
<div class="row tight">
|
||||
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input
|
||||
type="range" min="50" max="100" step="1"
|
||||
value={Math.round(cropFill * 100)}
|
||||
aria-label="Crop fill percent"
|
||||
on:input={(e) => onFill(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="val">{Math.round(cropFill * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">
|
||||
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
|
||||
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
|
||||
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
|
||||
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tick-distance">Tick distance in source photo: {tickDistance.toFixed(1)} px</p>
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
|
||||
<button type="button" class="primary" disabled={tickDistance < 4} on:click={approveAxis}>Use tape line</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; line-height: 1.45; }
|
||||
.stage { width: 100%; height: min(56vh, 560px); min-height: 280px; overflow: hidden; background: #161616; border: 1px solid #333; }
|
||||
canvas { display: block; touch-action: none; cursor: grab; }
|
||||
.control { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.control label { min-width: 88px; color: #bbb; font-size: 0.9rem; }
|
||||
input[type='range'] { flex: 1; min-width: 0; accent-color: #00e5ff; }
|
||||
.value { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; color: #00e5ff; }
|
||||
.minor { min-height: 40px; border: 1px solid #444; background: #1d1d1d; color: #ccc; padding: 8px 10px; }
|
||||
.tick-distance { margin: 10px 0 0; color: #aaa; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.row.tight { margin-top: 8px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.tab { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.dim { color: #777; }
|
||||
.primary { flex: 1; min-height: 48px; padding: 12px; font-size: 1rem; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 0.7; min-height: 48px; padding: 12px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
|
||||
import { calibrationScale } from '../png/crop';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { createMaskedPreviewBlob } from '../png/maskedPreview';
|
||||
import { getTaxonomy } from '../api/slabs';
|
||||
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
photosReadyPngAlt,
|
||||
@@ -20,9 +14,6 @@
|
||||
export let state: Extract<Capture, { k: 'measure' }>;
|
||||
export let setLength: (e: number) => void;
|
||||
export let setThickness: (e: number) => void;
|
||||
export let setSpecies: (id: number | null) => void;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let setTape: (tape: TapeTransform) => void;
|
||||
export let calculate: () => Promise<void>;
|
||||
export let back: () => void;
|
||||
export let pngUrl: string | null;
|
||||
@@ -32,51 +23,6 @@
|
||||
let calcBusy = false;
|
||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||
let speciesSel = state.speciesId == null ? '' : String(state.speciesId);
|
||||
let speciesOpts: { id: number; name: string }[] = [];
|
||||
let speciesLoadErr = '';
|
||||
|
||||
let cropFill = getCropFill();
|
||||
/** Exclusive stage gestures: Zoom/Pan (default) vs Rotate. Sliders stay available either way. */
|
||||
let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: tape = state.axis.tape;
|
||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||
|
||||
let maskedPreview: Blob | null = null;
|
||||
let previewError = '';
|
||||
let previewGeneration = 0;
|
||||
let previewSource: Blob | null = null;
|
||||
let previewMask: Uint8Array | null = null;
|
||||
let previewW = 0;
|
||||
let previewH = 0;
|
||||
|
||||
// Build the tape photo once from the oriented source plus the approved mask.
|
||||
// Never fall back to the raw source here: tape alignment must show the same
|
||||
// background-removed slab footprint that was approved in Mask.
|
||||
$: {
|
||||
const sourceBlob = state.source.blob;
|
||||
const bitset = state.approvedMask.bitset;
|
||||
const width = state.source.widthPx;
|
||||
const height = state.source.heightPx;
|
||||
if (sourceBlob !== previewSource || bitset !== previewMask || width !== previewW || height !== previewH) {
|
||||
previewSource = sourceBlob;
|
||||
previewMask = bitset;
|
||||
previewW = width;
|
||||
previewH = height;
|
||||
maskedPreview = null;
|
||||
previewError = '';
|
||||
const generation = ++previewGeneration;
|
||||
void createMaskedPreviewBlob(sourceBlob, bitset)
|
||||
.then((blob) => {
|
||||
if (generation === previewGeneration) maskedPreview = blob;
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation === previewGeneration) previewError = 'Could not build the background-removed preview.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let lineupUrls: string[] = [];
|
||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||
@@ -90,8 +36,7 @@
|
||||
inventoryPng: state.results.inventoryPng,
|
||||
overlayPng: state.results.overlayPng,
|
||||
batchApply: state.batchApply,
|
||||
sourceBelowBlock: state.results.sourceBelowBlock,
|
||||
sourceBelowWarn: state.results.sourceBelowWarn,
|
||||
sourceBelowFloor: state.results.sourceBelowFloor,
|
||||
});
|
||||
lineupUrls = lineupItems.map((it) =>
|
||||
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
|
||||
@@ -101,68 +46,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local select only when session speciesId changes (draft restore / setSpecies).
|
||||
// Do NOT depend on speciesSel — that fights bind:value and can clear a choice
|
||||
// before setSpecies lands (or wipe restored ids while options are still loading).
|
||||
let syncedSpeciesId: number | null | undefined = undefined;
|
||||
$: if (syncedSpeciesId !== state.speciesId) {
|
||||
syncedSpeciesId = state.speciesId;
|
||||
speciesSel = state.speciesId == null ? '' : String(state.speciesId);
|
||||
}
|
||||
|
||||
let speciesOptsReady = false;
|
||||
onMount(() => {
|
||||
void getTaxonomy()
|
||||
.then((tax) => {
|
||||
speciesOpts = speciesLeavesFromTaxonomy(tax.categories).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
}));
|
||||
speciesOptsReady = true;
|
||||
})
|
||||
.catch(() => {
|
||||
speciesLoadErr = 'Could not load species list.';
|
||||
speciesOpts = [];
|
||||
speciesOptsReady = true;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
previewGeneration += 1;
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
});
|
||||
|
||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||
|
||||
const nudgeTilt = (delta: number) => {
|
||||
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
|
||||
};
|
||||
|
||||
const setTilt = (deg: number) => {
|
||||
const clamped = clampTiltDeg(deg);
|
||||
setTape({ ...tape, tiltDeg: clamped });
|
||||
setAxisAngle(clamped);
|
||||
};
|
||||
|
||||
const nudgeZoom = (dir: number) => {
|
||||
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
|
||||
setTape({ ...tape, zoom: z });
|
||||
};
|
||||
|
||||
const onFill = (pct: number) => {
|
||||
const fill = Math.min(1, Math.max(0.01, pct / 100));
|
||||
cropFill = setCropFill(fill);
|
||||
const ticks = defaultTicks(cropFill);
|
||||
setTape({ ...tape, ...ticks });
|
||||
};
|
||||
|
||||
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
|
||||
|
||||
const positiveInches = (raw: string): number | null => {
|
||||
const inches = Number.parseFloat(raw);
|
||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||
};
|
||||
|
||||
$: tapeScale = calibrationScale(
|
||||
state.axis.tapeLine.start,
|
||||
state.axis.tapeLine.end,
|
||||
positiveInches(String(lengthIn)) ?? 0,
|
||||
);
|
||||
|
||||
const commitLength = () => {
|
||||
const inches = positiveInches(String(lengthIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
@@ -175,21 +73,14 @@
|
||||
setThickness(eighths);
|
||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
const commitSpecies = () => {
|
||||
setSpecies(speciesSel === '' ? null : Number(speciesSel));
|
||||
};
|
||||
|
||||
$: canCalc =
|
||||
positiveInches(String(lengthIn)) !== null
|
||||
&& positiveInches(String(thicknessIn)) !== null
|
||||
&& speciesSel !== '';
|
||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null && tapeScale > 0;
|
||||
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
try {
|
||||
commitLength();
|
||||
commitThickness();
|
||||
commitSpecies();
|
||||
await calculate();
|
||||
} finally { calcBusy = false; }
|
||||
};
|
||||
@@ -199,110 +90,17 @@
|
||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
||||
|
||||
{#if !photosReady}
|
||||
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
|
||||
<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>
|
||||
|
||||
<div class="mode-toggle" role="group" aria-label="Stage gesture mode">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn"
|
||||
class:active={gestureMode === 'zoompan'}
|
||||
aria-pressed={gestureMode === 'zoompan'}
|
||||
data-testid="gesture-mode-zoompan"
|
||||
on:click={() => (gestureMode = 'zoompan')}
|
||||
>Zoom / Pan</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn"
|
||||
class:active={gestureMode === 'rotate'}
|
||||
aria-pressed={gestureMode === 'rotate'}
|
||||
data-testid="gesture-mode-rotate"
|
||||
on:click={() => (gestureMode = 'rotate')}
|
||||
>Rotate</button>
|
||||
</div>
|
||||
<p class="nudge mode-hint">
|
||||
{#if gestureMode === 'zoompan'}
|
||||
Pinch to zoom and drag to pan. Rotation gestures are ignored — switch to Rotate or use the tilt slider.
|
||||
{:else}
|
||||
Two-finger twist to rotate (or use the tilt slider). Pan and pinch-zoom are ignored in this mode.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if maskedPreview}
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
blob={maskedPreview}
|
||||
{tape}
|
||||
{cropFill}
|
||||
{gestureMode}
|
||||
onTapeChange={onTapeChange}
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
||||
/>
|
||||
{:else}
|
||||
<div class="preview-loading" role="status">
|
||||
{previewError || 'Preparing background-removed preview…'}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="-90" max="90" step="0.1"
|
||||
value={clampTiltDeg(tiltShown)}
|
||||
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||
<span class="val">{tiltShown}°</span>
|
||||
</div>
|
||||
<p class="nudge">±90° · slider always works · in Rotate mode: ← → tilt 1° · Shift+← → 0.1° · two-finger twist</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="50" max="300" step="1"
|
||||
value={Math.round(tape.zoom * 100)}
|
||||
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
|
||||
<span class="val">{Math.round(tape.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div class="row tight">
|
||||
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input
|
||||
type="range" min="50" max="100" step="1"
|
||||
value={Math.round(cropFill * 100)}
|
||||
aria-label="Crop fill percent"
|
||||
on:input={(e) => onFill(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="val">{Math.round(cropFill * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">
|
||||
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
|
||||
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
|
||||
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
|
||||
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<span class="label">Tape length L (distance between ticks)</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Length L"
|
||||
@@ -330,32 +128,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field species">
|
||||
<label class="label" for="measure-species">Species</label>
|
||||
{#if !speciesOptsReady}
|
||||
<select id="measure-species" class="sel" data-testid="measure-species" aria-label="Species" disabled>
|
||||
<option value="">Loading species…</option>
|
||||
</select>
|
||||
{:else}
|
||||
<select
|
||||
id="measure-species"
|
||||
class="sel"
|
||||
data-testid="measure-species"
|
||||
aria-label="Species"
|
||||
bind:value={speciesSel}
|
||||
on:change={commitSpecies}
|
||||
>
|
||||
<option value="">Select species…</option>
|
||||
{#each speciesOpts as o (o.id)}
|
||||
<option value={String(o.id)}>{o.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if speciesLoadErr}
|
||||
<p class="species-err" role="status">{speciesLoadErr}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="scale">Tape ticks: {Math.hypot(state.axis.tapeLine.end[0] - state.axis.tapeLine.start[0], state.axis.tapeLine.end[1] - state.axis.tapeLine.start[1]).toFixed(1)} source-photo px · Scale {tapeScale.toFixed(2)} px/in</p>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
@@ -375,16 +148,9 @@
|
||||
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
|
||||
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
|
||||
<div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div>
|
||||
</div>
|
||||
{#if state.results.sourceBelowBlock}
|
||||
<div class="warn" role="alert" data-testid="size-floor-block">
|
||||
Source shorter side is below 300px — inventory PNG blocked. Retake a larger photo.
|
||||
</div>
|
||||
{:else if state.results.sourceBelowWarn}
|
||||
<div class="warn" data-testid="size-floor-warn">
|
||||
Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality.
|
||||
</div>
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{/if}
|
||||
|
||||
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||
@@ -401,9 +167,6 @@
|
||||
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.warn}
|
||||
<span class="item-warn" data-testid="lineup-warn">{item.warn}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}
|
||||
<li class="lineup-item error" role="alert">
|
||||
@@ -415,7 +178,7 @@
|
||||
</ol>
|
||||
|
||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
@@ -435,59 +198,28 @@
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||
.mode-toggle {
|
||||
display: flex; gap: 0; margin: 10px 0 4px;
|
||||
border: 1px solid #444; border-radius: 10px; overflow: hidden; width: fit-content; max-width: 100%;
|
||||
}
|
||||
.mode-btn {
|
||||
min-height: 44px; padding: 10px 18px; font-size: 0.95rem; font-weight: 600;
|
||||
border: 0; background: #1d1d1d; color: #aaa; cursor: pointer;
|
||||
}
|
||||
.mode-btn + .mode-btn { border-left: 1px solid #444; }
|
||||
.mode-btn.active { background: #00e5ff; color: #002; }
|
||||
.mode-hint { margin: 0 0 10px; }
|
||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||
.field { flex: 1; }
|
||||
.field.species { margin-top: 16px; flex: none; }
|
||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
||||
.field input, .sel {
|
||||
.scale { margin: 8px 0 0; color: #aaa; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
||||
.field input {
|
||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
||||
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.species-err { color: #ffb4a2; font-size: 0.85rem; margin: 8px 0 0; }
|
||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.row.tight { margin-top: 8px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.tab { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.dim { color: #777; }
|
||||
.results { margin-top: 8px; }
|
||||
.results .nums { font-size: 1rem; }
|
||||
.results .nums div { font-size: 1rem; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.item-warn { display: block; margin-top: 6px; color: #c07020; font-size: 0.8rem; font-weight: 600; }
|
||||
.lineup {
|
||||
list-style: none; padding: 0; margin: 16px 0 0;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { STAGE_H, STAGE_W, clampTickPair, clampTiltDeg, defaultTicks, lineX } from './tapeGeometry';
|
||||
import { fitDisplay } from './fitDisplay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
@@ -18,49 +14,15 @@
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
/** #99 tape-line mode: fixed middle line + movable photo. */
|
||||
export let mode: 'bbox' | 'tape' = 'bbox';
|
||||
export let tape: TapeTransform | null = null;
|
||||
export let cropFill: number = getCropFill();
|
||||
export let onTapeChange: ((t: TapeTransform) => void) | null = null;
|
||||
/** When false, tape overlay is display-only (Measure step). */
|
||||
export let interactive = true;
|
||||
/**
|
||||
* Exclusive stage gestures so pinch/drag don't fight.
|
||||
* zoompan (default): pinch zoom + one-finger pan; ignore rotate gestures.
|
||||
* rotate: two-finger twist only; ignore pan/zoom from those gestures.
|
||||
* Tilt/zoom sliders outside the stage stay available either way.
|
||||
*/
|
||||
export let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let tapeWrap: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0;
|
||||
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
|
||||
let parentW = 0;
|
||||
let decodeGen = 0;
|
||||
let decoding = false;
|
||||
let focusTick: 'A' | 'B' = 'A';
|
||||
|
||||
// Local editable copy while dragging; flushed via onTapeChange.
|
||||
let live: TapeTransform = tape ?? {
|
||||
tiltDeg: 0, zoom: 1, panX: 0, panY: 0, ...defaultTicks(cropFill),
|
||||
};
|
||||
$: if (tape) live = { ...tape };
|
||||
|
||||
const emit = (next: TapeTransform) => {
|
||||
live = next;
|
||||
onTapeChange?.(next);
|
||||
};
|
||||
|
||||
const applyFit = () => {
|
||||
if (!canvas || !imgW) return;
|
||||
if (mode === 'tape') {
|
||||
// Photo is sized to the logical stage; CSS transform handles pan/zoom/tilt.
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
return;
|
||||
}
|
||||
if (parentW <= 0 && stage) parentW = stage.clientWidth;
|
||||
const fit = fitDisplay(parentW, imgW, imgH, maxH);
|
||||
if (!fit) return;
|
||||
@@ -69,7 +31,7 @@
|
||||
};
|
||||
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas || mode === 'tape') return;
|
||||
if (!canvas) return;
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
@@ -100,6 +62,7 @@
|
||||
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
// The axis reads corners[3] → corners[0], the long side.
|
||||
const a = c[3];
|
||||
const b = c[0];
|
||||
ctx.strokeStyle = '#ff3d71';
|
||||
@@ -143,185 +106,19 @@
|
||||
}
|
||||
};
|
||||
|
||||
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
|
||||
|
||||
// ---- tape pointer interactions ----
|
||||
type DragKind = null | 'pan' | 'tickA' | 'tickB';
|
||||
let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null;
|
||||
let pointers = new Map<number, { x: number; y: number }>();
|
||||
let pinchStartDist = 0;
|
||||
let pinchStartZoom = 1;
|
||||
let pinchStartAngle = 0;
|
||||
let pinchStartTilt = 0;
|
||||
|
||||
const PAN_DAMP = 0.4; // Keep pointer pan movement deliberate without changing keyboard nudges.
|
||||
const ROTATE_DAMP = 0.4; // Soften two-finger and rotation-handle tilt changes.
|
||||
const PINCH_ZOOM_DAMP = 0.4; // Blend pinch distance changes toward 1 to avoid zoom jumps.
|
||||
|
||||
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
|
||||
const el = tapeWrap;
|
||||
if (!el) return { x: 0, y: 0 };
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - r.left) / Math.max(r.width, 1)) * STAGE_W,
|
||||
y: ((clientY - r.top) / Math.max(r.height, 1)) * STAGE_H,
|
||||
};
|
||||
};
|
||||
|
||||
const hitTick = (x: number, y: number): 'A' | 'B' | null => {
|
||||
// ≥44×44 CSS px hit → ~34 stage units on a ~352px-wide stage (wireframe HIT_R).
|
||||
const HIT = 34;
|
||||
const lx = lineX();
|
||||
if (Math.hypot(x - lx, y - live.tickA) <= HIT) return 'A';
|
||||
if (Math.hypot(x - lx, y - live.tickB) <= HIT) return 'B';
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
const pts = [...pointers.values()];
|
||||
const dx = pts[1].x - pts[0].x;
|
||||
const dy = pts[1].y - pts[0].y;
|
||||
pinchStartDist = Math.hypot(dx, dy) || 1;
|
||||
pinchStartZoom = live.zoom;
|
||||
pinchStartAngle = Math.atan2(dy, dx);
|
||||
pinchStartTilt = live.tiltDeg;
|
||||
drag = null;
|
||||
return;
|
||||
}
|
||||
const p = stagePoint(e.clientX, e.clientY);
|
||||
const tick = hitTick(p.x, p.y);
|
||||
if (tick) {
|
||||
focusTick = tick;
|
||||
drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
return;
|
||||
}
|
||||
// One-finger pan only in Zoom/Pan mode (Rotate mode uses slider + two-finger twist).
|
||||
if (gestureMode === 'zoompan') {
|
||||
drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
const pts = [...pointers.values()];
|
||||
const dx = pts[1].x - pts[0].x;
|
||||
const dy = pts[1].y - pts[0].y;
|
||||
if (gestureMode === 'zoompan') {
|
||||
const dist = Math.hypot(dx, dy) || 1;
|
||||
const ratio = dist / pinchStartDist;
|
||||
const softenedRatio = 1 + (ratio - 1) * PINCH_ZOOM_DAMP;
|
||||
const z = Math.min(3, Math.max(0.5, pinchStartZoom * softenedRatio));
|
||||
emit({ ...live, zoom: z });
|
||||
} else {
|
||||
// Screen y-down atan2 delta matches CSS rotate (positive = clockwise).
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const deltaDeg = ((angle - pinchStartAngle) * 180) / Math.PI;
|
||||
emit({ ...live, tiltDeg: clampTiltDeg(pinchStartTilt + deltaDeg * ROTATE_DAMP) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!drag) return;
|
||||
const p = stagePoint(e.clientX, e.clientY);
|
||||
if (drag.kind === 'pan') {
|
||||
if (gestureMode !== 'zoompan') return;
|
||||
emit({
|
||||
...live,
|
||||
panX: drag.panX0 + (p.x - drag.x0) * PAN_DAMP,
|
||||
panY: drag.panY0 + (p.y - drag.y0) * PAN_DAMP,
|
||||
});
|
||||
} else if (drag.kind === 'tickA' || drag.kind === 'tickB') {
|
||||
const pair = drag.kind === 'tickA'
|
||||
? clampTickPair(p.y, live.tickB)
|
||||
: clampTickPair(live.tickA, p.y);
|
||||
emit({ ...live, ...pair });
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) drag = null;
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!interactive || mode !== 'tape' || gestureMode !== 'zoompan') return;
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
||||
emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) });
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
const fine = e.shiftKey;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
if (gestureMode !== 'rotate') return;
|
||||
e.preventDefault();
|
||||
const step = fine ? 0.1 : 1;
|
||||
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
||||
emit({ ...live, tiltDeg: clampTiltDeg(live.tiltDeg + dir * step) });
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const step = fine ? 0.1 : 1;
|
||||
const dir = e.key === 'ArrowUp' ? -1 : 1;
|
||||
if (focusTick === 'A') {
|
||||
emit({ ...live, ...clampTickPair(live.tickA + dir * step, live.tickB) });
|
||||
} else {
|
||||
emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) });
|
||||
}
|
||||
} else if (e.key === '+' || e.key === '=') {
|
||||
if (gestureMode !== 'zoompan') return;
|
||||
e.preventDefault();
|
||||
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
if (gestureMode !== 'zoompan') return;
|
||||
e.preventDefault();
|
||||
emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) });
|
||||
}
|
||||
};
|
||||
|
||||
const fitPhoto = () => emit({ ...live, zoom: 1, panX: 0, panY: 0 });
|
||||
|
||||
let vbScale = 1;
|
||||
const updateVbScale = () => {
|
||||
if (!tapeWrap) return;
|
||||
vbScale = tapeWrap.clientWidth / STAGE_W || 1;
|
||||
};
|
||||
|
||||
$: inset = STAGE_H * cropMarginFraction(cropFill);
|
||||
$: photoTransform = `translate(${live.panX}px, ${live.panY}px) translate(${STAGE_W / 2}px, ${STAGE_H / 2}px) rotate(${live.tiltDeg}deg) scale(${live.zoom}) translate(${-STAGE_W / 2}px, ${-STAGE_H / 2}px)`;
|
||||
// Contain-fit the canvas bitmap into the logical stage.
|
||||
$: photoLayerStyle = imgW > 0 && imgH > 0
|
||||
? (() => {
|
||||
const scale0 = Math.min(STAGE_W / imgW, STAGE_H / imgH);
|
||||
const w = imgW * scale0;
|
||||
const h = imgH * scale0;
|
||||
const left = (STAGE_W - w) / 2;
|
||||
const top = (STAGE_H - h) / 2;
|
||||
return `position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;transform-origin:center center;`;
|
||||
})()
|
||||
: 'position:absolute;inset:0;';
|
||||
$: if (blob || bbox || maskBitset) void tick().then(redraw);
|
||||
|
||||
onMount(() => {
|
||||
if (stage) parentW = stage.clientWidth;
|
||||
updateVbScale();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
const w = e.contentRect.width;
|
||||
if (mode === 'tape') {
|
||||
updateVbScale();
|
||||
}
|
||||
if (w === parentW) return;
|
||||
parentW = w;
|
||||
applyFit();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
if (tapeWrap) ro.observe(tapeWrap);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {
|
||||
@@ -329,85 +126,16 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if mode === 'tape'}
|
||||
<div
|
||||
class="tape-stage"
|
||||
bind:this={stage}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label="Fixed tape line over a movable slab photo"
|
||||
on:keydown={onKeyDown}
|
||||
>
|
||||
<div
|
||||
class="tw"
|
||||
class:dragging={!!drag}
|
||||
class:mode-rotate={gestureMode === 'rotate'}
|
||||
bind:this={tapeWrap}
|
||||
role="group"
|
||||
aria-label="Tape alignment stage"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel={onWheel}
|
||||
on:dblclick={() => interactive && fitPhoto()}
|
||||
>
|
||||
<div class="vb" style="width:{STAGE_W}px;height:{STAGE_H}px;transform:scale({vbScale});">
|
||||
<div class="photo-layer" style="{photoLayerStyle} transform: {photoTransform};">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
<!-- crop end bands (proposal visual; aspect stays 3:4) -->
|
||||
<div class="band top" style="height:{inset}px"></div>
|
||||
<div class="band bot" style="height:{inset}px"></div>
|
||||
<svg class="fixture" viewBox="0 0 {STAGE_W} {STAGE_H}" width={STAGE_W} height={STAGE_H} aria-hidden="true">
|
||||
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round" />
|
||||
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||
{#each [['A', live.tickA], ['B', live.tickB]] as [id, y]}
|
||||
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round" />
|
||||
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||
<circle cx={lineX()} cy={y} r="5" fill={focusTick === id ? '#ff3d71' : '#000'} stroke="#fff" stroke-width="1.5" />
|
||||
<circle class="tick-hit" cx={lineX()} cy={y} r="34" fill="transparent" />
|
||||
{/each}
|
||||
</svg>
|
||||
{#if decoding && imgW === 0}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage" bind:this={stage}>
|
||||
<div class="stage" bind:this={stage}>
|
||||
{#if imgW > 0 || decoding || blob}
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stage { width: 100%; display: flex; justify-content: center; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; width: 100%; height: 100%; }
|
||||
.loading { padding: 40px; color: #999; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.tape-stage { width: 100%; outline: none; }
|
||||
.tw {
|
||||
position: relative; width: 100%; aspect-ratio: 3 / 4;
|
||||
background: #111; border: 1px solid #333; border-radius: 8px; overflow: hidden;
|
||||
touch-action: none; user-select: none; cursor: grab;
|
||||
}
|
||||
.tw.dragging { cursor: grabbing; }
|
||||
.tw.mode-rotate { cursor: ew-resize; }
|
||||
.vb {
|
||||
position: absolute; top: 0; left: 0;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vb > :global(*) { pointer-events: auto; }
|
||||
.photo-layer { will-change: transform; pointer-events: none; }
|
||||
.photo-layer canvas { border-radius: 0; background: transparent; pointer-events: none; }
|
||||
.band { position: absolute; left: 0; right: 0; background: #000; opacity: 0.3; pointer-events: none; z-index: 1; }
|
||||
.band.top { top: 0; }
|
||||
.band.bot { bottom: 0; }
|
||||
.fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
||||
.tick-hit { pointer-events: auto; cursor: ns-resize; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; }
|
||||
.loading { padding: 40px; color: #999; }
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ABox {
|
||||
// Minimum-area bounding rectangle of the mask, source px.
|
||||
export interface BBox {
|
||||
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
|
||||
longSidePx: number; // geometric extent only; scale prefers tape tick span (#99)
|
||||
longSidePx: number; // geometric extent only; scale uses mask span along the axis
|
||||
angleDeg: number; // long-side angle, degrees, [0, 180)
|
||||
}
|
||||
|
||||
@@ -43,26 +43,20 @@ export interface Mask {
|
||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||
bbox: BBox;
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the crop_fill PNG crop (default 90%)
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%)
|
||||
}
|
||||
|
||||
// Tape-line calibration (#99): photo tilt/pan/zoom under a fixed middle line;
|
||||
// ticks declare L. bbox is derived for overlay/width sampling direction.
|
||||
/** The owner-positioned start/end points of the tape in oriented source pixels. */
|
||||
export interface TapeLine {
|
||||
tiltDeg: number;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
/** Tick Y in logical stage units (STAGE_H = 640). */
|
||||
tickA: number;
|
||||
tickB: number;
|
||||
/** Image-space distance between ticks (px); drives scale = spanPx / L. */
|
||||
spanPx: number;
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
// corners[3] → corners[0] (the long side).
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
tape: TapeLine;
|
||||
tapeLine: TapeLine;
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
@@ -82,17 +76,14 @@ export interface Measures {
|
||||
inventoryPng: Blob;
|
||||
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
|
||||
overlayPng?: Blob;
|
||||
/** Shorter side of slab bbox < 300 px — hard fail / exclude clean cal. */
|
||||
sourceBelowBlock: boolean;
|
||||
/** Shorter side in 300–799 px — encode/include with warn. */
|
||||
sourceBelowWarn: boolean;
|
||||
sourceBelowFloor: boolean;
|
||||
}
|
||||
|
||||
// Result of applying the calibration mask to one batch sibling. The
|
||||
// calibration slot is null because its inventory PNG lives in Measures.
|
||||
export type BatchPhotoResult =
|
||||
| null
|
||||
| { status: 'ok'; inventoryPng: Blob; /** Soft size-floor concern; photo still included. */ warn?: string }
|
||||
| { status: 'ok'; inventoryPng: Blob }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||
@@ -118,11 +109,9 @@ export type Capture =
|
||||
k: 'measure';
|
||||
source: Source;
|
||||
approvedMask: Mask;
|
||||
axis: Axis; // editable tape until Calculate; frozen after results
|
||||
axis: Axis; // frozen
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
/** Woo species leaf id; set on Measure. Persisted on draft + createSlab; Review does not edit. */
|
||||
speciesId: number | null;
|
||||
results: Measures | null;
|
||||
/** Capture-order sibling results from the post-Calculate batch pass. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
@@ -156,11 +145,9 @@ export interface Draft {
|
||||
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
|
||||
tapeLine?: TapeLine; // owner-placed tape endpoints; absent on pre-M4 drafts
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
/** Woo species leaf; absent on legacy drafts. */
|
||||
species_id?: number | null;
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||
inventoryPng: Blob | null; // stored so Continue can show Measure results
|
||||
/** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */
|
||||
|
||||
@@ -40,22 +40,3 @@ test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||
assert.equal(inToEighths(0.07), 1);
|
||||
assert.equal(inToEighths(1.999), 16);
|
||||
});
|
||||
|
||||
test('MeasurePanel exposes Species select (Woo taxonomy leaves)', () => {
|
||||
assert.match(panelSrc, /data-testid="measure-species"/);
|
||||
assert.match(panelSrc, /speciesLeavesFromTaxonomy|getTaxonomy/);
|
||||
assert.match(panelSrc, /aria-label="Species"/);
|
||||
});
|
||||
|
||||
test('MeasurePanel keeps interactive tape controls (merged Axis)', () => {
|
||||
assert.match(panelSrc, /mode="tape"/);
|
||||
assert.match(panelSrc, /onTapeChange/);
|
||||
assert.ok(!/Approve tape line/.test(panelSrc), 'Approve tape folded into Calculate');
|
||||
assert.match(panelSrc, /Calculate/);
|
||||
});
|
||||
|
||||
test('Calculate requires Species (carries to createSlab / Create title)', () => {
|
||||
assert.match(panelSrc, /speciesSel !== ''/);
|
||||
assert.match(panelSrc, /speciesOptsReady/);
|
||||
assert.match(panelSrc, /photos-ready-species/);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
|
||||
null,
|
||||
{ status: 'error', message: 'empty mask: no slab pixels detected' },
|
||||
{ status: 'ok', inventoryPng: ok },
|
||||
{ status: 'error', message: 'PNG size floor: shorter side is below 300 px' },
|
||||
{ status: 'error', message: 'PNG size floor: shorter side is below 800 px' },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
@@ -118,7 +118,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
|
||||
const err3 = lineup[4];
|
||||
assert.equal(err3.kind, 'error');
|
||||
if (err3.kind === 'error') {
|
||||
assert.match(err3.message, /floor|300/i);
|
||||
assert.match(err3.message, /floor|800/i);
|
||||
}
|
||||
|
||||
// Successful sibling still appears after cal+overlay, in capture order among siblings
|
||||
@@ -151,37 +151,16 @@ test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): skip clean cal when sourceBelowBlock; keep overlay + siblings (#64)', () => {
|
||||
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('below'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
|
||||
sourceBelowBlock: true,
|
||||
sourceBelowFloor: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
|
||||
});
|
||||
|
||||
test('test(photos): warn band keeps clean cal + attaches warn; siblings with warn stay png (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('warn-cal'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [
|
||||
null,
|
||||
{ status: 'ok', inventoryPng: png('sib'), warn: 'PNG size warn: shorter side is below 800 px (300–799); photo included' },
|
||||
],
|
||||
sourceBelowWarn: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']);
|
||||
assert.equal(lineup[0].kind, 'png');
|
||||
if (lineup[0].kind === 'png') {
|
||||
assert.match(lineup[0].warn ?? '', /warn|800|300/i);
|
||||
}
|
||||
assert.equal(lineup[2].kind, 'png');
|
||||
if (lineup[2].kind === 'png') {
|
||||
assert.match(lineup[2].warn ?? '', /warn|800/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
|
||||
assert.deepEqual(buildPhotosReadyLineup({}), []);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -8,8 +8,6 @@ export type PhotosReadyLineupItem =
|
||||
blob: Blob;
|
||||
/** Capture-order index for non-calibration batch inventory PNGs. */
|
||||
batchIndex?: number;
|
||||
/** Soft size-floor warn (300–799); photo still shown. */
|
||||
warn?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
@@ -21,45 +19,25 @@ export interface PhotosReadyLineupInput {
|
||||
inventoryPng?: Blob | null;
|
||||
overlayPng?: Blob | null;
|
||||
batchApply?: BatchPhotoResult[] | null;
|
||||
/** When true, omit the clean calibration PNG (hard block < 300 px). */
|
||||
sourceBelowBlock?: boolean;
|
||||
/** Soft warn for calibration (300–799); clean cal still included. */
|
||||
sourceBelowWarn?: boolean;
|
||||
/** Calibration warn message when sourceBelowWarn. */
|
||||
calibrationWarn?: string;
|
||||
/** When true, omit the clean calibration PNG (M1 floor warn path). */
|
||||
sourceBelowFloor?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Photos-ready lineup:
|
||||
* 1. Clean calibration inventory PNG (skipped only on hard block)
|
||||
* 1. Clean calibration inventory PNG
|
||||
* 2. Dimension-overlay copy
|
||||
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
|
||||
* with error slots for empty mask / block-floor failures; warn stays on png.
|
||||
* with error slots for empty mask / floor failures.
|
||||
*/
|
||||
export function buildPhotosReadyLineup(
|
||||
input: PhotosReadyLineupInput,
|
||||
): PhotosReadyLineupItem[] {
|
||||
const items: PhotosReadyLineupItem[] = [];
|
||||
const {
|
||||
inventoryPng,
|
||||
overlayPng,
|
||||
batchApply,
|
||||
sourceBelowBlock,
|
||||
sourceBelowWarn,
|
||||
calibrationWarn,
|
||||
} = input;
|
||||
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
|
||||
|
||||
if (inventoryPng && !sourceBelowBlock) {
|
||||
const warn = sourceBelowWarn
|
||||
? (calibrationWarn
|
||||
?? 'PNG size warn: shorter side is below 800 px (300–799); photo included')
|
||||
: undefined;
|
||||
items.push({
|
||||
kind: 'png',
|
||||
role: 'calibration',
|
||||
blob: inventoryPng,
|
||||
...(warn ? { warn } : {}),
|
||||
});
|
||||
if (inventoryPng && !sourceBelowFloor) {
|
||||
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
|
||||
}
|
||||
if (overlayPng) {
|
||||
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
|
||||
@@ -75,7 +53,6 @@ export function buildPhotosReadyLineup(
|
||||
role: 'batch',
|
||||
blob: slot.inventoryPng,
|
||||
batchIndex: i,
|
||||
...(slot.warn ? { warn: slot.warn } : {}),
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
|
||||
@@ -211,8 +211,7 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
|
||||
// Distinctive PNG blob so tests cannot confuse with source JPEG thumbs
|
||||
return {
|
||||
blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -270,8 +269,7 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['ok-png'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -289,15 +287,15 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: below block floor (<300) on one sibling → per-photo error; others proceed', async () => {
|
||||
test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // ok
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // below block floor
|
||||
const c = tinyJpeg('c'); // below floor
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === c) {
|
||||
return { w: 250, h: 200, slabPx: 250 * 200, slabBounds: { x: 0, y: 0, w: 250, h: 200 } };
|
||||
return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
@@ -305,11 +303,10 @@ test('#62: below block floor (<300) on one sibling → per-photo error; others p
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
|
||||
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
|
||||
return {
|
||||
blob: new Blob([short < 300 ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowBlock: short < 300,
|
||||
sourceBelowWarn: short >= 300 && short < 800,
|
||||
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowFloor: below,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -320,47 +317,9 @@ test('#62: below block floor (<300) on one sibling → per-photo error; others p
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error');
|
||||
assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error');
|
||||
if (apply![2]?.status === 'error') {
|
||||
assert.match(apply![2].message, /floor|300|size/i, 'clear per-photo size-floor error');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: warn band (300–799) on sibling → ok with warn; photo included', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // ok
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // warn band
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === c) {
|
||||
return { w: 500, h: 400, slabPx: 500 * 400, slabBounds: { x: 0, y: 0, w: 500, h: 400 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
|
||||
return {
|
||||
blob: new Blob([short < 800 ? 'warn' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowBlock: short < 300,
|
||||
sourceBelowWarn: short >= 300 && short < 800,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok');
|
||||
if (apply![2]?.status === 'ok') {
|
||||
assert.match(apply![2].warn ?? '', /warn|800|300/i, 'warn message attached');
|
||||
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
@@ -371,8 +330,7 @@ test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['png'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -415,8 +373,7 @@ test('#62: draft persists batchApply after Calculate (for #64 UI later)', async
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-png'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -444,8 +401,7 @@ test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mas
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -470,8 +426,7 @@ test('#62: Calculate does not re-enter Mask / does not open Mask controls for si
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['x'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -508,8 +463,7 @@ test('#62: resume restores batchApply alongside calibration results', async () =
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['resume-png'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
|
||||
@@ -343,11 +343,16 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
|
||||
|
||||
s.approveMask();
|
||||
cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure', 'approve Mask → combined Measure (tape+fields)');
|
||||
assert.equal(cap.k, 'axis', 'approve Mask → Length axis');
|
||||
if (cap.k === 'axis') {
|
||||
assert.equal(cap.source.blob, b, 'Axis must keep the calibration source');
|
||||
}
|
||||
|
||||
s.approveAxis();
|
||||
cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure', 'approve Axis → Measure');
|
||||
if (cap.k === 'measure') {
|
||||
assert.equal(cap.source.blob, b, 'Measure must keep the calibration source');
|
||||
assert.equal(cap.results, null);
|
||||
assert.equal(cap.speciesId, null);
|
||||
}
|
||||
|
||||
s.dispose();
|
||||
@@ -373,9 +378,10 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
||||
s.setSheet('black');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.setTapeLine({ start: [0, 10], end: [39, 10] });
|
||||
s.approveAxis();
|
||||
|
||||
// L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L
|
||||
// 39 source-photo px between ticks / 40 owner-declared inches = 0.975 px/in.
|
||||
s.setLength(320);
|
||||
s.setThickness(16);
|
||||
await s.calculate();
|
||||
@@ -388,7 +394,7 @@ test('#61: Calculate produces inventory PNG + M1 measures for calibration source
|
||||
const r = cap.results!;
|
||||
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
||||
assert.equal(typeof r.scale, 'number');
|
||||
assert.ok(r.scale > 0, 'scale px/in > 0');
|
||||
assert.equal(r.scale, 39 / 40, 'scale comes from the positioned tape endpoints');
|
||||
assert.equal(typeof r.sqft, 'number');
|
||||
assert.equal(typeof r.bdft, 'number');
|
||||
// CAPTURE.md: bdft = sqft × thickness_in
|
||||
@@ -438,20 +444,22 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
||||
assert.equal(before!.batch![2].blob, c);
|
||||
|
||||
s.approveMask();
|
||||
const tapeLine = { start: [2, 3] as [number, number], end: [35, 14] as [number, number] };
|
||||
s.setTapeLine(tapeLine);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const after = store.peek();
|
||||
assert.ok(after);
|
||||
assert.equal(after!.stage, 'measure', 'mask approve lands on combined tape+measure');
|
||||
assert.equal(after!.stage, 'axis');
|
||||
assert.equal(after!.calibrationIndex, 1);
|
||||
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
||||
assert.deepEqual(after!.tapeLine, tapeLine, 'draft persists the owner-positioned tape endpoints');
|
||||
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
|
||||
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
||||
assert.equal(after!.batch![1].blob, b);
|
||||
assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots');
|
||||
assert.equal(after!.species_id ?? null, null);
|
||||
|
||||
// approveAxis is a no-op on measure — batch still untouched.
|
||||
// Approve axis too — batch still untouched.
|
||||
s.approveAxis();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const meas = store.peek();
|
||||
@@ -460,6 +468,7 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
||||
assert.equal(meas!.batch![0].blob, a);
|
||||
assert.equal(meas!.batch![2].blob, c);
|
||||
assert.equal(meas!.calibrationIndex, 1);
|
||||
assert.deepEqual(meas!.tapeLine, tapeLine, 'measure draft keeps the same calibration endpoints');
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
@@ -516,10 +525,10 @@ test('#61: draft resume through measure restores calibration source (not photos[
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('#61: App runs single Mask/Measure panels (no per-batch Mask loop)', () => {
|
||||
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.match(app, /MaskPanel/);
|
||||
assert.ok(!/AxisPanel/.test(app), 'AxisPanel folded into MeasurePanel');
|
||||
assert.match(app, /AxisPanel/);
|
||||
assert.match(app, /MeasurePanel/);
|
||||
// Dishonest proxy: must not iterate batch photos into Mask UI.
|
||||
assert.ok(
|
||||
|
||||
@@ -185,8 +185,7 @@ test('test(photos): Calculate stores overlayPng on results, clean inventory unto
|
||||
encodeN++;
|
||||
return {
|
||||
blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -222,8 +221,7 @@ test('test(photos): single-photo batch still yields clean + overlayPng (#63)', a
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only-clean'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -245,8 +243,7 @@ test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', asy
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-clean'], { type: 'image/png' }),
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
|
||||
+69
-207
@@ -1,20 +1,17 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type {
|
||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, TapeLine, WorkerIn, WorkerOut,
|
||||
} from './capture.types';
|
||||
import {
|
||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||
} from './capture.types';
|
||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||
import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { initialTapeLine, rectAlongLine } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { bakeTiltTransform, rotateCalibration } from '../png/rotateCal';
|
||||
import { calibrationScale, FILL_DEFAULT, fillFromSetting } from '../png/crop';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
import type { TapeLine } from './capture.types';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
@@ -44,13 +41,12 @@ export interface CaptureSession {
|
||||
setSensitivity(n: number): void;
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
/** #99: replace axis rectangle nudge with full tape transform. */
|
||||
setTape(tape: TapeTransform): void;
|
||||
setTapeLine(tapeLine: TapeLine): void;
|
||||
/** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
|
||||
setCropFill(pctOrFraction: number): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
setSpecies(id: number | null): void;
|
||||
calculate(): Promise<void>;
|
||||
back(): void;
|
||||
getInventoryUrl(): string | null;
|
||||
@@ -62,7 +58,7 @@ export interface CaptureSession {
|
||||
export interface SessionOpts {
|
||||
draft?: DraftStore;
|
||||
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }>;
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
|
||||
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
|
||||
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
|
||||
}
|
||||
@@ -90,6 +86,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pngUrl: string | null = null;
|
||||
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) => {
|
||||
state = next;
|
||||
@@ -213,7 +211,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
tapeLine: state.axis.tapeLine,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
@@ -223,9 +222,9 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
tapeLine: state.axis.tapeLine,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
species_id: state.speciesId,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
overlayPng: state.results?.overlayPng ?? null,
|
||||
@@ -319,37 +318,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
|
||||
const tapeFromDraftOrBbox = (d: Draft, bbox: import('./capture.types').BBox, w: number, h: number): TapeLine => {
|
||||
if (d.tape && Number.isFinite(d.tape.spanPx)) {
|
||||
return { ...d.tape };
|
||||
}
|
||||
// Seed tilt so the mask long side sits near vertical under the fixed line.
|
||||
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
|
||||
const t = defaultTape(getCropFill(), seedTilt);
|
||||
const ep = w > 0 && h > 0 ? tapeEndpoints(t, w, h) : { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
return { ...t, spanPx: ep.spanPx };
|
||||
};
|
||||
|
||||
const axisWithTape = (bbox: import('./capture.types').BBox, tape: TapeTransform, w: number, h: number) => {
|
||||
const ep = w > 0 && h > 0
|
||||
? tapeEndpoints(tape, w, h)
|
||||
: { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
const angleDeg = ep.spanPx > 0
|
||||
? ((Math.atan2(ep.uy, ep.ux) * 180) / Math.PI + 180) % 180
|
||||
: bbox.angleDeg;
|
||||
const nextBbox = orientedRect(bbox, angleDeg);
|
||||
const line: TapeLine = {
|
||||
tiltDeg: clampTiltDeg(tape.tiltDeg),
|
||||
zoom: tape.zoom,
|
||||
panX: tape.panX,
|
||||
panY: tape.panY,
|
||||
tickA: tape.tickA,
|
||||
tickB: tape.tickB,
|
||||
spanPx: ep.spanPx,
|
||||
};
|
||||
return { bbox: nextBbox, tape: line };
|
||||
};
|
||||
const draftTapeLine = (d: Draft) => d.tapeLine ?? initialTapeLine(d.source.widthPx, d.source.heightPx);
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
batchPhotos = batchPhotosOf(d);
|
||||
@@ -377,54 +346,32 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
// Legacy axis-only drafts land on the combined tape+measure screen.
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask, axis,
|
||||
lengthEighths: d.lengthEighths || 0,
|
||||
thicknessEighths: d.thicknessEighths || 0,
|
||||
speciesId: d.species_id ?? null,
|
||||
results: null,
|
||||
});
|
||||
const tapeLine = draftTapeLine(d);
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: {
|
||||
bbox: rectAlongLine(draftAxisBbox(d), tapeLine.start, tapeLine.end), tapeLine,
|
||||
} });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
{
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis,
|
||||
axis: {
|
||||
bbox: rectAlongLine(draftAxisBbox(d), draftTapeLine(d).start, draftTapeLine(d).end),
|
||||
tapeLine: draftTapeLine(d),
|
||||
},
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
speciesId: d.species_id ?? null,
|
||||
results: d.results
|
||||
? {
|
||||
...d.results,
|
||||
inventoryPng: d.inventoryPng ?? new Blob(),
|
||||
overlayPng: d.overlayPng ?? undefined,
|
||||
// Migrate legacy sourceBelowFloor → warn; missing flags default false.
|
||||
sourceBelowBlock: (d.results as { sourceBelowBlock?: boolean }).sourceBelowBlock ?? false,
|
||||
sourceBelowWarn:
|
||||
(d.results as { sourceBelowWarn?: boolean }).sourceBelowWarn
|
||||
?? (d.results as { sourceBelowFloor?: boolean }).sourceBelowFloor
|
||||
?? false,
|
||||
}
|
||||
: null,
|
||||
batchApply: d.batchApply,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
makeWorker(source.blob);
|
||||
@@ -538,84 +485,52 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
runPass();
|
||||
},
|
||||
|
||||
// #61: combined tape+measure uses the same calibration source; draft.batch siblings stay untouched.
|
||||
// #61: Axis uses the same calibration source; draft.batch siblings stay untouched.
|
||||
approveMask() {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
const overlay = state.overlay;
|
||||
const bbox = {
|
||||
corners: overlay.bbox.corners.map((c) => [...c] as [number, number]),
|
||||
longSidePx: overlay.bbox.longSidePx,
|
||||
angleDeg: overlay.bbox.angleDeg,
|
||||
};
|
||||
const w = state.source.widthPx;
|
||||
const h = state.source.heightPx;
|
||||
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
|
||||
const tape0 = defaultTape(getCropFill(), seedTilt);
|
||||
const axis = axisWithTape(bbox, tape0, w, h);
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: overlay, axis,
|
||||
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
|
||||
});
|
||||
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();
|
||||
},
|
||||
|
||||
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
|
||||
setAxisAngle(deg: number) {
|
||||
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
|
||||
const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) };
|
||||
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
}
|
||||
},
|
||||
|
||||
setTape(tape: TapeTransform) {
|
||||
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
|
||||
const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) };
|
||||
const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
}
|
||||
},
|
||||
|
||||
// Folded into Calculate on the combined screen; kept for tests / legacy axis drafts.
|
||||
approveAxis() {
|
||||
if (state.k === 'measure') {
|
||||
void persist();
|
||||
return;
|
||||
}
|
||||
setTapeLine(tapeLine: TapeLine) {
|
||||
if (state.k !== 'axis') return;
|
||||
const start: [number, number] = [...tapeLine.start];
|
||||
const end: [number, number] = [...tapeLine.end];
|
||||
const bbox = rectAlongLine(state.axis.bbox, start, end);
|
||||
if (!(bbox.longSidePx > 0)) return;
|
||||
set({ ...state, axis: { bbox, tapeLine: { start, end } } });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setCropFill(pctOrFraction: number) {
|
||||
// #99: non-retro — only future encodes use this; stored PNGs are untouched.
|
||||
cropFill = fillFromSetting(pctOrFraction);
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
const axis = axisWithTape(
|
||||
state.axis.bbox,
|
||||
state.axis.tape,
|
||||
state.source.widthPx,
|
||||
state.source.heightPx,
|
||||
);
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis,
|
||||
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
|
||||
axis: { bbox: state.axis.bbox, tapeLine: state.axis.tapeLine },
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
},
|
||||
|
||||
setLength(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, lengthEighths: Math.max(0, Math.round(e)), results: null });
|
||||
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setThickness(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)), results: null });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setSpecies(id: number | null) {
|
||||
if (state.k !== 'measure') return;
|
||||
const speciesId = id != null && Number.isFinite(id) ? Math.round(id) : null;
|
||||
set({ ...state, speciesId });
|
||||
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
@@ -625,83 +540,42 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const scale = calibrationScale(axis.tapeLine.start, axis.tapeLine.end, eighthsToIn(lengthEighths));
|
||||
if (!(scale > 0) || !Number.isFinite(scale)) return;
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const tapeAxis = axis.tape.spanPx > 0
|
||||
? (() => {
|
||||
const ep = tapeEndpoints(axis.tape, source.widthPx, source.heightPx);
|
||||
return { start: ep.start, ux: ep.ux, uy: ep.uy, spanPx: ep.spanPx };
|
||||
})()
|
||||
: null;
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
tapeAxis,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx, scale,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowBlock = false;
|
||||
let belowWarn = false;
|
||||
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
|
||||
let calBitmap = bitmap;
|
||||
let calMask = approvedMask;
|
||||
let calSource = source;
|
||||
let rotatedExtra: ImageBitmap | null = null;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const rotated = await rotateCalibration(bitmap, approvedMask, source, axis.tape.tiltDeg);
|
||||
calBitmap = rotated.bitmap;
|
||||
calMask = rotated.mask;
|
||||
calSource = { ...source, widthPx: rotated.source.widthPx, heightPx: rotated.source.heightPx };
|
||||
if (rotated.bitmap !== bitmap) rotatedExtra = rotated.bitmap;
|
||||
} catch {
|
||||
/* keep unrotated cal if bake fails */
|
||||
}
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill });
|
||||
pngBlob = r.blob;
|
||||
belowBlock = r.sourceBelowBlock;
|
||||
belowWarn = r.sourceBelowWarn;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
// CAPTURE.md: inventory is image/png even when encode is unavailable.
|
||||
pngBlob = new Blob([], { type: 'image/png' });
|
||||
}
|
||||
bitmap.close();
|
||||
if (rotatedExtra) rotatedExtra.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
|
||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||
// Overlay uses the same baked orientation as the clean cal PNG (#99 scope).
|
||||
// Length / width directions follow the tape (not wood MAR): map tick A→B
|
||||
// through the same bake as rotateCal, then orient the overlay bbox to that.
|
||||
let overlayBlob: Blob;
|
||||
try {
|
||||
const preEp = axis.tape.spanPx > 0
|
||||
? tapeEndpoints(axis.tape, source.widthPx, source.heightPx)
|
||||
: null;
|
||||
const bakeXform = bakeTiltTransform(source.widthPx, source.heightPx, axis.tape.tiltDeg);
|
||||
let overlayBbox = calMask.bbox;
|
||||
let overlayAxis: { start: [number, number]; end: [number, number] } | undefined;
|
||||
if (preEp && preEp.spanPx > 0) {
|
||||
const start = bakeXform.mapPoint(preEp.start[0], preEp.start[1]);
|
||||
const end = bakeXform.mapPoint(preEp.end[0], preEp.end[1]);
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 180) % 180;
|
||||
overlayBbox = orientedRect(calMask.bbox, angleDeg);
|
||||
overlayAxis = { start, end };
|
||||
}
|
||||
overlayBlob = await encodeOverlay({
|
||||
inventoryPng: pngBlob,
|
||||
bitset: calMask.bitset,
|
||||
widthPx: calSource.widthPx,
|
||||
heightPx: calSource.heightPx,
|
||||
bbox: overlayBbox,
|
||||
bitset: approvedMask.bitset,
|
||||
widthPx: source.widthPx,
|
||||
heightPx: source.heightPx,
|
||||
bbox: axis.bbox,
|
||||
scale: base.scale,
|
||||
lengthIn: eighthsToIn(lengthEighths),
|
||||
lengthLabel: fmtEighths(lengthEighths),
|
||||
slabBounds: calMask.slabBounds,
|
||||
source: calSource,
|
||||
axis: overlayAxis,
|
||||
slabBounds: approvedMask.slabBounds,
|
||||
source,
|
||||
});
|
||||
} catch {
|
||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||
@@ -712,8 +586,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
...base,
|
||||
inventoryPng: pngBlob,
|
||||
overlayPng: overlayBlob,
|
||||
sourceBelowBlock: belowBlock,
|
||||
sourceBelowWarn: belowWarn,
|
||||
sourceBelowFloor: belowFloor,
|
||||
};
|
||||
const params = approvedMask.params;
|
||||
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||
@@ -745,21 +618,16 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
heightPx: derived.heightPx,
|
||||
};
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean };
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
try {
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill });
|
||||
} finally {
|
||||
siblingBitmap.close();
|
||||
}
|
||||
if (encoded.sourceBelowBlock) {
|
||||
throw new Error('PNG size floor: shorter side is below 300 px');
|
||||
if (encoded.sourceBelowFloor) {
|
||||
throw new Error('PNG size floor: shorter side is below 800 px');
|
||||
}
|
||||
const warn = encoded.sourceBelowWarn
|
||||
? 'PNG size warn: shorter side is below 800 px (300–799); photo included'
|
||||
: undefined;
|
||||
batchApply[index] = warn
|
||||
? { status: 'ok', inventoryPng: encoded.blob, warn }
|
||||
: { status: 'ok', inventoryPng: encoded.blob };
|
||||
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||
batchApply[index] = { status: 'error', message };
|
||||
@@ -781,19 +649,13 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
set({ k: 'pickCalibration', photos: [...batchPhotos] });
|
||||
void persist();
|
||||
} else if (state.k === 'axis') {
|
||||
// Legacy: back to mask keeps the last approved mask as the live overlay
|
||||
// back to mask keeps the last approved mask as the live overlay
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
if (state.results) {
|
||||
// Photos ready → re-edit tape + L/thickness/species on the same screen.
|
||||
set({ ...state, results: null, batchApply: undefined });
|
||||
if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; }
|
||||
void persist();
|
||||
} else {
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
}
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: {
|
||||
bbox: state.axis.bbox, tapeLine: state.axis.tapeLine,
|
||||
} });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
STAGE_H, STAGE_W, TILT_MAX, TILT_MIN, clampTickPair, clampTiltDeg, containFit,
|
||||
defaultTape, defaultTicks, lineX, stageToImage, tapeEndpoints,
|
||||
} from './tapeGeometry.ts';
|
||||
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts';
|
||||
|
||||
test('defaultTicks sit at crop insets for 90% fill', () => {
|
||||
const t = defaultTicks(0.9);
|
||||
const inset = STAGE_H * cropMarginFraction(0.9);
|
||||
assert.ok(Math.abs(t.tickA - inset) < 1e-9);
|
||||
assert.ok(Math.abs(t.tickB - (STAGE_H - inset)) < 1e-9);
|
||||
});
|
||||
|
||||
test('lineX is the middle of the 3:4 stage', () => {
|
||||
assert.equal(lineX(), STAGE_W / 2);
|
||||
});
|
||||
|
||||
test('identity tape: stage centre maps near image centre', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const tape = defaultTape(DEFAULT_CROP_FILL, 0);
|
||||
const [ix, iy] = stageToImage(STAGE_W / 2, STAGE_H / 2, tape, imgW, imgH);
|
||||
assert.ok(Math.abs(ix - imgW / 2) < 1e-6, `ix=${ix}`);
|
||||
assert.ok(Math.abs(iy - imgH / 2) < 1e-6, `iy=${iy}`);
|
||||
});
|
||||
|
||||
test('tapeEndpoints span equals tick distance in image px at identity (matching aspect)', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const tape = defaultTape(0.9, 0);
|
||||
const ep = tapeEndpoints(tape, imgW, imgH);
|
||||
const { scale0 } = containFit(imgW, imgH);
|
||||
const expected = Math.abs(tape.tickB - tape.tickA) / scale0;
|
||||
assert.ok(Math.abs(ep.spanPx - expected) < 1e-6, `span ${ep.spanPx} vs ${expected}`);
|
||||
// Direction should be +y (down the stage → down the image)
|
||||
assert.ok(Math.abs(ep.ux) < 1e-6, `ux=${ep.ux}`);
|
||||
assert.ok(ep.uy > 0.99, `uy=${ep.uy}`);
|
||||
});
|
||||
|
||||
test('zoom scales inverse: 2× zoom halves image-space tick span', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const base = defaultTape(0.9, 0);
|
||||
const zoomed = { ...base, zoom: 2 };
|
||||
const a = tapeEndpoints(base, imgW, imgH).spanPx;
|
||||
const b = tapeEndpoints(zoomed, imgW, imgH).spanPx;
|
||||
assert.ok(Math.abs(b * 2 - a) < 1e-6, `base=${a} zoomed=${b}`);
|
||||
});
|
||||
|
||||
test('clampTickPair enforces gap and order', () => {
|
||||
const p = clampTickPair(400, 100, 40);
|
||||
assert.ok(p.tickB - p.tickA >= 40);
|
||||
assert.ok(p.tickA <= p.tickB);
|
||||
});
|
||||
|
||||
test('clampTiltDeg clamps to ±90', () => {
|
||||
assert.equal(TILT_MIN, -90);
|
||||
assert.equal(TILT_MAX, 90);
|
||||
assert.equal(clampTiltDeg(0), 0);
|
||||
assert.equal(clampTiltDeg(45.5), 45.5);
|
||||
assert.equal(clampTiltDeg(-45.5), -45.5);
|
||||
assert.equal(clampTiltDeg(90), 90);
|
||||
assert.equal(clampTiltDeg(-90), -90);
|
||||
assert.equal(clampTiltDeg(91), 90);
|
||||
assert.equal(clampTiltDeg(-100), -90);
|
||||
assert.equal(clampTiltDeg(Number.NaN), 0);
|
||||
});
|
||||
|
||||
test('defaultTape clamps seed tilt', () => {
|
||||
assert.equal(defaultTape(0.9, 120).tiltDeg, 90);
|
||||
assert.equal(defaultTape(0.9, -200).tiltDeg, -90);
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Pure tape-line geometry (#99).
|
||||
* Fixed vertical line in the middle of a 3:4 stage; photo pans/zooms/tilts;
|
||||
* ticks slide on the line; L↔px from tick distance in image space.
|
||||
*/
|
||||
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop';
|
||||
|
||||
/** Logical stage matching the wireframe / inventory aspect. */
|
||||
export const STAGE_W = 480;
|
||||
export const STAGE_H = 640;
|
||||
|
||||
/** Interactive tilt limits (degrees). Slider midpoint = 0; both ways ±90. */
|
||||
export const TILT_MIN = -90;
|
||||
export const TILT_MAX = 90;
|
||||
|
||||
export function clampTiltDeg(deg: number): number {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
return Math.min(TILT_MAX, Math.max(TILT_MIN, deg));
|
||||
}
|
||||
|
||||
export interface TapeTransform {
|
||||
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
|
||||
tiltDeg: number;
|
||||
zoom: number;
|
||||
/** Pan in stage units. */
|
||||
panX: number;
|
||||
panY: number;
|
||||
/** Tick Y positions in stage units. */
|
||||
tickA: number;
|
||||
tickB: number;
|
||||
}
|
||||
|
||||
export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number; tickB: number } {
|
||||
const inset = STAGE_H * cropMarginFraction(fill);
|
||||
return { tickA: inset, tickB: STAGE_H - inset };
|
||||
}
|
||||
|
||||
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
|
||||
const t = defaultTicks(fill);
|
||||
return {
|
||||
tiltDeg: clampTiltDeg(seedTiltDeg),
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
tickA: t.tickA,
|
||||
tickB: t.tickB,
|
||||
};
|
||||
}
|
||||
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
|
||||
/** Contain-fit of the photo into the stage at zoom=1, pan=0. */
|
||||
export function containFit(imgW: number, imgH: number): { scale0: number; offX: number; offY: number } {
|
||||
const scale0 = Math.min(STAGE_W / Math.max(imgW, 1), STAGE_H / Math.max(imgH, 1));
|
||||
return {
|
||||
scale0,
|
||||
offX: (STAGE_W - imgW * scale0) / 2,
|
||||
offY: (STAGE_H - imgH * scale0) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a stage point through the inverse photo transform into source image pixels.
|
||||
* Transform order (forward): contain-fit → zoom+tilt about stage centre → pan.
|
||||
*/
|
||||
export function stageToImage(
|
||||
sx: number,
|
||||
sy: number,
|
||||
tape: TapeTransform,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): [number, number] {
|
||||
const cx = STAGE_W / 2;
|
||||
const cy = STAGE_H / 2;
|
||||
const { scale0, offX, offY } = containFit(imgW, imgH);
|
||||
// undo pan
|
||||
let x = sx - tape.panX - cx;
|
||||
let y = sy - tape.panY - cy;
|
||||
// undo rotate(tilt) scale(zoom) about centre
|
||||
const z = tape.zoom || 1;
|
||||
const a = -rad(tape.tiltDeg);
|
||||
const cos = Math.cos(a);
|
||||
const sin = Math.sin(a);
|
||||
const xr = (x * cos - y * sin) / z;
|
||||
const yr = (x * sin + y * cos) / z;
|
||||
x = xr + cx;
|
||||
y = yr + cy;
|
||||
// undo contain fit
|
||||
const ix = (x - offX) / scale0;
|
||||
const iy = (y - offY) / scale0;
|
||||
return [ix, iy];
|
||||
}
|
||||
|
||||
export function lineX(): number {
|
||||
return STAGE_W / 2;
|
||||
}
|
||||
|
||||
/** Image-space endpoints of the two ticks + span in px. */
|
||||
export function tapeEndpoints(
|
||||
tape: TapeTransform,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): { start: [number, number]; end: [number, number]; spanPx: number; ux: number; uy: number } {
|
||||
const start = stageToImage(lineX(), tape.tickA, tape, imgW, imgH);
|
||||
const end = stageToImage(lineX(), tape.tickB, tape, imgW, imgH);
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const spanPx = Math.hypot(dx, dy);
|
||||
const len = spanPx || 1;
|
||||
return { start, end, spanPx, ux: dx / len, uy: dy / len };
|
||||
}
|
||||
|
||||
export function clampTickPair(a: number, b: number, gap = 40): { tickA: number; tickB: number } {
|
||||
let tickA = a;
|
||||
let tickB = b;
|
||||
if (tickB < tickA) {
|
||||
const t = tickA;
|
||||
tickA = tickB;
|
||||
tickB = t;
|
||||
}
|
||||
const minY = 0;
|
||||
const maxY = STAGE_H;
|
||||
tickA = Math.max(minY, Math.min(tickA, maxY - gap));
|
||||
tickB = Math.min(maxY, Math.max(tickB, tickA + gap));
|
||||
return { tickA, tickB };
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { decodeName } from './decodeName';
|
||||
|
||||
test('decodeName: ampersand and common entities', () => {
|
||||
assert.equal(decodeName('Cookies & Burl Slices'), 'Cookies & Burl Slices');
|
||||
assert.equal(decodeName('A < B > C'), 'A < B > C');
|
||||
assert.equal(decodeName('say "hi" 'there''), 'say "hi" \'there\'');
|
||||
assert.equal(decodeName('plain'), 'plain');
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Decode common HTML entities Woo puts in taxonomy display names. */
|
||||
export function decodeName(name: string): string {
|
||||
return name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -11,15 +11,12 @@ import type { PhotosReadyLineupItem } from '../capture/photosReadyLineup';
|
||||
export interface CalibratedCapture {
|
||||
lengthIn: number;
|
||||
thicknessIn: number;
|
||||
/** Woo species leaf from Measure; null if unset. Review does not edit. */
|
||||
speciesId: number | null;
|
||||
sqft: number;
|
||||
bdft: number;
|
||||
widthMinIn: number;
|
||||
widthMaxIn: number;
|
||||
widthAvgIn: number;
|
||||
sourceBelowBlock: boolean;
|
||||
sourceBelowWarn: boolean;
|
||||
sourceBelowFloor: boolean;
|
||||
lineup: PhotosReadyLineupItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
let contentEnabled = false;
|
||||
let reachable = false;
|
||||
let description: string | null = null;
|
||||
let shortDescription: string | null = null;
|
||||
let call1Suggestions: Record<string, unknown> | null = null;
|
||||
let error = '';
|
||||
let busy = false;
|
||||
|
||||
@@ -30,14 +30,10 @@
|
||||
busy = true;
|
||||
error = '';
|
||||
description = null;
|
||||
shortDescription = null;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
if (res.ok) {
|
||||
description = body.description;
|
||||
shortDescription = body.short_description ?? null;
|
||||
}
|
||||
if (res.ok) description = body.description;
|
||||
else error = body.error?.message || `call2 failed (${res.status})`;
|
||||
} catch {
|
||||
error = 'network error';
|
||||
@@ -45,6 +41,25 @@
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function classify() {
|
||||
if (!slabId) {
|
||||
error = 'Enter a slab id.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
call1Suggestions = null;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
if (res.ok) call1Suggestions = body.suggestions;
|
||||
else error = body.error?.message || `call1 failed (${res.status})`;
|
||||
} catch {
|
||||
error = 'network error';
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
checkReadiness();
|
||||
</script>
|
||||
|
||||
@@ -53,7 +68,6 @@
|
||||
<p class="state">
|
||||
Content generation: {contentEnabled ? 'on' : 'off'} · endpoint: {reachable ? 'reachable' : 'unreachable'}
|
||||
</p>
|
||||
<p class="note">Call 1 (vision classification) is disabled on this branch. Call 2 description generation stays.</p>
|
||||
{#if !contentEnabled || !reachable}
|
||||
<p class="note">The Generate text control is disabled: it appears only when content generation is
|
||||
enabled and the inference endpoint is reachable.</p>
|
||||
@@ -64,10 +78,13 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<button on:click={generate} disabled={busy}>{busy ? 'Working…' : 'Generate description (Call 2)'}</button>
|
||||
<button on:click={classify} disabled={busy}>Classify metadata (Call 1)</button>
|
||||
</div>
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
{#if shortDescription}<p class="desc"><strong>Short:</strong> {shortDescription}</p>{/if}
|
||||
{#if description}<p class="desc"><strong>Long:</strong> {description}</p>{/if}
|
||||
{#if description}<p class="desc">{description}</p>{/if}
|
||||
{#if call1Suggestions}
|
||||
<pre class="sug">{JSON.stringify(call1Suggestions, null, 2)}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="mini" on:click={checkReadiness}>Recheck readiness</button>
|
||||
</div>
|
||||
@@ -81,5 +98,6 @@
|
||||
.note { color: #e8a; }
|
||||
.err { color: #e88; }
|
||||
.desc { margin-top: 12px; }
|
||||
.sug { background: #1c1c1c; padding: 8px; }
|
||||
.mini { margin-top: 12px; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import {
|
||||
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
|
||||
type Slab, type Taxonomy, type PhotoRow,
|
||||
} from '../../api/slabs';
|
||||
import PublishPreview from './PublishPreview.svelte';
|
||||
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
|
||||
import { getSlab, publishSlab, ApiError, type Slab } from '../../api/slabs';
|
||||
|
||||
// One-column flow: Review hands to Publish by id (UX.md §1).
|
||||
// Idle phase = Publish preview (glanceable confirm) before Woo draft POST.
|
||||
export let slabId: string;
|
||||
export let onPublished: (wooProductId: number, draft: boolean) => void;
|
||||
export let onBack: () => void;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let slab: Slab | null = null;
|
||||
let tax: Taxonomy | null = null;
|
||||
let photos: PhotoRow[] = [];
|
||||
let preview: PublishPreviewModel | null = null;
|
||||
let loading = true;
|
||||
let loadError = '';
|
||||
|
||||
@@ -69,68 +60,34 @@
|
||||
function backToReview() { onBack(); }
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [s, t, ph] = await Promise.all([
|
||||
getSlab(slabId),
|
||||
getTaxonomy().catch(() => null),
|
||||
listPhotos(slabId).catch(() => [] as PhotoRow[]),
|
||||
]);
|
||||
slab = s;
|
||||
tax = t;
|
||||
photos = ph;
|
||||
preview = buildPublishPreviewModel(
|
||||
s,
|
||||
t,
|
||||
ph,
|
||||
(id, kind) => photoUrl(slabId, id, kind),
|
||||
);
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
try { slab = await getSlab(slabId); }
|
||||
catch (e) { loadError = e instanceof ApiError ? e.message : 'Could not load the slab.'; }
|
||||
finally { loading = false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<section class="pub" aria-busy="true" data-testid="publish-loading">
|
||||
<h1>Publish preview</h1>
|
||||
<p class="busy">Loading…</p>
|
||||
</section>
|
||||
<section class="pub" aria-busy="true" data-testid="publish-loading"><h1>Publish to WooCommerce</h1><p class="busy">Loading…</p></section>
|
||||
{:else if loadError}
|
||||
<section class="pub">
|
||||
<h1>Publish preview</h1>
|
||||
<p class="err" role="alert">{loadError}</p>
|
||||
<section class="pub"><h1>Publish to WooCommerce</h1><p class="err" role="alert">{loadError}</p>
|
||||
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="pub" data-testid="publish-screen">
|
||||
<h1>Publish preview</h1>
|
||||
<p class="lede">Confirm what’s about to publish to WooCommerce as a draft.</p>
|
||||
<h1>Publish to WooCommerce</h1>
|
||||
<p class="meta">SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
|
||||
|
||||
{#if preview && (st.phase === 'idle' || st.phase === 'working')}
|
||||
<PublishPreview model={preview} />
|
||||
{:else if slab && st.phase !== 'idle' && st.phase !== 'working'}
|
||||
<p class="meta">SKU {slab.sku || '—'} · {slab.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
|
||||
{/if}
|
||||
|
||||
{#if st.phase === 'idle'}
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish}>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
{:else if st.phase === 'working'}
|
||||
<button type="button" class="primary full" data-testid="publish-btn" disabled>
|
||||
Publishing…
|
||||
{#if st.phase === 'idle' || st.phase === 'working'}
|
||||
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish} disabled={st.phase === 'working'}>
|
||||
{st.phase === 'working' ? 'Publishing…' : 'Publish'}
|
||||
</button>
|
||||
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview} disabled={st.phase === 'working'}>Back to Review</button>
|
||||
{/if}
|
||||
|
||||
{#if st.phase === 'success'}
|
||||
<div class="status ok" data-testid="publish-ok">Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}</div>
|
||||
<p class="meta">Photos purged · device draft cleared.</p>
|
||||
<button type="button" class="primary full" data-testid="new-slab" on:click={() => {}}>Start a new slab</button>
|
||||
<button type="button" class="primary" data-testid="new-slab" on:click={() => {}}>Start a new slab</button>
|
||||
{:else if st.phase === 'failure' && st.fail}
|
||||
{#if st.fail.duplicateSku}
|
||||
<div class="step-x" data-testid="dup-sku">
|
||||
@@ -138,15 +95,15 @@
|
||||
<h2>SKU already exists</h2>
|
||||
<p class="meta">“{slab?.sku}” is already in the store.</p>
|
||||
</div>
|
||||
<button type="button" class="primary full" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
|
||||
<button type="button" class="ghost full" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
|
||||
<button type="button" class="primary" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
|
||||
<button type="button" class="ghost" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
|
||||
{:else if st.fail.code === 'unpublishable'}
|
||||
<div class="step-x" data-testid="not-ready">
|
||||
<div class="x n">✕</div>
|
||||
<h2>Not ready to publish</h2>
|
||||
<p class="meta">Some fields are missing. Go back and finish Review.</p>
|
||||
</div>
|
||||
<button type="button" class="primary full" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||
<button type="button" class="primary" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||
{:else}
|
||||
<div class="step-x" data-testid="store-failure">
|
||||
<div class="x n" class:rec={st.fail.recoverable}>{st.fail.partialMedia ? '⚠' : '↻'}</div>
|
||||
@@ -155,31 +112,19 @@
|
||||
{#if st.fail.partialMedia}<span class="tag warn">orphan media — remove manually</span>{/if}
|
||||
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
|
||||
</div>
|
||||
<button type="button" class="primary full" data-testid="retry-publish" on:click={doPublish}>Retry</button>
|
||||
<button type="button" class="primary" data-testid="retry-publish" on:click={doPublish}>Retry</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Match Review margins (UI nits): tight horizontal padding, mobile-first column. */
|
||||
.pub { max-width: 560px; margin: 0 auto; color: #eee; padding: 4px 4px 20px; box-sizing: border-box; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 6px; }
|
||||
h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||
.lede { color: #9a9a9a; font-size: .82rem; margin: 0 0 14px; }
|
||||
.pub { max-width: 520px; margin: 0 auto; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 6px; } h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||
.meta { color: #888; font-size: .82rem; margin: 4px 0 10px; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
|
||||
.primary.full, .ghost.full { width: 100%; margin-top: 12px; }
|
||||
.ghost {
|
||||
box-sizing: border-box; padding: 14px 18px; border-radius: 3px;
|
||||
border: 1px solid #444; background: transparent; color: #ccc;
|
||||
font-size: .9rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.primary { width: 100%; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; margin-top: 12px; }
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { width: 100%; padding: 13px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; margin-top: 10px; }
|
||||
.status { font-weight: 700; padding: 11px 13px; border-radius: 3px; font-size: .92rem; }
|
||||
.status.ok { background: #122a12; color: #7fd37f; }
|
||||
.step-x { background: #2a1512; border-radius: 3px; padding: 14px; margin: 14px 0; }
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PublishPreviewModel } from './publishPreviewModel';
|
||||
|
||||
export let model: PublishPreviewModel;
|
||||
</script>
|
||||
|
||||
<div class="preview" data-testid="publish-preview">
|
||||
{#if model.photos.length}
|
||||
<div class="thumbs" data-testid="preview-thumbs" aria-label="Photo lineup">
|
||||
{#each model.photos as p, i (p.id)}
|
||||
<div class="thumb" class:primary={p.primary} data-testid="preview-thumb" data-primary={p.primary ? 'true' : ''}>
|
||||
<img src={p.url} alt="Photo {i + 1}" />
|
||||
{#if p.primary}<span class="pri">PRIMARY</span>{/if}
|
||||
<span class="seq" aria-hidden="true">{i + 1}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="head" data-testid="preview-head">
|
||||
<div class="sku" data-testid="preview-sku">SKU {model.sku}</div>
|
||||
<h2 class="title" data-testid="preview-title">{model.title}</h2>
|
||||
<div class="price" data-testid="preview-price">{model.price}</div>
|
||||
</div>
|
||||
|
||||
<dl class="facts" data-testid="preview-facts">
|
||||
{#each model.facts as f (f.label)}
|
||||
<div class="fact" data-testid="preview-fact" data-label={f.label}>
|
||||
<dt>{f.label}</dt>
|
||||
<dd>{f.value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
|
||||
<div class="block" data-testid="preview-short">
|
||||
<div class="lbl">Short description</div>
|
||||
<p class="body short">{model.shortDescription}</p>
|
||||
</div>
|
||||
|
||||
<div class="block" data-testid="preview-long">
|
||||
<div class="lbl">Description</div>
|
||||
<pre class="body long">{model.description}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview { color: #eee; }
|
||||
.thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.thumbs { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background-color: #eee;
|
||||
background-image:
|
||||
linear-gradient(45deg,#ccc 25%,transparent 25%),
|
||||
linear-gradient(-45deg,#ccc 25%,transparent 25%),
|
||||
linear-gradient(45deg,transparent 75%,#ccc 75%),
|
||||
linear-gradient(-45deg,transparent 75%,#ccc 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0,0 8px,8px -8px,-8px 0;
|
||||
}
|
||||
.thumb.primary { border-color: #00e5ff; }
|
||||
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.pri {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: #00e5ff; color: #002;
|
||||
font-size: .52rem; font-weight: 800;
|
||||
padding: 1px 4px; border-radius: 2px; letter-spacing: .03em;
|
||||
}
|
||||
.seq {
|
||||
position: absolute; bottom: 4px; left: 4px;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: rgba(0,0,0,.7); color: #fff;
|
||||
font-size: .62rem; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.head { margin: 0 0 12px; }
|
||||
.sku { color: #888; font-size: .78rem; margin: 0 0 4px; }
|
||||
.title { font-size: 1.15rem; font-weight: 700; margin: 0 0 6px; line-height: 1.3; }
|
||||
.price { font-size: 1.05rem; font-weight: 700; color: #7fd37f; }
|
||||
|
||||
.facts { margin: 0 0 14px; }
|
||||
.fact {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
border-bottom: 1px solid #2a2a2a; padding: 6px 0; font-size: .85rem;
|
||||
}
|
||||
.fact dt { color: #9a9a9a; margin: 0; flex: none; }
|
||||
.fact dd {
|
||||
margin: 0; text-align: right; font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.block { margin: 0 0 14px; }
|
||||
.lbl { display: block; font-size: .78rem; color: #bbb; margin: 0 0 4px; }
|
||||
.body {
|
||||
margin: 0; padding: 10px 12px;
|
||||
background: #141414; border: 1px solid #333; border-radius: 3px;
|
||||
color: #ddd; font-size: .86rem; line-height: 1.45;
|
||||
}
|
||||
.short { white-space: pre-wrap; word-break: break-word; }
|
||||
.long {
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
font-family: inherit; max-height: 320px; overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,50 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const publishSrc = readFileSync(join(dir, 'Publish.svelte'), 'utf8');
|
||||
const previewSrc = readFileSync(join(dir, 'PublishPreview.svelte'), 'utf8');
|
||||
const modelSrc = readFileSync(join(dir, 'publishPreviewModel.ts'), 'utf8');
|
||||
|
||||
test('Publish uses dedicated PublishPreview before Woo POST', () => {
|
||||
assert.match(publishSrc, /import PublishPreview from '\.\/PublishPreview\.svelte'/);
|
||||
assert.match(publishSrc, /buildPublishPreviewModel/);
|
||||
assert.match(publishSrc, /data-testid="publish-preview"|PublishPreview/);
|
||||
// Idle confirm still posts only via doPublish / publish-btn
|
||||
assert.match(publishSrc, /data-testid="publish-btn"/);
|
||||
assert.match(publishSrc, /data-testid="back-review"/);
|
||||
assert.match(publishSrc, /publishSlab\(slabId\)/);
|
||||
});
|
||||
|
||||
test('PublishPreview layout: thumbs → head → facts → short → long', () => {
|
||||
assert.match(previewSrc, /data-testid="publish-preview"/);
|
||||
assert.match(previewSrc, /data-testid="preview-thumbs"/);
|
||||
assert.match(previewSrc, /data-testid="preview-head"/);
|
||||
assert.match(previewSrc, /data-testid="preview-sku"/);
|
||||
assert.match(previewSrc, /data-testid="preview-title"/);
|
||||
assert.match(previewSrc, /data-testid="preview-price"/);
|
||||
assert.match(previewSrc, /data-testid="preview-facts"/);
|
||||
assert.match(previewSrc, /data-testid="preview-short"/);
|
||||
assert.match(previewSrc, /data-testid="preview-long"/);
|
||||
// Plain-text long description (pre-wrap), not HTML render
|
||||
assert.match(previewSrc, /white-space:\s*pre-wrap/);
|
||||
assert.match(previewSrc, /<pre class="body long"/);
|
||||
});
|
||||
|
||||
test('Preview model resolves taxonomy names; does not invent fields', () => {
|
||||
assert.match(modelSrc, /species_id/);
|
||||
assert.match(modelSrc, /wood_category_ids/);
|
||||
assert.match(modelSrc, /edge_type_term_id/);
|
||||
assert.match(modelSrc, /figure_term_ids/);
|
||||
assert.match(modelSrc, /grade_term_id/);
|
||||
assert.match(modelSrc, /moisture_term_id/);
|
||||
assert.match(modelSrc, /feat_tag_ids/);
|
||||
assert.match(modelSrc, /width_min_in/);
|
||||
assert.match(modelSrc, /bdft/);
|
||||
assert.match(modelSrc, /decodeName/);
|
||||
// No invented brand/voice fields
|
||||
assert.equal(modelSrc.includes('brand_voice'), false);
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
buildPublishPreviewModel,
|
||||
formatWidths,
|
||||
} from './publishPreviewModel';
|
||||
import type { PhotoRow, Slab, Taxonomy } from '../../api/slabs';
|
||||
|
||||
function baseSlab(over: Partial<Slab> = {}): Slab {
|
||||
return {
|
||||
id: 's1',
|
||||
sku: 'SLAB-99',
|
||||
status: 'ready',
|
||||
price: 420.5,
|
||||
price_source: 'recommendation',
|
||||
length_in: 96,
|
||||
thickness_in: 2,
|
||||
sqft: 8,
|
||||
bdft: 16,
|
||||
width_min_in: 12,
|
||||
width_max_in: 18,
|
||||
width_avg_in: 15,
|
||||
species_id: 10,
|
||||
wood_category_ids: [20, 21],
|
||||
edge_type_term_id: 30,
|
||||
figure_term_ids: [40],
|
||||
grade_term_id: 50,
|
||||
thickness_term_id: 60,
|
||||
moisture_term_id: 70,
|
||||
fig_tag_ids: [],
|
||||
feat_tag_ids: [80],
|
||||
title: 'Live Edge Cedar Slab',
|
||||
short_description: '96″ × 12–18″ × 2″ cedar',
|
||||
description: 'A solid cedar slab.\n\n- Kiln dried\n- Live edge',
|
||||
content_source: 'llm',
|
||||
species_confidence: null,
|
||||
figure_confidence: null,
|
||||
grade_confidence: null,
|
||||
inference_status: null,
|
||||
calibration_photo_id: null,
|
||||
woo_product_id: null,
|
||||
client_rev: 1,
|
||||
server_rev: 2,
|
||||
created_at: 't',
|
||||
updated_at: 't',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const tax: Taxonomy = {
|
||||
categories: [
|
||||
{ id: 1, name: 'Species', slug: 'species', parent_id: null, woo_id: null },
|
||||
{ id: 10, name: 'Western Red Cedar', slug: 'wrc', parent_id: 1, woo_id: null },
|
||||
{ id: 2, name: 'wcat', slug: 'wcat', parent_id: null, woo_id: null },
|
||||
{ id: 20, name: 'Live edge wood', slug: 'liveedge', parent_id: 2, woo_id: null },
|
||||
{ id: 21, name: 'Slabs & Lumber', slug: 'snl', parent_id: 2, woo_id: null },
|
||||
],
|
||||
attributes: [
|
||||
{
|
||||
id: 1, name: 'Edge type', slug: 'edge',
|
||||
terms: [{ id: 30, name: 'Live Edge', slug: 'le' }],
|
||||
},
|
||||
{
|
||||
id: 2, name: 'Figure', slug: 'figure',
|
||||
terms: [{ id: 40, name: 'Quilted', slug: 'quilt' }],
|
||||
},
|
||||
{
|
||||
id: 3, name: 'Grade', slug: 'grade',
|
||||
terms: [{ id: 50, name: 'Select', slug: 'sel' }],
|
||||
},
|
||||
{
|
||||
id: 4, name: 'Moisture', slug: 'moisture',
|
||||
terms: [{ id: 70, name: 'Kiln dried', slug: 'kd' }],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{ id: 80, name: 'Bookmatched', slug: 'feat-bookmatched' },
|
||||
],
|
||||
taxonomy_anchor: null,
|
||||
taxonomy_last_sync_at: null,
|
||||
};
|
||||
|
||||
const photos: PhotoRow[] = [
|
||||
{
|
||||
id: 'p2', seq: 2, is_calibration: 0, role: 'batch',
|
||||
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
|
||||
},
|
||||
{
|
||||
id: 'p1', seq: 1, is_calibration: 1, role: 'calibration',
|
||||
width_px: 100, height_px: 100, has_original: 1, has_processed: 0,
|
||||
},
|
||||
];
|
||||
|
||||
test('formatWidths joins min–max and avg', () => {
|
||||
assert.equal(formatWidths(baseSlab()), '12–18″ (avg 15″)');
|
||||
});
|
||||
|
||||
test('buildPublishPreviewModel: ordered thumbs, decoded names, all facts', () => {
|
||||
const m = buildPublishPreviewModel(baseSlab(), tax, photos, (id, kind) => `/p/${id}/${kind}`);
|
||||
assert.deepEqual(m.photos.map((p) => p.id), ['p1', 'p2']);
|
||||
assert.equal(m.photos[0].primary, true);
|
||||
assert.equal(m.photos[0].url, '/p/p1/original');
|
||||
assert.equal(m.photos[1].url, '/p/p2/processed');
|
||||
assert.equal(m.sku, 'SLAB-99');
|
||||
assert.equal(m.title, 'Live Edge Cedar Slab');
|
||||
assert.equal(m.price, '$420.50');
|
||||
assert.equal(m.shortDescription, '96″ × 12–18″ × 2″ cedar');
|
||||
assert.match(m.description, /Kiln dried/);
|
||||
|
||||
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
|
||||
assert.equal(by.Species, 'Western Red Cedar');
|
||||
assert.equal(by['Wood categories'], 'Live edge wood, Slabs & Lumber');
|
||||
assert.equal(by.Edge, 'Live Edge');
|
||||
assert.equal(by.Figure, 'Quilted');
|
||||
assert.equal(by.Grade, 'Select');
|
||||
assert.equal(by.Moisture, 'Kiln dried');
|
||||
assert.equal(by.Features, 'Bookmatched');
|
||||
assert.equal(by.Length, '96″');
|
||||
assert.equal(by.Widths, '12–18″ (avg 15″)');
|
||||
assert.equal(by.Thickness, '2″');
|
||||
assert.equal(by.Bdft, '16.00');
|
||||
});
|
||||
|
||||
test('buildPublishPreviewModel: empty optional fields show em dash', () => {
|
||||
const m = buildPublishPreviewModel(
|
||||
baseSlab({
|
||||
figure_term_ids: [],
|
||||
feat_tag_ids: [],
|
||||
edge_type_term_id: null,
|
||||
title: null,
|
||||
short_description: null,
|
||||
description: null,
|
||||
price: null,
|
||||
sku: '',
|
||||
}),
|
||||
tax,
|
||||
[],
|
||||
() => '',
|
||||
);
|
||||
assert.equal(m.photos.length, 0);
|
||||
assert.equal(m.title, '—');
|
||||
assert.equal(m.sku, '—');
|
||||
assert.equal(m.price, '—');
|
||||
assert.equal(m.shortDescription, '—');
|
||||
assert.equal(m.description, '—');
|
||||
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
|
||||
assert.equal(by.Edge, '—');
|
||||
assert.equal(by.Figure, '—');
|
||||
assert.equal(by.Features, '—');
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Pure builders for the Publish preview confirmation screen.
|
||||
* Resolves taxonomy ids → display names; never invents fields beyond the slab.
|
||||
*/
|
||||
import { decodeName } from '../decodeName';
|
||||
import type { PhotoRow, Slab, Taxonomy, TaxAttribute, TaxCategory, TaxTag } from '../../api/slabs';
|
||||
|
||||
export interface PreviewPhoto {
|
||||
id: string;
|
||||
url: string;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
/** Leftmost (seq order) = Woo cover. */
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
export interface PreviewFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PublishPreviewModel {
|
||||
photos: PreviewPhoto[];
|
||||
sku: string;
|
||||
title: string;
|
||||
price: string;
|
||||
facts: PreviewFact[];
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const fmt1 = (v: number) => {
|
||||
const s = v.toFixed(1);
|
||||
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||||
};
|
||||
const fmt2 = (v: number) => v.toFixed(2);
|
||||
|
||||
function catName(cats: TaxCategory[], id: number | null | undefined): string {
|
||||
if (id == null) return '—';
|
||||
const c = cats.find((x) => x.id === id);
|
||||
return c ? decodeName(c.name) : `#${id}`;
|
||||
}
|
||||
|
||||
function termName(attrs: TaxAttribute[], id: number | null | undefined): string {
|
||||
if (id == null) return '—';
|
||||
for (const a of attrs) {
|
||||
const t = a.terms.find((x) => x.id === id);
|
||||
if (t) return decodeName(t.name);
|
||||
}
|
||||
return `#${id}`;
|
||||
}
|
||||
|
||||
function attrTermsByName(attrs: TaxAttribute[], name: string): { id: number; name: string }[] {
|
||||
const attr = attrs.find((a) => a.name.toLowerCase() === name.toLowerCase());
|
||||
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: decodeName(t.name) }));
|
||||
}
|
||||
|
||||
function namesForIds(
|
||||
ids: number[],
|
||||
lookup: (id: number) => string | null,
|
||||
): string {
|
||||
if (!ids.length) return '—';
|
||||
return ids.map((id) => lookup(id) ?? `#${id}`).join(', ');
|
||||
}
|
||||
|
||||
function tagName(tags: TaxTag[], id: number): string | null {
|
||||
const t = tags.find((x) => x.id === id);
|
||||
return t ? decodeName(t.name) : null;
|
||||
}
|
||||
|
||||
/** Widths as "min–max (avg)" inches, matching Review dims language. */
|
||||
export function formatWidths(slab: Pick<Slab, 'width_min_in' | 'width_max_in' | 'width_avg_in'>): string {
|
||||
return `${fmt1(slab.width_min_in)}–${fmt1(slab.width_max_in)}″ (avg ${fmt1(slab.width_avg_in)}″)`;
|
||||
}
|
||||
|
||||
export function buildPublishPreviewModel(
|
||||
slab: Slab,
|
||||
tax: Taxonomy | null,
|
||||
photos: PhotoRow[],
|
||||
urlFor: (photoId: string, kind: 'original' | 'processed') => string,
|
||||
): PublishPreviewModel {
|
||||
const cats = tax?.categories ?? [];
|
||||
const attrs = tax?.attributes ?? [];
|
||||
const tags = tax?.tags ?? [];
|
||||
|
||||
const figureTerms = attrTermsByName(attrs, 'figure');
|
||||
const figureLookup = (id: number) => {
|
||||
const t = figureTerms.find((x) => x.id === id);
|
||||
return t ? t.name : null;
|
||||
};
|
||||
|
||||
const ordered = [...photos].sort((a, b) => a.seq - b.seq);
|
||||
const previewPhotos: PreviewPhoto[] = ordered.map((p, i) => ({
|
||||
id: p.id,
|
||||
url: urlFor(p.id, p.has_processed ? 'processed' : 'original'),
|
||||
role: p.role ?? 'batch',
|
||||
primary: i === 0,
|
||||
}));
|
||||
|
||||
const woodNames = namesForIds(slab.wood_category_ids ?? [], (id) => {
|
||||
const c = cats.find((x) => x.id === id);
|
||||
return c ? decodeName(c.name) : null;
|
||||
});
|
||||
|
||||
const facts: PreviewFact[] = [
|
||||
{ label: 'Species', value: catName(cats, slab.species_id) },
|
||||
{ label: 'Wood categories', value: woodNames },
|
||||
{ label: 'Edge', value: termName(attrs, slab.edge_type_term_id) },
|
||||
{
|
||||
label: 'Figure',
|
||||
value: namesForIds(slab.figure_term_ids ?? [], figureLookup),
|
||||
},
|
||||
{ label: 'Grade', value: termName(attrs, slab.grade_term_id) },
|
||||
{ label: 'Moisture', value: termName(attrs, slab.moisture_term_id) },
|
||||
{
|
||||
label: 'Features',
|
||||
value: namesForIds(slab.feat_tag_ids ?? [], (id) => tagName(tags, id)),
|
||||
},
|
||||
{ label: 'Length', value: `${fmt1(slab.length_in)}″` },
|
||||
{ label: 'Widths', value: formatWidths(slab) },
|
||||
{ label: 'Thickness', value: `${fmt1(slab.thickness_in)}″` },
|
||||
{ label: 'Bdft', value: fmt2(slab.bdft) },
|
||||
];
|
||||
|
||||
return {
|
||||
photos: previewPhotos,
|
||||
sku: slab.sku || '—',
|
||||
title: slab.title || '—',
|
||||
price: slab.price != null ? `$${slab.price.toFixed(2)}` : '—',
|
||||
facts,
|
||||
shortDescription: (slab.short_description ?? '').trim() || '—',
|
||||
description: (slab.description ?? '').trim() || '—',
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferContent, getCall2Prompt,
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
listPhotos, persistLineup, photoUrl, ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
||||
type Call2Prompt,
|
||||
} from '../../api/slabs';
|
||||
import type { CalibratedCapture } from '../flow';
|
||||
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
||||
import MultiSelect from './MultiSelect.svelte';
|
||||
import { decodeName } from '../decodeName';
|
||||
import { deriveFigTagIds, scopedFeatTags } from './figTags';
|
||||
import { deriveThicknessTermId } from './thicknessBand';
|
||||
import {
|
||||
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
import { nextPrimary } from './woodPrimary';
|
||||
import { missingCreateDescLabels } from './createDescGate';
|
||||
import { orderWoodCatGroups } from './woodCatOrder';
|
||||
import { applyMoistureDefault } from './moistureDefault';
|
||||
|
||||
// Props handed off by App (one-column flow, UX.md §1).
|
||||
export let slabId: string;
|
||||
@@ -33,7 +27,7 @@
|
||||
let loading = true;
|
||||
|
||||
// Picker option types (id + name + whether the AI recommended it).
|
||||
interface Opt { id: number; name: string; ai: boolean; lowIn?: number; slug?: string }
|
||||
interface Opt { id: number; name: string; ai: boolean; lowIn?: number }
|
||||
const byId = <T extends { id: number }>(arr: T[]) => {
|
||||
const m = new Map<number, T>();
|
||||
for (const x of arr) m.set(x.id, x);
|
||||
@@ -41,19 +35,22 @@
|
||||
};
|
||||
|
||||
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
|
||||
let speciesOpts: Opt[] = [];
|
||||
let woodCatGroups: import('./MultiSelect.svelte').Group[] = [];
|
||||
let edgeOpts: Opt[] = [];
|
||||
let figureOpts: Opt[] = [];
|
||||
let gradeOpts: Opt[] = [];
|
||||
let moistureOpts: Opt[] = [];
|
||||
let figTagOpts: Opt[] = [];
|
||||
let featTagOpts: Opt[] = [];
|
||||
|
||||
function deriveOptions() {
|
||||
if (!tax) return;
|
||||
const cats: TaxCategory[] = tax.categories;
|
||||
// Resolve the wood-category grouper BY SLUG (QUALITY-105 / METADATA-5 — never
|
||||
// hardcode live Woo term ids). Species is chosen on Measure and is not editable
|
||||
// on Review; only `wcat` leaves are listed here.
|
||||
// Resolve the two top-level groupers BY SLUG (QUALITY-105 / METADATA-5 — never
|
||||
// hardcode live Woo term ids): `species` → species options; `wcat` → wood
|
||||
// categories. Both are parent_id:null groupers; their children are the leaves.
|
||||
const speciesGrouper = cats.find((c) => c.parent_id === null && c.slug === 'species');
|
||||
const wcatGrouper = cats.find((c) => c.parent_id === null && c.slug === 'wcat');
|
||||
const childrenOf = (g: TaxCategory | undefined): TaxCategory[] =>
|
||||
g ? cats.filter((c) => c.parent_id === g.id) : [];
|
||||
@@ -67,53 +64,47 @@
|
||||
// attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined.
|
||||
const m = t.name.match(/\d+(?:[./]\d+)?/);
|
||||
const lowIn = m ? Number(m[0]) : undefined;
|
||||
return { id: t.id, name: decodeName(t.name), ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
|
||||
return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
|
||||
});
|
||||
};
|
||||
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
|
||||
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
|
||||
// assigned). Each direct child of `wcat` that has its own children becomes a parent
|
||||
// group with those children as leaves; a child with no children is a leaf under the
|
||||
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
|
||||
// UI order only: pin Slabs & Lumber (snl) parent first; Live edge wood (liveedge) leaf first.
|
||||
const rawGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
||||
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
||||
const kids = cats.filter((c) => c.parent_id === parent.id);
|
||||
const leaves: Opt[] = kids.length
|
||||
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false, slug: c.slug }))
|
||||
: [{ id: parent.id, name: decodeName(parent.name), ai: false, slug: parent.slug }];
|
||||
return {
|
||||
parent: { id: parent.id, name: decodeName(parent.name), slug: parent.slug },
|
||||
leaves,
|
||||
};
|
||||
? kids.map((c) => ({ id: c.id, name: c.name, ai: false }))
|
||||
: [{ id: parent.id, name: parent.name, ai: false }];
|
||||
return { parent: { id: parent.id, name: parent.name }, leaves };
|
||||
});
|
||||
woodCatGroups = orderWoodCatGroups(rawGroups);
|
||||
edgeOpts = termsOf('edge type');
|
||||
figureOpts = termsOf('figure');
|
||||
gradeOpts = termsOf('grade');
|
||||
// No thickness options: the band is derived server-side and never shown here
|
||||
// (METADATA-15 / QUALITY-95).
|
||||
moistureOpts = termsOf('moisture');
|
||||
// feat-* product tags (METADATA-30): slug prefix with hyphen. fig-* tags are
|
||||
// derived from Figure attribute selections (no separate picker).
|
||||
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
|
||||
// filtered by the family the backend keeps. Never invent a prefix — if the
|
||||
// store has none, the picker is legitimately empty.
|
||||
const tags: TaxTag[] = tax.tags ?? [];
|
||||
featTagOpts = scopedFeatTags(tags).map((t) => ({
|
||||
id: t.id, name: decodeName(t.name), ai: false,
|
||||
}));
|
||||
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||
}
|
||||
|
||||
// --- Editable model (mirrors the live slab) ---
|
||||
// Species is set on Measure / createSlab — Review only reads it (no picker).
|
||||
// speciesSel mirrors the resolved id for hydrate/debug; gates use effectiveSpeciesId.
|
||||
let speciesSel: string = '';
|
||||
// #90: single-selects bind a STRING var to the native <select>, because
|
||||
// <option value={id}> coerces the option value to a string. Binding a
|
||||
// `number | ''` var directly to those string values is the classic
|
||||
// number/string mismatch that leaves the selected option blank after AI
|
||||
// prefill or reload. `Number(...)` at commit normalizes back.
|
||||
let speciesSel: string = '';
|
||||
let edgeSel: string = '';
|
||||
let gradeSel: string = '';
|
||||
let woodCatIds: number[] = [];
|
||||
let figureIds: number[] = [];
|
||||
let moistureSel: string = '';
|
||||
let moistureId: number | '' = '';
|
||||
let figTagIds: number[] = [];
|
||||
let featTagIds: number[] = [];
|
||||
let primaryCatId: number | '' = '';
|
||||
@@ -135,56 +126,31 @@
|
||||
let reviewBusy = false;
|
||||
let reviewError = '';
|
||||
// #89: explicit "Create" actions. `createBusy` covers the deterministic
|
||||
// title/short-desc generation; `canCreateTitle` is its gate (species already
|
||||
// on the slab from Measure + ≥1 wood category). `canCreateDesc` gates Call 2
|
||||
// (writes short + long) on title + taxonomy + content LLM — not short/desc/price.
|
||||
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
|
||||
// category). `canCreateDesc` gates the AI long-description on title+short-desc
|
||||
// being populated AND the content LLM being enabled.
|
||||
let createBusy = false;
|
||||
let createError = '';
|
||||
// Prefer slab.species_id; fall back to Capture handoff if createSlab omitted it.
|
||||
$: resolvedSpeciesId = effectiveSpeciesId(slab?.species_id, capture?.speciesId ?? null);
|
||||
$: canCreateTitle = canCreateTitleFromSpecies(resolvedSpeciesId, woodCatIds.length);
|
||||
// Create-desc gate: recompute on every field change (no blur required).
|
||||
// Short/description are Call 2 outputs; price is publish-only.
|
||||
$: createDescMissing = missingCreateDescLabels({
|
||||
contentLlmEnabled: canGenerateContent,
|
||||
title,
|
||||
speciesId: resolvedSpeciesId,
|
||||
woodCategoryIds: woodCatIds,
|
||||
primaryCatId,
|
||||
edgeTermId: edgeSel,
|
||||
figureTermIds: figureIds,
|
||||
gradeTermId: gradeSel,
|
||||
moistureTermId: moistureSel,
|
||||
});
|
||||
$: canCreateDesc = createDescMissing.length === 0;
|
||||
$: canCreateTitle =
|
||||
speciesSel !== '' && woodCatIds.length > 0;
|
||||
$: canCreateDesc =
|
||||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
||||
// Monotonic token: each commit captures the current value and ignores its
|
||||
// result if a newer commit has since started. Without this, two concurrent
|
||||
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||||
// a stale "not ready" review clobbers the final ready one — the
|
||||
// continue-to-Publish button stays disabled even though the status reads ready.
|
||||
let commitToken = 0;
|
||||
// Live ready/missing: commit on input/change without requiring blur (extend Create-desc gate).
|
||||
let fieldCommitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let descCommitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function scheduleFieldCommit() {
|
||||
if (fieldCommitTimer) clearTimeout(fieldCommitTimer);
|
||||
fieldCommitTimer = setTimeout(() => {
|
||||
fieldCommitTimer = undefined;
|
||||
void commitReview();
|
||||
}, 250);
|
||||
}
|
||||
function scheduleDescCommit() {
|
||||
if (descCommitTimer) clearTimeout(descCommitTimer);
|
||||
descCommitTimer = setTimeout(() => {
|
||||
descCommitTimer = undefined;
|
||||
void commitDescription();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// --- Inference ---
|
||||
let aiBusy = false;
|
||||
let aiNote = '';
|
||||
let genBusy = false;
|
||||
let genError = '';
|
||||
let canGenerateContent = false;
|
||||
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
|
||||
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : null);
|
||||
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
|
||||
const fmt2 = (v: number) => v.toFixed(2);
|
||||
|
||||
@@ -274,8 +240,6 @@
|
||||
onDestroy(() => {
|
||||
for (const u of photoUrls) URL.revokeObjectURL(u);
|
||||
photoUrls = [];
|
||||
if (fieldCommitTimer) clearTimeout(fieldCommitTimer);
|
||||
if (descCommitTimer) clearTimeout(descCommitTimer);
|
||||
});
|
||||
|
||||
// Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT
|
||||
@@ -356,23 +320,13 @@
|
||||
}
|
||||
|
||||
function hydrate(s: Slab, t: Taxonomy | null) {
|
||||
const sid = effectiveSpeciesId(s.species_id, capture?.speciesId ?? null);
|
||||
speciesSel = sid != null ? String(sid) : '';
|
||||
speciesSel = s.species_id != null ? String(s.species_id) : '';
|
||||
woodCatIds = [...(s.wood_category_ids ?? [])];
|
||||
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
||||
figureIds = [...(s.figure_term_ids ?? [])];
|
||||
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
||||
// Moisture default Kiln dried (slug kd) when empty — mount commitReview persists it.
|
||||
{
|
||||
const moistAttr = t?.attributes.find((a) => a.name.toLowerCase() === 'moisture');
|
||||
const moistTerms = (moistAttr?.terms ?? []).map((x) => ({
|
||||
id: x.id, name: x.name, slug: x.slug,
|
||||
}));
|
||||
const applied = applyMoistureDefault(s.moisture_term_id, moistTerms);
|
||||
moistureSel = applied === '' ? '' : String(applied);
|
||||
// Keep native <select> in sync when we programmatically default empty → kd.
|
||||
void syncSelects();
|
||||
}
|
||||
moistureId = s.moisture_term_id ?? '';
|
||||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||
title = s.title ?? '';
|
||||
shortDescription = s.short_description ?? '';
|
||||
@@ -383,29 +337,37 @@
|
||||
sku = s.sku ?? '';
|
||||
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
||||
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
||||
// fig_tag_ids are derived reactively from figureIds (no hydrate of stored fig tags).
|
||||
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
||||
// its weight behind the value the owner kept).
|
||||
aiRecommendations = [];
|
||||
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
|
||||
aiRecommendations.push(s.species_id);
|
||||
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
|
||||
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
||||
}
|
||||
|
||||
// Call 1 out: no AI badges on options. Groups/opts pass through as-is.
|
||||
$: woodCatGroupsView = woodCatGroups;
|
||||
$: figureOptsView = figureOpts;
|
||||
$: featTagOptsView = featTagOpts;
|
||||
// fig_tag_ids derived from selected Figure term names ↔ fig-* tag names (casefold).
|
||||
$: figTagIds = tax
|
||||
? deriveFigTagIds(figureIds, figureOpts, tax.tags ?? [])
|
||||
: figTagIds;
|
||||
// AI-marked option views (badge on rows after Call 1 / stored confidences).
|
||||
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
$: woodCatGroupsView = woodCatGroups.map((g) => ({
|
||||
parent: g.parent,
|
||||
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
|
||||
}));
|
||||
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
// Flat leaf name lookup for the primary radio set (REVIEW-15).
|
||||
$: woodLeafById = new Map(
|
||||
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
|
||||
);
|
||||
$: primaryOpts = woodCatIds
|
||||
.map((id) => woodLeafById.get(id))
|
||||
.filter((o): o is Opt => !!o);
|
||||
.filter((o): o is Opt => !!o)
|
||||
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
|
||||
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
|
||||
// measured thickness against the live synced Woo bands. It is deliberately absent
|
||||
// from this screen — no owner control, no read-only echo. The only thickness the
|
||||
// owner sees here is the measured value in the summary above.
|
||||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||||
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
||||
// #89: the deterministic title/short_description are written here — the server's
|
||||
// review returns the build from the current metadata, and if the field is empty
|
||||
@@ -417,20 +379,12 @@
|
||||
reviewBusy = true;
|
||||
reviewError = '';
|
||||
try {
|
||||
// Repair Measure species onto the row before probing title (review reads the DB).
|
||||
if (resolvedSpeciesId != null && slab.species_id == null) {
|
||||
slab = await updateSlab(slabId, {
|
||||
species_id: resolvedSpeciesId,
|
||||
client_rev: slab.server_rev,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
}
|
||||
// Run the deterministic review FIRST (reads the current metadata) to get the
|
||||
// title/short_description build before we persist.
|
||||
// Do NOT send price/sku here: review POST persists those overrides and bumps
|
||||
// server_rev (#80), which would stale the client_rev on the PUT below.
|
||||
const probe = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
|
||||
@@ -438,14 +392,13 @@
|
||||
if (probe.title && title === '') title = probe.title;
|
||||
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
|
||||
|
||||
// Always echo known species_id (slab or Capture repair) so Review PUTs never
|
||||
// leave a Measure-chosen species off the row; never send null (would wipe).
|
||||
const patch: Record<string, unknown> = {
|
||||
const patch = {
|
||||
species_id: speciesSel === '' ? null : Number(speciesSel),
|
||||
wood_category_ids: woodCatIds,
|
||||
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
||||
figure_term_ids: figureIds,
|
||||
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
|
||||
moisture_term_id: moistureSel === '' ? null : Number(moistureSel),
|
||||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||
fig_tag_ids: figTagIds,
|
||||
feat_tag_ids: featTagIds,
|
||||
title: title || null,
|
||||
@@ -456,21 +409,17 @@
|
||||
sku: sku || null,
|
||||
client_rev: slab.server_rev,
|
||||
};
|
||||
if (resolvedSpeciesId != null) patch.species_id = resolvedSpeciesId;
|
||||
const fresh = await updateSlab(slabId, patch as Parameters<typeof updateSlab>[1]);
|
||||
const fresh = await updateSlab(slabId, patch);
|
||||
if (myToken !== commitToken) return; // superseded; don't clobber the latest
|
||||
slab = fresh;
|
||||
// Re-run the review against the just-persisted row (authoritative ready/missing).
|
||||
// No price/sku in payload — already on the row from the PUT; sending them
|
||||
// would bump server_rev again and stale the next edit's client_rev.
|
||||
review = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
|
||||
// Post-PUT review may newly have species+wood cats — fill empty Create fields.
|
||||
if (review.title && title === '') title = review.title;
|
||||
if (review.short_description && shortDescription === '') shortDescription = review.short_description;
|
||||
} catch (e) {
|
||||
if (myToken !== commitToken) return;
|
||||
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||||
@@ -490,14 +439,6 @@
|
||||
createBusy = true;
|
||||
createError = '';
|
||||
try {
|
||||
// 0. Repair: if Measure chose a species but createSlab omitted it, write it
|
||||
// before the deterministic build (review reads species_id from the row).
|
||||
if (resolvedSpeciesId != null && slab.species_id == null) {
|
||||
slab = await updateSlab(slabId, {
|
||||
species_id: resolvedSpeciesId,
|
||||
client_rev: slab.server_rev,
|
||||
});
|
||||
}
|
||||
// 1. Run the deterministic review to get title / short_description.
|
||||
const res = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
@@ -508,11 +449,10 @@
|
||||
if (res.short_description) shortDescription = res.short_description;
|
||||
// 3. Persist the generated prose (single PUT, fresh client_rev).
|
||||
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
|
||||
const prosePatch: Parameters<typeof updateSlab>[1] = {
|
||||
const prosePatch = {
|
||||
title: title || null,
|
||||
short_description: shortDescription || null,
|
||||
client_rev: (slab as Slab).server_rev,
|
||||
...(resolvedSpeciesId != null ? { species_id: resolvedSpeciesId } : {}),
|
||||
};
|
||||
const fresh = await updateSlab(slabId, prosePatch);
|
||||
slab = fresh;
|
||||
@@ -554,6 +494,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function runInference() {
|
||||
if (!slab) return;
|
||||
aiBusy = true;
|
||||
aiNote = '';
|
||||
try {
|
||||
const r = await inferTaxon(slabId);
|
||||
const s = r.suggestions;
|
||||
const recs: number[] = [];
|
||||
if (s.species_id) { speciesSel = String(s.species_id); recs.push(s.species_id); }
|
||||
if (s.wood_category_ids) {
|
||||
woodCatIds = [...s.wood_category_ids];
|
||||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||||
recs.push(...s.wood_category_ids);
|
||||
}
|
||||
if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id);
|
||||
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
|
||||
if (s.feature_tag_ids?.length) {
|
||||
featTagIds = [...s.feature_tag_ids];
|
||||
recs.push(...s.feature_tag_ids);
|
||||
}
|
||||
// Grade is not AI-suggested (METADATA-40) — owner judgment. Do not auto-fill.
|
||||
aiRecommendations = recs;
|
||||
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
||||
await commitReview();
|
||||
// #90: force the <select> DOM to reflect the prefilled values after Svelte's
|
||||
// own select_option(reset) has run.
|
||||
syncSelects();
|
||||
} catch (e) {
|
||||
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
|
||||
} finally {
|
||||
aiBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateContent() {
|
||||
if (!slab || !canCreateDesc) return;
|
||||
genBusy = true;
|
||||
@@ -561,12 +535,10 @@
|
||||
try {
|
||||
const r = await inferContent(slabId);
|
||||
description = r.description;
|
||||
if (r.short_description) shortDescription = r.short_description;
|
||||
contentSource = 'llm';
|
||||
// Persist Call 2 short + long (commitReview does not send description — see #89).
|
||||
// commitReview() no longer sends description (see #89), so persist it explicitly.
|
||||
const myToken = ++commitToken;
|
||||
const fresh = await updateSlab(slabId, {
|
||||
short_description: shortDescription || null,
|
||||
description: description || null,
|
||||
content_source: contentSource || null,
|
||||
client_rev: slab.server_rev,
|
||||
@@ -580,40 +552,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// View prompt: read-only assembled Call 2 text + photo slots (shared server assembler).
|
||||
let viewPromptOpen = false;
|
||||
let viewPromptBusy = false;
|
||||
let viewPromptError = '';
|
||||
let viewPrompt: Call2Prompt | null = null;
|
||||
let viewPromptCopied = false;
|
||||
|
||||
async function loadViewPrompt() {
|
||||
viewPromptBusy = true;
|
||||
viewPromptError = '';
|
||||
viewPromptCopied = false;
|
||||
try {
|
||||
// Persist current Review fields first so the assembled text matches what Create desc would send.
|
||||
await commitReview();
|
||||
viewPrompt = await getCall2Prompt(slabId);
|
||||
viewPromptOpen = true;
|
||||
} catch (e) {
|
||||
viewPromptError = e instanceof ApiError ? e.message : 'Could not load the prompt.';
|
||||
viewPromptOpen = true;
|
||||
} finally {
|
||||
viewPromptBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyViewPrompt() {
|
||||
if (!viewPrompt?.text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(viewPrompt.text);
|
||||
viewPromptCopied = true;
|
||||
} catch {
|
||||
viewPromptError = 'Clipboard unavailable — select the text and copy manually.';
|
||||
}
|
||||
}
|
||||
|
||||
const missingCount = () => (review ? review.missing.length : null);
|
||||
const ready = () => review?.ready === true;
|
||||
// The button's enabled state must derive from the SAME reactive `review` object
|
||||
@@ -637,13 +575,13 @@
|
||||
// option matches the bound value. Idempotent: only writes when they differ.
|
||||
async function syncSelects() {
|
||||
await tick();
|
||||
if (speciesEl && speciesEl.value !== speciesSel) speciesEl.value = speciesSel;
|
||||
if (edgeEl && edgeEl.value !== edgeSel) edgeEl.value = edgeSel;
|
||||
if (gradeEl && gradeEl.value !== gradeSel) gradeEl.value = gradeSel;
|
||||
if (moistureEl && moistureEl.value !== moistureSel) moistureEl.value = moistureSel;
|
||||
}
|
||||
let speciesEl: HTMLSelectElement | undefined;
|
||||
let edgeEl: HTMLSelectElement | undefined;
|
||||
let gradeEl: HTMLSelectElement | undefined;
|
||||
let moistureEl: HTMLSelectElement | undefined;
|
||||
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
|
||||
function toggleWoodCat(id: number) {
|
||||
woodCatIds = toggleMulti(woodCatIds, id);
|
||||
@@ -671,8 +609,6 @@
|
||||
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||
} finally {
|
||||
loading = false;
|
||||
// Form mounts after loading flips false — sync selects (incl. moisture kd default).
|
||||
void syncSelects();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -751,12 +687,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Call 1 OUT (overnight-whole-app): Suggest-with-AI removed. Call 2 generateContent stays. -->
|
||||
<!-- Species: chosen on Measure; no Review picker (value stays on the slab from createSlab). -->
|
||||
{#if settings?.inference_enabled}
|
||||
<div class="aibar">
|
||||
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
|
||||
{aiBusy ? 'Reading slab…' : 'Suggest with AI'}
|
||||
</button>
|
||||
{#if aiNote}<p class="ai-note" data-testid="ai-note">{aiNote}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Species (single, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
|
||||
<select id="species" class="sel" data-testid="species" bind:this={speciesEl} bind:value={speciesSel} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each speciesOpts as o (o.id)}
|
||||
<option value={o.id} data-ai={aiRecommendations.includes(o.id) ? 'true' : ''}>{o.name}{aiRecommendations.includes(o.id) ? ' (AI)' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
||||
<div class="field" data-testid="wood-cat-field">
|
||||
<label class="lbl">Wood categories</label>
|
||||
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
groups={woodCatGroupsView}
|
||||
@@ -764,7 +717,6 @@
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={toggleWoodCat}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
{#if primaryOpts.length}
|
||||
@@ -782,6 +734,7 @@
|
||||
>
|
||||
<span class="ps-radio" aria-hidden="true"></span>
|
||||
<span class="ps-name">{o.name}</span>
|
||||
{#if o.ai}<span class="ps-tag">AI</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<p class="ps-note">
|
||||
@@ -792,9 +745,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edge type (single) -->
|
||||
<!-- Edge type (single, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="edge">Edge type</label>
|
||||
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
|
||||
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each edgeOpts as o (o.id)}
|
||||
@@ -803,9 +756,9 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Figure (multi, searchable list); fig-* tags derived from selections -->
|
||||
<!-- Figure (multi, searchable list, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl">Figure</label>
|
||||
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Figure"
|
||||
options={figureOptsView}
|
||||
@@ -813,7 +766,6 @@
|
||||
placeholder="Choose figure"
|
||||
testid="figure"
|
||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
@@ -827,17 +779,30 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Features = feat-* product tags (optional; Call 1 out — no AI badge) -->
|
||||
<!-- fig-* product tags (optional, owner-only, no AI) -->
|
||||
<div class="field">
|
||||
<label class="lbl">Features <span class="sug">optional</span></label>
|
||||
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
|
||||
<MultiSelect
|
||||
label="Features"
|
||||
label="fig-* tags"
|
||||
options={figTagOpts}
|
||||
selected={figTagIds}
|
||||
placeholder="Choose fig-* tags"
|
||||
testid="fig-tag"
|
||||
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- feat-* product tags (optional, AI-assisted by Call 1) -->
|
||||
<div class="field">
|
||||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="feat-* tags"
|
||||
options={featTagOptsView}
|
||||
selected={featTagIds}
|
||||
placeholder="Choose features"
|
||||
placeholder="Choose feat-* tags"
|
||||
testid="feat-tag"
|
||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
@@ -846,7 +811,7 @@
|
||||
<div class="field">
|
||||
|
||||
<label class="lbl" for="moisture">Moisture</label>
|
||||
<select id="moisture" class="sel" data-testid="moisture" bind:this={moistureEl} bind:value={moistureSel} on:change={commitReview}>
|
||||
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
@@ -856,8 +821,8 @@
|
||||
<div class="field">
|
||||
<label class="lbl" for="title">Title</label>
|
||||
<div class="create-row">
|
||||
<input id="title" class="inp" data-testid="title" bind:value={title} on:input={scheduleFieldCommit} />
|
||||
<button type="button" class="btn-sec" data-testid="create-title"
|
||||
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||||
<button type="button" class="create-btn" data-testid="create-title"
|
||||
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||||
{createBusy ? 'Building…' : 'Create title'}
|
||||
</button>
|
||||
@@ -866,8 +831,8 @@
|
||||
<div class="field">
|
||||
<label class="lbl" for="short-desc">Short description</label>
|
||||
<div class="create-row">
|
||||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} on:input={scheduleFieldCommit} />
|
||||
<button type="button" class="btn-sec" data-testid="create-short-desc"
|
||||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||||
<button type="button" class="create-btn" data-testid="create-short-desc"
|
||||
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||||
{createBusy ? 'Building…' : 'Create short desc'}
|
||||
</button>
|
||||
@@ -875,60 +840,12 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="desc">Description</label>
|
||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:input={scheduleDescCommit} on:blur={commitDescription}></textarea>
|
||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
||||
{#if canCreateDesc}
|
||||
<div class="gen">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sec"
|
||||
data-testid="create-desc"
|
||||
on:click={generateContent}
|
||||
disabled={!canCreateDesc || genBusy}
|
||||
title={canCreateDesc ? 'Generate short + long description (Call 2)' : `Missing: ${createDescMissing.join(', ')}`}
|
||||
>
|
||||
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
|
||||
{genBusy ? 'Writing…' : 'Create desc'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sec"
|
||||
data-testid="view-prompt"
|
||||
on:click={() => { if (viewPromptOpen) viewPromptOpen = false; else void loadViewPrompt(); }}
|
||||
disabled={viewPromptBusy}
|
||||
>
|
||||
{viewPromptBusy ? 'Loading…' : (viewPromptOpen ? 'Hide prompt' : 'View prompt')}
|
||||
</button>
|
||||
</div>
|
||||
{#if !canCreateDesc}
|
||||
<p class="nudge" data-testid="create-desc-missing">
|
||||
Create desc needs: {createDescMissing.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if viewPromptOpen}
|
||||
<div class="prompt-panel" data-testid="view-prompt-panel">
|
||||
<div class="prompt-head">
|
||||
<span class="prompt-title">Call 2 prompt (read-only)</span>
|
||||
<button type="button" class="mini" data-testid="copy-prompt" on:click={copyViewPrompt} disabled={!viewPrompt?.text}>
|
||||
{viewPromptCopied ? 'Copied' : 'Copy text'}
|
||||
</button>
|
||||
<button type="button" class="mini" data-testid="close-prompt" on:click={() => (viewPromptOpen = false)}>Close</button>
|
||||
</div>
|
||||
{#if viewPromptError}<p class="nudge" role="alert">{viewPromptError}</p>{/if}
|
||||
{#if viewPrompt}
|
||||
<pre class="prompt-text" data-testid="prompt-text">{viewPrompt.text}</pre>
|
||||
<div class="prompt-photos" data-testid="prompt-photos">
|
||||
<div class="prompt-photos-lbl">Photo slots (no base64) — up to 8 processed PNGs</div>
|
||||
{#if viewPrompt.photos.length === 0}
|
||||
<p class="nudge">No processed photos on this slab.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each viewPrompt.photos as ph (ph.id)}
|
||||
<li data-testid="prompt-photo-slot">
|
||||
seq={ph.seq} · id={ph.id} · {ph.bytes} bytes · long-edge {ph.long_edge_px ?? '?'}px
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||
@@ -940,11 +857,11 @@
|
||||
<label class="lbl" for="price">Price ($)
|
||||
{#if review?.price_suggestion != null}<span class="sug">suggested ${review.price_suggestion.toFixed(2)}</span>{/if}
|
||||
</label>
|
||||
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:input={scheduleFieldCommit} on:change={commitReview} />
|
||||
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:change={commitReview} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="sku">SKU <span class="sug">last field, 3–24 chars</span></label>
|
||||
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:input={scheduleFieldCommit} on:blur={commitReview} />
|
||||
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:blur={commitReview} />
|
||||
{#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)}
|
||||
<p class="nudge">SKU must be 3–24 alphanumerics or hyphens.</p>
|
||||
{/if}
|
||||
@@ -984,11 +901,16 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.review { max-width: 560px; margin: 0 auto; color: #eee; padding: 4px 4px 20px; box-sizing: border-box; }
|
||||
.review { max-width: 560px; margin: 0 auto; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
|
||||
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
.aibar { margin: 12px 0; }
|
||||
.ai-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .85rem; }
|
||||
.ai-btn:disabled { background: #333; color: #777; }
|
||||
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
|
||||
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||||
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
||||
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
||||
and a border pokes out of its container by that padding+border (20px here) —
|
||||
which becomes real page overflow whenever the column sits at the viewport edge
|
||||
@@ -999,25 +921,15 @@
|
||||
.field { margin-bottom: 12px; }
|
||||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||
.gen { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
/* Unified secondary actions: Create title/short/desc, View prompt — same size & label weight. */
|
||||
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||||
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
|
||||
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
|
||||
.create-row { display: flex; gap: 8px; align-items: stretch; }
|
||||
.create-row .inp { flex: 1; }
|
||||
.btn-sec { box-sizing: border-box; padding: 9px 12px; border: 1px solid #444; border-radius: 3px;
|
||||
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
|
||||
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
||||
.btn-sec:hover { background: #242424; }
|
||||
.btn-sec:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
|
||||
.prompt-panel { margin-top: 10px; border: 1px solid #333; border-radius: 3px; background: #141414; padding: 10px 12px; }
|
||||
.prompt-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.prompt-title { font-size: .78rem; color: #9a9a9a; flex: 1; min-width: 120px; }
|
||||
.mini { box-sizing: border-box; padding: 6px 10px; border: 1px solid #444; border-radius: 3px;
|
||||
background: #1d1d1d; color: #ccc; font-size: .74rem; font-weight: 600; cursor: pointer; }
|
||||
.mini:disabled { color: #666; cursor: not-allowed; }
|
||||
.prompt-text { margin: 0; padding: 10px; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 3px;
|
||||
color: #ddd; font-size: .72rem; line-height: 1.45; white-space: pre-wrap; word-break: break-word; max-height: 280px; overflow: auto; }
|
||||
.prompt-photos { margin-top: 10px; }
|
||||
.prompt-photos-lbl { font-size: .72rem; color: #888; margin-bottom: 4px; }
|
||||
.prompt-photos ul { margin: 0; padding-left: 18px; color: #aaa; font-size: .72rem; }
|
||||
.create-btn:hover { background: #242424; }
|
||||
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
|
||||
|
||||
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
|
||||
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
|
||||
@@ -1031,14 +943,15 @@
|
||||
.ps-opt.on .ps-radio { border-color: #00e5ff; }
|
||||
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
|
||||
.ps-name { flex: 1; }
|
||||
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
|
||||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||
.status.ok { background: #122a12; color: #7fd37f; }
|
||||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; cursor: pointer; }
|
||||
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
|
||||
.ghost { box-sizing: border-box; padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; font-size: .9rem; font-weight: 600; cursor: pointer; }
|
||||
.primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.err { color: #e88; } .busy { color: #aaa; }
|
||||
|
||||
/* #90 Photo strip */
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
canCreateDesc,
|
||||
missingCreateDesc,
|
||||
missingCreateDescLabels,
|
||||
} from './createDescGate';
|
||||
|
||||
const full = {
|
||||
contentLlmEnabled: true,
|
||||
title: 'Figured Walnut Live Edge Slab',
|
||||
speciesId: 11,
|
||||
woodCategoryIds: [21],
|
||||
primaryCatId: 21,
|
||||
edgeTermId: '321',
|
||||
figureTermIds: [328],
|
||||
gradeTermId: 342,
|
||||
moistureTermId: '326',
|
||||
};
|
||||
|
||||
test('Create desc gate: full set passes; does not require short or description', () => {
|
||||
assert.equal(canCreateDesc(full), true);
|
||||
assert.deepEqual(missingCreateDesc(full), []);
|
||||
});
|
||||
|
||||
test('Create desc gate: empty price still passes when other fields full', () => {
|
||||
// Price is publish-only; Create desc must not gate on it.
|
||||
assert.equal(canCreateDesc(full), true);
|
||||
assert.deepEqual(missingCreateDesc(full), []);
|
||||
});
|
||||
|
||||
test('Create desc gate: lists each missing field; no short/description/price key', () => {
|
||||
const miss = missingCreateDesc({
|
||||
...full,
|
||||
contentLlmEnabled: false,
|
||||
title: '',
|
||||
speciesId: null,
|
||||
woodCategoryIds: [],
|
||||
primaryCatId: '',
|
||||
edgeTermId: '',
|
||||
figureTermIds: [],
|
||||
gradeTermId: '',
|
||||
moistureTermId: '',
|
||||
});
|
||||
assert.deepEqual(miss, [
|
||||
'content_llm',
|
||||
'title',
|
||||
'species',
|
||||
'wood_categories',
|
||||
'primary',
|
||||
'edge',
|
||||
'figure',
|
||||
'grade',
|
||||
'moisture',
|
||||
]);
|
||||
assert.equal(miss.includes('short_description'), false);
|
||||
assert.equal(miss.includes('description'), false);
|
||||
assert.equal(miss.includes('price'), false);
|
||||
const labels = missingCreateDescLabels({
|
||||
...full,
|
||||
figureTermIds: [],
|
||||
});
|
||||
assert.ok(labels.includes('≥1 figure'));
|
||||
assert.equal(labels.includes('price'), false);
|
||||
});
|
||||
|
||||
test('Create desc gate: recomputes on field change (reactive-ready)', () => {
|
||||
assert.equal(canCreateDesc({ ...full, figureTermIds: [] }), false);
|
||||
assert.equal(canCreateDesc({ ...full, figureTermIds: [1] }), true);
|
||||
assert.equal(canCreateDesc({ ...full, moistureTermId: '' }), false);
|
||||
assert.equal(canCreateDesc({ ...full, moistureTermId: 326 }), true);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Create-desc gate (Call 2): disable Create desc unless these are present.
|
||||
* Does NOT require short_description, description, or price — Call 2 writes
|
||||
* short + long; price stays publish-only.
|
||||
*/
|
||||
|
||||
export type CreateDescInput = {
|
||||
contentLlmEnabled: boolean;
|
||||
title: string;
|
||||
speciesId: number | null | undefined;
|
||||
woodCategoryIds: number[];
|
||||
primaryCatId: number | '' | null | undefined;
|
||||
edgeTermId: number | string | '' | null | undefined;
|
||||
figureTermIds: number[];
|
||||
gradeTermId: number | string | '' | null | undefined;
|
||||
moistureTermId: number | string | '' | null | undefined;
|
||||
};
|
||||
|
||||
/** Human labels shown under Create desc when the gate fails. */
|
||||
export const CREATE_DESC_LABELS: Record<string, string> = {
|
||||
content_llm: 'content LLM on',
|
||||
title: 'title',
|
||||
species: 'species',
|
||||
wood_categories: '≥1 wood category',
|
||||
primary: 'primary wood category',
|
||||
edge: 'edge type',
|
||||
figure: '≥1 figure',
|
||||
grade: 'grade',
|
||||
moisture: 'moisture',
|
||||
};
|
||||
|
||||
function presentId(v: number | string | '' | null | undefined): boolean {
|
||||
if (v === null || v === undefined || v === '') return false;
|
||||
if (typeof v === 'number') return Number.isFinite(v);
|
||||
return String(v).trim() !== '';
|
||||
}
|
||||
|
||||
/** Return missing field keys (stable order). Empty ⇒ Create desc enabled. */
|
||||
export function missingCreateDesc(input: CreateDescInput): string[] {
|
||||
const missing: string[] = [];
|
||||
if (!input.contentLlmEnabled) missing.push('content_llm');
|
||||
if (!String(input.title ?? '').trim()) missing.push('title');
|
||||
if (input.speciesId == null || !Number.isFinite(Number(input.speciesId))) missing.push('species');
|
||||
if (!input.woodCategoryIds?.length) missing.push('wood_categories');
|
||||
if (!presentId(input.primaryCatId)) missing.push('primary');
|
||||
if (!presentId(input.edgeTermId)) missing.push('edge');
|
||||
if (!input.figureTermIds?.length) missing.push('figure');
|
||||
if (!presentId(input.gradeTermId)) missing.push('grade');
|
||||
if (!presentId(input.moistureTermId)) missing.push('moisture');
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function canCreateDesc(input: CreateDescInput): boolean {
|
||||
return missingCreateDesc(input).length === 0;
|
||||
}
|
||||
|
||||
export function missingCreateDescLabels(input: CreateDescInput): string[] {
|
||||
return missingCreateDesc(input).map((k) => CREATE_DESC_LABELS[k] ?? k);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
deriveFigTagIds,
|
||||
normName,
|
||||
scopedFeatTags,
|
||||
scopedFigTags,
|
||||
} from './figTags';
|
||||
|
||||
test('scoped tags require hyphenated slug prefix (not bare fig/feat)', () => {
|
||||
const tags = [
|
||||
{ id: 1, name: 'Curl', slug: 'fig-curl' },
|
||||
{ id: 2, name: 'Knot', slug: 'feat-knot' },
|
||||
{ id: 3, name: 'Figured', slug: 'figured' }, // no hyphen — out
|
||||
{ id: 4, name: 'Featured', slug: 'featured' },
|
||||
{ id: 5, name: 'fig-fake-name', slug: 'other' },
|
||||
];
|
||||
assert.deepEqual(scopedFigTags(tags).map((t) => t.id), [1]);
|
||||
assert.deepEqual(scopedFeatTags(tags).map((t) => t.id), [2]);
|
||||
});
|
||||
|
||||
test('deriveFigTagIds: casefold/strip name equality; Curl≠Curly', () => {
|
||||
const terms = [
|
||||
{ id: 10, name: 'Curl' },
|
||||
{ id: 11, name: ' Birdseye ' },
|
||||
];
|
||||
const tags = [
|
||||
{ id: 100, name: 'curl', slug: 'fig-curl' },
|
||||
{ id: 101, name: 'Curly', slug: 'fig-curly' },
|
||||
{ id: 102, name: 'Birdseye', slug: 'fig-birdseye' },
|
||||
{ id: 103, name: 'Knot', slug: 'feat-knot' },
|
||||
];
|
||||
assert.deepEqual(deriveFigTagIds([10], terms, tags), [100]);
|
||||
assert.deepEqual(deriveFigTagIds([10, 11], terms, tags).sort(), [100, 102]);
|
||||
// Curly tag stays unmatched when only Curl is selected
|
||||
assert.ok(!deriveFigTagIds([10], terms, tags).includes(101));
|
||||
assert.deepEqual(deriveFigTagIds([], terms, tags), []);
|
||||
});
|
||||
|
||||
test('normName strips and lowercases', () => {
|
||||
assert.equal(normName(' Curl '), 'curl');
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Derive fig-* product tag ids from selected Figure attribute terms.
|
||||
* Match is case-insensitive name equality (casefold/strip) against tags whose
|
||||
* slug starts with `fig-`. No hardcoded name bridges (Curl ≠ Curly).
|
||||
*/
|
||||
export function normName(name: string): string {
|
||||
// JS has no String.casefold; toLowerCase covers ASCII wood-term names.
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function scopedFigTags<T extends { slug: string }>(tags: T[]): T[] {
|
||||
return tags.filter((t) => t.slug.startsWith('fig-'));
|
||||
}
|
||||
|
||||
export function scopedFeatTags<T extends { slug: string }>(tags: T[]): T[] {
|
||||
return tags.filter((t) => t.slug.startsWith('feat-'));
|
||||
}
|
||||
|
||||
export function deriveFigTagIds(
|
||||
figureTermIds: number[],
|
||||
figureTerms: { id: number; name: string }[],
|
||||
tags: { id: number; name: string; slug: string }[],
|
||||
): number[] {
|
||||
const selected = new Set(
|
||||
figureTerms
|
||||
.filter((t) => figureTermIds.includes(t.id))
|
||||
.map((t) => normName(t.name)),
|
||||
);
|
||||
if (selected.size === 0) return [];
|
||||
return scopedFigTags(tags)
|
||||
.filter((t) => selected.has(normName(t.name)))
|
||||
.map((t) => t.id);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyMoistureDefault, resolveKilnDriedId } from './moistureDefault';
|
||||
|
||||
const terms = [
|
||||
{ id: 1, name: 'Air dried', slug: 'ad' },
|
||||
{ id: 326, name: 'Kiln dried', slug: 'kd' },
|
||||
];
|
||||
|
||||
test('moisture default: slug kd wins; name match fallback; scoped empty stays empty', () => {
|
||||
assert.equal(resolveKilnDriedId(terms), 326);
|
||||
assert.equal(resolveKilnDriedId([{ id: 9, name: 'Kiln dried', slug: 'kiln-dried' }]), 9);
|
||||
assert.equal(resolveKilnDriedId([{ id: 1, name: 'Air dried', slug: 'ad' }]), null);
|
||||
assert.equal(applyMoistureDefault('', terms), 326);
|
||||
assert.equal(applyMoistureDefault(null, terms), 326);
|
||||
assert.equal(applyMoistureDefault(1, terms), 1);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Moisture default: Kiln dried (slug `kd` / name match). METADATA-15.
|
||||
* Resolve by synced taxonomy — never a hardcoded Woo term id.
|
||||
*/
|
||||
|
||||
export type MoistureTerm = { id: number; name: string; slug: string };
|
||||
|
||||
export function resolveKilnDriedId(terms: MoistureTerm[]): number | null {
|
||||
const bySlug = terms.find((t) => t.slug.toLowerCase() === 'kd');
|
||||
if (bySlug) return bySlug.id;
|
||||
const byName = terms.find((t) => {
|
||||
const n = t.name.trim().toLowerCase();
|
||||
return n === 'kiln dried' || n === 'kiln-dried';
|
||||
});
|
||||
return byName ? byName.id : null;
|
||||
}
|
||||
|
||||
/** If moisture is empty, return kiln-dried id; else keep current. */
|
||||
export function applyMoistureDefault(
|
||||
current: number | '' | null | undefined,
|
||||
terms: MoistureTerm[],
|
||||
): number | '' {
|
||||
if (current !== null && current !== undefined && current !== '') {
|
||||
return typeof current === 'number' ? current : Number(current) || '';
|
||||
}
|
||||
const kd = resolveKilnDriedId(terms);
|
||||
return kd == null ? '' : kd;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const reviewSrc = readFileSync(join(dir, 'Review.svelte'), 'utf8');
|
||||
const figSrc = readFileSync(join(dir, 'figTags.ts'), 'utf8');
|
||||
|
||||
test('Review UI: no AI assist labels or AI badge chrome', () => {
|
||||
assert.equal(reviewSrc.includes('AI assist'), false);
|
||||
assert.equal(reviewSrc.includes('class="ai"'), false);
|
||||
assert.equal(reviewSrc.includes('ps-tag'), false);
|
||||
});
|
||||
|
||||
test('Review UI: no fig-* MultiSelect picker', () => {
|
||||
assert.equal(reviewSrc.includes('data-testid="fig-tag"'), false);
|
||||
assert.equal(reviewSrc.includes('figTagOpts'), false);
|
||||
assert.equal(reviewSrc.includes('label="fig-* tags"'), false);
|
||||
assert.equal(reviewSrc.includes('placeholder="Choose fig-* tags"'), false);
|
||||
assert.match(reviewSrc, /deriveFigTagIds/);
|
||||
assert.match(reviewSrc, /Features/);
|
||||
assert.match(reviewSrc, /testid="feat-tag"/);
|
||||
});
|
||||
|
||||
test('Tag family scope uses hyphenated slug prefix', () => {
|
||||
assert.match(figSrc, /startsWith\('fig-'\)/);
|
||||
assert.match(figSrc, /startsWith\('feat-'\)/);
|
||||
assert.equal(/startsWith\('fig'\)/.test(figSrc), false);
|
||||
assert.equal(/startsWith\('feat'\)/.test(figSrc), false);
|
||||
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
|
||||
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
|
||||
});
|
||||
|
||||
test('Review UI: View prompt + Create desc gate wiring', () => {
|
||||
assert.match(reviewSrc, /data-testid="view-prompt"/);
|
||||
assert.match(reviewSrc, /getCall2Prompt/);
|
||||
assert.match(reviewSrc, /createDescMissing/);
|
||||
assert.match(reviewSrc, /missingCreateDescLabels/);
|
||||
assert.match(reviewSrc, /orderWoodCatGroups/);
|
||||
assert.match(reviewSrc, /applyMoistureDefault/);
|
||||
assert.match(reviewSrc, /onCommit=\{commitReview\}/);
|
||||
// Description is not part of the Create-desc gate label list
|
||||
assert.equal(reviewSrc.includes("Create desc needs: description"), false);
|
||||
});
|
||||
|
||||
test('Review UI: live field commit + unified secondary buttons + moisture default', () => {
|
||||
assert.match(reviewSrc, /scheduleFieldCommit/);
|
||||
assert.match(reviewSrc, /scheduleDescCommit/);
|
||||
assert.match(reviewSrc, /class="btn-sec"/);
|
||||
assert.match(reviewSrc, /applyMoistureDefault/);
|
||||
assert.match(reviewSrc, /void syncSelects\(\)/);
|
||||
// No leftover white unstyled view-prompt class
|
||||
assert.equal(reviewSrc.includes('view-prompt-btn'), false);
|
||||
assert.equal(reviewSrc.includes('create-btn'), false);
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
||||
|
||||
test('effectiveSpeciesId prefers slab over capture', () => {
|
||||
assert.equal(effectiveSpeciesId(281, 11), 281);
|
||||
assert.equal(effectiveSpeciesId(null, 11), 11);
|
||||
assert.equal(effectiveSpeciesId(undefined, 11), 11);
|
||||
assert.equal(effectiveSpeciesId(null, null), null);
|
||||
assert.equal(effectiveSpeciesId(undefined, undefined), null);
|
||||
});
|
||||
|
||||
test('effectiveSpeciesId ignores non-finite values', () => {
|
||||
assert.equal(effectiveSpeciesId(Number.NaN, 11), 11);
|
||||
assert.equal(effectiveSpeciesId(null, Number.NaN), null);
|
||||
});
|
||||
|
||||
test('canCreateTitleFromSpecies requires species + ≥1 wood category', () => {
|
||||
assert.equal(canCreateTitleFromSpecies(281, 1), true);
|
||||
assert.equal(canCreateTitleFromSpecies(281, 0), false);
|
||||
assert.equal(canCreateTitleFromSpecies(null, 2), false);
|
||||
assert.equal(canCreateTitleFromSpecies(null, 0), false);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
/** Resolve Woo species leaf for Review Create-title gate + PUTs.
|
||||
*
|
||||
* Species is chosen on Measure and stored on the slab at createSlab. Review has
|
||||
* no picker; Create title/short-desc read species from the slab (Capture
|
||||
* handoff is a repair fallback when create omitted it).
|
||||
*/
|
||||
|
||||
export function effectiveSpeciesId(
|
||||
slabSpeciesId: number | null | undefined,
|
||||
captureSpeciesId: number | null | undefined,
|
||||
): number | null {
|
||||
if (slabSpeciesId != null && Number.isFinite(Number(slabSpeciesId))) {
|
||||
return Math.round(Number(slabSpeciesId));
|
||||
}
|
||||
if (captureSpeciesId != null && Number.isFinite(Number(captureSpeciesId))) {
|
||||
return Math.round(Number(captureSpeciesId));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canCreateTitleFromSpecies(
|
||||
speciesId: number | null,
|
||||
woodCategoryCount: number,
|
||||
): boolean {
|
||||
return speciesId != null && woodCategoryCount > 0;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { orderWoodCatGroups, PIN_LEAF_SLUG, PIN_PARENT_SLUG } from './woodCatOrder';
|
||||
|
||||
test('wood cat UI: snl parent first; liveedge leaf first under its parent', () => {
|
||||
const groups = orderWoodCatGroups([
|
||||
{
|
||||
parent: { id: 2, name: 'Turning', slug: 'turn' },
|
||||
leaves: [
|
||||
{ id: 20, name: 'Bowl blanks', slug: 'bowl' },
|
||||
{ id: 21, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||
],
|
||||
},
|
||||
{
|
||||
parent: { id: 1, name: 'Slabs & Lumber', slug: PIN_PARENT_SLUG },
|
||||
leaves: [
|
||||
{ id: 11, name: 'Dimensional', slug: 'dim' },
|
||||
{ id: 10, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(groups[0].parent.slug, PIN_PARENT_SLUG);
|
||||
assert.equal(groups[0].leaves[0].slug, PIN_LEAF_SLUG);
|
||||
assert.equal(groups[0].leaves[1].slug, 'dim');
|
||||
assert.equal(groups[1].parent.slug, 'turn');
|
||||
// liveedge also pinned first under Turning when present
|
||||
assert.equal(groups[1].leaves[0].slug, PIN_LEAF_SLUG);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* UI-only wood-category display order on Review.
|
||||
* Pin parent Slabs & Lumber (`snl`) first; pin leaf Live edge wood (`liveedge`)
|
||||
* first under its parent. Never changes assignment ids — sort only.
|
||||
*/
|
||||
|
||||
export const PIN_PARENT_SLUG = 'snl';
|
||||
export const PIN_LEAF_SLUG = 'liveedge';
|
||||
|
||||
export type OrderedLeaf = { id: number; name: string; slug?: string; ai?: boolean };
|
||||
export type OrderedGroup = {
|
||||
parent: { id: number; name: string; slug?: string };
|
||||
leaves: OrderedLeaf[];
|
||||
};
|
||||
|
||||
function pinFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
|
||||
const pinned: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const it of items) {
|
||||
if (isPinned(it)) pinned.push(it);
|
||||
else rest.push(it);
|
||||
}
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
/** Sort groups + leaves for the wood-category MultiSelect. */
|
||||
export function orderWoodCatGroups<T extends OrderedGroup>(groups: T[]): T[] {
|
||||
const withLeaves = groups.map((g) => ({
|
||||
...g,
|
||||
leaves: pinFirst(g.leaves, (l) => (l.slug ?? '') === PIN_LEAF_SLUG),
|
||||
})) as T[];
|
||||
return pinFirst(withLeaves, (g) => (g.parent.slug ?? '') === PIN_PARENT_SLUG);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { decodeName } from '../decodeName';
|
||||
import { onMount } from 'svelte';
|
||||
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
||||
|
||||
@@ -18,6 +17,7 @@
|
||||
brand_voice: string | null;
|
||||
geo_context: string | null;
|
||||
woo_create_status: string | null;
|
||||
crop_fill: number;
|
||||
}
|
||||
|
||||
interface PriceRule {
|
||||
@@ -53,6 +53,16 @@
|
||||
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
|
||||
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
|
||||
|
||||
function decodeName(name: string): string {
|
||||
// Woo sometimes returns HTML entities in category names.
|
||||
return name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function loadRates() {
|
||||
ratesLoading = true;
|
||||
ratesMessage = '';
|
||||
@@ -196,6 +206,7 @@
|
||||
brand_voice: settings.brand_voice || '',
|
||||
geo_context: settings.geo_context || '',
|
||||
woo_create_status: settings.woo_create_status || 'draft',
|
||||
crop_fill: settings.crop_fill ?? 90,
|
||||
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
|
||||
woo_app_password: secretValue(settings.woo_app_password),
|
||||
inference_base_url: secretValue(settings.inference_base_url),
|
||||
@@ -221,6 +232,7 @@
|
||||
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
|
||||
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
|
||||
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
|
||||
if (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
|
||||
// secrets only if provided (write-only)
|
||||
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
|
||||
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
|
||||
@@ -317,7 +329,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (Call 1 — DISABLED on this branch; toggle kept for settings round-trip)</label>
|
||||
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (default false)</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -342,6 +354,12 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — inventory PNG + reference line</label>
|
||||
<input id="crop-fill" type="range" min="1" max="100" step="1" bind:value={form.crop_fill} />
|
||||
<span>{form.crop_fill}%</span>
|
||||
</div>
|
||||
|
||||
<button on:click={save} disabled={saving}>Save Changes</button>
|
||||
{#if message}<p class="msg">{message}</p>{/if}
|
||||
|
||||
@@ -406,7 +424,10 @@
|
||||
<style>
|
||||
.settings { max-width: 600px; color: #eee; }
|
||||
label { display: block; margin: 8px 0 4px; }
|
||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; }
|
||||
input, select, option { color: #eee; background: #1c1c1c; width: 100%; padding: 6px; box-sizing: border-box; }
|
||||
/* width:100% + the UA default input margins overflowed narrow viewports
|
||||
(mobile Settings scrolled 16px sideways). Zero the horizontal margins. */
|
||||
.settings input, .settings select { margin-left: 0; margin-right: 0; }
|
||||
option { color: #eee; background: #1c1c1c; }
|
||||
button { margin-top: 16px; }
|
||||
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
|
||||
import { measure } from '../measure/measure.ts';
|
||||
import { minAreaRect, orientedRect, rectAlongLine } from './rect.ts';
|
||||
|
||||
// 100×20 horizontal slab
|
||||
const W = 100, H = 20;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
|
||||
|
||||
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
|
||||
const sx = maskSpanAlong(bitset, W, H, 1, 0);
|
||||
const sy = maskSpanAlong(bitset, W, H, 0, 1);
|
||||
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
|
||||
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
|
||||
});
|
||||
|
||||
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||
const prev = minAreaRect(bitset, W, H);
|
||||
assert.ok(prev, 'minAreaRect returned a box');
|
||||
@@ -29,23 +21,15 @@ test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||
});
|
||||
|
||||
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
||||
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
||||
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
||||
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
||||
const TW = 20, TH = 100;
|
||||
const thin = new Uint8Array(TW * TH).fill(1);
|
||||
const thinBox = minAreaRect(thin, TW, TH);
|
||||
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
||||
const rotated = orientedRect(thinBox, 0);
|
||||
const lengthEighths = 24; // 3 in
|
||||
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
|
||||
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
|
||||
// far from the old longSidePx/3 ≈ 33.
|
||||
const ex = rotated.corners[0][0] - rotated.corners[3][0];
|
||||
const ey = rotated.corners[0][1] - rotated.corners[3][1];
|
||||
const elen = Math.hypot(ex, ey);
|
||||
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
|
||||
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
|
||||
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
|
||||
test('rectAlongLine sets the approved axis from the owner-positioned tape endpoints', () => {
|
||||
const previous = minAreaRect(bitset, W, H)!;
|
||||
const start: [number, number] = [20, 30];
|
||||
const end: [number, number] = [80, 70];
|
||||
const calibrated = rectAlongLine(previous, start, end);
|
||||
const center: [number, number] = [50, 50];
|
||||
assert.ok(Math.abs(calibrated.angleDeg - (Math.atan2(40, 60) * 180) / Math.PI) < 1e-9);
|
||||
assert.ok(Math.abs(calibrated.longSidePx - Math.hypot(60, 40)) < 1e-9);
|
||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[0], 0) / 4 - center[0]) < 1e-9);
|
||||
assert.ok(Math.abs(calibrated.corners.reduce((sum, point) => sum + point[1], 0) / 4 - center[1]) < 1e-9);
|
||||
assert.notEqual(calibrated.angleDeg, previous.angleDeg, 'direction comes from tape endpoints, not mask rectangle');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ABox, BBox } from '../capture/capture.types';
|
||||
import type { ABox, BBox, TapeLine } from '../capture/capture.types';
|
||||
|
||||
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
||||
@@ -113,20 +113,40 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
||||
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||
}
|
||||
|
||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||
// extent in px. 0 when the mask is empty.
|
||||
// #99: demoted to an optional sanity check — declared L comes from tape tick
|
||||
// distance in image space, not this projected guess.
|
||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < min) min = p;
|
||||
if (p > max) max = p;
|
||||
/** Build the approved axis rectangle from the owner's two tape-line endpoints. */
|
||||
export function rectAlongLine(prev: BBox, start: [number, number], end: [number, number]): BBox {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > 0) || !Number.isFinite(length)) return prev;
|
||||
|
||||
const halfW = Math.hypot(
|
||||
prev.corners[0][0] - prev.corners[1][0],
|
||||
prev.corners[0][1] - prev.corners[1][1],
|
||||
) / 2;
|
||||
const cx = (start[0] + end[0]) / 2;
|
||||
const cy = (start[1] + end[1]) / 2;
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const vx = -uy;
|
||||
const vy = ux;
|
||||
const halfL = length / 2;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [along, across] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([
|
||||
cx + along * halfL * ux + across * halfW * vx,
|
||||
cy + along * halfL * uy + across * halfW * vy,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return max < min ? 0 : max - min;
|
||||
const angleDeg = ((Math.atan2(uy, ux) * 180) / Math.PI + 180) % 180;
|
||||
return { corners, longSidePx: length, angleDeg };
|
||||
}
|
||||
|
||||
/** Initial guide positions; the owner still places both ticks at the tape ends. */
|
||||
export function initialTapeLine(width: number, height: number): TapeLine {
|
||||
const w = Math.max(1, width);
|
||||
const h = Math.max(1, height);
|
||||
return w >= h
|
||||
? { start: [w * 0.1, h / 2], end: [w * 0.9, h / 2] }
|
||||
: { start: [w / 2, h * 0.1], end: [w / 2, h * 0.9] };
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { keepLargestComponent } from './largestComponent.ts';
|
||||
|
||||
test('keepLargestComponent: two blobs → only larger remains', () => {
|
||||
// 10×6 grid: large 4×3 block (12 px) at left, small 2×2 (4 px) at right
|
||||
const W = 10, H = 6;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y <= 3; y++)
|
||||
for (let x = 1; x <= 4; x++) bitset[y * W + x] = 1;
|
||||
for (let y = 2; y <= 3; y++)
|
||||
for (let x = 7; x <= 8; x++) bitset[y * W + x] = 1;
|
||||
|
||||
const before = bitset.reduce((a, c) => a + c, 0);
|
||||
assert.equal(before, 16);
|
||||
|
||||
const slabPx = keepLargestComponent(bitset, W, H);
|
||||
assert.equal(slabPx, 12, 'slabPx is pixel count of largest blob');
|
||||
|
||||
// Large blob intact
|
||||
for (let y = 1; y <= 3; y++)
|
||||
for (let x = 1; x <= 4; x++)
|
||||
assert.equal(bitset[y * W + x], 1, `large (${x},${y}) kept`);
|
||||
|
||||
// Small blob zeroed
|
||||
for (let y = 2; y <= 3; y++)
|
||||
for (let x = 7; x <= 8; x++)
|
||||
assert.equal(bitset[y * W + x], 0, `small (${x},${y}) removed`);
|
||||
|
||||
assert.equal(bitset.reduce((a, c) => a + c, 0), 12);
|
||||
});
|
||||
|
||||
test('keepLargestComponent: empty bitset → 0', () => {
|
||||
const bitset = new Uint8Array(4 * 4);
|
||||
assert.equal(keepLargestComponent(bitset, 4, 4), 0);
|
||||
});
|
||||
|
||||
test('keepLargestComponent: single blob unchanged', () => {
|
||||
const W = 5, H = 5;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y <= 3; y++)
|
||||
for (let x = 1; x <= 3; x++) bitset[y * W + x] = 1;
|
||||
const slabPx = keepLargestComponent(bitset, W, H);
|
||||
assert.equal(slabPx, 9);
|
||||
assert.equal(bitset.reduce((a, c) => a + c, 0), 9);
|
||||
});
|
||||
|
||||
test('keepLargestComponent: 4-connected — diagonal touch is two components', () => {
|
||||
// Two pixels only touching diagonally; larger is the two-pixel L on the left
|
||||
// of a separate single pixel that is diagonal to nothing of equal size...
|
||||
// Layout: three pixels as an L (size 3) and one diagonal fleck (size 1)
|
||||
// . # .
|
||||
// # # .
|
||||
// . . #
|
||||
const W = 3, H = 3;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
bitset[0 * W + 1] = 1;
|
||||
bitset[1 * W + 0] = 1;
|
||||
bitset[1 * W + 1] = 1;
|
||||
bitset[2 * W + 2] = 1; // diagonal-only to the L
|
||||
|
||||
const slabPx = keepLargestComponent(bitset, W, H);
|
||||
assert.equal(slabPx, 3);
|
||||
assert.equal(bitset[2 * W + 2], 0, 'diagonal fleck removed under 4-conn');
|
||||
assert.equal(bitset[0 * W + 1], 1);
|
||||
assert.equal(bitset[1 * W + 0], 1);
|
||||
assert.equal(bitset[1 * W + 1], 1);
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Keep only the single largest connected component in a slab bitset.
|
||||
* Sheet gaps can leave stray chunks after sheet removal; those must not
|
||||
* affect bbox / slabBounds / slabPx.
|
||||
*
|
||||
* Connectivity: 4-connected (N/E/S/W). Mutates bitset in place; returns
|
||||
* remaining slab pixel count.
|
||||
*/
|
||||
export function keepLargestComponent(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
): number {
|
||||
const n = w * h;
|
||||
if (n === 0) return 0;
|
||||
|
||||
// labels[i] = component id (1-based), 0 = background / unvisited
|
||||
const labels = new Int32Array(n);
|
||||
const sizes: number[] = [0]; // sizes[id]
|
||||
let nextId = 0;
|
||||
|
||||
const qx = new Int32Array(n);
|
||||
const qy = new Int32Array(n);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = row + x;
|
||||
if (!bitset[i] || labels[i]) continue;
|
||||
nextId++;
|
||||
let size = 0;
|
||||
let qh = 0, qt = 0;
|
||||
qx[qt] = x;
|
||||
qy[qt] = y;
|
||||
qt++;
|
||||
labels[i] = nextId;
|
||||
while (qh < qt) {
|
||||
const cx = qx[qh];
|
||||
const cy = qy[qh];
|
||||
qh++;
|
||||
size++;
|
||||
// 4-connected neighbors
|
||||
const nbrs: [number, number][] = [
|
||||
[cx - 1, cy],
|
||||
[cx + 1, cy],
|
||||
[cx, cy - 1],
|
||||
[cx, cy + 1],
|
||||
];
|
||||
for (const [nx, ny] of nbrs) {
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
const ni = ny * w + nx;
|
||||
if (!bitset[ni] || labels[ni]) continue;
|
||||
labels[ni] = nextId;
|
||||
qx[qt] = nx;
|
||||
qy[qt] = ny;
|
||||
qt++;
|
||||
}
|
||||
}
|
||||
sizes[nextId] = size;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextId === 0) return 0;
|
||||
|
||||
let bestId = 1;
|
||||
let bestSize = sizes[1];
|
||||
for (let id = 2; id <= nextId; id++) {
|
||||
if (sizes[id] > bestSize) {
|
||||
bestSize = sizes[id];
|
||||
bestId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextId === 1) return bestSize;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (bitset[i] && labels[i] !== bestId) bitset[i] = 0;
|
||||
}
|
||||
return bestSize;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
|
||||
import { measure } from './measure.ts';
|
||||
|
||||
test('#99: tape span drives scale, not maskSpanAlong', () => {
|
||||
const W = 480, H = 100;
|
||||
const bitset = new Uint8Array(W * H).fill(1);
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
// Declared L = 48"; tape span = 240 px → 5 px/in (not the full 480 mask span).
|
||||
const m = measure(bitset, W, H, box, 384, 8, W * H, {
|
||||
start: [100, 50],
|
||||
ux: 1,
|
||||
uy: 0,
|
||||
spanPx: 240,
|
||||
});
|
||||
assert.ok(Math.abs(m.scale - 5) < 1e-9, `scale ${m.scale}`);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ function tapered(W: number, H: number, lengthEighths: number, tipPx: number) {
|
||||
|
||||
test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
|
||||
const { bitset, box, slabPx, lengthEighths } = tapered(480, 100, 384, 20); // tips 2in, bulk 10in
|
||||
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx);
|
||||
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx, 10);
|
||||
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
|
||||
@@ -38,7 +38,7 @@ test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
|
||||
|
||||
test('uniform slab: interior stations keep the full width', () => {
|
||||
const { bitset, box, slabPx } = uniform(480, 100);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 10);
|
||||
assert.equal(m.widths.every6In.length, 7);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
|
||||
@@ -47,12 +47,19 @@ test('uniform slab: interior stations keep the full width', () => {
|
||||
|
||||
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(120, 100);
|
||||
const m = measure(bitset, 120, 100, box, 96, 8, slabPx);
|
||||
const m = measure(bitset, 120, 100, box, 96, 8, slabPx, 10);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
|
||||
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(100, 100);
|
||||
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
|
||||
const m = measure(bitset, 100, 100, box, 80, 8, slabPx, 10);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
|
||||
test('measurement scale is supplied by tape calibration, not inferred from mask span', () => {
|
||||
const { bitset, box, slabPx } = uniform(480, 100);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx, 20);
|
||||
assert.equal(m.scale, 20);
|
||||
assert.ok(m.sqft < 1, `area uses tape scale, got ${m.sqft}`);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// Display / direction: corners[3] → corners[0] is the long side.
|
||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||
return { start: bbox.corners[3], end: bbox.corners[0] };
|
||||
}
|
||||
|
||||
export interface TapeAxis {
|
||||
/** Image-space tape start (tick A). */
|
||||
start: [number, number];
|
||||
/** Image-space unit direction tick A → tick B. */
|
||||
ux: number;
|
||||
uy: number;
|
||||
/** Image-space distance between ticks (owner-declared tape length in px). */
|
||||
spanPx: number;
|
||||
}
|
||||
|
||||
// Wood end→end along the approved direction: project the mask onto the axis
|
||||
// unit vector and return the segment on the bbox center line covering that span.
|
||||
// Used only as a fallback / sanity path when no tape ticks are present (#99).
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array, w: number, h: number, bbox: BBox,
|
||||
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
|
||||
@@ -82,38 +70,19 @@ function sampleWidths(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param tape When provided (#99), scale = tape.spanPx / L and width stations
|
||||
* walk from the tick-A endpoint. maskSpanAlong is not used for scale.
|
||||
*/
|
||||
export function measure(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
tape?: TapeAxis | null,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowBlock: false; sourceBelowWarn: false } {
|
||||
scale: number,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
let start: [number, number];
|
||||
let ux: number;
|
||||
let uy: number;
|
||||
let scale: number;
|
||||
if (tape && tape.spanPx > 0 && lengthIn > 0) {
|
||||
start = tape.start;
|
||||
ux = tape.ux;
|
||||
uy = tape.uy;
|
||||
scale = tape.spanPx / lengthIn;
|
||||
// Optional sanity: maskSpanAlong remains available for diagnostics only.
|
||||
void maskSpanAlong;
|
||||
} else {
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
// Legacy fallback when no tape ticks — keep prior behaviour for old drafts.
|
||||
scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
start = seg.start;
|
||||
ux = seg.ux;
|
||||
uy = seg.uy;
|
||||
if (!(scale > 0) || !Number.isFinite(scale)) {
|
||||
throw new RangeError('Calibration scale must be a positive finite value');
|
||||
}
|
||||
const every6In = sampleWidths(bitset, w, h, start, ux, uy, scale, lengthIn);
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
const min = Math.min(...every6In);
|
||||
@@ -132,7 +101,6 @@ export function measure(
|
||||
widths,
|
||||
sqft,
|
||||
bdft,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,56 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { clampFill, cropMarginFraction, DEFAULT_CROP_FILL } from './crop.ts';
|
||||
import { _resetCropFillForTests, getCropFill, setCropFill } from '../settings/cropFill.ts';
|
||||
import { composeInventoryFrame } from './inventory.ts';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
import { test } from 'node:test';
|
||||
import { calibrationScale, photoToScreen, screenToPhoto } from './crop.ts';
|
||||
|
||||
test('cropMarginFraction(0.9) === 0.05', () => {
|
||||
assert.ok(Math.abs(cropMarginFraction(0.9) - 0.05) < 1e-12);
|
||||
assert.equal(DEFAULT_CROP_FILL, 0.9);
|
||||
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('clampFill bounds', () => {
|
||||
assert.equal(clampFill(2), 1);
|
||||
assert.equal(clampFill(0), 0.01);
|
||||
assert.equal(clampFill(Number.NaN), DEFAULT_CROP_FILL);
|
||||
});
|
||||
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 },
|
||||
];
|
||||
|
||||
test('getCropFill / setCropFill round-trip', () => {
|
||||
_resetCropFillForTests();
|
||||
assert.equal(getCropFill(), 0.9);
|
||||
assert.equal(setCropFill(0.8), 0.8);
|
||||
assert.equal(getCropFill(), 0.8);
|
||||
_resetCropFillForTests();
|
||||
});
|
||||
|
||||
test('composeInventoryFrame honors cropFill override (#99)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const mask: Pick<Mask, 'bitset' | 'slabBounds'> = {
|
||||
bitset,
|
||||
slabBounds: { x: 100, y: 100, w: 2000, h: 1800 },
|
||||
};
|
||||
const rgba = new Uint8Array(source.widthPx * source.heightPx * 4).fill(255);
|
||||
const out90 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.9);
|
||||
const out80 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.8);
|
||||
|
||||
const extent = (frame: typeof out90) => {
|
||||
let minx = frame.width, maxx = -1, miny = frame.height, maxy = -1;
|
||||
for (let y = 0; y < frame.height; y++) {
|
||||
for (let x = 0; x < frame.width; x++) {
|
||||
if (frame.rgba[(y * frame.width + x) * 4 + 3] !== 0) {
|
||||
if (x < minx) minx = x;
|
||||
if (x > maxx) maxx = x;
|
||||
if (y < miny) miny = y;
|
||||
if (y > maxy) maxy = y;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { w: maxx - minx + 1, h: maxy - miny + 1 };
|
||||
};
|
||||
const e90 = extent(out90);
|
||||
const e80 = extent(out80);
|
||||
assert.ok(Math.abs(e90.w - 720) <= 2, `90% w ${e90.w}`);
|
||||
assert.ok(e80.w < e90.w - 10, `80% fill must be tighter than 90%: ${e80.w} vs ${e90.w}`);
|
||||
});
|
||||
|
||||
+106
-21
@@ -1,31 +1,116 @@
|
||||
/**
|
||||
* Shared crop constants for inventory PNG + tape-line overlay (#99).
|
||||
* Aspect is fixed 3:4; crop_fill only moves the long-axis end insets.
|
||||
* Non-retro: changing fill affects future encodes only, not stored PNGs.
|
||||
* Single source of truth for the inventory crop geometry (issue #99, req. 4).
|
||||
*
|
||||
* Size floors (shorter side of slab bbox, source px):
|
||||
* - FLOOR_WARN_PX (800): target frame shorter side when source allows;
|
||||
* 300–799 → warn but still encode / include.
|
||||
* - FLOOR_BLOCK_PX (300): hard fail / exclude below this.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const DEFAULT_CROP_FILL = 0.9;
|
||||
/** 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;
|
||||
/** Target inventory frame shorter side when source can supply it. */
|
||||
export const FLOOR_WARN_PX = 800;
|
||||
/** Hard-block threshold: shorter side below this fails / excludes the photo. */
|
||||
export const FLOOR_BLOCK_PX = 300;
|
||||
/** @deprecated Prefer FLOOR_WARN_PX; kept as alias for the target frame size. */
|
||||
export const FLOOR_PX = FLOOR_WARN_PX;
|
||||
/** Shorter side of the output frame, px. */
|
||||
export const FLOOR = 800;
|
||||
|
||||
/** Fraction of the long axis inset from each end: (1 - fill) / 2. */
|
||||
export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number {
|
||||
const f = clampFill(fill);
|
||||
return (1 - f) / 2;
|
||||
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;
|
||||
}
|
||||
|
||||
export function clampFill(fill: number): number {
|
||||
if (!Number.isFinite(fill)) return DEFAULT_CROP_FILL;
|
||||
return Math.min(1, Math.max(0.01, fill));
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
|
||||
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
|
||||
|
||||
assert.equal(out.sourceBelowBlock, true, '4px slab is below block floor');
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width < 800, 'undersized source stays small');
|
||||
|
||||
let opaque = 0;
|
||||
@@ -67,11 +66,10 @@ test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => {
|
||||
);
|
||||
assert.equal(out.width, 800);
|
||||
assert.equal(out.height, 1067);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
});
|
||||
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, no warn/block', () => {
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 1200, heightPx: 1000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
@@ -79,24 +77,10 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, no w
|
||||
maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
assert.equal(out.width, 800);
|
||||
});
|
||||
|
||||
test('warn band: shorter side 500 (300–799) → sourceBelowWarn, encode allowed, no upscale past source', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 600, heightPx: 500 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 600, h: 500 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowWarn, true);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed warn floor`);
|
||||
});
|
||||
|
||||
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
@@ -123,7 +107,7 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
||||
});
|
||||
|
||||
test('belowWarn (was belowFloor): 900×700 → warn not block, outW capped at warn floor', () => {
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
@@ -131,20 +115,6 @@ test('belowWarn (was belowFloor): 900×700 → warn not block, outW capped at wa
|
||||
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowWarn, true);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the warn floor`);
|
||||
});
|
||||
|
||||
test('belowBlock: shorter side 200 → sourceBelowBlock, no upscale', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 250, heightPx: 200 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 250, h: 200 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowBlock, true);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.ok(out.width <= 800);
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
import { ASPECT_H, ASPECT_W, FLOOR_WARN_PX, FLOOR_BLOCK_PX, DEFAULT_CROP_FILL } from './crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
import { FILL_DEFAULT, cropFrameSize, cropOffsets } from './crop';
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
@@ -9,27 +8,23 @@ export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
/** Optional override; default reads live settings.crop_fill (#99). */
|
||||
cropFill?: number;
|
||||
/** Crop fill, 0<fill<=1. Defaults to FILL_DEFAULT (the 90% settings default). */
|
||||
fill?: number;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
/** Shorter side of slab bbox is below FLOOR_BLOCK_PX (hard fail). */
|
||||
sourceBelowBlock: boolean;
|
||||
/** Shorter side is in [FLOOR_BLOCK_PX, FLOOR_WARN_PX) — encode/include with warn. */
|
||||
sourceBelowWarn: boolean;
|
||||
sourceBelowFloor: boolean;
|
||||
}
|
||||
|
||||
export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
cropFill: number = DEFAULT_CROP_FILL,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): Frame {
|
||||
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
const y0 = Math.max(0, Math.round(b.y));
|
||||
@@ -37,18 +32,8 @@ export function composeInventoryFrame(
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const shortSide = Math.min(sw, sh);
|
||||
const sourceBelowBlock = shortSide < FLOOR_BLOCK_PX;
|
||||
const sourceBelowWarn = !sourceBelowBlock && shortSide < FLOOR_WARN_PX;
|
||||
// Undersized vs warn floor: frame from available source (no upscale to 800).
|
||||
const undersized = shortSide < FLOOR_WARN_PX;
|
||||
const outW = undersized ? Math.max(1, Math.min(sw, FLOOR_WARN_PX)) : FLOOR_WARN_PX;
|
||||
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 { outW, outH, belowFloor } = cropFrameSize(sw, sh);
|
||||
const { scale, dw, dh, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||
const rgba = new Uint8Array(outW * outH * 4);
|
||||
const xEnd = ox + dw;
|
||||
const yEnd = oy + dh;
|
||||
@@ -67,14 +52,13 @@ export function composeInventoryFrame(
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return { width: outW, height: outH, rgba, sourceBelowBlock, sourceBelowWarn };
|
||||
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
||||
}
|
||||
|
||||
export async function encodeInventoryPng(
|
||||
input: InventoryInput,
|
||||
): Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }> {
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
const { bitmap, mask, source } = input;
|
||||
const fill = input.cropFill ?? getCropFill();
|
||||
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||
const srcCtx = srcCanvas.getContext('2d')!;
|
||||
srcCtx.drawImage(bitmap, 0, 0);
|
||||
@@ -83,15 +67,11 @@ export async function encodeInventoryPng(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
fill,
|
||||
input.fill,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0);
|
||||
const blob = await out.convertToBlob({ type: 'image/png' });
|
||||
return {
|
||||
blob,
|
||||
sourceBelowBlock: frame.sourceBelowBlock,
|
||||
sourceBelowWarn: frame.sourceBelowWarn,
|
||||
};
|
||||
return { blob, sourceBelowFloor: frame.sourceBelowFloor };
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyMaskAlpha } from './maskedPreview';
|
||||
|
||||
test('applyMaskAlpha keeps slab pixels and clears the background', () => {
|
||||
const rgba = new Uint8ClampedArray([
|
||||
1, 2, 3, 255,
|
||||
4, 5, 6, 255,
|
||||
7, 8, 9, 255,
|
||||
]);
|
||||
const out = applyMaskAlpha(rgba, new Uint8Array([1, 0]));
|
||||
assert.deepEqual([...out], [1, 2, 3, 255, 4, 5, 6, 0, 7, 8, 9, 0]);
|
||||
assert.deepEqual([...rgba], [1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255]);
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
/**
|
||||
* Apply a source-grid mask to decoded RGBA pixels without changing their
|
||||
* dimensions. The transparent pixels remain in the full source frame so the
|
||||
* tape transform and all measurement geometry keep using source coordinates.
|
||||
*/
|
||||
export function applyMaskAlpha(
|
||||
rgba: Uint8ClampedArray,
|
||||
bitset: Uint8Array,
|
||||
): Uint8ClampedArray {
|
||||
const out = new Uint8ClampedArray(rgba);
|
||||
const pixels = out.length / 4;
|
||||
for (let p = 0; p < pixels; p++) {
|
||||
if (p >= bitset.length || !bitset[p]) out[p * 4 + 3] = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an EXIF-oriented source photo with the approved mask as alpha.
|
||||
* This is display-only; the original source blob and mask stay in session
|
||||
* state for geometry, measurement, and inventory encoding.
|
||||
*/
|
||||
export async function createMaskedPreviewBlob(
|
||||
source: Blob,
|
||||
bitset: Uint8Array,
|
||||
): Promise<Blob> {
|
||||
const bitmap = await decodeOrientedBitmap(source);
|
||||
try {
|
||||
const width = bitmap.width;
|
||||
const height = bitmap.height;
|
||||
const hasOffscreenCanvas = typeof OffscreenCanvas !== 'undefined';
|
||||
const canvas: OffscreenCanvas | HTMLCanvasElement = hasOffscreenCanvas
|
||||
? new OffscreenCanvas(width, height)
|
||||
: Object.assign(document.createElement('canvas'), { width, height });
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
||||
if (!ctx) throw new Error('Could not create preview canvas');
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const image = ctx.getImageData(0, 0, width, height);
|
||||
image.data.set(applyMaskAlpha(image.data, bitset));
|
||||
ctx.putImageData(image, 0, 0);
|
||||
if (hasOffscreenCanvas) {
|
||||
return await (canvas as OffscreenCanvas).convertToBlob({ type: 'image/png' });
|
||||
}
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
(canvas as HTMLCanvasElement).toBlob((blob: Blob | null) => (blob ? resolve(blob) : reject(new Error('Could not encode preview'))), 'image/png');
|
||||
});
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
@@ -1117,110 +1117,3 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥
|
||||
const tipSpanSrc = tipHi - tipLo;
|
||||
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: when MAR angle ≠ tape angle, length || tape and width ⊥ tape (~2°)', () => {
|
||||
// Wood MAR is horizontal (angleDeg 0); tape / approved axis is vertical (90°).
|
||||
// Without optional axis (or orientedRect to tape), widths would be vertical.
|
||||
// With tape-driven axis, length is vertical and width left→right ≈ horizontal.
|
||||
const W = 240, H = 400;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
// Slightly elongated horizontally so MAR long side ≈ 0°, but still a tall slab.
|
||||
for (let y = 40; y <= 360; y++) {
|
||||
for (let x = 40; x <= 200; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const mar = minAreaRect(bitset, W, H)!;
|
||||
// Force wood-long-axis bbox to 0° — intentionally NOT the tape (vertical).
|
||||
const woodBbox = orientedRect(mar, 0);
|
||||
assert.equal(woodBbox.angleDeg, 0);
|
||||
|
||||
// Tape vertical: tick A near bottom, B near top (image y↓).
|
||||
const tapeStart: [number, number] = [120, 350];
|
||||
const tapeEnd: [number, number] = [120, 50];
|
||||
const tapeAngleDeg = ((Math.atan2(tapeEnd[1] - tapeStart[1], tapeEnd[0] - tapeStart[0]) * 180) / Math.PI + 180) % 180;
|
||||
// Overlay bbox keeps MAR extents but direction from tape (session.calculate path).
|
||||
const tapeBbox = orientedRect(woodBbox, tapeAngleDeg);
|
||||
const scale = 10;
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: tapeBbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
axis: { start: tapeStart, end: tapeEnd },
|
||||
});
|
||||
|
||||
// Length a→b must be parallel to tape (vertical → |dx| small vs |dy|).
|
||||
const ldx = plan.length.b[0] - plan.length.a[0];
|
||||
const ldy = plan.length.b[1] - plan.length.a[1];
|
||||
const llen = Math.hypot(ldx, ldy) || 1;
|
||||
const lux = ldx / llen, luy = ldy / llen;
|
||||
const tdx = tapeEnd[0] - tapeStart[0], tdy = tapeEnd[1] - tapeStart[1];
|
||||
const tlen = Math.hypot(tdx, tdy) || 1;
|
||||
const tux = tdx / tlen, tuy = tdy / tlen;
|
||||
const lengthDot = Math.abs(lux * tux + luy * tuy);
|
||||
assert.ok(lengthDot > 0.999, `length must || tape (dot=${lengthDot})`);
|
||||
|
||||
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||
// Tape-perpendicular = horizontal for a vertical tape.
|
||||
for (const w of plan.widths) {
|
||||
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
|
||||
let deg = (ang * 180) / Math.PI;
|
||||
if (deg > 90) deg -= 180;
|
||||
if (deg < -90) deg += 180;
|
||||
assert.ok(
|
||||
Math.abs(deg) <= 2,
|
||||
`width station ${w.stationIn} left→right must be within 2° of tape-perpendicular/horizontal (deg=${deg.toFixed(2)})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Contrast: same wood bbox WITHOUT tape axis would follow MAR (horizontal length).
|
||||
const woodPlan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: woodBbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
const wdx = woodPlan.length.b[0] - woodPlan.length.a[0];
|
||||
const wdy = woodPlan.length.b[1] - woodPlan.length.a[1];
|
||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||
// Wood MAR length should NOT be parallel to vertical tape.
|
||||
const woodDot = Math.abs((wdx / wlen) * tux + (wdy / wlen) * tuy);
|
||||
assert.ok(woodDot < 0.5, `wood-MAR overlay must disagree with tape (dot=${woodDot})`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: optional axis alone drives direction even if bbox angle wrong', () => {
|
||||
const W = 200, H = 400;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 50; y <= 350; y++) {
|
||||
for (let x = 70; x <= 130; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const mar = minAreaRect(bitset, W, H)!;
|
||||
// Wrong bbox angle on purpose (horizontal) while axis is vertical.
|
||||
const wrongBbox = orientedRect(mar, 0);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: wrongBbox,
|
||||
scale: 10,
|
||||
lengthIn: 30,
|
||||
lengthLabel: '30"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
axis: { start: [100, 340], end: [100, 60] },
|
||||
});
|
||||
assert.ok(plan.widths.length > 0);
|
||||
for (const w of plan.widths) {
|
||||
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
|
||||
let deg = (ang * 180) / Math.PI;
|
||||
if (deg > 90) deg -= 180;
|
||||
if (deg < -90) deg += 180;
|
||||
assert.ok(Math.abs(deg) <= 2, `axis override: width deg=${deg.toFixed(2)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
import { DEFAULT_CROP_FILL } from './crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
import { FILL_DEFAULT, cropOffsets } from './crop';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
@@ -81,12 +80,6 @@ export interface OverlayPlanInput {
|
||||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||||
inventoryW?: number;
|
||||
inventoryH?: number;
|
||||
/**
|
||||
* Optional explicit length axis (e.g. baked tape tick A→B). When set, (ux,uy)
|
||||
* come from this segment instead of bbox corners — keeps width ⊥ tape even
|
||||
* when MAR wood long side differs from the tape.
|
||||
*/
|
||||
axis?: { start: [number, number]; end: [number, number] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +153,6 @@ function maskAxisSegment(
|
||||
w: number,
|
||||
h: number,
|
||||
bbox: BBox,
|
||||
axis?: { start: [number, number]; end: [number, number] },
|
||||
): {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
@@ -171,7 +163,7 @@ function maskAxisSegment(
|
||||
uy: number;
|
||||
span: number;
|
||||
} {
|
||||
const { start: a, end: b } = axis ?? axisEndpoints(bbox);
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
@@ -277,9 +269,9 @@ function fmtInches(inches: number): string {
|
||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const {
|
||||
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||||
inventoryW, inventoryH, axis,
|
||||
inventoryW, inventoryH,
|
||||
} = input;
|
||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox, axis);
|
||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
||||
// Re-orient span ends so EXIF bottom establishes station origin; axis still
|
||||
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
|
||||
const oriented = pickBottomTowardTop(seg.start, seg.end);
|
||||
@@ -494,20 +486,15 @@ export function inventorySourceMap(
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
cropFill: number = DEFAULT_CROP_FILL,
|
||||
fill: number = FILL_DEFAULT,
|
||||
): InventoryMap {
|
||||
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
|
||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
const { scale, ox, oy } = cropOffsets(sw, sh, outW, outH, fill);
|
||||
return {
|
||||
outW,
|
||||
outH,
|
||||
@@ -527,10 +514,6 @@ export interface EncodeOverlayInput {
|
||||
lengthLabel: string;
|
||||
slabBounds: { x: number; y: number; w: number; h: number };
|
||||
source: { widthPx: number; heightPx: number };
|
||||
/** Optional override; default reads live settings.crop_fill (#99). */
|
||||
cropFill?: number;
|
||||
/** Optional explicit length axis in source px (baked tape A→B). */
|
||||
axis?: { start: [number, number]; end: [number, number] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,8 +528,7 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
const fill = input.cropFill ?? getCropFill();
|
||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
|
||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset: input.bitset,
|
||||
widthPx: input.widthPx,
|
||||
@@ -558,7 +540,6 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
axis: input.axis,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { bakeTiltTransform } from './rotateCal.ts';
|
||||
|
||||
test('bakeTiltTransform: identity ≈0° leaves points and size unchanged', () => {
|
||||
const x = bakeTiltTransform(800, 600, 0);
|
||||
assert.equal(x.identity, true);
|
||||
assert.equal(x.outW, 800);
|
||||
assert.equal(x.outH, 600);
|
||||
assert.deepEqual(x.mapPoint(100, 200), [100, 200]);
|
||||
assert.deepEqual(x.mapPoint(0, 0), [0, 0]);
|
||||
});
|
||||
|
||||
test('bakeTiltTransform: 360° folds to identity', () => {
|
||||
const x = bakeTiltTransform(400, 300, 360);
|
||||
assert.equal(x.identity, true);
|
||||
assert.deepEqual(x.mapPoint(10, 20), [10, 20]);
|
||||
});
|
||||
|
||||
test('bakeTiltTransform: 90° CW maps centre→centre; corner into AABB canvas', () => {
|
||||
const srcW = 200, srcH = 100;
|
||||
const x = bakeTiltTransform(srcW, srcH, 90);
|
||||
assert.equal(x.identity, false);
|
||||
// Rotated AABB of 200×100 about centre ≈ 100×200 (ceil may add 1px from float).
|
||||
assert.ok(x.outW >= 100 && x.outW <= 101, `outW=${x.outW}`);
|
||||
assert.ok(x.outH >= 200 && x.outH <= 201, `outH=${x.outH}`);
|
||||
const [cx, cy] = x.mapPoint(srcW / 2, srcH / 2);
|
||||
assert.ok(Math.abs(cx - x.outW / 2) < 1e-9);
|
||||
assert.ok(Math.abs(cy - x.outH / 2) < 1e-9);
|
||||
// Top-left (0,0) relative (−100,−50) → CW90 → (~50,~−100) → +out/2
|
||||
const [tlx, tly] = x.mapPoint(0, 0);
|
||||
assert.ok(Math.abs(tlx - (50 + x.outW / 2)) < 1e-4, `tlx=${tlx}`);
|
||||
assert.ok(Math.abs(tly - (-100 + x.outH / 2)) < 1e-4, `tly=${tly}`);
|
||||
});
|
||||
|
||||
test('bakeTiltTransform: maps a tape-like vertical segment; direction rotates with tilt', () => {
|
||||
const srcW = 400, srcH = 600;
|
||||
const start: [number, number] = [200, 100];
|
||||
const end: [number, number] = [200, 500];
|
||||
const preUx = 0, preUy = 1; // vertical down
|
||||
for (const tilt of [15, 30, -20, 45]) {
|
||||
const x = bakeTiltTransform(srcW, srcH, tilt);
|
||||
const a = x.mapPoint(start[0], start[1]);
|
||||
const b = x.mapPoint(end[0], end[1]);
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
// Expected: rotate (preUx, preUy) clockwise by tilt
|
||||
const rad = (((tilt % 360) + 360) % 360) * Math.PI / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const expUx = preUx * cos - preUy * sin;
|
||||
const expUy = preUx * sin + preUy * cos;
|
||||
const dot = ux * expUx + uy * expUy;
|
||||
assert.ok(dot > 0.999, `tilt=${tilt} direction mismatch dot=${dot}`);
|
||||
}
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope).
|
||||
* Used only for the calibration inventory PNG + dimension-overlay clone.
|
||||
* Batch siblings are never passed through here.
|
||||
*/
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
|
||||
export interface RotatedCal {
|
||||
bitmap: ImageBitmap;
|
||||
mask: Mask;
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
}
|
||||
|
||||
/** Pure math for the same clockwise-about-centre bake used by rotateCalibration. */
|
||||
export interface BakeTiltTransform {
|
||||
/** True when tilt is ~0° (identity; no resample). */
|
||||
identity: boolean;
|
||||
tiltDeg: number;
|
||||
srcW: number;
|
||||
srcH: number;
|
||||
outW: number;
|
||||
outH: number;
|
||||
/** Map a point from pre-bake source coords into baked canvas coords. */
|
||||
mapPoint: (x: number, y: number) => [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clockwise rotation about image centre; output canvas sized to AABB of rotated
|
||||
* corners — identical sizing to rotateCalibration (canvas translate+rotate path).
|
||||
*/
|
||||
export function bakeTiltTransform(
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
tiltDeg: number,
|
||||
): BakeTiltTransform {
|
||||
const a = ((tiltDeg % 360) + 360) % 360;
|
||||
if (a < 0.05 || a > 359.95) {
|
||||
return {
|
||||
identity: true,
|
||||
tiltDeg: a,
|
||||
srcW,
|
||||
srcH,
|
||||
outW: srcW,
|
||||
outH: srcH,
|
||||
mapPoint: (x, y) => [x, y],
|
||||
};
|
||||
}
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[srcW, 0],
|
||||
[srcW, srcH],
|
||||
[0, srcH],
|
||||
].map(([x, y]) => {
|
||||
const cx = x - srcW / 2;
|
||||
const cy = y - srcH / 2;
|
||||
return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number];
|
||||
});
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const [x, y] of corners) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
const outW = Math.max(1, Math.ceil(maxX - minX));
|
||||
const outH = Math.max(1, Math.ceil(maxY - minY));
|
||||
return {
|
||||
identity: false,
|
||||
tiltDeg: a,
|
||||
srcW,
|
||||
srcH,
|
||||
outW,
|
||||
outH,
|
||||
mapPoint: (x, y) => {
|
||||
const cx = x - srcW / 2;
|
||||
const cy = y - srcH / 2;
|
||||
return [cx * cos - cy * sin + outW / 2, cx * sin + cy * cos + outH / 2];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate).
|
||||
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
|
||||
* Identity (≈0°) returns clones of inputs without resampling.
|
||||
*/
|
||||
export async function rotateCalibration(
|
||||
bitmap: ImageBitmap,
|
||||
mask: Mask,
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>,
|
||||
tiltDeg: number,
|
||||
): Promise<RotatedCal> {
|
||||
const xform = bakeTiltTransform(source.widthPx, source.heightPx, tiltDeg);
|
||||
if (xform.identity) {
|
||||
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
|
||||
return { bitmap, mask, source };
|
||||
}
|
||||
const { srcW, srcH, outW, outH, tiltDeg: a } = xform;
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
ctx.rotate(rad);
|
||||
ctx.drawImage(bitmap, -srcW / 2, -srcH / 2);
|
||||
|
||||
// Nearest-neighbour resample of the bitset into the rotated frame.
|
||||
const bitset = new Uint8Array(outW * outH);
|
||||
const invCos = Math.cos(-rad);
|
||||
const invSin = Math.sin(-rad);
|
||||
for (let y = 0; y < outH; y++) {
|
||||
for (let x = 0; x < outW; x++) {
|
||||
const dx = x - outW / 2;
|
||||
const dy = y - outH / 2;
|
||||
const sx = Math.round(dx * invCos - dy * invSin + srcW / 2);
|
||||
const sy = Math.round(dx * invSin + dy * invCos + srcH / 2);
|
||||
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
|
||||
bitset[y * outW + x] = mask.bitset[sy * srcW + sx];
|
||||
}
|
||||
}
|
||||
let slabPx = 0;
|
||||
for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++;
|
||||
const bounds = slabBounds(bitset, outW, outH);
|
||||
const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox;
|
||||
const outBmp = canvas.transferToImageBitmap();
|
||||
return {
|
||||
bitmap: outBmp,
|
||||
mask: {
|
||||
params: mask.params,
|
||||
slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds: bounds,
|
||||
},
|
||||
source: { widthPx: outW, heightPx: outH },
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Thin frontend-only crop_fill setting (#99).
|
||||
* Default 0.9 — replaces duplicated FILL = 0.9 in png/inventory + png/overlay.
|
||||
* Not a full M3 Settings surface; localStorage so it survives reloads.
|
||||
*/
|
||||
import { DEFAULT_CROP_FILL, clampFill } from '../png/crop';
|
||||
|
||||
const KEY = 'slabuploader.crop_fill';
|
||||
|
||||
let cached: number | null = null;
|
||||
|
||||
export function getCropFill(): number {
|
||||
if (cached != null) return cached;
|
||||
if (typeof localStorage === 'undefined') {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw == null) {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
cached = clampFill(Number.parseFloat(raw));
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCropFill(fill: number): number {
|
||||
const next = clampFill(fill);
|
||||
cached = next;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(next));
|
||||
} catch {
|
||||
/* private mode / quota — in-memory still updated */
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Test helper: clear cached value (and optional storage). */
|
||||
export function _resetCropFillForTests(): void {
|
||||
cached = null;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
||||
import { keepLargestComponent } from '../mask/largestComponent';
|
||||
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let data: Uint8ClampedArray | null = null;
|
||||
@@ -34,11 +33,6 @@ function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
if (slabPx === 0) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
// Sheet gaps leave stray chunks; lock onto the single largest slab object.
|
||||
slabPx = keepLargestComponent(bitset, w, h);
|
||||
if (slabPx === 0) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
const bbox = minAreaRect(bitset, w, h);
|
||||
if (!bbox) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
|
||||
@@ -20,8 +20,6 @@ export interface SeedMeasure {
|
||||
sqft: number;
|
||||
bdft: number;
|
||||
widthIn: number;
|
||||
/** Woo species leaf; required for Review-ready e2e now that Review has no species picker. */
|
||||
speciesId?: number | null;
|
||||
}
|
||||
|
||||
const toEighths = (inches: number) => Math.round(inches * 8);
|
||||
@@ -60,7 +58,6 @@ export async function seedMeasureDraft(
|
||||
axisBbox: null,
|
||||
lengthEighths: payload.lengthEighths,
|
||||
thicknessEighths: payload.thicknessEighths,
|
||||
species_id: payload.speciesId ?? null,
|
||||
results: {
|
||||
lengthEighths: payload.lengthEighths,
|
||||
thicknessEighths: payload.thicknessEighths,
|
||||
@@ -73,8 +70,7 @@ export async function seedMeasureDraft(
|
||||
},
|
||||
sqft: payload.sqft,
|
||||
bdft: payload.bdft,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
sourceBelowFloor: false,
|
||||
},
|
||||
inventoryPng: png,
|
||||
overlayPng: null,
|
||||
@@ -101,7 +97,6 @@ export async function seedMeasureDraft(
|
||||
widthEighths,
|
||||
sqft: d.sqft,
|
||||
bdft: d.bdft,
|
||||
speciesId: d.speciesId ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,20 +243,6 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
await json(route, slab);
|
||||
return;
|
||||
}
|
||||
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
|
||||
// Publish preview + Review strip: ordered lineup (empty is fine for golden path).
|
||||
await json(route, [
|
||||
{
|
||||
id: 'photo-1', seq: 1, is_calibration: 1, role: 'calibration',
|
||||
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
|
||||
},
|
||||
{
|
||||
id: 'photo-2', seq: 2, is_calibration: 0, role: 'overlay',
|
||||
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
|
||||
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
|
||||
// in the form body, not JSON, so echo a stable synthetic id.
|
||||
@@ -265,7 +251,6 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||
slab_id: slab.id,
|
||||
seq: 1,
|
||||
is_calibration: 1,
|
||||
role: 'calibration',
|
||||
original_retained: false,
|
||||
processed_retained: true,
|
||||
}, 201);
|
||||
@@ -309,7 +294,6 @@ async function reachReview(page: Page) {
|
||||
await page.goto('/?noload');
|
||||
await seedMeasureDraft(page, {
|
||||
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
|
||||
speciesId: SPECIES_ID,
|
||||
});
|
||||
// Phase 2: reload WITHOUT ?noload so onMount probes IDB and shows Resume.
|
||||
await page.goto('/');
|
||||
@@ -325,8 +309,7 @@ async function reachReview(page: Page) {
|
||||
|
||||
/** Fill every ready-gating field to make the slab publishable. */
|
||||
async function completeForReady(page: Page) {
|
||||
// Species comes from Measure/createSlab (seeded on the draft); no Review picker.
|
||||
await expect(page.getByTestId('species')).toHaveCount(0);
|
||||
await page.getByTestId('species').selectOption(String(SPECIES_ID));
|
||||
// Wood categories: open, select Live edge, Done.
|
||||
await page.getByTestId('wood-cat-btn').click();
|
||||
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||
@@ -339,8 +322,6 @@ async function completeForReady(page: Page) {
|
||||
await page.getByTestId('create-title').click();
|
||||
// Wait for the build to complete (title field populated = deterministic copy).
|
||||
await expect(page.getByTestId('title')).not.toHaveValue('');
|
||||
await expect(page.getByTestId('title')).toHaveValue(/Cedar/);
|
||||
await expect(page.getByTestId('short-desc')).toHaveValue(/Cedar/);
|
||||
await page.getByTestId('sku').fill('SLAB-0001');
|
||||
// SKU commits on blur — press Tab to move focus off it (deterministic, single
|
||||
// commit; a body click can re-focus the SKU and re-fire blur).
|
||||
@@ -356,21 +337,13 @@ test.describe('M3 Review→Publish flow (#77)', () => {
|
||||
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
|
||||
await expect(page.getByTestId('title')).toHaveValue('');
|
||||
await completeForReady(page);
|
||||
// Species from Measure draft must land on the slab (Create title reads it).
|
||||
expect(slab.species_id).toBe(SPECIES_ID);
|
||||
// #89: the Create build populated the deterministic copy on the slab row.
|
||||
expect(slab.title).toBeTruthy();
|
||||
expect(slab.short_description).toBeTruthy();
|
||||
expect(String(slab.title)).toMatch(/Cedar/);
|
||||
// Continue to Publish is enabled and hands off.
|
||||
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||
await page.getByTestId('continue-publish').click();
|
||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||
// Publish preview: glanceable confirm before Woo draft POST.
|
||||
await expect(page.getByTestId('publish-preview')).toBeVisible();
|
||||
await expect(page.getByTestId('preview-title')).toBeVisible();
|
||||
await expect(page.getByTestId('preview-facts')).toBeVisible();
|
||||
await expect(page.getByTestId('back-review')).toBeVisible();
|
||||
// Publish → success → Done panel (draft in UAT store).
|
||||
await page.getByTestId('publish-btn').click();
|
||||
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
|
||||
};
|
||||
|
||||
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
test('batch → pick → mask → measure (tape+fields) → photos ready + screenshots', async ({ page }) => {
|
||||
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
||||
@@ -87,8 +87,11 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
await shot(page, '03-mask.png');
|
||||
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
|
||||
await shot(page, '04-axis.png');
|
||||
await page.getByRole('button', { name: 'Approve axis' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
|
||||
await shot(page, '04-tape-measure.png');
|
||||
await page.getByLabel('Length L').fill('48');
|
||||
await page.getByLabel('Thickness').fill('2');
|
||||
// blur/change commits eighths into session before Calculate.
|
||||
@@ -114,7 +117,7 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
|
||||
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
||||
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
||||
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-tape-measure.png', '05-measure.png', '06-photos-ready.png'];
|
||||
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
|
||||
const lines = [
|
||||
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
||||
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
||||
|
||||
@@ -165,10 +165,7 @@ async function mockApi(page: Page) {
|
||||
|
||||
async function reachReview(page: Page) {
|
||||
await page.goto('/?noload');
|
||||
await seedMeasureDraft(page, {
|
||||
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
|
||||
speciesId: SPECIES_ID,
|
||||
});
|
||||
await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -178,8 +175,7 @@ async function reachReview(page: Page) {
|
||||
}
|
||||
|
||||
async function completeForReady(page: Page) {
|
||||
// Species comes from Measure/createSlab (seeded on the draft); no Review picker.
|
||||
await expect(page.getByTestId('species')).toHaveCount(0);
|
||||
await page.getByTestId('species').selectOption(String(SPECIES_ID));
|
||||
await page.getByTestId('wood-cat-btn').click();
|
||||
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||
await page.getByTestId('wood-cat-done').click();
|
||||
|
||||
@@ -5,13 +5,4 @@ import sveltePreprocess from 'svelte-preprocess';
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ preprocess: sveltePreprocess() })],
|
||||
worker: { format: 'es' },
|
||||
// overnight-whole-app box UAT: vite preview on :18181 → FastAPI on :18182
|
||||
server: {
|
||||
proxy: { '/api': { target: 'http://127.0.0.1:18182', changeOrigin: true } },
|
||||
},
|
||||
preview: {
|
||||
// Cloudflare quick tunnel Host header (public UAT without Tailscale)
|
||||
allowedHosts: true,
|
||||
proxy: { '/api': { target: 'http://127.0.0.1:18182', changeOrigin: true } },
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user