4 Commits

Author SHA1 Message Date
grokbot_ty be2ea6c4ea feat(capture): tape-line overlay UI replaces axis rectangle (#99)
Pointer-Events pan/zoom/tilt under a fixed black ~2pt line with
rifle-scope end ticks (≥44×44 hit). AxisPanel becomes Tape line step;
session persists tape transform; scale = tick distance / L.
sveltedrag unavailable for Svelte 4 — Pointer Events path used.
2026-09-25 17:41:29 -07:00
grokbot_ty 323f29b649 feat(capture): shared crop_fill + tape geometry for #99
- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay
- Thin localStorage settings store; crop % only moves long-axis end insets
- Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px
- measure() prefers tape tick span for scale; maskSpanAlong demoted
- rotateCalibration bakes cal tilt into inventory + overlay only
- Fix stale 80%-fill comment on slabBounds
2026-09-25 17:41:29 -07:00
grokbot_ty b44e231038 docs(design): add approved #99 tape-line overlay wireframe
Wireframe settles fixed middle line + movable slab, tick-declared L,
and shared crop_fill. Scaffolding scrubbed before shipping into the app.
2026-09-25 17:41:29 -07:00
grokbot_ty abef27cf2a docs(deploy): fallback UAT on Grok Bot computer :18181
When Tailscale cannot reach ubudual5060, Capture UAT serves from the
bot computer on :18181. Primary overnight remains :10881. Never touch
Ty local :18080 or legacy :10880.
2026-09-25 17:30:14 -07:00
40 changed files with 1772 additions and 1829 deletions
@@ -1,203 +0,0 @@
# Requirements reconciliation — decision record
- Date: 2026-09-18
- Branch: `docs/clean-main`
- Participants: Ty + opencode
- Status: working record. Gets distilled into `docs/REQUIREMENTS.md`, then retired.
This captures the decisions made while consolidating the scattered requirements, so
context is not lost between sessions. Loose ends are collected in section 16.
## 1. Why this exists
Requirements are scattered across layers of docs (old `main` specs, current branch docs,
`archive/`), with duplicates and contradictions. Goal: one clean `main` holding all shipped
code plus one reconciled requirement set, with M3 scoped as a subset of it. Coding is paused
until that exists.
## 2. Git state established
1. `pre-simplification` tag created at `13bfa792bb3ed968b333675c85bcec0c451e2635` (old main tip; retains the deleted specs: ARCHITECTURE, IMPL-PLAN, OPENAPI, DATA-MODEL, TEST-SPEC, UX, UX-DESIGN, TECH-SPEC-PIPELINE, DEPLOYMENT, PROMPTS, Build-release-standard).
2. `main` fast-forwarded to `d754cac` (the M1>M2>M3 code line). `main` now holds all shipped code plus current docs.
3. Local `m3-ds` created, tracking `origin/m3-ds`.
4. Phase 1 scaffold committed as `b42e3cb` on branch `docs/clean-main`.
5. Nothing pushed. `origin/main` still at the old tip until we decide to push.
## 3. Process rules adopted
1. Coding is paused until the requirement set is reconciled.
2. Requirements live only in `docs/REQUIREMENTS.md`, one row per requirement, with stable IDs.
3. Milestones are expressed as explicit subsets of requirement IDs (`docs/MILESTONES.md`).
4. Conflict resolution rule: current/later docs plus the code that actually runs beat the tag and `archive/`. "Typically later docs prevail."
5. The structure is enforced by `tools/check_requirements.py` (registered docs, unique IDs, traceability).
6. Verification discipline: no "done" without raw evidence; CI is the only mechanical gate.
## 4. Namespaces (owner jobs, not tech layers)
1. `CAPTURE` — ecommerce-ready pics: intake, pick calibration photo, BG removal, crop, axis, length/width, calculations, inventory PNG, dimension overlay, lineup.
2. `METADATA` — building the listing: attributes and tags, title, short/deep description, the two inference calls.
3. `REVIEW` — confirm and correct: confidence display, field gating, tweaks, per-slab price suggestion and override, ready gate.
4. `PUBLISH` — WooCommerce create, draft/live, duplicate SKU, cleanup.
5. `SETTINGS` — configured before the session: Woo connection, inference config, species `$/bdft` rates, brand/geo.
6. `APP` — foundation: device draft, slab status, resume, server seams, secret handling.
7. `OPS` — foundation: deploy, hosts, release, UAT.
8. `QUALITY` — foundation: test obligations, vectors, anti-metrics.
## 5. Capture decisions
1. Mask controls are exactly two: `Sheet` (auto/green/black) and `Sensitivity` (0–100). Edge offset and Feather are dropped. The Sheet toggle itself is a candidate for removal (auto-detect stays).
2. Sensitivity changes are debounced 2000 ms; a Sheet change runs an immediate pass.
3. The owner uploads all photos of the slab, then selects one as the calibration photo. That photo drives every downstream step, is duplicated with dimensions added, and becomes the first showcase image.
4. Crop is 90% fill, shorter side at least 800 px when the source allows (shipped values beat the old 80%/1600).
5. Length and thickness are the only user-set numbers. Widths, sqft, and bdft are computed from the approved mask and are immutable in production (no typed override).
6. Widths are sampled every 6 inches along the approved axis, dropping the first and last 6 inches.
7. Thickness band uses the round-up model: measured `1"` lands in the `1–1.5"` store band.
8. Woo image order is locked: clean calibration PNG, then dimension-overlay copy, then remaining batch photos in the order added. Minimum two images per listing; cap five.
## 6. Ready set (submission-ready)
Required:
1. At least two images — clean first pic plus its dimension-overlay copy.
2. `sku` matching `[A-Z0-9-]{3,24}`.
3. `length_in` and `thickness_in`.
4. Widths / sqft / bdft (computed, immutable).
5. `categories` — exactly one species leaf.
6. `categories` — at least one wood-category leaf.
7. `attributes[Edge Type]` — one term.
8. `attributes[Figure]` — at least one term.
9. `attributes[Grade]` — exactly one term.
10. `attributes[Thickness]` — one term (round-up band).
11. `attributes[Moisture]` — one term, default kiln-dried.
12. `regular_price`.
13. `name`, `short_description`, `description`.
Optional: all Woo product tags (figure family and features family).
Woo schema field mapping lives in section 6 of this record and will become the `METADATA` requirements.
Note: title / short description / description are required on the product. Because deterministic templates are the inference-off fallback, they are guaranteed non-empty at create.
## 7. Taxonomy and categories
1. Categories sync from Woo; the product is assigned to leaf categories. Parents are never assigned but matter in context: the site shows them (breadcrumbs/filters) and Call 2 receives the parent path as context.
2. Species is exactly one synced leaf. Wood category is one or more synced leaves.
3. In review, the owner designates a primary wood category and any secondary ones. Default primary is the first category selected. How the owner corrects it, and how they stay aware of it, is a UX problem deferred to design.
4. Primary drives the title. Primary plus secondary drives the description prose.
5. The primary leaf selects a specialized Call 2 prompt. This is the "common pattern."
6. Wood-category leaves: Knife blanks, Specialty cuts, Dimensional wood, Live edge wood, Bowl blanks, logs and caps, Pen blanks, Pepper mill blanks. All are settled prose/prompt work items except `Specialty cuts`, which is parked.
7. All Woo product tags are optional. Tags come from the synced Woo set; no hardcoded names or prefixes. Wrinkle: one figure tag is bare `fig` (Figured), so a `fig-*` glob would drop it — the filter must be data-driven.
8. Current tags. Features: `feat-mincr` (Crack/Check minor), `feat-resin` (Resin Ready). Figure: `fig` (Figured), `fig-brdseye`, `fig-chatoy`, `fig-crotch`, `fig-curl`, `fig-fback`, `fig-flame`, `fig-hfig`, `fig-marbled`, `fig-quilted`, `fig-splt`.
## 8. Locked attributes
1. `attributes[Edge Type]` — 1 term.
2. `attributes[Figure]` — at least 1 term; includes `plain` for basic slabs; never left empty.
3. `attributes[Grade]` — exactly 1 term.
4. `attributes[Thickness]` — 1 term, round-up band.
5. `attributes[Moisture]` — 1 term, default kiln-dried, never inferred.
6. Missing attributes or terms are skipped, never created. v1 does not invent taxonomy.
## 9. Text generation (Call 2)
1. Call 2 produces `name`, `short_description`, and `description`.
2. The template is a consistency/style guide in the prompt; the model has creative license. It is not assembled deterministically in code.
3. With inference off, the deterministic template is the fallback that fills those three fields.
4. Call 2 receives all metadata confirmed or changed in review — not the raw Call 1 suggestions.
5. Dimensions are supplied as a literal fact block (species, figure term, length, thickness, average width, width min–max, board feet). The model uses those exact strings and invents no numbers.
6. There is no `L × W × T` rectangle assumption; slabs are irregular and width varies.
7. The dimension-overlay image is not sent to the model.
8. A post-check for number drift is parked as `later`; if it is ever added it is plain deterministic comparison, not an AI call.
9. The title separator is a hyphen: ` - `.
## 10. Inference transport
1. Calls are asynchronous: the request returns `202`, the client polls and shows phase feedback.
2. Phases: uploading, analyzing, still working. Polling is the heartbeat that distinguishes dead connectivity from a working model.
3. Tunable defaults: ack 15 s, upload-stall 20 s, overall inference deadline 120 s after ack, poll every 2 s backing off to 10 s.
4. On timeout or failure, fail gracefully with a plain message and a Retry action. Retry is offered only if `test-inference` previously passed. A retry replaces any in-flight job (idempotent).
5. Inference input is the clean processed image (masked, cropped), composited on soft white, no alpha, downscaled to at most 1024 px long edge, JPEG quality 80.
6. Only the pixel cap is exposed in Settings. JPEG quality 80 lives in a server config file.
7. Call 1 and Call 2 both receive the images. Call 2 additionally receives the literal fact block.
8. Provider threads are dropped; always full resend to the provider.
9. Inference never blocks create and never auto-publishes.
## 11. Prompt management
1. Prompts are stored in SQLite as immutable revisions, seeded from bundled repo defaults on first run.
2. Saving appends a revision; an active pointer selects which revision is live.
3. Reset to default re-activates the bundled default; reset to a revision re-activates that revision.
4. Each inference call records the revision id/hash it used.
5. A runtime debug flag gates both the Advanced settings UI and the prompt-write endpoints. Single build, not two.
6. This is post-M3 (`later`).
## 12. Pricing
1. A flat `$/bdft` rate per species is configured in Settings, matched to a synced species leaf. No tiers in v1.
2. The per-slab suggestion is `bdft × rate`; the owner may override and the override sticks. No rule means an empty price the owner types.
3. The price field and its suggestion live in the review screen.
## 13. Publish
1. Create sequence is the documented one: validate the ready set, look up the SKU (stop if it exists), refresh taxonomy if older than one hour, resolve categories/tags/attributes, upload inventory PNGs, `POST /products`, then finalize.
2. On success the local slab row is deleted. WooCommerce is the only source of truth; `sync_log` is retained; photos are purged; the device draft is cleared.
3. `SLAB-UAT-*` always creates a Woo draft.
4. A duplicate SKU blocks create; there is no update path.
5. A stale value on submit is a field-level error telling the owner to pick from the current list.
## 14. Settings
1. Compose env: `WOO_BASE_URL` (https only), `SLAB_AES_KEY`.
2. Encrypted in SQLite: `woo_wp_username`, `woo_app_password`, `inference_base_url`, `inference_api_key`.
3. Plain settings: `inference_model`, `inference_enabled`, `content_llm_enabled`, `woo_create_status` (default draft), `brand_voice`, `geo_context`.
4. Derived: `taxonomy_last_sync_at`, `taxonomy_anchor`.
5. Auth is a WordPress application password for a dedicated low-privilege user; not consumer keys, not the login password.
## 15. Scope mapping
Shipped: M1 Capture, M2 Photos, capture knob prefs.
M3: store-connection and AI-connection settings; taxonomy sync; the five attributes; Call 1; Call 2; title/description generation; review screen with field gating; pricing; publish; local draft cleanup.
Later: prompt-management UI, U2Net, offline capture, OCR/ruler, batch create, number-drift check, Specialty cuts prose, primary-category correction UX.
## 16. Open / parked items
1. `attributes` element shape on `POST /products` is open — needs a live Whidbey product sample or the official Woo REST fields. Do not invent.
2. Server persistence approach is open — full OpenAPI slab CRUD vs a thinner FastAPI path.
3. `Specialty cuts` leaf prose is parked.
4. Primary-category correction and awareness is a UX problem, deferred.
5. Number-drift check is `later`.
6. Prompt management is post-M3.
7. The QUALITY/OPS extraction re-run is still pending (the first pass was truncated).
8. "Later docs prevail" is the rule, but the tag/archive still hold material not yet reconciled (e.g. U2Net, browser floor, TES vectors, deploy details).
## 17. Work items
1. ~~Re-run the QUALITY/OPS extraction.~~ Done 2026-09-18 (TEST-SPEC ~110 rows; plan/deploy/release ~120 rows).
2. Write `docs/REQUIREMENTS.md` namespace by namespace, starting with `CAPTURE`.
3. Draft the per-leaf title/prose work once the `METADATA` section is being written.
4. Migrate capability/contract docs into their homes, make docs branch-neutral, rebuild the structure check, wire CI.
## 18. Rulings deferred to a final cleanup pass
These surfaced during the QUALITY/OPS extraction. They do not block writing `REQUIREMENTS.md`; park them and reconcile in the cleanup pass.
1. Test directory layout: old TEST-SPEC mandates Vitest and `frontend/src/__tests__/`; shipped code uses the Node test runner (`node --import tsx --test`) and `frontend/tests/`. Contradiction.
2. Test coverage target (~95% for deterministic modules) — adopt or drop.
3. "The Test Bar" role and who authors reports versus Ty's sign-off.
4. `docs/checks/check-structure.sh` gate timing and criteria — now replaced by `tools/check_requirements.py`.
5. Deploy ports: proposal `18080/18443` versus UAT `:10881`, and `APP_HOSTNAME` binding.
6. Backup retention "7 daily + 4 weekly" has no verification test.
7. `SLAB_AES_KEY_OLD` rekey env placement (host vs compose).
8. Smoke step 5: local `published` versus Woo `draft` mismatch — intended or not.
9. Release process: `Build-release-standard` points at GitHub while the repo is Gitea.
10. Taxonomy sync endpoint path divergence across docs.
11. Ownership of `deploy/INVENTORY.md` across Gate B vs Phase 6.
12. Scope labels: do shipped/M3/later map to phases or to release milestones.
13. `C1`/`C2` (Call 1 / Call 2) namespace ownership ambiguity.
14. Whether `UAT-PRACTICE.md` change control is a gate.
15. Definition of "real fixtures" acceptance for TV-3, TV-6, TV-7, TV-10.
16. `INF-1` ownership: SETTINGS toggle versus QUALITY suite obligation.
17. Server-side API surface (`revision_conflict`, validation module, slab CRUD) belongs to the open server-persistence decision.
18. `output_px_min` "never below 1600" from old DEPLOYMENT/IMPL-PLAN versus shipped 800 floor.
+11 -30
View File
@@ -1,51 +1,32 @@
# AGENTS.md
## STOP — coding paused (Phase 2: requirements reconciliation)
Do not implement Milestone 3 or any feature right now. Trunk docs are being consolidated.
Scope authority is `docs/REQUIREMENTS.md` + `docs/MILESTONES.md`. M3 will be re-derived as a subset of requirement IDs.
The Milestone 3 text below is the pre-reconciliation state and is not authoritative until Phase 2 completes.
## Structure contract (read before creating any doc)
Requirements live only in `docs/REQUIREMENTS.md`; milestone scope in `docs/MILESTONES.md`.
Before creating or moving any doc, look up its home in `docs/STRUCTURE.md` (artifact registry + namespace routing).
If no home exists, stop and ask Ty — do not create a new artifact.
Run `python tools/check_requirements.py` after any docs change; it must pass.
## Agent context
Read `docs/README.md` first, then this file, `docs/CAPTURE.md`, `docs/PHOTOS.md`, and `docs/CREATE.md`.
Read this file, `docs/CAPTURE.md`, and `docs/PHOTOS.md`.
Implement Milestone 2 only. Specification: `docs/PHOTOS.md`.
PRD: `docs/PRODUCT.md` for destination and field names. Do not implement Milestone 3 or 4.
Do not widen Milestone 2 into Complete-listing taxonomy, SKU, price, copy, or Woo create.
Implement **Milestone 3** only. Specification: `docs/CREATE.md`.
Product overview: `docs/PRODUCT.md`.
## Milestone 2
**M3 scope** (what is in vs out) comes from Ty conversations and `docs/CREATE.md`.
**Behavior** for Woo sync, metadata, pricing, and inference is written into `docs/CREATE.md` from the technical requirements on `main`. Do not treat older milestone splits on `main` as scope authority. Do not code against `main` docs by cross-reference. Use `docs/CREATE.md` on this branch.
Photos.
Acceptance: on a phone, batch of 1–N photos, user-picked calibration photo through Milestone 1 mask/axis/measure, every successful shot as a 3:4 transparent inventory PNG lined up, Milestone 1 numbers on screen, dimension-overlay copy of the calibration PNG present.
Do not implement work outside `docs/CREATE.md` unless Ty expands scope in chat.
Do not touch deploy port `:10880`. UAT redeploy target is overnight Docker on ubudual5060 `:10881`.
Milestone 2 is inference-free. Batch mask/crop reuse and dimension overlays run in the browser as ordinary code. There is no inference URL and no model call in this milestone.
## Milestone 3
## Milestone 1 (historical)
Create. Exit criteria: a Photos-ready slab is **submission-ready for WooCommerce**, proven by a `SLAB-UAT-*` Woo draft that matches the reviewed ready set. Inference-off path required.
## Milestone 1 and 2 (shipped inputs)
- Capture: `docs/CAPTURE.md`
- Photos: `docs/PHOTOS.md` (UAT on `:10881`)
Capture. Spec: `docs/CAPTURE.md`. Remains the calibration math and PNG contract Milestone 2 reuses.
## Invariants
- Quantity per listing is one.
- User enters length, thickness, and SKU.
- App may propose remaining listing fields. User reviews before create.
- App proposes remaining listing fields. User reviews before create.
- Non-math fields remain editable until create.
- `bdft = sqft × thickness_in`
- Price proposal is `bdft × species $/bdft`. User may replace it. Missing rate leaves price empty.
- User-entered length, thickness, SKU, price, and taxonomy override computed or inferred values.
- **Woo attributes** means synced species and wood categories; Edge, Figure, Grade, Thickness, Moisture; `fig-*` and `feat-*`. Store is source of truth.
- Woo auth is WordPress application password (server-side). `WOO_BASE_URL` is compose/env. Inference URL is Settings.
- SKUs matching `SLAB-UAT-*` create as Woo draft.
- Create works with inference off.
- Source below the PNG size floor requires a new photo.
+3 -11
View File
@@ -2,20 +2,12 @@
Lists one-of-a-kind wood slabs on Whidbey Wood Store.
Start at [`docs/README.md`](docs/README.md) for goals, what already shipped, and Milestone 3.
| Artifact | File |
|---|---|
| Docs map | `docs/README.md` |
| Structure contract | `docs/STRUCTURE.md` |
| Requirements (normative) | `docs/REQUIREMENTS.md` |
| Milestones (ID subsets) | `docs/MILESTONES.md` |
| Product overview | `docs/PRODUCT.md` |
| PRD | `docs/PRODUCT.md` |
| Invariants and agent context | `AGENTS.md` |
| Milestone 1 Capture (shipped) | `docs/CAPTURE.md` |
| Milestone 2 Photos (shipped) | `docs/PHOTOS.md` |
| Milestone 3 Create (paused — requirements reconciliation) | `docs/CREATE.md` |
| UAT practice walk | `docs/UAT-PRACTICE.md` |
| Milestone 1 | `docs/CAPTURE.md` |
| Milestone 2 | `docs/PHOTOS.md` |
| Hosting | `deploy/` |
Owner: Ty. Store: https://www.whidbeywoodstore.com/
+21 -6
View File
@@ -1,20 +1,35 @@
# Host
## Overnight Milestone 2 Capture (UAT)
## Overnight Capture UAT (primary — when Tailscale is up)
- Host: `ubudual5060` / Tailscale `100.64.0.9`
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`)
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`) — or salvage rebuild of the same role
- Port: **10881** → 80
- URL: `http://100.64.0.9:10881`
- Replaces prior overnight container `slabuploader-m1-overnight` (same port).
## Ty local Node/SvelteKit (do not overwrite)
## Fallback UAT — Grok Bot computer (when Tailscale to 5060 is down)
Use this path for salvage / Capture UAT until the box can reach `100.64.0.9` again.
- Host: Grok Bot computer (slab-m1 box)
- Serve: static Capture build (`frontend` → `npm run build`, then `vite preview` or equivalent static server)
- Port: **18181** (chosen so it never collides with Ty local on 5060)
- Bind: `0.0.0.0:18181`
- URL (from this box): `http://127.0.0.1:18181` — Ty reaches it via the bot computer / whatever path the app exposes; Tailscale IP of this box when mesh returns
- Branch for salvage: `salvage-one-woo-draft` (cut from `m2-ds`)
- Do **not** require Docker on the box for this fallback.
When Tailscale is restored, prefer primary overnight `:10881` again and stop advertising the fallback as the main UAT URL.
## Ty local Node/SvelteKit on 5060 (do not overwrite)
- Host: `ubudual5060` / `100.64.0.9`
- Container: `slabuploader-m1`
- Port: **18080** → 80 (inventory previously listed 10880; do not bind overnight there)
- Port: **18080** → 80
- URL: `http://100.64.0.9:18080`
- **Never** recreate or remap this container for overnight UAT.
- **Never** recreate or remap this container for overnight or box-fallback UAT.
- **Never** bind overnight or fallback UAT to **10880** either (legacy Ty local).
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local port.
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local ports.
Do not put a host name in the product spec.
-201
View File
@@ -1,201 +0,0 @@
# Listing copy and the store
> On branch `m3-ds`, Milestone 3 exit and scope live in `docs/CREATE.md`. This file is the listing-copy and Woo create/payload reference brought from `main` so coding does not need to open `main`. If anything conflicts with a Ty conversation lock in `CREATE.md`, `CREATE.md` wins.
How titles and descriptions are built, how we talk to WooCommerce, and the exact create payload. Templates always run. Generate text is optional and never publishes on its own.
## 1. Titles and descriptions
### 1.1 Templates (always on)
Templates live in Settings. Defaults below. Placeholders use `{field}`.
**title** (default):
```
{species} {length_ft}ft {length_in_remainder}in × {width_avg_in}in Wood Slab — {character}
```
Example: `Black Walnut 16ft 0in × 18.2in Wood Slab — Cathedral`
- `length_ft` = floor(length_in / 12), `length_in_remainder` = length_in mod 12 (1 dp).
**short_title** (default):
```
{species} Slab — {character}
```
Example: `Black Walnut Slab — Cathedral`
**description** (default, multi-paragraph):
```
A one-of-a-one {character} {species} slab, {length_ft}ft {length_in_remainder}in long with an
average width of {width_avg_in}in and {thickness_in}in thickness ({bdft} bdft).
Each slab is naturally shaped — width varies along the length at {width_min_in}in to
{width_max_in}in. Sold by the slab; dimensions as measured. {geo_sentence}
```
`geo_sentence` is the geography sentence from Settings, e.g.
`"Locally milled and shipped from the Pacific Northwest."` (default if unset: none).
All template math fields come from the deterministic pipeline (never from the LLM).
### 1.2 Generate text (optional)
The mill owner taps Generate text on review. It never runs by itself. It is off unless `content_llm_enabled` is true.
The model writes prose only (character, color, grain, uses). Measured length, width, thickness, square feet, and board feet are facts in the prompt. The model does not invent numbers. Templates inject those numbers into the title and description after the prose comes back.
The mill owner can always edit the result. `content_source` is `llm` when this path ran.
If the text endpoint is missing or down, disable the button. Templates still work. Publish is never blocked because Generate text failed.
### 1.3 Audit
Every content generation (template or LLM) is logged with inputs (template version,
slab facts), the LLM request/response payload when used (inference calls logged for audit),
and the final stored content.
## 2. WooCommerce
### 2.1 Auth & client
- REST v3 surface unchanged: `GET/POST/PATCH/DELETE {WOO_BASE_URL}/wp-json/wc/v3/…`
- Auth is **WordPress Application Passwords**, not WooCommerce consumer keys
(`ck_`/`cs_`) and not the user's login password.
- Create a **dedicated low-privilege WordPress user** (shop manager, or a custom
role with product create/edit + media upload). A dedicated low-privilege user, not the admin account, is used.
- Generate the application password under **Users → Profile → Application
Passwords**. WordPress requires **HTTPS** for that UI; the store must already
be on TLS (Caddy enforces HTTPS on the app side too).
- Basic auth shape: `woo_wp_username:woo_app_password` (username + application
password). Spaces in the generated password are ignored by WordPress; store
the value as entered.
- FastAPI is the sole Woo caller. Credentials live encrypted at rest in SQLite
(AES-GCM, `SLAB_AES_KEY` env). The browser never holds them.
- Rotation: revoke the application password in the WP user profile, enter a new
one in Settings UI, then `POST /api/v1/settings/test-woo`.
- Client: `httpx` with timeouts (connect 10s, read 60s for image-heavy create).
- Store URL is **`WOO_BASE_URL`** from Docker Compose / host `.env` (HTTPS only).
FastAPI refuses `http://` at startup. Not a Settings field; UI shows it read-only.
### 2.2 Store lists (species, tags, attributes)
`POST /api/v1/admin/taxonomy/sync` pulls, in order:
1. `GET /products/categories?per_page=100` (paginate) → species + wood-category leaves
2. `GET /products/tags?per_page=100` → `fig-*` / `feat-*` only (other tags ignored)
3. `GET /products/attributes` (+ terms) → five locked attributes
Rebuilds cache tables in one transaction. Failure → `502 woo_error`; previous
cache retained (never delete-then-fail).
**Manufactured stamps.** Woo REST has **no** usable last-modified on categories,
attributes, or tags. FastAPI persists `taxonomy_last_sync_at` (every successful
pull) and `taxonomy_anchor` (bump only when stored-subset hash changes, including
deletions). Client re-pulls the full taxonomy iff the anchor advanced. No Woo-side
taxonomy timestamp; no per-navigation sync.
**Triggers:** (1) successful `test-woo` **always** resyncs; (2) publish if
`now - taxonomy_last_sync_at > 1 hour` then reconcile (bump only on real change);
(3) optional app launch / Settings refresh. If a picked id vanished after a refresh, show that field as no longer valid. The selection is not silently swapped.
**Species exclusivity.** UI pickers, pricing seeds, and Call 1 species lists are
**exactly** the synced species-category leaves. No hardcoded, invented, or fallback
species set. A species new in Woo is selectable only after the next sync stores it.
### 2.3 What goes on the listing
When vision is on and it is at least 0.7 sure, it fills species, wood categories,
edge, figure, grade, and feature tags (character, inclusions, voids, checks map
to existing `feat-*` tags only). The mill owner can change every filled value.
Typed values always win.
If it is less sure, those fields stay empty. That empty-start subset is not
the submit list. Review will not continue until the full ready set is filled:
SKU, length, thickness, price, species, wood category, edge type, figure,
grade, thickness band, moisture, fig-* tags, and inventory PNG(s). Species
plus one figure is not enough. Presentation: `docs/UX.md`.
Low-confidence guesses are not filled.
Names from vision or from typing match store ids case-insensitive, trimmed. No
match leaves the field empty so they can pick from the list. The store will still
reject a missing required field at publish.
Field rules:
- **Categories**: species leaf (1 required) plus wood-category leaf ids (1+).
- **Tags**: `fig-*` (1+ required) and `feat-*` (0+). Only documented
character or species product tags are used. Woo is authoritative for tag definitions.
- **Attributes**: the five locked Woo attributes only — Edge Type, Figure, Grade,
Thickness, Moisture — using term ids from the cache. Species,
character, length, or free-text thickness as product attributes. Missing
attributes are skipped, not created (v1: no attribute creation).
Vision only suggests. It never blocks publish. With vision off, everything is typed.
### 2.4 Create payload
`POST /products` — body (only non-null fields sent):
```jsonc
{
"name": "{title}",
"type": "simple",
"status": "{woo_create_status}", // draft | publish; default draft; SLAB-UAT-* forced draft
"sku": "{sku}",
"regular_price": "{price}", // 2 dp string, e.g. "2730.00"
"description": "{description}",
"short_description": "{short_title}",
"categories": [ { "id": <species category id> } ],
"tags": [ { "id": <fig-* tag id> }, { "id": <feat-* tag id> }, … ],
"images": [
{ "src": "<data URL or pre-uploaded attachment URL>", "alt": "{species} {character} slab" }
],
"attributes": [ … if present … ],
"virtual": false,
"downloadable": false,
"catalog_visibility": "visible"
}
```
**Image upload strategy** (Woo v3): `images[].src` accepts a URL or a data URL.
For reliability the server:
1. For each customer-facing inventory photo in `seq` order (processed transparent PNGs
from the client TECH-SPEC path): `POST /media` with the **PNG bytes** → get attachment
`source_url`. Woo accepts `image/*`; keep alpha. JPEG re-encoding drops alpha and must not happen.
2. Reference those `source_url` values in `images` (≤5 inventory photos).
3. Only `kind=inventory` photos. Calibration photos do not exist in this design.
**Status** comes from Settings `woo_create_status` (`draft` or `publish`, default
`draft`). Practice SKUs `SLAB-UAT-*` are always created as drafts, even if Settings
say publish. Existing SKUs are not edited in this version. After a successful create,
the server deletes the original and processed photos. A slab marked published in the
app means the store create succeeded. That is not the same as WooCommerce "publish."
The force-draft rule is in `AGENTS.md`. This file only owns the payload and the steps.
### 2.5 Duplicate SKU
Before create: `GET /products?sku={sku}` (any store status).
- Found: stop. The phone shows the SKU field with Edit SKU and Open existing listing. This version does not update the other product.
- Not found: create. Still do the check every time.
### 2.6 After a failed publish
The mill owner sees a message on the field, not a status number or a stack. The form stays on the phone. The server marks the slab failed so they can try again.
If the SKU already exists: under SKU, "This SKU already exists in the store (any status)." Edit SKU, or open the existing listing (admin link when we have it).
If a category, attribute, or price is no longer valid: under that field, "This value is no longer valid. Pick from the current list," with the current store options.
The server still writes `sync_log`. Success still sets `woo_product_id`, marks published locally, and deletes photos.
- **Partial-failure note** (risk, see IMPL-PLAN): if media uploads succeed but the
final product POST fails, orphaned Woo attachments may exist. v1: log it; surface
a manual cleanup note. Not auto-deleted.
## 3. End-to-end publish sequence (server side)
```
1. check the listing against the shared rules (current copy of the rules on submit)
2. look up the SKU in the store (any status). Stop if it already exists.
3. refresh store lists if the last successful pull is older than 1 hour. Only bump the client list-version if something actually changed.
4. resolve categories, tags, and attributes from the synced lists (vision names or typed names)
5. upload processed PNG inventory photos to media, keep the URLs
6. POST /products with draft/publish per Settings, always draft for SLAB-UAT-* SKUs
7. on success: log, mark published locally, delete originals and processed photos
on failure: log, mark failed, keep photos, put the error on the field (§2.6)
```
## 4. What the model may and may not do
Templates, numbers, matching names to store ids, payload, dedupe, logging: code.
Generate text may write description prose only. It must not invent dimensions. It may only pick species and attributes that exist in the synced store lists.
Presentation: `docs/UX.md`. The wire uses `{ field, code, message, actions[] }`. The mill owner never sees those codes. If the phone's copy of the rules is stale, refresh the copy and retry. That is not a duplicate-SKU conflict.
-431
View File
@@ -1,431 +0,0 @@
# Milestone 3 Create
## Purpose
Milestone 3 takes a Photos-ready device draft and makes the slab **submission-ready for WooCommerce**.
Submission-ready means the mill owner has filled the full store listing package against **live synced Woo attributes**, with price set, and can create the product in the store (practice SKUs as Woo drafts). Photos and measures from Milestones 1 and 2 are inputs. They are not enough by themselves.
**Exit criteria.** On the M3 UAT deploy, a Photos-ready slab reaches submission-ready and a `SLAB-UAT-*` product exists in WooCommerce as a **draft** whose categories, attributes, tags, price, and inventory images match the reviewed ready set. Create still works with inference off.
## How to read this file
This file is the Milestone 3 source of truth on branch `m3-ds`.
- **What is in M3** comes from Ty conversations on this branch (not from older milestone splits on `main`).
- **How Woo sync, metadata, pricing, and inference behave** is taken from the technical requirements on `main` (`CONTENT-WOO`, `DATA-MODEL`, `UX`, `ARCHITECTURE`, `OPENAPI`, `AGENTS`, `UAT-PRACTICE`, `DEPLOYMENT`) and written here so coding does not need to open `main`.
Related shipped specs: `docs/CAPTURE.md`, `docs/PHOTOS.md`. Listing copy and Woo payload detail: `docs/CONTENT-WOO.md` (from `main`). Product overview: `docs/PRODUCT.md`. Folder map: `docs/README.md`.
## Preconditions (already shipped)
On this branch before M3 work:
- Milestone 1 Capture works in the browser (`docs/CAPTURE.md`).
- Milestone 2 Photos works in the browser (`docs/PHOTOS.md`). UAT evidence on `:10881`.
- Device draft holds Photos-ready inventory PNGs, calibration measures, and related state.
- `frontend/` holds that client path. `backend/` does not yet implement the Woo client or inference proxy.
M3 adds the server path, Settings, taxonomy sync, review/ready fields, pricing, inference dependency, and Woo create.
## In scope
1. WooCommerce auth with WordPress application password (server-side).
2. Sync of **Woo attributes** from the store and correct use of those lists for the listing.
3. Review UI and rules to reach submission-ready (full ready set).
4. Pricing proposal from species `$/bdft` rules and user override.
5. OpenAI-compatible inference endpoint as a Settings dependency, with Call 1 / Call 2 behavior when enabled.
6. Create product in WooCommerce (force draft for `SLAB-UAT-*`), then clear the device draft on success.
## Out of scope unless Ty expands M3 in chat
- Creating new Woo categories, attributes, terms, or tags from the app.
- Updating an existing store SKU in place.
- Bulk create.
- Touching deploy port `:10880`.
- Dumping work onto `m2-ds` or doc-simplification branches.
---
## Woo attributes
Locked name for the synced taxonomy package. WooCommerce is the source of truth. The app caches ids. It does not invent the lists.
### Categories (product categories, leaf only)
| Field | Cardinality | Rule |
|---|---|---|
| Species | exactly 1 | Synced species leaves only. No hardcoded or fallback species set. A new store species appears only after the next successful sync. |
| Wood category | 1 or more | Leaf ids. Parents are grouping only. |
### Attributes (exactly five locked Woo attributes)
| Attribute | Cardinality | Rule |
|---|---|---|
| Edge Type | 1 | Term id from sync. |
| Figure | 1 or more | Term ids from sync. |
| Grade | 1 or more | Term ids from sync. |
| Thickness | 1 | Measured thickness rounded **up** into the store band (example: 1" lands in the store band labeled like `1"-1 1/2"` when that is the store label). |
| Moisture | 1 | Defaults to kiln-dried. User sets or keeps it. Never inferred. |
v1 does not create missing attributes or terms. If a term is missing in Woo, skip it. Do not invent it.
Species, character, length, and free-text thickness are **not** Woo product attributes. Only the five locked attributes go on the create payload.
### Tags
| Prefix | Cardinality | Rule |
|---|---|---|
| `fig-*` | 1 or more | Required. Other Woo tags are ignored at sync. |
| `feat-*` | 0 or more | Optional. Character, inclusions, voids, and checks map onto existing `feat-*` tags only. |
---
## Auth and store URL
From `main` deploy and agents rules, adopted here:
- FastAPI is the only Woo caller. The browser never holds store credentials.
- Auth is a WordPress **application password** for a dedicated low-privilege WP user (shop manager or custom role with product create/edit and media upload). Not WooCommerce consumer keys (`ck_` / `cs_`). Not the store admin login password.
- Basic auth shape: `woo_wp_username` + `woo_app_password`.
- Store URL is **`WOO_BASE_URL`** from `deploy/.env` / Docker Compose (`https://` only). Injected into the FastAPI container. Settings shows it read-only. Not a SQLite settings row. Change store means edit `.env` and recreate the FastAPI container.
- Username and application password are entered in Settings UI, stored AES-GCM encrypted in SQLite (`SLAB_AES_KEY` env).
- REST surface: `{WOO_BASE_URL}/wp-json/wc/v3/…`
- Client: `httpx` with connect timeout 10s and read timeout 60s for image-heavy create.
- Rotate: revoke the application password in WP Users → Profile, enter a new one in Settings, then run `test-woo`.
---
## Taxonomy sync
Endpoint and behavior from `main` OPENAPI / CONTENT-WOO / ARCHITECTURE:
`POST /api/v1/admin/taxonomy/sync` pulls, in order:
1. `GET /products/categories?per_page=100` (paginate) → species and wood-category leaves after app filter.
2. `GET /products/tags?per_page=100` → keep only `fig-*` and `feat-*`.
3. `GET /products/attributes` plus terms → the five locked attributes.
Rebuild cache tables in one transaction. On failure return `502 woo_error` and **keep** the previous cache. Never delete-then-fail.
**Stamps.** Woo REST has no usable last-modified for categories, attributes, or tags. FastAPI stores:
- `taxonomy_last_sync_at` on every successful pull
- `taxonomy_anchor` bumped only when the stored-subset hash changes (including deletions)
Client re-pulls the full taxonomy only when the anchor advances.
**Triggers.**
1. Successful `POST /api/v1/settings/test-woo` always resyncs.
2. On publish, if `now - taxonomy_last_sync_at > 1 hour`, reconcile (bump anchor only on real change).
3. Optional app launch or Settings refresh.
If a picked id disappeared after refresh, show that field as no longer valid. Do not silently swap another value.
`GET /api/v1/admin/taxonomy` returns the cached groups plus `taxonomy_anchor` and `taxonomy_last_sync_at`.
---
## Submission-ready (ready set)
A slab is submission-ready only when **all** of the following are present. Species plus one figure is not enough.
Required:
- At least one inventory PNG (from Milestone 2)
- SKU matching `[A-Z0-9-]{3,24}` (practice runs use `SLAB-UAT-*`)
- Length and thickness (from capture, still editable on review)
- Client-computed widths, sqft, and bdft (user typed number wins if overridden)
- Exactly one species leaf
- At least one wood-category leaf
- One edge type term
- At least one figure term
- At least one grade term
- Thickness band term (round-up from measured thickness)
- One moisture term (default kiln-dried)
- At least one `fig-*` tag
- Price (see Pricing)
Optional:
- `feat-*` tags (zero or more)
- Title, short title, description (may be typed, filled by templates at create, or written by Generate text when inference is on). Not required to reach ready.
Inline per-field nudges show empty required fields as the owner works. The slab cannot reach ready until the full set is filled.
Server status name for this state: `ready` (see data model below). Publish/create is blocked with `422 unpublishable` until then.
---
## Pricing
From `main` DATA-MODEL / UX / AGENTS:
- Settings (or admin API) holds `pricing_rules`: one flat `$/bdft` per species name that matches a synced species leaf. No tiers in v1.
- Seed rule rows from synced species. The mill owner sets the rate per species.
- Proposal: `price = bdft × price_per_bdft` for the selected species.
- User may replace the proposal. Override sticks (`price_source` = `override` vs `recommendation`).
- If no rule exists for the species, price starts empty and the owner types it.
- Publish/create is allowed with a manual price.
- `bdft = sqft × thickness_in` (deterministic).
Admin API surface from `main` OPENAPI:
- `GET /api/v1/admin/pricing`
- `POST /api/v1/admin/pricing`
- `PUT /api/v1/admin/pricing/{id}`
- `DELETE /api/v1/admin/pricing/{id}`
---
## Inference
M3 includes the OpenAI-compatible endpoint as a real server dependency and the Call 1 / Call 2 rules below. Create must pass with inference **off**.
### Settings (not compose)
Stored encrypted in SQLite Settings (not `deploy/.env`):
- `inference_base_url`
- `inference_api_key`
- `inference_model`
- `inference_enabled`
- `content_llm_enabled`
FastAPI proxies all inference calls. The browser never holds the key.
`POST /api/v1/settings/test-inference` returns `{ ok, mode: stateful|stateless|fail, vision_capable, detail }`. Mode `fail` blocks Call 2 enablement only. Inference-off create still works.
### Call 1 (vision / taxonomy assist)
- Runs only when `inference_enabled` is true (auto after calibrated draft upload when on, or via `POST /api/v1/slabs/{id}/infer-taxon`).
- Input: inventory photos downscaled to max 1024px, user metadata, synced Woo taxonomy lists.
- Output: species, wood categories, edge, figure, grade, and feature-tag suggestions constrained to synced lists, each with confidence.
- Default confidence threshold 0.7 (configurable). At or above threshold: pre-fill on review, still editable. Below: leave empty. Do not show low-confidence guesses as defaults.
- Moisture is never inferred.
- Typed values always win over model values.
- Vision never blocks create and never auto-publishes.
### Call 2 (Generate text)
- User-triggered only (`POST /api/v1/slabs/{id}/infer-content`). Never auto.
- Requires successful `test-inference` (`ok: true`).
- Model writes prose only (character, color, grain, uses). It must not invent dimensions.
- Server assembles title, short title, and description from templates with prose injected.
- If the endpoint is down, disable the button. Templates still work. Create is never blocked by Generate text failure.
### Templates (always available)
Full defaults and Woo payload text live in `docs/CONTENT-WOO.md`. Summary here:
**title**
```
{species} {length_ft}ft {length_in_remainder}in × {width_avg_in}in Wood Slab - {character}
```
Example from CONTENT-WOO: `Black Walnut 16ft 0in × 18.2in Wood Slab - Cathedral`
**short_title**
```
{species} Slab - {character}
```
**description** (multi-paragraph)
```
A one-of-a-one {character} {species} slab, {length_ft}ft {length_in_remainder}in long with an
average width of {width_avg_in}in and {thickness_in}in thickness ({bdft} bdft).
Each slab is naturally shaped. Width varies along the length at {width_min_in}in to
{width_max_in}in. Sold by the slab; dimensions as measured. {geo_sentence}
```
**What `{character}` means (locked from CONTENT-WOO example + PROMPTS on `main`).**
The word after the dash in the example is `Cathedral`, which is a **Figure** attribute term.
Fill `{character}` with the primary (first) Figure term **name** from the ready set.
If somehow no figure is present, leave that slot empty (ready set normally requires ≥1 figure).
Do not confuse this with `feat-*` tags. Call 1 free-text "character / inclusions / voids / checks" map to `feat-*` tags, not to this title slot.
All template math fields come from the deterministic pipeline, never from the LLM.
---
## Review and create flow
Mill-owner path after Photos-ready:
1. Upload calibrated / Photos-ready draft to the server (`POST /api/v1/slabs` multipart) when the M3 client reaches that step. Status becomes `calibrated`. If inference is on, Call 1 may run.
2. **Review.** Owner fills or overrides every ready-set field. Pickers show only synced Woo attributes.
3. When the ready set is complete, status becomes `ready` (submission-ready).
4. Owner taps create/publish → `POST /api/v1/slabs/{id}/publish`.
5. Server runs the create sequence below. Client polls `GET /api/v1/slabs/{id}`.
### Create sequence (server)
1. Check the listing against the ready-set rules.
2. Look up SKU in the store (`GET /products?sku={sku}`, any status). Stop if found.
3. Refresh taxonomy if last successful pull is older than 1 hour.
4. Resolve categories, tags, and attributes from the synced lists.
5. Upload inventory PNGs via `POST /media`, keep alpha. Reference `source_url` values on the product. Cap at 5 images. **Woo image order (locked):** (1) clean calibration inventory PNG, (2) dimension-overlay copy of that calibration PNG, (3+) remaining batch inventory PNGs in the order they were added in the web app. The overlay is part of the submission. Do not upload non-inventory artifacts outside that lineup.
6. `POST /products` with status from Settings `woo_create_status` (`draft` or `publish`, default `draft`). **`SLAB-UAT-*` always forces draft.**
7. On success: log, set `woo_product_id`, mark published locally, delete original and processed photos, clear device draft.
8. On failure: log, mark failed, keep photos, put a field-level error on the phone. No raw status codes for the mill owner.
### Create payload (shape)
`POST /products` sends only non-null fields. Conceptual body:
```jsonc
{
"name": "{title}",
"type": "simple",
"status": "{woo_create_status}",
"sku": "{sku}",
"regular_price": "{price}",
"description": "{description}",
"short_description": "{short_title}",
"categories": [ { "id": <species leaf id> }, { "id": <wood-category leaf id> }, ... ],
"tags": [ { "id": <fig-* and feat-* ids> } ],
"images": [ { "src": "<media source_url>", "alt": "{species} {character} slab" } ],
"attributes": [ /* Edge, Figure, Grade, Thickness, Moisture term ids when present */ ],
"virtual": false,
"downloadable": false,
"catalog_visibility": "visible"
}
```
App "published" means the store create succeeded. That is not the same as the Woo product being live when status is draft.
### Duplicate SKU
Before create, if the SKU exists in any store status: block. Show Edit SKU and Open existing listing. This version does not update the other product. API: `409 duplicate_sku`.
### Stale taxonomy on submit
If a category, attribute, or price is no longer valid after refresh: under that field, tell the owner to pick from the current list and supply current options (`stale_field`).
---
## Settings keys (M3)
| Key | Where | Notes |
|---|---|---|
| `WOO_BASE_URL` | compose / `.env` | HTTPS store URL. Read-only in UI. |
| `SLAB_AES_KEY` | compose / `.env` | AES master key. Never in DB or repo. |
| `woo_wp_username` | Settings (encrypted) | Dedicated WP user. |
| `woo_app_password` | Settings (encrypted) | Application password. |
| `woo_create_status` | Settings | `draft` or `publish`. Default `draft`. |
| `inference_base_url` | Settings (encrypted) | OpenAI-compatible base URL. |
| `inference_api_key` | Settings (encrypted) | |
| `inference_model` | Settings | |
| `inference_enabled` | Settings | |
| `content_llm_enabled` | Settings | Gates Generate text. |
| `taxonomy_last_sync_at` | Settings / derived | |
| `taxonomy_anchor` | Settings / derived | |
| `brand_voice`, `geo_context` | Settings | Copy helpers. |
Relevant Settings API: `GET/PUT /api/v1/settings`, `POST /api/v1/settings/test-woo`, `POST /api/v1/settings/test-inference`.
---
## Data shape (submission-ready fields)
Core slab fields M3 must persist (from `main` DATA-MODEL, adopted here):
| Field | Rule |
|---|---|
| `sku` | Required. Unique. |
| `status` | `draft` → `calibrated` → `ready` → `publishing` → `published`, or `failed` |
| `length_in`, `thickness_in` | Required. 1/8" step. |
| `sqft`, `bdft`, widths | From client math. |
| `species_id` | 1 species leaf. |
| `wood_category_ids` | 1 or more. |
| `edge_type_term_id` | 1. |
| `figure_term_ids` | 1 or more. |
| `grade_term_ids` | 1 or more. |
| `thickness_term_id` | Round-up band. |
| `moisture_term_id` | 1. Default kiln-dried. |
| `fig_tag_ids` | 1 or more. |
| `feat_tag_ids` | 0 or more. |
| `price`, `price_per_bdft`, `price_source` | See Pricing. |
| `title`, `short_title`, `description` | Optional for ready. |
Cache tables: `woo_categories`, `woo_attributes`, `woo_attribute_terms`, `woo_tags`, `pricing_rules`.
Device draft (IndexedDB on the phone) is not a WooCommerce draft. Clear the device draft after successful create.
---
## Practice / UAT rules
- Every practice listing uses SKU prefix `SLAB-UAT-*`.
- `SLAB-UAT-*` always creates a WooCommerce draft, even if Settings say publish.
- Customers must not see practice listings on the storefront.
- Inference is **off** for the claimable exit walk. Optional ON path only after inference Settings pass `test-inference`.
- UAT deploy for this branch: ubudual5060 `http://100.64.0.9:10881`. Do not use `:10880`.
---
## API surface M3 must implement (summary)
Base path `/api/v1` (health may also be `/api/health`).
| Area | Methods |
|---|---|
| Health | `GET /api/health` |
| Slabs | `POST/GET/PUT/DELETE /api/v1/slabs`, `GET /api/v1/slabs/{id}` |
| Photos | original/processed GET while retained |
| Inference | `POST .../infer-taxon`, `POST .../infer-content` |
| Publish | `POST .../publish` → 202, poll slab |
| Taxonomy | `GET/POST /api/v1/admin/taxonomy`, `.../sync` |
| Pricing | CRUD `/api/v1/admin/pricing` |
| Settings | `GET/PUT /api/v1/settings`, `test-woo`, `test-inference` |
Full request and response field lists on historical `main` `docs/OPENAPI.md` are the detail reference if a field is missing here. Prefer extending this file over coding from `main` directly.
---
## Done when (exit criteria)
**Product state induced.** Overnight UAT app on ubudual5060 `http://100.64.0.9:10881` (or the current M3 image of this branch), with `WOO_BASE_URL` and Woo application password pointed at the intended store.
**Evidence from that state.**
1. `test-woo` succeeds and taxonomy sync fills Woo attributes into the app cache. Pickers offer only synced ids.
2. A Photos-ready slab can be filled to the **full ready set** (submission-ready) using those lists and pricing rules, with **inference off**.
3. Create with `SLAB-UAT-*` yields a WooCommerce **draft** whose categories, attributes, tags, price, and inventory images match the reviewed ready set.
4. Duplicate SKU is blocked. Device draft clears after success.
5. Inference Settings accept an OpenAI-compatible base URL, key, and model. `test-inference` runs. Create still succeeds with inference disabled.
## Dishonest proxies (do not count as pass)
- Hardcoded or fixture taxonomy lists treated as synced.
- Mock Woo client that never hits the real store for the exit walk.
- Unit tests alone without a real `SLAB-UAT-*` draft in the store.
- Consumer-key auth instead of application password.
- Inference "wired" as dead config with no server proxy, or create that requires inference on.
- Claiming submission-ready when required ready-set fields are still empty.
- Touching `:10880` or parking work on `m2-ds` / doc-simplification.
## Open decisions (must lock before task breakdown)
Locked from Ty conversation or from `docs/CONTENT-WOO.md`:
1. **`categories` on `POST /products`.** **Locked from CONTENT-WOO field rules:** send every ready-set category id (species leaf + each wood-category leaf) as `[{"id": n}, ...]`. The single-id example in CONTENT-WOO §2.4 is incomplete relative to those field rules.
2. **Template `{character}`.** **Locked from CONTENT-WOO example (`… Wood Slab - Cathedral`):** use the primary Figure term **name**. `feat-*` tags are separate (Call 1 free-text character/inclusions/voids/checks map there).
3. **Dimension-overlay PNG.** **Locked (Ty 2026-09-16):** Woo image order is (1) clean calibration PNG, (2) dimension-overlay copy, (3+) other inventory PNGs in add order. Cap ≤5.
Still open (CONTENT-WOO does not fully settle these):
4. **`attributes` element shape on `POST /products`.** CONTENT-WOO still has `"attributes": [ … if present … ]` with no fields. Need a live Whidbey product JSON sample, or Ty pointing at the official Woo REST fields to use. Do not invent.
5. **Server persistence.** CONTENT-WOO describes create sequence and payload. It does not choose full OpenAPI slab CRUD vs a thinner FastAPI path on this branch. Still need a lock before coding the server store.
Non-blocking notes (shipped M1/M2 win on this branch): fill target 90% and 800px floor (not main's 80%/1600); Vite + Svelte SPA (not SvelteKit docs); overlay draw spacing may differ from measure spacing by design.
If store-specific term labels differ from examples in this file, the synced store lists win.
-21
View File
@@ -1,21 +0,0 @@
# Milestones
A milestone is an explicit subset of requirement IDs from `docs/REQUIREMENTS.md`. No prose scope here.
Populated in Phase 2. Until then every milestone is empty and coding is paused.
## M1 — Capture (shipped)
- IDs: —
## M2 — Photos (shipped)
- IDs: —
## M3 — Create (paused)
- IDs: —
## Later
- IDs: —
+2 -2
View File
@@ -70,6 +70,6 @@ On a phone UAT build:
## Out of scope
- Complete-listing fields (SKU, species, category, edge, figure, grade, thickness band, moisture, `fig-*` / `feat-*`, title, short title, description, price)
- WooCommerce create and submission-ready taxonomy (Milestone 3: `docs/CREATE.md`)
- Work beyond `docs/CREATE.md` unless Ty expands M3 in chat
- WooCommerce create (Milestone 3)
- Inference / Assist (Milestone 4)
- Changing Milestone 1 mask/axis/measure math except to reuse its outputs for batch apply and overlays
+36 -27
View File
@@ -1,4 +1,4 @@
# Product overview
# Product requirements
SlabUploader creates one WooCommerce listing per wood slab for Whidbey Wood Store.
@@ -6,12 +6,8 @@ Each slab is one unit and one listing. Photos and metadata are the inventory rec
Photographs are taken of the slab on a green or black sheet. That is the capture starting state.
Owner: Ty. Store: https://www.whidbeywoodstore.com/
## Complete listing
A finished listing has:
- Inventory photo(s), uniform 3:4 transparent PNG
- SKU, length, thickness, price
- Species, wood category, edge type, figure, grade
@@ -20,9 +16,7 @@ A finished listing has:
- Title, short title, description
- Zero or more `feat-*` tags
Store categories, attributes, and `fig-*` / `feat-*` tags are the filters shoppers use. Measured values feed title and description templates.
The locked name for that synced taxonomy package is **Woo attributes**. Details live in `docs/CREATE.md`.
Store categories, attributes, and `fig-*` / `feat-*` tags are the filters. Measured values feed title and description templates.
## Decision rights
@@ -36,33 +30,48 @@ The locked name for that synced taxonomy package is **Woo attributes**. Details
| Price | App proposal `bdft × species $/bdft`; user may replace |
| Title, short title, description | Templates from listing facts; user may edit |
User-entered length, thickness, SKU, price, and taxonomy override computed or inferred values.
## System seams
Milestones 1 and 2 run in the browser. No backend and no inference URL on the shipped path.
Milestones 1 and 2 run in the browser. No backend and no inference URL.
Milestone 1 handoff is the device draft: source photo, approved mask, inventory PNG, length, thickness, widths, sqft, bdft.
Milestone 2 extends the draft with the photo batch, calibration choice, per-photo inventory PNGs, and the dimension-overlay calibration copy.
Milestone 3 uses FastAPI as the Woo client. Store credentials stay on the server.
Milestone 3 introduces FastAPI as the Woo client and inference proxy. Store credentials and inference secrets stay on the server. Spec: `docs/CREATE.md`.
## Create
## Create rules (summary)
`SLAB-UAT-*` creates as a WooCommerce draft (store status).
Duplicate SKU blocks create.
A listing can be created with inference off.
A device draft in the app is separate from a WooCommerce draft.
Full behavior is in `docs/CREATE.md`.
## Milestones
- `SLAB-UAT-*` creates as a WooCommerce draft (store status).
- Duplicate SKU blocks create.
- A listing can be created with inference off.
- A device draft in the app is separate from a WooCommerce draft.
Work is split into four milestones. Each milestone is done when its acceptance can be demonstrated.
## Milestone status on `m3-ds`
### Milestone 1 — Capture
| Milestone | Spec | Status |
|---|---|---|
| 1 Capture | `docs/CAPTURE.md` | Shipped |
| 2 Photos | `docs/PHOTOS.md` | Shipped (UAT `:10881`) |
| 3 Create | `docs/CREATE.md` | Current work. Exit: submission-ready for WooCommerce (`CREATE.md` Done when). Scope from Ty conversations; behavior for sync/metadata/pricing/inference taken from `main` into `CREATE.md`. |
Spec: `docs/CAPTURE.md`. Done historically; handoff into Milestone 2.
Anything beyond `docs/CREATE.md` is out of M3 unless Ty expands scope in chat.
User stories:
- Photograph a slab on a green or black sheet and remove the background.
- Set length from the bounding rectangle and get widths, sqft, and bdft.
- Leave and later Continue or Start fresh.
Acceptance: inventory PNG and measurements visible on a phone. No Woo create.
### Milestone 2 — Photos
Spec: `docs/PHOTOS.md`. Current work.
Batch photos of one slab, pick calibration, run Milestone 1 on that reference, apply the same mask params and crop to the rest, preview all inventory PNGs lined up, plus a dimension-overlay copy of the calibration PNG.
Acceptance: on a phone, 1–N store-ready inventory PNGs for the slab (including dimension overlay on the calibration copy) with Milestone 1 measures visible. No Woo create. No Complete-listing taxonomy in this milestone.
### Milestone 3 — Create
WooCommerce draft for a practice SKU. Then clear the device draft.
Acceptance: `SLAB-UAT-*` exists in the store as draft.
### Milestone 4 — Assist
Optional vision and generated copy on the manual path from Milestones 1–3.
Acceptance: create still works with inference off.
-60
View File
@@ -1,60 +0,0 @@
# Docs map (branch `m3-ds`)
Read this file first. It tells you what SlabUploader is for, what already ships, and what Milestone 3 must build.
**Who this is for.** Mill owner (Ty), humans reviewing scope, and agents implementing on this branch.
**Source of truth for Milestone 3.** `docs/CREATE.md` plus the Ty conversations that wrote it. Older milestone and task splits on `main` are obsolete for M3. Do not open `main` for scope once you are coding on `m3-ds`.
## High-level project goal
SlabUploader lists one-of-a-kind wood slabs on Whidbey Wood Store (WooCommerce).
One slab is one unit and one listing. Photos and metadata are the inventory record. The mill owner photographs a slab on a green or black sheet, gets store-ready transparent PNGs and measurements on the phone, fills the store's taxonomy and price, then creates a WooCommerce product.
Store: https://www.whidbeywoodstore.com/
## What is already implemented (pre-M3)
| Milestone | Spec | Status on this branch |
|---|---|---|
| 1 Capture | `docs/CAPTURE.md` | Shipped. Browser-only mask, length axis, measure, inventory PNG, device draft. |
| 2 Photos | `docs/PHOTOS.md` | Shipped. Batch photos, pick calibration, apply mask/crop to the batch, dimension overlay, Photos-ready lineup. UAT on `:10881`. |
There is no Woo create yet. There is no Complete-listing taxonomy UI yet. There is no FastAPI Woo client on this branch (`backend/` is a stub). Inference is not wired.
## What Milestone 3 builds
Spec: `docs/CREATE.md`.
**Exit criteria.** A Photos-ready slab becomes **submission-ready for WooCommerce**, and a practice `SLAB-UAT-*` draft exists in the store that matches the reviewed ready set. Create must still work with inference off.
In plain terms, M3 connects the Photos-ready device draft to the real store:
1. Talk to WooCommerce with a WordPress application password (server-side only).
2. Sync and use **Woo attributes** (categories, five locked attributes, `fig-*` / `feat-*` tags) per the requirements taken from `main` and written into `CREATE.md`.
3. Pricing from species `$/bdft` rules, with user override.
4. Inference Settings (OpenAI-compatible URL, key, model) and Call 1 / Call 2 rules when enabled.
5. Review to the full ready set, then create the Woo product (force draft for `SLAB-UAT-*`).
Technical detail for Woo sync, metadata, pricing, and inference is copied into `docs/CREATE.md` from `main` so you do not need to open `main` while coding. Older milestone splits on `main` are still not the scope authority for what M3 includes.
## How to read the folder
| File | Role |
|---|---|
| `README.md` (this file) | Map: goals, shipped, M3. |
| `PRODUCT.md` | Product overview: complete listing, decision rights, seams. |
| `CAPTURE.md` | Milestone 1 Capture (shipped). |
| `PHOTOS.md` | Milestone 2 Photos (shipped). |
| `CREATE.md` | Milestone 3 Create (current work). Read this before task breakdown or coding. |
| `CONTENT-WOO.md` | Listing copy templates and Woo create/payload detail (from `main`). |
| `UAT-PRACTICE.md` | Human practice walk for a `SLAB-UAT-*` listing. Defers to `CREATE.md` on conflict. |
| `archive/` | Historical only. Not M3 scope. |
## Deploy reminders for this branch
- UAT overnight Docker: ubudual5060 `http://100.64.0.9:10881`
- Do not touch `:10880` (Ty Node/SvelteKit)
- Store URL (`WOO_BASE_URL`) lives in compose / host `.env`
- Woo application password and inference URL live in encrypted Settings, not in compose
-179
View File
@@ -1,179 +0,0 @@
# Requirements
Normative source of truth. One row per requirement. Enforced by `tools/check_requirements.py`.
A requirement is defined here and nowhere else. Capability and contract docs cite IDs; they do not restate them.
Format and rules: `docs/STRUCTURE.md`.
Scope: `shipped` | `M3` | `later`.
`Test` is a test path, a requirement ID, or `—` (traceability gap). A path given for a `shipped` requirement must exist.
Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplification` tag; decisions recorded in `.audit/2026-09-18-reconciliation-decisions.md`.
## CAPTURE — ecommerce-ready capture
| ID | Requirement | Scope | Test |
|---|---|---|---|
| CAPTURE-1 | Mask, measure, crop, and PNG output are a deterministic function of photo pixels and user inputs, with no inference, model, or network in the math. | shipped | — |
| CAPTURE-2 | Supported clients are iOS Safari and Android Chrome, plus desktop Chrome, Edge, and Firefox; unsupported clients get a hard stop, and there are no polyfills. | shipped | — |
| CAPTURE-3 | Capture is online only; offline capture is deferred. | later | — |
| CAPTURE-4 | Photos are taken on a green or black sheet with the slab fully on the sheet; the first photo is top-down with the lens parallel to the broad face. | shipped | — |
| CAPTURE-5 | A batch of one or more photos of one slab is uploaded, and the owner selects which photo is the calibration photo. | shipped | — |
| CAPTURE-6 | The calibration photo drives mask, length axis, measure, and crop; other batch photos reuse the approved sheet, sensitivity, and crop rules. | shipped | — |
| CAPTURE-7 | Sheet detection samples a 2 to 5 percent strip on all four borders and classifies green or black by HSV; otherwise the owner picks the sheet. | shipped | frontend/src/mask/sheetKeep.test.ts |
| CAPTURE-8 | Mask controls are exactly Sheet (auto, green, black) and Sensitivity (0 to 100, step 1); Edge offset and Feather are removed. | shipped | — |
| CAPTURE-9 | A Sensitivity change debounces 2000 ms; a Sheet change runs immediately; the overlay recomputes in a worker and the last good overlay stays visible. | shipped | frontend/src/capture/session.calibrate.test.ts |
| CAPTURE-10 | The mask overlay renders on the source photo and the owner approves it; approval is blocked when the mask has no slab pixels. | shipped | — |
| CAPTURE-11 | The mask must be a single contiguous slab contour; otherwise the resolution is a retake. | shipped | — |
| CAPTURE-12 | The length axis is the long side of the minimum-area bounding rectangle of the mask; the owner rotates it to the prevailing length and confirms. | shipped | frontend/src/geometry/rect.test.ts |
| CAPTURE-13 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
| CAPTURE-14 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
| CAPTURE-15 | scale equals longest_axis_px divided by measured length, where longest_axis_px is the mask span end to end along the approved axis, not the rectangle edge. | shipped | — |
| CAPTURE-16 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts |
| CAPTURE-17 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
| CAPTURE-18 | 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-19 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts |
| CAPTURE-20 | 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-21 | 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-22 | 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-23 | 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-24 | 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 |
| CAPTURE-25 | Overlay width stations run every 12 inches starting 6 inches from the bottom while station is at most length minus 6; the bottom end follows the calibration photo EXIF orientation. | shipped | frontend/src/png/overlay.test.ts |
| CAPTURE-26 | The photos-ready lineup order is clean calibration PNG, dimension-overlay copy, then remaining batch photos in capture order. | shipped | frontend/src/capture/photosReadyLineup.test.ts |
| CAPTURE-27 | The client computes all deterministic math; the server stores the client numbers and never recomputes them as source of truth. | shipped | — |
| CAPTURE-28 | Load-bearing client APIs are image decode, canvas or OffscreenCanvas, a worker, PNG encode, IndexedDB, and fetch; no OpenCV, ONNX, or U2Net on the shipped path. | shipped | — |
| CAPTURE-29 | Capture knob preferences persist in localStorage until reset to default. | shipped | — |
| CAPTURE-30 | Mask tuning has no apply step and no server round trip. | shipped | — |
| CAPTURE-31 | Failures such as a broken mask or undersized source never silently guess; the resolution is retake. | shipped | — |
| CAPTURE-32 | Length is stored and displayed to the nearest 1/8 inch, rounded values are what is stored, and sqin appears nowhere in the UI, tests, or copy. | shipped | — |
| CAPTURE-33 | U2Net on-demand server matting is deferred. | later | — |
## METADATA — listing attributes and copy
| ID | Requirement | Scope | Test |
|---|---|---|---|
| METADATA-1 | The listing taxonomy is exactly the synced WooCommerce set; the app never hardcodes, invents, or falls back to its own lists. | M3 | — |
| METADATA-2 | Product categories are exactly one species leaf and one or more wood-category leaves; parents group only and are never assigned. | M3 | — |
| METADATA-3 | The five locked attributes are Edge Type (1), Figure (at least 1, includes plain), Grade (exactly 1), Thickness (1, round-up band), and Moisture (1, default kiln-dried, never inferred). | M3 | — |
| METADATA-4 | Missing attributes or terms are skipped, never created. | M3 | — |
| METADATA-5 | Species, character, length, and free-text thickness are not product attributes. | M3 | — |
| METADATA-6 | Product tags come from the synced Woo set and are all optional; no hardcoded prefixes, and the bare fig tag is kept. | M3 | — |
| METADATA-7 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | — |
| METADATA-8 | Call 1 returns species, wood categories, edge, figure, grade, and feature-tag suggestions constrained to the synced lists, each with confidence. | M3 | — |
| METADATA-9 | Call 1 confidence at or above the threshold (default 0.7) pre-fills review fields and below it leaves them empty; suggestions are always editable. | M3 | — |
| METADATA-10 | Typed values always win over inferred values. | M3 | — |
| METADATA-11 | Call 2 is user-triggered only and produces name, short_description, and description; it never runs automatically. | M3 | — |
| METADATA-12 | Call 2 receives all metadata confirmed or changed in review plus the literal dimension fact block; the template guides style and the model has creative license. | M3 | — |
| METADATA-13 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
| METADATA-14 | With inference off, deterministic templates produce name, short_description, and description so create never lacks prose. | M3 | — |
| METADATA-15 | Inference never blocks create and never auto-publishes. | M3 | — |
| METADATA-16 | The title uses the primary figure term name as the character, with a hyphen separator. | M3 | — |
| METADATA-17 | The primary wood category selects a specialized Call 2 prompt. | M3 | — |
| METADATA-18 | The dimension fact block carries exact strings for species, figure term, length, thickness, average width, width min to max, and board feet; there is no L by W by T rectangle assumption. | M3 | — |
| METADATA-19 | The dimension-overlay image is never sent to the model. | M3 | — |
## REVIEW — confirm, tweak, ready
| ID | Requirement | Scope | Test |
|---|---|---|---|
| REVIEW-1 | Submission-ready requires at least two images (clean first pic plus its dimension-overlay copy), SKU, length, thickness, computed widths and sqft and bdft, exactly one species leaf, at least one wood-category leaf, one Edge Type, at least one Figure, exactly one Grade, one Thickness band, one Moisture, and a price. | M3 | — |
| REVIEW-2 | Title, short_description, and description are required on the product and are satisfied by typed, generated, or templated text. | M3 | — |
| REVIEW-3 | In review the owner declares one primary wood category and any secondary ones; primary drives the title and primary plus secondary drive the description; the default primary is the first category selected. | M3 | — |
| REVIEW-4 | Every empty required field shows an inline nudge as the owner works; the slab cannot reach ready until the full set is filled, and there is no single submit-time gap list. | M3 | — |
| REVIEW-5 | The price suggestion is bdft times the species rate from Settings; the owner may override and the override sticks; no rule leaves the price empty for manual entry. | M3 | — |
| REVIEW-6 | Widths, sqft, and bdft are read-only in production, derived from the mask, and are not overridable. | M3 | — |
| REVIEW-7 | A picked taxonomy id that disappeared after a refresh shows as no longer valid and is never silently swapped for another value. | M3 | — |
| REVIEW-8 | The owner can edit any field, including generated title and description, until create. | M3 | — |
## PUBLISH — store submission
| ID | Requirement | Scope | Test |
|---|---|---|---|
| PUBLISH-1 | The create sequence validates the ready set, looks up the SKU and stops if it exists, refreshes taxonomy when the last sync is over one hour old, resolves categories and tags and attributes, uploads images, calls POST products, then finalizes. | M3 | — |
| PUBLISH-2 | SKUs matching SLAB-UAT-* always create a WooCommerce draft regardless of the create-status setting. | M3 | — |
| PUBLISH-3 | A duplicate SKU blocks create and offers edit-SKU or open-existing; this version has no update path. | M3 | — |
| PUBLISH-4 | A stale value on submit is a field-level error that shows the current options and never a raw code. | M3 | — |
| PUBLISH-5 | On success the server sets woo_product_id, purges photos, deletes the local slab row, and clears the device draft; WooCommerce is the source of truth. | M3 | — |
| PUBLISH-6 | On failure the slab is marked failed, photos are retained, sync_log is written, and a plain field-level message is shown; retry works from the same screen. | M3 | — |
| PUBLISH-7 | The owner never sees raw status codes, stack traces, or internal error codes. | M3 | — |
| PUBLISH-8 | Images upload as processed PNGs with alpha, capped at five, and only inventory images are uploaded. | M3 | — |
| PUBLISH-9 | The Woo image order is the photos-ready lineup: clean calibration, dimension-overlay copy, then the rest in capture order. | M3 | — |
| PUBLISH-10 | Published means the store create succeeded, not that the product is live when status is draft. | M3 | — |
| PUBLISH-11 | Only non-null fields are sent; the payload carries name, type simple, status, sku, regular_price, description, short_description, categories, tags, images, attributes, virtual false, downloadable false, catalog_visibility visible. | M3 | — |
## SETTINGS — configured once
| ID | Requirement | Scope | Test |
|---|---|---|---|
| SETTINGS-1 | WOO_BASE_URL lives in compose or .env, is https only, is injected into FastAPI, and is shown read-only in Settings; it is not a SQLite settings row. | M3 | — |
| SETTINGS-2 | FastAPI refuses to start when WOO_BASE_URL is missing or not https. | M3 | — |
| SETTINGS-3 | Woo auth is a WordPress application password for a dedicated low-privilege user, not consumer keys and not the admin login. | M3 | — |
| SETTINGS-4 | Woo username and application password are entered in Settings and stored AES-GCM in SQLite; the browser never keeps them. | M3 | — |
| SETTINGS-5 | SLAB_AES_KEY lives in compose or .env as a 32-byte base64 value, never in the DB or repo, and a missing key refuses startup. | M3 | — |
| SETTINGS-6 | Inference base URL and API key are stored encrypted in SQLite, while inference_model, inference_enabled, content_llm_enabled, brand_voice, and geo_context are settings. | M3 | — |
| SETTINGS-7 | Species rates are configured in Settings as one flat dollars-per-bdft value per synced species leaf, with no tiers. | M3 | — |
| SETTINGS-8 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — |
| SETTINGS-9 | 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-10 | 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-11 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — |
| SETTINGS-12 | 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-13 | woo_create_status defaults to draft. | M3 | — |
| SETTINGS-14 | The vision pixel cap is the only user-facing vision setting; the JPEG quality lives in server config. | M3 | — |
| SETTINGS-15 | Secrets never appear in any GET response; only a configured boolean is returned. | M3 | — |
## APP — device draft and state
| ID | Requirement | Scope | Test |
|---|---|---|---|
| APP-1 | Slab status runs draft, calibrated, ready, publishing, published, or failed. | shipped | — |
| APP-2 | The device draft is separate from a WooCommerce draft and is restored on open through Resume; Continue restores the last approved state and Start fresh discards. | shipped | — |
| APP-3 | The device draft is cleared after a successful create. | M3 | — |
| APP-4 | Slab ids are client-generated, timestamps are UTC ISO-8601, and client_rev and server_rev support optimistic concurrency. | M3 | — |
| APP-5 | FastAPI is the only caller of WooCommerce and of the inference endpoint; the browser holds no store or inference credentials. | shipped | — |
| APP-6 | The API base is /api/v1 and health is at /api/health. | M3 | — |
| APP-7 | Errors use a uniform envelope and are JSON; lists are returned in full without pagination. | M3 | — |
| APP-8 | The server stores the client-sent derived numbers and does not recompute them as source of truth. | shipped | — |
## OPS — deploy and release
| ID | Requirement | Scope | Test |
|---|---|---|---|
| OPS-1 | Deploy is Docker Compose plus Caddy on an inventoried host, reachable only over LAN or Tailscale with HTTPS. | shipped | — |
| OPS-2 | WOO_BASE_URL and SLAB_AES_KEY are supplied as compose environment and never stored in the repo. | shipped | — |
| OPS-3 | deploy/INVENTORY.md is filled before any port bind, covering host, hostname, ports, and DNS reachability from the phone. | shipped | — |
| OPS-4 | Services bind to localhost and Caddy is the only public surface. | shipped | — |
| OPS-5 | Base images are pinned, not latest. | shipped | — |
| OPS-6 | Backup is a WAL-safe SQLite backup plus a tar of the images volume, with retention of 7 daily and 4 weekly. | later | — |
| OPS-7 | Restore is stop, restore DB and images, start, and it is tested at least once before backup is considered done. | later | — |
| OPS-8 | The UAT environment is ubudual5060 at http://100.64.0.9:10881; port 10880 is off limits. | M3 | — |
| OPS-9 | Inference is a remote, configurable endpoint, not part of the app host. | shipped | — |
| OPS-10 | The repository on gitea-atd is the source of truth. | shipped | — |
## QUALITY — verification
| ID | Requirement | Scope | Test |
|---|---|---|---|
| QUALITY-1 | Tests are written with the code; no feature ships without a passing test and its shown output. | shipped | — |
| QUALITY-2 | Deterministic modules are test-driven and covered before any UI consumes them. | shipped | — |
| QUALITY-3 | Unit tests for the deterministic core cover vectors TV-1 through TV-10. | shipped | — |
| QUALITY-4 | The full suite passes with inference disabled, asserting zero inference calls. | M3 | — |
| QUALITY-5 | Pass or fail is only claimed with raw runner evidence; missing evidence means not run. | shipped | — |
| QUALITY-6 | Ty reads raw results and owns gate exit; agents never declare a gate green. | shipped | — |
| QUALITY-7 | Integration tests use a Woo mock and cover success, error, duplicate SKU, stale field, and taxonomy anchor. | M3 | — |
| QUALITY-8 | Property checks assert bdft is monotonic in thickness and square feet. | shipped | — |
| QUALITY-9 | The deterministic functions assert the bdft identity and the no-upscale path. | shipped | — |
| QUALITY-10 | UI and end-to-end tests run against a Compose stack with mocked Woo. | M3 | — |
| QUALITY-11 | Pipeline vector TV-1: an interior green patch not touching the border survives; U2Net is not invoked. | shipped | — |
| QUALITY-12 | Pipeline vector TV-2: dark interior wood on a black sheet survives, and flood fill isolates only border-connected regions. | shipped | — |
| QUALITY-13 | Pipeline vector TV-3: a 96 inch slab, 1.5 inch thick, 0.64 sqft mask yields 0.96 bdft. | shipped | — |
| QUALITY-14 | Pipeline vector TV-4: width samples at 6 inch stations on a 96 inch axis report min, max, and average perpendicular to the confirmed axis. | shipped | — |
| QUALITY-15 | Pipeline vector TV-5: one square end and one 45 degree cut, the length axis follows the prevailing length. | shipped | — |
| QUALITY-16 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
| QUALITY-17 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
| QUALITY-18 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, an override of 150.00 is stored, and no rule leaves the price empty for manual entry. | M3 | — |
| QUALITY-19 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — |
| QUALITY-20 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
| QUALITY-21 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
| QUALITY-22 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
| QUALITY-23 | Browser end-to-end capture through publish is verified on iOS Safari, Android Chrome, and desktop Chrome, Edge, and Firefox. | later | — |
| QUALITY-24 | No raw status code, stack trace, or internal code reaches the owner in any tested path. | M3 | — |
-106
View File
@@ -1,106 +0,0 @@
# Structure contract
This file defines the shape of the `docs/` system and how requirements enter it.
`tools/check_requirements.py` enforces it.
If the repo and this file disagree, fix one deliberately. Do not let them drift.
## Artifact registry
Every Markdown file under `docs/` is registered here. `docs/archive/**` and `docs/_templates/**` are exempt.
| Path | Role |
|---|---|
| `docs/README.md` | index |
| `docs/PRODUCT.md` | intent |
| `docs/STRUCTURE.md` | meta |
| `docs/REQUIREMENTS.md` | requirements |
| `docs/MILESTONES.md` | scope |
| `docs/CAPTURE.md` | capability |
| `docs/PHOTOS.md` | capability |
| `docs/CREATE.md` | capability |
| `docs/CONTENT-WOO.md` | contract |
| `docs/UAT-PRACTICE.md` | practice |
| `docs/capabilities/README.md` | index |
| `docs/contracts/README.md` | index |
Roles:
- **index** — navigation only.
- **intent** — why and what; not testable requirements.
- **meta** — this contract.
- **requirements** — the only normative text.
- **scope** — milestone-to-ID mapping.
- **capability** — behavior and acceptance for one capability; cites IDs, does not define them.
- **contract** — cross-cutting interface detail; cites IDs, does not define them.
- **practice** — human walkthrough.
## ID namespaces
Namespaces are **owner jobs, not technology layers**. A requirement belongs to the job it serves, and each namespace owns that job's screen, rules, server behavior, and data. There is no separate API, data, or UX layer to fight over.
All requirement text lives only in `docs/REQUIREMENTS.md`. This table assigns the home and prefix.
| Prefix | The owner is... | Covers |
|---|---|---|
| `CAPTURE` | capturing ecommerce-ready pics | photo intake, pick calibration/dimension photo, BG removal, crop, axis, length/width, calculations, inventory PNG, dimension overlay, lineup |
| `METADATA` | building the listing | wood attributes and tags, title, short/deep description, via up to 2 inference calls |
| `REVIEW` | reviewing and correcting | confidence display, field gating, tweaks, per-slab price suggestion and override, ready gate |
| `PUBLISH` | submitting to the store | WooCommerce create, draft/live, duplicate SKU, cleanup |
| `SETTINGS` | configuring before the session | Woo connection, inference config, species `$/bdft` rates, brand/geo |
| `APP` | (foundation, not a job) | device draft, slab status, resume, server seams, secret handling |
| `OPS` | (foundation, not a job) | deploy, hosts, release, UAT |
| `QUALITY` | (foundation, not a job) | test obligations, vectors, anti-metrics |
Price has two homes by design: the species `$/bdft` rates are configured in `SETTINGS`; the per-slab suggestion and override live in `REVIEW`.
## Requirement format
Defined only in `docs/REQUIREMENTS.md`, as table rows:
```
| ID | Requirement | Scope | Test |
| CAPTURE-1 | Statement of one testable requirement | shipped | frontend/src/measure/measure.test.ts |
```
- `Scope` is `shipped`, `M3`, or `later`.
- `Test` is a repo-relative test path, a requirement ID, or `—`. A path given for a `shipped` requirement must exist; a given ID must resolve to a defined requirement.
## How to change a requirement
- **Modify:** edit the single row in `REQUIREMENTS.md`; update `Test`; if scope changed, update `MILESTONES.md`. Do not create a doc.
- **Add:** pick the prefix and home from the namespace table; append a row; add the test. Do not create a doc.
- **New domain:** that is a structure change. Add a namespace row and a home here first, deliberately. Do not create a drive-by artifact.
Nothing outside `REQUIREMENTS.md` may define a requirement. Capability and contract docs cite IDs only.
## Enforced by `tools/check_requirements.py`
1. Every `docs/` Markdown file (except `archive/**` and `_templates/**`) is registered above.
2. Every registered file exists on disk.
3. Requirement IDs match `^[A-Z]+-\d+$`, are unique, and use a registered prefix.
4. Scope is one of `shipped | M3 | later`.
5. `Test` references resolve: a path given for a `shipped` requirement exists; an ID given resolves to a defined requirement; `—` is allowed.
6. Requirement rows appear only in `REQUIREMENTS.md`.
7. Every ID listed in `MILESTONES.md` exists in `REQUIREMENTS.md`.
## Planned (Phase 2)
- Migrate capability docs to `docs/capabilities/` and contracts to `docs/contracts/`.
- Populate `REQUIREMENTS.md` from the `pre-simplification` tag and the current docs.
- Enforce branch-neutral language in trunk docs.
- Wire this check into CI.
## Migration plan (Phase 2)
| Current | Canonical home |
|---|---|
| `docs/CAPTURE.md` | `docs/capabilities/CAPTURE.md` |
| `docs/PHOTOS.md` | `docs/capabilities/PHOTOS.md` |
| `docs/CREATE.md` | `docs/capabilities/CREATE.md` |
| `docs/CONTENT-WOO.md` | `docs/contracts/CONTENT-WOO.md` |
| tag `OPENAPI.md` | `docs/contracts/API.md` |
| tag `DATA-MODEL.md` | `docs/contracts/DATA-MODEL.md` |
| tag `UX.md` | `docs/contracts/UX.md` |
| tag `PROMPTS.md` | `docs/contracts/PROMPTS.md` |
| tag `TEST-SPEC.md` | `docs/contracts/TEST-SPEC.md` |
-230
View File
@@ -1,230 +0,0 @@
# UAT Practice Walk: one `SLAB-UAT-*` listing
Status: WALK · for Ty to run · SlabUploader
This is the walkable practice plan. It proves one listing end to end without touching a live product.
Follow the sections in order. Each one names what you do, what you should see, and where the rule
lives.
**Inference is OFF for this walk.** The optional ON path is only valid after Gate C is green and
the inference endpoint is already configured. Inference stays off here.
---
> On branch `m3-ds`, Milestone 3 exit criteria and field rules live in `docs/CREATE.md`. This walk is the human practice path for a `SLAB-UAT-*` listing. Prefer `CREATE.md` if anything conflicts.
## 1. Host and deploy preconditions
Before this walk, the app must already be on the inventoried host. This section only checks that.
Host IPs and hostnames come from `deploy/INVENTORY.md`.
1. **Inventory is filled.** `deploy/INVENTORY.md` has `APP_HOST`, `APP_HOSTNAME`, ports, Caddy
location, and the smoke results. It is the only source for what runs where on this host.
See `docs/DEPLOYMENT.md` §1 for the worksheet rules.
2. **Reachable over LAN/Tailscale.** Your phone reaches `https://${APP_HOSTNAME}` on the same
network (LAN or Tailscale). No public exposure. Verify with a browser, not `curl`.
3. **HTTPS only.** The URL is `https://`. The browser shows a valid certificate. If the phone
warns, the Caddy TLS trust is not set up yet. That is a deploy problem, not a listing problem.
4. **Health passes.** In a browser on the phone, open `https://${APP_HOSTNAME}/api/health`.
You should see an ok response. This is the same check in `docs/DEPLOYMENT.md` §8 step 1.
5. **Store URL is set.** `WOO_BASE_URL` is in the host `.env` and points at the store over
`https://`. It is not something you type in the app. See `docs/DEPLOYMENT.md` §3 and §5.
If any of these fail, fix the deploy before continuing. This walk does not cover Gate B host work.
It assumes the app is up and reachable.
---
## 2. WordPress Application Password setup
You do this once per store, in the store's WordPress admin. Do it before you open the app's
Settings.
1. **Store is on HTTPS.** WordPress only offers Application Passwords over HTTPS. The store must
already be on TLS. If it is not, stop and fix the store first.
2. **Dedicated low-privilege user.** Create or use a WordPress user that can create and edit
products and upload media. The store admin login is not reused. A shop manager role or a custom
role with product create/edit plus media upload is enough.
3. **Generate the password.** Sign in as that user. Go to **Users → Profile → Application
Passwords**. Generate a password and label it "SlabUploader". Copy the password. WordPress
shows it once.
4. **Enter it in the app.** Open the app's Settings on the phone. Enter the WordPress username and
the application password. The server stores them encrypted. The browser does not keep them.
5. **Run `test-woo`.** In Settings, tap the test. It should report ok. This also resyncs the
store's species, categories, tags, and attributes into the app. If it fails, the username or
password is wrong, or the store is unreachable.
Rules: this is a WordPress Application Password, not WooCommerce consumer keys (`ck_`/`cs_`).
The app never calls the store from the browser. FastAPI is the only caller. Rotate by revoking
the old password in the WP profile and entering a new one in Settings, then re-running
`test-woo`. Full lock: `AGENTS.md` §4 and §5, `docs/DEPLOYMENT.md` §5.
---
## 3. Practice build rules
These rules keep practice runs invisible to customers. They are locked. Changes are not made for
the walk.
- **SKU prefix `SLAB-UAT-*`.** Every practice listing uses a SKU that starts with `SLAB-UAT-`.
Pick a unique one each time, for example `SLAB-UAT-001`.
- **Always a Woo draft.** A `SLAB-UAT-*` SKU creates a WooCommerce draft even if Settings say
publish. Customers cannot open it. This is hard rule in `AGENTS.md` §5.
- **`woo_create_status` defaults to draft.** The Settings field that controls create status
defaults to `draft`. Practice uses the default. If it is `publish`, the
`SLAB-UAT-*` rule still forces a draft.
- **Customers never see practice listings.** A Woo draft is hidden from the storefront. If you
can see the product on the live store, something is wrong.
If a practice SKU is not `SLAB-UAT-*`, it is not a practice run. Real SKUs are not published in
this walk.
---
## 4. Prove one listing end to end
This is the main walk. Run it on a phone browser first, then repeat on a PC browser. Both are in
the signed compatibility matrix (`docs/UX.md`, signed 2026-09-06).
Supported browsers: iOS Safari, Android Chrome on phones; desktop Chrome, Edge, Firefox on PC.
Exact version pins are tracked under issue #4. If the browser is not supported, the app shows a
hard stop. Switch to a supported browser.
### 4.1 Capture
1. Open the app on the phone. Start a new slab.
2. Take 1 to 5 photos of the slab. The first should be top-down, lens parallel to the broad
face. A tilted shot makes the numbers wrong. Retake if it is tilted.
3. **Mask overlay and four knobs.** The app draws a mask over the photo. You see which pixels are
slab and which are not. Tune the four knobs: **Sheet** (auto/green/black), **Sensitivity**,
**Edge offset**, **Feather**. Each change re-runs the mask immediately. Look at the edge
against the real wood. If it is still off, retake the photo.
4. **Confirm the mask.** Tap confirm.
5. **Length axis.** The app draws the bounding rectangle and the length axis. Confirm it follows
the slab's long direction. Rotate if it is skewed. On a nearly square slab, you say which way
is length.
6. **Enter numbers by hand.** Length in 1/8" steps. Thickness as a 1/8" pick. SKU: type
`SLAB-UAT-001` (or your chosen practice SKU).
7. **Check the computed numbers.** The browser shows square feet, board feet, and width samples.
Board feet = square feet × thickness in inches. No divide-by-12. If the numbers look wrong,
go back and fix the mask or the length.
8. **Continue.** This uploads the originals, the processed PNGs, and the numbers to the server.
The slab status becomes `calibrated`.
### 4.2 Review
1. You are on the review screen. Fill every required field. Inference is OFF, so everything starts
empty.
2. **Species.** Pick exactly one from the synced store list. The list came from `test-woo`.
Only species in the list may be typed.
3. **Wood categories.** Pick at least one from the synced list.
4. **Edge type, figure, grade.** Pick from the synced lists. At least one figure, at least one
grade.
5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into.
6. **Moisture.** Keep the default (kiln-dried) or change it.
7. **fig-* tags.** Pick at least one.
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
9. **Price.** A recommendation fills from the species $/bdft rule times board feet. You can
override it. If there is no rule for the species, the price starts empty. Type it. Publish
is still allowed with a manual price.
10. **Title and description.** You can type them, or leave them for templates at publish.
Templates inject the deterministic numbers. Generate text is OFF for this walk.
Every empty required field shows an inline nudge where it sits. The slab cannot reach `ready`
until the full set is filled.
### 4.3 Publish
1. Tap **Publish**.
2. The server checks the SKU against the store. If `SLAB-UAT-001` is new, it creates the
product.
3. **What you should see:** a success message on the screen. The local draft and its photos are
gone from the app. The slab status in the app is `published`.
4. **What that does NOT mean:** the product is not live in the store. It is a WooCommerce draft.
The app's "published" means the store create succeeded and the local row was cleaned up.
### 4.4 Confirm in the store
1. Open the store's WooCommerce admin.
2. Find the product by SKU `SLAB-UAT-001`.
3. **It must be a draft.** Status is draft, not published.
4. **Check the fields:** SKU, price, category (species), tags (fig-*, feat-*), attributes
(Edge Type, Figure, Grade, Thickness, Moisture), and images (the processed transparent PNGs).
5. **Open the live storefront.** The product must not appear. A draft is invisible to customers.
### 4.5 Repeat on a PC browser
Repeat 4.1 through 4.4 on a desktop browser (Chrome, Edge, or Firefox). Use a different
practice SKU, for example `SLAB-UAT-002`. The flow must work the same way. This is the signed
PC-browser compatibility check.
---
## 5. Pass and fail checks
You can see all of these without reading code.
| Check | Where | Pass looks like |
|---|---|---|
| Health endpoint | Phone browser, `https://${APP_HOSTNAME}/api/health` | ok response |
| `test-woo` ok | App Settings | ok; species list is populated |
| `woo_create_status` is draft | App Settings | shows `draft` (or `publish`, but `SLAB-UAT-*` forces draft anyway) |
| Slab reaches `calibrated` | App, after capture | status is `calibrated` |
| Slab reaches `ready` | App, after review | no empty required fields; inline nudges gone |
| Publish succeeds | App, after Publish | success message; local draft is gone |
| Woo product exists as draft | Store admin, products | SKU `SLAB-UAT-*`, status draft |
| SKU prefix correct | Store admin | starts with `SLAB-UAT-` |
| Store not live | Live storefront URL | product does not appear |
| Board feet identity | App review screen, or a fixture | bdft = sqft × thickness in inches; no divide-by-12 |
| Images are processed PNGs | Store admin, product images | transparent PNGs, not the original JPEGs |
### Duplicate SKU recovery
If you publish the same SKU twice, the second time the app stops before creating a second product.
Under the SKU field you see: "This SKU already exists in the store (any status)." Two paths:
edit the SKU to a new value, or open the existing listing. The draft stays in the app. Nothing is
lost. This is the expected behavior, not a bug. See `docs/CONTENT-WOO.md` §2.5.
### What a fail looks like
- **No success message after Publish.** The app shows a plain message that the listing could not
be created. No status number, no stack trace. The server kept a log. You can retry from the
same screen without re-photographing.
- **A value is no longer valid.** If a category, attribute, or price was removed from the store
between sync and publish, that field shows "This value is no longer valid. Pick from the
current list." Re-pick and retry.
- **Store unreachable.** A plain message says the store could not be reached. Check the network
and retry.
---
## 6. Cleanup
After the walk, decide what to do with the Woo draft UAT product.
- **Leave it or delete it.** Both are fine. It is a draft. Customers cannot see it. If you delete
it, the store admin is the place to do it. The app does not delete store products.
- **Never customer-visible.** If the product is visible on the live storefront, it is not a
draft. That is a bug. Stop and report it.
**Real live publish is a separate step.** It is not part of this walk. A real product uses a
non-UAT SKU (no `SLAB-UAT-` prefix) and is published only after you review it. That is a
different decision. That step is not run here. The name of that step is **final POC publish**, and it
is gated on your review of one real listing.
---
## Where each rule lives
| Rule | Source |
|---|---|
| Practice SKU forces draft | `AGENTS.md` §5 |
| `woo_create_status` default draft | `AGENTS.md` §5, `docs/CONTENT-WOO.md` §2.4 |
| App Password setup, not consumer keys | `AGENTS.md` §4, `docs/DEPLOYMENT.md` §5 |
| Host inventory before deploy | `docs/DEPLOYMENT.md` §1, `deploy/INVENTORY.md` |
| Capture → review → publish flow | `docs/UX.md` (SIGNED 2026-09-06) |
| Board feet identity | `docs/TECH-SPEC-PIPELINE.md` |
| Duplicate SKU recovery | `docs/CONTENT-WOO.md` §2.5, `docs/UX.md` Publish |
| PC browser compatibility | `docs/UX.md` Browser compatibility |
| Gate C = early Phase 1 | `docs/IMPL-PLAN.md` Gates map and Phase 1 |
-20
View File
@@ -1,20 +0,0 @@
<!--
Template: decision record. One file per decision.
Record why a choice was made when the reason is not obvious from the code or requirements.
-->
# ADR-<n>: <title>
- Date: <yyyy-mm-dd>
- Status: `<proposed | accepted | superseded>`
## Context
<the situation and forces>
## Decision
<what was decided>
## Consequences
<what this makes easier, harder, or impossible>
-17
View File
@@ -1,17 +0,0 @@
<!--
Template: capability doc. Home: docs/capabilities/<NAME>.md
Behavior and acceptance only. Cite requirement IDs; never define them.
-->
# <Capability>
Status: `<shipped | in progress | planned>`
Cites: `<IDs from docs/REQUIREMENTS.md>`
## Behavior
<what the capability does, in plain terms>
## Acceptance
<what must be observed for this capability to be considered done, and where the evidence lives>
-11
View File
@@ -1,11 +0,0 @@
<!--
Template: contract doc. Home: docs/contracts/<NAME>.md
Cross-cutting interface detail. Cite requirement IDs; never define them.
-->
# <Contract>
Cites: `<IDs from docs/REQUIREMENTS.md>`
## <Interface detail>
<schemas, endpoints, prompts, screen rules — non-normative detail that supports the cited requirements>
-6
View File
@@ -1,6 +0,0 @@
<!--
Template: one requirement row.
Append to the correct namespace section of docs/REQUIREMENTS.md.
Do not define this requirement anywhere else.
-->
| `<PREFIX>-<n>` | <one testable statement> | `<shipped|M3|later>` | `<repo-relative test path>` or `—` |
-8
View File
@@ -15,11 +15,3 @@ Live spec on this branch:
- `docs/PRODUCT.md`
- `docs/CAPTURE.md`
- `deploy/` for hosting only
## M3 note (2026-09-16)
For Milestone 3 on branch `m3-ds`, do **not** treat docs frozen on `main` as scope SoT. Use `docs/CREATE.md` on `m3-ds` (conversation-locked). Archive / `main` files remain historical reference only.
## M3 note (updated 2026-09-16)
M3 exit on `m3-ds` is submission-ready for WooCommerce (`docs/CREATE.md`). Technical requirements for Woo sync, metadata, pricing, and inference were taken from historical `main` docs and written into `CREATE.md`. Do not use `main` milestone splits as M3 scope.
-13
View File
@@ -1,13 +0,0 @@
# Capability docs
One file per capability. Behavior and acceptance only.
Rules (`docs/STRUCTURE.md`): a capability doc **cites** requirement IDs from `docs/REQUIREMENTS.md`; it never defines or restates them.
Expected files (Phase 2 migration):
- `CAPTURE.md` — Milestone 1 Capture
- `PHOTOS.md` — Milestone 2 Photos
- `CREATE.md` — Milestone 3 Create
Template: `docs/_templates/capability.md`.
-16
View File
@@ -1,16 +0,0 @@
# Contract docs
Cross-cutting interface detail shared by capabilities.
Rules (`docs/STRUCTURE.md`): a contract doc **cites** requirement IDs from `docs/REQUIREMENTS.md`; it never defines or restates them.
Expected files (Phase 2 migration):
- `API.md` — FastAPI HTTP contract (from tag `OPENAPI.md`)
- `DATA-MODEL.md` — SQLite schema and status machine (from tag `DATA-MODEL.md`)
- `UX.md` — screens, field recovery, outcomes (from tag `UX.md`)
- `PROMPTS.md` — Call 1 / Call 2 prompt text (from tag `PROMPTS.md`)
- `TEST-SPEC.md` — test pyramid, vectors, dishonest proxies (from tag `TEST-SPEC.md`)
- `CONTENT-WOO.md` — listing copy and Woo payload detail (migrated from `docs/CONTENT-WOO.md`)
Template: `docs/_templates/contract.md`.
+713
View File
@@ -0,0 +1,713 @@
<!doctype html>
<!--
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
rectangle (calibration length + crop)".
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
the line; the ticks belong to the line, slide along it, and L = tick to tick;
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
in from the top and the bottom — and never changes the aspect ratio.
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
production workflow:
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
- the requirements-coverage board below the stage
- the "was / now" pair of mini diagrams
- the PROPOSAL / OPEN markers and the implementation-notes card
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
- the window.__wf debug hook
What stays: the fixed middle line + its two end ticks, the slab transform set
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
semantics, and the single shared crop_fill.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
<style>
:root{
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
.panel{padding:10px 4px;color:var(--fg)}
h1{font-size:1.3rem;margin:0 0 10px}
h3{font-size:.95rem;margin:0 0 8px}
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
.row{display:flex;gap:10px;margin-top:16px}
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
/* ---- the stage: fixed line + movable slab ---- */
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
.tw.dragging svg{cursor:grabbing}
.tickg{cursor:ns-resize}
.rotg{cursor:ew-resize}
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
.angle,.croprow{display:flex;align-items:center;gap:10px}
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
input[type=range]{flex:1}
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
.nums span:first-child{color:var(--mut)}
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
.nums .dim{color:#777}
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
.status.ok{background:#12210f;color:var(--ok)}
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
.plan code{color:#ddd;font-size:.72rem}
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
/* replacement two-up */
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
.swap .gone{color:#ff9d8a}
/* scope lineup */
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
.rot-badge.no{background:#3a3a3a;color:#bbb}
/* fields + settings */
.fields{display:flex;gap:16px;margin-top:16px}
.field{flex:1}
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
.settings .msg.ok{color:var(--ok)}
/* requirement coverage board (outside the phone) */
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
.cov td:first-child{color:var(--fg);white-space:nowrap}
.cov .where{color:#ddd}
/* narrow screens: stack the table instead of forcing its 400px min width */
@media (max-width:760px){
.cov thead{display:none}
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
.cov tr:last-child{border-bottom:0}
.cov td{border:0;padding:1px 0}
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
.cov td:nth-child(3)::before{content:"→ ";color:#666}
}
.hidden{display:none}
</style>
</head>
<body>
<div class="top">
<h1>SlabUploader · tape-line overlay — issue #99</h1>
<div class="flowbar" id="flowbar"></div>
<div class="vp">
<span>Viewport</span>
<span id="vpval">412</span>
<button data-w="390">390</button>
<button data-w="412" class="on">412</button>
<button data-w="720">720</button>
<button data-w="1440">1440</button>
</div>
</div>
<div class="stage"><div id="phone"></div></div>
<div class="cov"><div class="cov-inner">
<h2>Issue #99 — requirement coverage in this wireframe</h2>
<table>
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
<tbody>
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
</tbody>
</table>
</div></div>
<script>
// ===========================================================================
// issue #99 — the tape line is a FIXED ENTITY.
//
// * The black ~2pt line and its two end ticks never move: not on tilt, not
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
// its long axis lies on the line, tick to tick.
// * The ticks belong to the line - they slide along it, never with the photo.
// * L = the distance between the two ticks (the owner's tape reading).
// ===========================================================================
const S = {
screen: "tape",
// slab transform (the only movable entity)
tilt: 14, // degrees
zoom: 1,
panX: 60, panY: 0,
// the line's ticks: y positions along the fixed line. They rest at the crop
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
// bottom - and the operator drags them onto their tape marks from there.
tickA: 32,
tickB: 608,
focus: "A",
fill: 90, // settings.crop_fill - global, default 90
// declared values (entered on Measure, shown on both steps)
L: "88",
thickness: "2.125",
savedMsg: "",
};
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
const VB = { w: 480, h: 640 };
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
// AXIS - the centreline running down the middle of the board, which is where
// the tape is laid. (Not a physical side edge.)
const SLAB = {
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
};
const TICK_LEN = 26; // rifle-scope end tick, total
const TICK_GAP = 40; // min separation between ticks
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
const HIT_R = 34;
const ALIGN_TOL = 3; // stage units
const rad = (a) => a * Math.PI / 180;
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
// crop frame, which is where the crop centres the slab's long axis.
const lineX = () => VB.w / 2;
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
const spanPx = () => Math.abs(S.tickB - S.tickA);
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
// slab transform: zoom + tilt about the stage centre, then pan
function mapPoint(p) {
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
const x = (p.x - cx) * z, y = (p.y - cy) * z;
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
}
function slabTransform() {
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
}
// where the slab's long axis crosses a given stage y
function axisXAt(y) {
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
}
const axisAngle = () => {
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
};
// the axis is vertical when its angle points straight down (or up) the screen
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
// ---------------------------------------------------------------- the stage
function slabSvg() {
return `
<g id="slab" transform="${slabTransform()}">
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
<g clip-path="url(#slabClip)" opacity=".8">
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
</g>
</g>`;
}
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
function fixtureSvg() {
const x = lineX();
const tick = (y, id) => {
const on = S.focus === id;
return `
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
};
return `
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
}
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
// from the top and the bottom, shaded here as the two bands it leaves outside.
function cropFrameSvg() {
const m = cropInset();
return `
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
}
function rotateHandleSvg() {
return `
<g class="rotg" data-rot="1">
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">&#8635;</text>
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
</g>`;
}
function stageSvg() {
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
${cropFrameSvg()}
${slabSvg()}
${fixtureSvg()}
${rotateHandleSvg()}
</svg>`;
}
// ------------------------------------------------------------------ screens
const screens = {
batch() {
return `<section class="panel">
<h1>Batch photos</h1>
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
</section>`;
},
mask() {
return `<section class="panel">
<h1>Mask</h1>
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
<div class="nums">
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
</div>
<div class="row">
<button class="ghost" data-act="to-batch">Back</button>
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
</div>
</section>`;
},
tape() {
const off = axisOffset(), ok = aligned();
return `<section class="panel">
<h1>Tape line</h1>
<p class="hint">Drag each tick to where your tape started and ended.</p>
<div class="tw" id="tw">${stageSvg()}</div>
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the &#8635; handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
<div class="controls">
<div>
<p class="ctl-label">Tilt the slab</p>
<div class="angle">
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">&#8722;</button>
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
<span class="val" id="tiltVal">${S.tilt}&#176;</span>
</div>
<p class="nudge">&larr; &rarr; tilt 1&#176; &nbsp;·&nbsp; Shift + &larr; &rarr; tilt 0.1&#176; &nbsp;·&nbsp; or drag the &#8635; handle</p>
</div>
<div>
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
<div class="angle">
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">&#8722;</button>
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
</div>
<p class="nudge">Wheel or pinch over the photo zooms to the cursor &nbsp;·&nbsp; <b>+</b> / <b>&#8722;</b> keys step 10% &nbsp;·&nbsp; double-click the photo to fit</p>
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
</div>
<div>
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
<p class="nudge">Grab a tick anywhere in its 44&#215;44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>&#8593; &#8595;</b> nudge 1px, Shift = 0.1px &mdash; its centre dot turns red to show which one.</p>
</div>
<div>
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
<div class="croprow">
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
<span class="val" id="fillVal">${S.fill}%</span>
</div>
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
</div>
</div>
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
? "The board's long axis is on the line &#10003;"
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}&#176;`}.`}</div>
<div class="nums">
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}&#8243;</span></div>
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
</div>
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}&#176; &nbsp;·&nbsp; zoom ${S.zoom.toFixed(2)}&#215; <span class="dim">(moving these never changes the line)</span></p>
<div class="swap">
<figure>${oldOverlayMini()}<figcaption class="gone">&#10007; removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
</div>
<div class="row">
<button class="ghost" data-act="to-mask">Back</button>
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
</div>
<div class="plan">
<h3>Two things on this screen are my proposal, not the issue</h3>
<ul>
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
</ul>
</div>
</section>`;
},
measure() {
const mini = (tilt, marks) => `
<svg viewBox="0 0 480 640">
<rect width="480" height="640" fill="#16281c"/>
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
</g>
</svg>`;
return `<section class="panel">
<h1>Photos ready</h1>
<div class="fields">
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
</div>
<div class="nums">
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
</div>
<div class="plan">
<h3>Rotation scope — req. 6</h3>
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
</div>
<ol class="lineup">
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}&#176;</span></li>
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}&#176;</span></li>
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
</ol>
<div class="row">
<button class="ghost" data-act="to-tape">Back</button>
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
</div>
</section>`;
},
settings() {
return `<section class="panel settings">
<h1>Settings</h1>
<div class="section">
<h3>Crop — global</h3>
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
<div class="croprow">
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
<span class="val" id="fillVal">${S.fill}%</span>
</div>
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
<button data-act="save-fill">Save Changes</button>
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
</div>
<div class="section">
<h3>Implementation notes</h3>
<ul class="plan" style="border:0;padding:0;background:none">
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
</ul>
</div>
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
</section>`;
},
};
// the two mini overlays for the was/now pair
function oldOverlayMini() {
return `<svg viewBox="0 0 120 160">
<rect width="120" height="160" fill="#141414"/>
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
</svg>`;
}
function newOverlayMini() {
return `<svg viewBox="0 0 120 160">
<rect width="120" height="160" fill="#141414"/>
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
</svg>`;
}
// ----------------------------------------------------------------- plumbing
const phone = document.getElementById('phone');
const flowbar = document.getElementById('flowbar');
const STEPS = [
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
["measure", "Photos ready"], ["settings", "&#9881; Settings"],
];
function render() {
phone.innerHTML = screens[S.screen]();
flowbar.innerHTML = STEPS.map(([k, label]) =>
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
if (S.screen === "tape") { bindStage(); refreshStage(); }
}
flowbar.addEventListener('click', (e) => {
const b = e.target.closest('[data-goto]');
if (!b) return;
S.screen = b.dataset.goto;
render();
});
document.querySelector('.vp').addEventListener('click', (e) => {
const b = e.target.closest('[data-w]');
if (!b) return;
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
document.getElementById('vpval').textContent = b.dataset.w;
});
// Replace only the stage + the live readouts, so an in-progress drag survives
// (a full panel re-render would destroy the element under the pointer).
function refreshStage() {
const host = document.getElementById('tw');
if (!host) return;
host.innerHTML = stageSvg();
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
set('tiltVal', S.tilt + '\u00B0');
set('zoomVal', Math.round(S.zoom * 100) + '%');
set('fillVal', S.fill + '%');
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
set('spanReadout', `${spanPx().toFixed(0)} px`);
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
const st = document.getElementById('alignstatus');
if (st) {
const ok = aligned(), off = axisOffset();
st.className = 'status' + (ok ? ' ok' : '');
st.innerHTML = ok
? "The board's long axis is on the line \u2713"
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
}
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
axisAngle: axisAngle(), aligned: aligned() };
}
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const norm360 = (a) => ((a % 360) + 360) % 360;
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
// twin, stepping about the stage centre so the slab stays where it is.
function stepZoom(dir) {
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
refreshStage();
}
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
function nudgeTick(dir, mult) {
const step = (mult === undefined ? 1 : mult);
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
render();
}
function bindStage() {
const host = document.getElementById('tw');
if (!host) return;
const toVB = (e) => {
const r = (host.querySelector('svg') || host).getBoundingClientRect();
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
};
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
function zoomAt(p, factor) {
const cx = VB.w / 2, cy = VB.h / 2;
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
if (z1 === z0) return;
const qx = cx + (p.x - cx - S.panX) / z0;
const qy = cy + (p.y - cy - S.panY) / z0;
S.zoom = z1;
S.panX = p.x - cx - z1 * (qx - cx);
S.panY = p.y - cy - z1 * (qy - cy);
refreshStage();
}
host.addEventListener('wheel', (e) => {
e.preventDefault();
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
// delta and the per-event factor. Plain wheel stays ~16% per notch.
const dy = clamp(e.deltaY, -200, 200);
const k = e.ctrlKey ? 0.01 : 0.0015;
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
}, { passive: false });
// belt-and-braces fit: Chromium suppresses this after our pointerdown
// preventDefault (see the tap tracking in end()), some engines still emit it
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
let drag = null, tb = null, moved = false, downP = null;
const off = { x: 0, y: 0 };
host.addEventListener('pointerdown', (e) => {
const tick = e.target.closest('[data-tick]');
const rot = e.target.closest('[data-rot]');
const p = toVB(e);
moved = false; downP = p;
if (tick) {
S.focus = tick.dataset.tick;
drag = { kind: "tick", id: S.focus };
} else if (rot) {
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
} else {
drag = { kind: "slab" };
off.x = p.x - S.panX; off.y = p.y - S.panY;
}
host.setPointerCapture(e.pointerId);
host.classList.add('dragging');
refreshStage();
e.preventDefault();
});
host.addEventListener('pointermove', (e) => {
if (!drag) return;
const p = toVB(e);
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
if (drag.kind === "tick") {
// ticks slide ALONG the fixed line — x is ignored on purpose
const y = clamp(p.y, 20, VB.h - 20);
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
} else if (drag.kind === "rot") {
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
} else {
S.panX = clamp(p.x - off.x, -400, 400);
S.panY = clamp(p.y - off.y, -400, 400);
}
refreshStage();
});
const end = (e) => {
if (!drag) return;
const wasSlab = drag.kind === "slab";
const startP = toVB(e);
drag = null;
host.classList.remove('dragging');
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
// pointerdown suppresses the whole compatibility mouse sequence (click and
// dblclick both stop arriving), so track the taps ourselves.
if (wasSlab && !moved) {
const now = performance.now();
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
tb = { t: now, x: startP.x, y: startP.y };
}
refreshStage();
};
host.addEventListener('pointerup', end);
host.addEventListener('pointercancel', end);
}
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
document.addEventListener('keydown', (e) => {
if (S.screen !== "tape") return;
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
e.preventDefault();
return;
}
if (!e.key.startsWith("Arrow")) return;
const fine = e.shiftKey ? 0.1 : 1;
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
else return;
e.preventDefault();
});
phone.addEventListener('click', (e) => {
const t = e.target.closest('[data-act]');
if (!t) return;
const a = t.dataset.act;
if (a === "tilt") tiltBy(+t.dataset.d);
else if (a === "zoom") stepZoom(+t.dataset.d);
else if (a === "fit") fitSlab();
else if (a === "to-mask") { S.screen = "mask"; render(); }
else if (a === "to-batch") { S.screen = "batch"; render(); }
else if (a === "to-tape") { S.screen = "tape"; render(); }
else if (a === "to-measure") { S.screen = "measure"; render(); }
else if (a === "to-settings") { S.screen = "settings"; render(); }
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
});
phone.addEventListener('input', (e) => {
const t = e.target;
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
else if (t.dataset.act === "fill-range") {
// crop % moves the overlay's ENDS in/out from the top and bottom; the
// rectangle's aspect never changes and the line's x never moves.
S.fill = +t.value;
const ct = cropTicks();
S.tickA = ct.a; S.tickB = ct.b;
refreshStage();
}
});
phone.addEventListener('change', (e) => {
const t = e.target;
if (t.dataset.act === "length") { S.L = t.value; render(); }
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
});
render();
</script>
</body>
</html>
+1
View File
@@ -75,6 +75,7 @@
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
setTape={session.setTape}
approveAxis={session.approveAxis}
back={session.back}
/>
+113 -17
View File
@@ -1,58 +1,154 @@
<script lang="ts">
import type { Capture } from './capture.types';
import type { TapeTransform } from './tapeGeometry';
import { defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill';
import { cropMarginFraction } from '../png/crop';
export let state: Extract<Capture, { k: 'axis' }>;
export let setAxisAngle: (deg: number) => void;
export let setTape: (tape: TapeTransform) => 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;
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
$: tape = state.axis.tape;
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
const onTapeChange = (t: TapeTransform) => setTape(t);
const nudgeTilt = (delta: number) => {
setTape({ ...tape, tiltDeg: tape.tiltDeg + delta });
};
const setTilt = (deg: number) => {
setTape({ ...tape, tiltDeg: deg });
// Keep legacy path warm for any callers still using setAxisAngle.
setAxisAngle(deg);
};
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>Length axis</h1>
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
<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}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
{tape}
{cropFill}
onTapeChange={onTapeChange}
/>
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
<input
type="range" min="0" max="179" step="1"
value={angleDeg}
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
<span class="val">{angleDeg}°</span>
<div 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="0" max="359" step="0.1"
value={((tiltShown % 360) + 360) % 360}
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">← → tilt 1° · Shift+← → 0.1° · or drag the ↻ handle</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="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.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: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
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; }
.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; }
</style>
+3 -1
View File
@@ -82,8 +82,10 @@
{#if !photosReady}
<PhotoStage
mode="tape"
interactive={false}
blob={state.source.blob}
bbox={state.axis.bbox}
tape={state.axis.tape}
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}
+268 -13
View File
@@ -1,8 +1,12 @@
<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, 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.
@@ -14,15 +18,42 @@
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;
let canvas: HTMLCanvasElement | undefined;
let stage: HTMLDivElement | undefined;
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
let tapeWrap: HTMLDivElement | undefined;
let imgW = 0, imgH = 0;
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;
@@ -31,7 +62,7 @@
};
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
if (!canvas) return;
if (!canvas || mode === 'tape') return;
if (maskBitset && maskW > 0 && maskH > 0) {
const off = document.createElement('canvas');
off.width = maskW;
@@ -62,7 +93,6 @@
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';
@@ -106,19 +136,170 @@
}
};
$: if (blob || bbox || maskBitset) void tick().then(redraw);
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
// ---- tape pointer interactions ----
type DragKind = null | 'pan' | 'tickA' | 'tickB' | 'rot';
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;
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 hitRot = (x: number, y: number): boolean => {
const cx = STAGE_W / 2;
const cy = STAGE_H - 26;
return Math.hypot(x - cx, y - cy) <= 22;
};
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()];
pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
pinchStartZoom = live.zoom;
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;
}
if (hitRot(p.x, p.y)) {
drag = { kind: 'rot', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
return;
}
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 dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
const z = Math.min(3, Math.max(0.5, pinchStartZoom * (dist / pinchStartDist)));
emit({ ...live, zoom: z });
return;
}
if (!drag) return;
const p = stagePoint(e.clientX, e.clientY);
if (drag.kind === 'pan') {
emit({ ...live, panX: drag.panX0 + (p.x - drag.x0), panY: drag.panY0 + (p.y - drag.y0) });
} 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 });
} else if (drag.kind === 'rot') {
const cx = STAGE_W / 2;
const cy = STAGE_H / 2;
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
const a1 = Math.atan2(p.y - cy, p.x - cx);
const delta = ((a1 - a0) * 180) / Math.PI;
emit({ ...live, tiltDeg: drag.tilt0 + delta });
}
};
const onPointerUp = (e: PointerEvent) => {
pointers.delete(e.pointerId);
if (pointers.size < 2) drag = null;
};
const onWheel = (e: WheelEvent) => {
if (!interactive || mode !== 'tape') 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') {
e.preventDefault();
const step = fine ? 0.1 : 1;
const dir = e.key === 'ArrowLeft' ? -1 : 1;
emit({ ...live, tiltDeg: 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 === '=') {
e.preventDefault();
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
} else if (e.key === '-' || e.key === '_') {
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;';
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(() => {
@@ -126,16 +307,90 @@
});
</script>
<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 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}
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}
{#if interactive}
<g class="rot-handle">
<circle cx={STAGE_W / 2} cy={STAGE_H - 26} r="17" fill="rgba(0,0,0,.6)" stroke="#00e5ff" stroke-width="1.5" />
<text x={STAGE_W / 2} y={STAGE_H - 21} fill="#00e5ff" font-size="15" text-anchor="middle">↻</text>
</g>
{/if}
</svg>
{#if decoding && imgW === 0}
<div class="loading">Decoding photo…</div>
{/if}
</div>
</div>
</div>
{:else}
<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}
<style>
.stage { width: 100%; display: flex; justify-content: center; }
canvas { display: block; border-radius: 8px; background: #222; }
.loading { padding: 40px; color: #999; }
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; }
.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; }
.rot-handle { pointer-events: auto; cursor: ew-resize; }
</style>
+18 -4
View File
@@ -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 uses mask span along the axis
longSidePx: number; // geometric extent only; scale prefers tape tick span (#99)
angleDeg: number; // long-side angle, degrees, [0, 180)
}
@@ -43,13 +43,26 @@ export interface Mask {
slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox;
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
slabBounds: ABox; // axis-aligned extent of slab px, for the crop_fill PNG crop (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.
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;
}
// 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;
}
export interface Widths {
@@ -138,6 +151,7 @@ export interface Draft {
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
+115 -16
View File
@@ -10,7 +10,11 @@ import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { rotateCalibration } from '../png/rotateCal';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { 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;
@@ -41,6 +45,8 @@ export interface CaptureSession {
setSheet(s: Sheet): void;
approveMask(): void;
setAxisAngle(deg: number): void;
/** #99: replace axis rectangle nudge with full tape transform. */
setTape(tape: TapeTransform): void;
approveAxis(): void;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
@@ -204,7 +210,7 @@ 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,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
@@ -214,7 +220,7 @@ 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,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
@@ -310,6 +316,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// 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 = 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: 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 restoreFromDraft = async (d: Draft) => {
batchPhotos = batchPhotosOf(d);
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
@@ -336,16 +373,29 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
const 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: 'axis', source, approvedMask: mask, axis });
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: { bbox: draftAxisBbox(d) },
axis,
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? {
@@ -356,6 +406,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
: null,
batchApply: d.batchApply,
});
}
} catch {
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
makeWorker(source.blob);
@@ -474,22 +525,47 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
const overlay = state.overlay;
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
const bbox = {
corners: overlay.bbox.corners.map((c) => [...c] as [number, number]),
longSidePx: overlay.bbox.longSidePx,
angleDeg: overlay.bbox.angleDeg,
};
const w = state.source.widthPx;
const h = state.source.heightPx;
const seedTilt = 90 - bbox.angleDeg;
const tape0 = defaultTape(getCropFill(), seedTilt);
const axis = axisWithTape(bbox, tape0, w, h);
set({ k: 'axis', source: state.source, approvedMask: overlay, axis });
void persist();
},
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
setAxisAngle(deg: number) {
if (state.k !== 'axis') return;
const a = ((Math.round(deg) % 180) + 180) % 180;
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
const tape = { ...state.axis.tape, tiltDeg: 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') return;
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
},
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: { bbox: state.axis.bbox },
axis,
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
@@ -514,14 +590,35 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) 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,
);
let pngBlob: Blob;
let belowFloor = 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;
try {
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
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 });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
} catch {
@@ -529,24 +626,26 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
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).
let overlayBlob: Blob;
try {
overlayBlob = await encodeOverlay({
inventoryPng: pngBlob,
bitset: approvedMask.bitset,
widthPx: source.widthPx,
heightPx: source.heightPx,
bbox: axis.bbox,
bitset: calMask.bitset,
widthPx: calSource.widthPx,
heightPx: calSource.heightPx,
bbox: calMask.bbox,
scale: base.scale,
lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
slabBounds: calMask.slabBounds,
source: calSource,
});
} catch {
// Deterministic tests may inject a non-decodable inventory blob; still
@@ -624,7 +723,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: state.axis });
}
},
+53
View File
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
STAGE_H, STAGE_W, clampTickPair, 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);
});
+117
View File
@@ -0,0 +1,117 @@
/**
* 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;
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: 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 };
}
+2
View File
@@ -115,6 +115,8 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
// 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++) {
+18
View File
@@ -0,0 +1,18 @@
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}`);
});
+36 -3
View File
@@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb
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 } {
@@ -71,16 +82,38 @@ 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'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8;
const seg = maskAxisSegment(bitset, w, h, bbox);
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
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;
}
const every6In = sampleWidths(bitset, w, h, start, ux, uy, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);
+56
View File
@@ -0,0 +1,56 @@
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';
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('clampFill bounds', () => {
assert.equal(clampFill(2), 1);
assert.equal(clampFill(0), 0.01);
assert.equal(clampFill(Number.NaN), DEFAULT_CROP_FILL);
});
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;
}
}
}
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}`);
});
+21
View File
@@ -0,0 +1,21 @@
/**
* 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.
*/
export const DEFAULT_CROP_FILL = 0.9;
export const ASPECT_W = 3;
export const ASPECT_H = 4;
export const FLOOR_PX = 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 function clampFill(fill: number): number {
if (!Number.isFinite(fill)) return DEFAULT_CROP_FILL;
return Math.min(1, Math.max(0.01, fill));
}
+10 -7
View File
@@ -1,9 +1,6 @@
import type { Mask, Source } from '../capture/capture.types';
const FLOOR = 800; // shorter side, px
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
const ASPECT_W = 3;
const ASPECT_H = 4;
import { ASPECT_H, ASPECT_W, FLOOR_PX, DEFAULT_CROP_FILL } from './crop';
import { getCropFill } from '../settings/cropFill';
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
@@ -12,6 +9,8 @@ export interface InventoryInput {
bitmap: ImageBitmap;
mask: MaskPick;
source: SourcePick;
/** Optional override; default reads live settings.crop_fill (#99). */
cropFill?: number;
}
export interface Frame {
@@ -25,7 +24,9 @@ export function composeInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
cropFill: number = DEFAULT_CROP_FILL,
): 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));
@@ -33,8 +34,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 belowFloor = Math.min(sw, sh) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
const belowFloor = Math.min(sw, sh) < FLOOR_PX;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR_PX)) : FLOOR_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;
@@ -66,6 +67,7 @@ export async function encodeInventoryPng(
input: InventoryInput,
): 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);
@@ -74,6 +76,7 @@ export async function encodeInventoryPng(
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
mask,
source,
fill,
);
const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!;
+8 -2
View File
@@ -1,5 +1,7 @@
import type { BBox } from '../capture/capture.types';
import { axisEndpoints } from '../measure/measure';
import { DEFAULT_CROP_FILL } from './crop';
import { getCropFill } from '../settings/cropFill';
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
export function overlayWidthStations(lengthIn: number): number[] {
@@ -485,8 +487,9 @@ export function inventorySourceMap(
source: { widthPx: number; heightPx: number },
outW: number,
outH: number,
cropFill: number = DEFAULT_CROP_FILL,
): InventoryMap {
const FILL = 0.9;
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)));
@@ -517,6 +520,8 @@ 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;
}
/**
@@ -531,7 +536,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
const fill = input.cropFill ?? getCropFill();
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
const plan = buildOverlayPlan({
bitset: input.bitset,
widthPx: input.widthPx,
+92
View File
@@ -0,0 +1,92 @@
/**
* 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'>;
}
/**
* 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 a = ((tiltDeg % 360) + 360) % 360;
if (a < 0.05 || a > 359.95) {
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
return { bitmap, mask, source };
}
const srcW = source.widthPx;
const srcH = source.heightPx;
const rad = (a * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// Axis-aligned bounds of rotated rectangle corners.
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));
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 },
};
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 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 */
}
}
}
-9
View File
@@ -1,9 +0,0 @@
# tools
Structure and verification gates. These are the only sanctioned way to claim the docs are clean or a slice is "done".
| Script | Purpose | Run |
|---|---|---|
| `check_requirements.py` | Enforce `docs/STRUCTURE.md`: registered docs, requirement IDs, traceability. | `python tools/check_requirements.py` |
Run from the repo root. Exit 0 = pass, exit 1 = fail.
-159
View File
@@ -1,159 +0,0 @@
#!/usr/bin/env python3
"""Enforce docs/STRUCTURE.md.
Run from the repo root:
python tools/check_requirements.py
Exit 0 = the docs structure is consistent. Exit 1 = it is not.
Checks are listed in docs/STRUCTURE.md.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DOCS = ROOT / "docs"
STRUCTURE = DOCS / "STRUCTURE.md"
REQUIREMENTS = DOCS / "REQUIREMENTS.md"
MILESTONES = DOCS / "MILESTONES.md"
EXEMPT_PREFIXES = (("docs", "archive"), ("docs", "_templates"))
ROLES = "index|intent|meta|requirements|scope|capability|contract|practice"
REGISTRY_ROW = re.compile(rf"^\|\s*`(docs/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|")
NAMESPACE_ROW = re.compile(r"^\|\s*`([A-Z]+)`\s*\|")
REQUIREMENT_ROW = re.compile(
r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$"
)
MILESTONE_IDS = re.compile(r"^-\s*IDs:\s*(.*)$")
ID_TOKEN = re.compile(r"^[A-Z]+-\d+$")
SCOPES = {"shipped", "M3", "later"}
PLACEHOLDER = {"", "—", "-", "*(none yet)*"}
def main() -> int:
errors: list[str] = []
if not STRUCTURE.exists():
print("FAIL check_requirements: docs/STRUCTURE.md is missing")
return 1
structure = STRUCTURE.read_text(encoding="utf-8")
registered: dict[str, str] = {}
for line in structure.splitlines():
match = REGISTRY_ROW.match(line)
if match:
registered[match.group(1)] = match.group(2)
namespaces = {
match.group(1)
for line in structure.splitlines()
if (match := NAMESPACE_ROW.match(line))
}
if not namespaces:
errors.append("STRUCTURE.md declares no ID namespaces")
# 1. every non-exempt doc is registered
for path in sorted(DOCS.rglob("*.md")):
rel = path.relative_to(ROOT).as_posix()
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES:
continue
if rel not in registered:
errors.append(f"unregistered doc: {rel}")
# 2. every registered doc exists
for rel in registered:
if not (ROOT / rel).exists():
errors.append(f"registered doc missing on disk: {rel}")
# 3-5. requirement rows
ids: dict[str, str] = {}
test_refs: list[tuple[str, str, str, str]] = []
if not REQUIREMENTS.exists():
errors.append("docs/REQUIREMENTS.md is missing")
else:
for number, line in enumerate(
REQUIREMENTS.read_text(encoding="utf-8").splitlines(), 1
):
match = REQUIREMENT_ROW.match(line)
if not match:
continue
prefix, digit, _statement, scope, test = match.groups()
rid = f"{prefix}-{digit}"
loc = f"docs/REQUIREMENTS.md:{number}"
if prefix not in namespaces:
errors.append(f"{loc} unknown namespace '{prefix}'")
if rid in ids:
errors.append(f"{loc} duplicate id {rid} (first seen at {ids[rid]})")
else:
ids[rid] = loc
if scope not in SCOPES:
errors.append(f"{loc} {rid} invalid scope '{scope}'")
test_ref = test.strip()
if test_ref not in PLACEHOLDER:
test_refs.append((loc, rid, test_ref, scope))
# 5. resolve test references: an existing path, a requirement ID, or —
for loc, rid, ref, scope in test_refs:
if ID_TOKEN.match(ref):
if ref not in ids:
errors.append(f"{loc} {rid} test ref {ref} is not a defined requirement")
elif "/" in ref or "\\" in ref or "." in ref:
if scope == "shipped" and not (ROOT / ref).exists():
errors.append(f"{loc} {rid} shipped test path not found: {ref}")
else:
errors.append(f"{loc} {rid} invalid Test ref '{ref}' (use a path, an ID, or —)")
# 6. requirement rows appear only in REQUIREMENTS.md
for path in sorted(DOCS.rglob("*.md")):
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES or path in {
REQUIREMENTS,
STRUCTURE,
}:
continue
rel = path.relative_to(ROOT).as_posix()
for number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
if REQUIREMENT_ROW.match(line):
errors.append(f"{rel}:{number} requirement row outside REQUIREMENTS.md")
# 7. milestone IDs resolve
if not MILESTONES.exists():
errors.append("docs/MILESTONES.md is missing")
else:
for number, line in enumerate(
MILESTONES.read_text(encoding="utf-8").splitlines(), 1
):
match = MILESTONE_IDS.match(line)
if not match:
continue
for token in match.group(1).split(","):
token = token.strip().strip("`").strip()
if token in PLACEHOLDER:
continue
if not ID_TOKEN.match(token):
errors.append(f"MILESTONES.md:{number} not an ID: '{token}'")
elif token not in ids:
errors.append(f"MILESTONES.md:{number} unknown ID {token}")
if errors:
print(f"FAIL check_requirements: {len(errors)} problem(s)")
for error in errors:
print(f" - {error}")
return 1
print(
f"PASS check_requirements: {len(registered)} docs, "
f"{len(namespaces)} namespaces, {len(ids)} requirements"
)
return 0
if __name__ == "__main__":
sys.exit(main())