Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d754cac9ef | |||
| 40080aee3a | |||
| eaf96d36e1 | |||
| 22599c924f | |||
| df40c4269f | |||
| 03d3fcf460 | |||
| 037a4b870a | |||
| b8e69b5239 | |||
| 495f179c2f | |||
| b386ab9670 | |||
| 2be2e7d615 | |||
| 6d182db81b | |||
| dc19cffcc0 | |||
| 05447c92fc | |||
| 415158d4d3 | |||
| 9bc35f7edd | |||
| 15895cae0c | |||
| 5650255e49 | |||
| 2be400e455 | |||
| 2c9a50ddf9 | |||
| 4c3c8b49eb | |||
| a22525023a | |||
| c674931f5c | |||
| 3936c575b2 | |||
| 9404d0130c | |||
| 8ff008f85c | |||
| d2ffa4cad2 | |||
| d18b96c10b | |||
| 4fa8126471 | |||
| ff4bb0f31e | |||
| e919fe9a35 | |||
| ab5bdafbd8 | |||
| 2eb825c84e | |||
| 4ab68d6b68 | |||
| eaedcb53dd | |||
| 11c8124674 | |||
| 44d428b6f4 | |||
| f20d84c543 | |||
| fbcd05169f | |||
| b3a9fdf330 | |||
| 114e0b5116 | |||
| 81e9184c7f | |||
| b328871549 | |||
| b781ffeb66 | |||
| 0e94b1fd2a | |||
| c6a48c8d91 | |||
| 0679e1cef7 | |||
| 503449452c | |||
| bb78e35ac3 | |||
| d86fc93ed6 |
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
backend
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.svelte-kit
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/README.md
|
||||
.audit
|
||||
docs/archive
|
||||
@@ -35,3 +35,11 @@ images/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Playwright e2e (#65) — keep README; ignore churn
|
||||
frontend/tests/e2e/artifacts/*.png
|
||||
frontend/tests/e2e/artifacts/run-log.txt
|
||||
frontend/tests/e2e/test-results/
|
||||
frontend/tests/e2e/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
|
||||
@@ -2,27 +2,39 @@
|
||||
|
||||
## Agent context
|
||||
|
||||
Read this file and `docs/CAPTURE.md`.
|
||||
Implement Milestone 1 only. Specification: `docs/CAPTURE.md`.
|
||||
PRD: `docs/PRODUCT.md` for destination and field names. Do not implement Milestone 2, 3, or 4.
|
||||
Read `docs/README.md` first, then this file, `docs/CAPTURE.md`, `docs/PHOTOS.md`, and `docs/CREATE.md`.
|
||||
|
||||
## Milestone 1
|
||||
Implement **Milestone 3** only. Specification: `docs/CREATE.md`.
|
||||
Product overview: `docs/PRODUCT.md`.
|
||||
|
||||
Capture.
|
||||
Acceptance: on a phone, one real slab, approved mask, approved length-axis rectangle, user length and thickness, inventory PNG on screen, widths and board feet on screen.
|
||||
**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.
|
||||
|
||||
Milestone 1 is inference-free. Background removal, rectangle, measurements, and PNG encode run in the browser as ordinary code. There is no inference URL and no model call in this milestone.
|
||||
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 3
|
||||
|
||||
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`)
|
||||
|
||||
## Invariants
|
||||
|
||||
- Quantity per listing is one.
|
||||
- User enters length, thickness, and SKU.
|
||||
- App proposes remaining listing fields. User reviews before create.
|
||||
- App may propose 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.
|
||||
- Existing store SKU blocks create.
|
||||
- Dimension overlay orientation follows the user-approved length axis from Milestone 1.
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| PRD | `docs/PRODUCT.md` |
|
||||
| Docs map | `docs/README.md` |
|
||||
| Product overview | `docs/PRODUCT.md` |
|
||||
| Invariants and agent context | `AGENTS.md` |
|
||||
| Milestone 1 | `docs/CAPTURE.md` |
|
||||
| Milestone 1 Capture (shipped) | `docs/CAPTURE.md` |
|
||||
| Milestone 2 Photos (shipped) | `docs/PHOTOS.md` |
|
||||
| Milestone 3 Create (current) | `docs/CREATE.md` |
|
||||
| UAT practice walk | `docs/UAT-PRACTICE.md` |
|
||||
| Hosting | `deploy/` |
|
||||
|
||||
Owner: Ty. Store: https://www.whidbeywoodstore.com/
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
# Host
|
||||
|
||||
Record the machine and URL when a container is started.
|
||||
## Overnight Milestone 2 Capture (UAT)
|
||||
|
||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`)
|
||||
- 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)
|
||||
|
||||
- Host: `ubudual5060` / `100.64.0.9`
|
||||
- Container: `slabuploader-m1`
|
||||
- Port: **18080** → 80 (inventory previously listed 10880; do not bind overnight there)
|
||||
- URL: `http://100.64.0.9:18080`
|
||||
- **Never** recreate or remap this container for overnight UAT.
|
||||
|
||||
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local port.
|
||||
Do not put a host name in the product spec.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA: fall back to index.html for any path
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Milestone 1 — Capture
|
||||
|
||||
Runs in the browser on phone and PC as ordinary client code.
|
||||
No inference URL. No model. No backend. Mask, rectangle, measurements, and PNG encode are deterministic functions of the photo and user inputs.
|
||||
No inference URL. No model. No backend. Mask, rectangle, measurements, crop, and PNG encode are deterministic functions of the photo and user inputs.
|
||||
|
||||
Milestone 1 uses one reference photo. Further angles are later milestones.
|
||||
Milestone 1 ends when the user sees the inventory PNG and measurements. Milestone 2 is Review. Milestone 3 is Create. This file does not create a WooCommerce product.
|
||||
@@ -33,9 +33,9 @@ Otherwise the app opens Add photo.
|
||||
3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step.
|
||||
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
6. App sets `scale = longest_axis_px / L`.
|
||||
7. App computes widths, sqft, and bdft from the mask, and writes the inventory PNG.
|
||||
8. User sees the PNG and the numbers. Milestone 1 stops here.
|
||||
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
|
||||
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 90% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
|
||||
8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
|
||||
|
||||
Calculate does not create a WooCommerce product.
|
||||
|
||||
@@ -53,13 +53,13 @@ One photo component is reused. Each state advances on approve. Back returns to t
|
||||
2. Add photo — camera or picker. One photo. Setup rules visible.
|
||||
3. Mask — background removal on that photo.
|
||||
4. Length axis — bounding rectangle and long-side axis on the same photo.
|
||||
5. Measure — length, thickness, Calculate, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
|
||||
5. Measure — length, thickness, Calculate, crop, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
|
||||
|
||||
## Display
|
||||
|
||||
Preview fits the device width. Height is capped so Mask and Measure controls stay on screen. Phone and PC use the same states.
|
||||
|
||||
The preview is display only. Mask, rectangle, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
|
||||
The preview is display only. Mask, rectangle, crop, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
|
||||
|
||||
## Runtime
|
||||
|
||||
@@ -83,22 +83,34 @@ Mask work runs in the worker. The last good overlay stays visible while a pass r
|
||||
|
||||
Approve is blocked when the mask has no slab pixels. User retakes.
|
||||
|
||||
User approves the mask when only the slab remains. That mask is the footprint for Length axis, measurements, and the inventory PNG.
|
||||
User approves the mask when only the slab remains. That mask is the footprint for Length axis, crop, measurements, and the inventory PNG.
|
||||
|
||||
## Length axis
|
||||
|
||||
On the same photo, draw the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
On the same photo, keep the approved mask in view and draw the minimum-area bounding rectangle of the mask with the length axis on the long side of that rectangle. User rotates the rectangle (slider with minus and plus) until that direction follows the slab length, then approves the axis.
|
||||
|
||||
## Measure
|
||||
|
||||
User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
|
||||
- `scale = longest_axis_px / L`
|
||||
- Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it
|
||||
- `scale = longest_axis_px / L` where `longest_axis_px` is the mask/wood span along the approved length direction (slab pixels end-to-end on that axis), not the rectangle edge length. The rectangle sets direction only; user-entered `L` is wood end→end along that direction.
|
||||
- Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0.
|
||||
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
||||
- `bdft = sqft × thickness_in` (2 decimals)
|
||||
- Crop: 3:4 portrait, slab centered, 90% fill at the widest and tallest points, transparent background
|
||||
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
|
||||
|
||||
User sees the inventory PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
||||
User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
||||
|
||||
## Crop
|
||||
|
||||
On Calculate, the app builds the inventory image from the approved mask:
|
||||
|
||||
1. Keep slab pixels from the source photo. Removed pixels are transparent.
|
||||
2. Place the slab in a 3:4 portrait frame, centered.
|
||||
3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
|
||||
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
||||
5. Encode as PNG with alpha.
|
||||
|
||||
## Device draft
|
||||
|
||||
@@ -121,8 +133,8 @@ The first inventory photo is the measurement reference. Later inventory photos u
|
||||
| Format | PNG, 8-bit RGBA (`image/png`) |
|
||||
| Alpha | Kept through encode and upload |
|
||||
| Aspect | 3:4 portrait |
|
||||
| Layout | Slab centered, 80% fill at the widest and tallest points |
|
||||
| Size | After crop, shorter side ≥ 1600px when the source can supply it |
|
||||
| Layout | Slab centered, 90% fill at the widest and tallest points |
|
||||
| Size | After crop, shorter side ≥ 800px when the source can supply it |
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
Editor preview of alpha is a light gray checkerboard. The storefront uses the theme background.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,431 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Milestone 2 — Photos
|
||||
|
||||
Builds on Milestone 1. Spec for calibration math and inventory PNG properties: `docs/CAPTURE.md`.
|
||||
This milestone gets every photo of one slab background-removed and cropped store-ready.
|
||||
It does not fill Complete-listing taxonomy, SKU, price, or copy. It does not create a WooCommerce product.
|
||||
No inference URL. No model. Mask and crop for non-calibration shots reuse the approved calibration params as ordinary client code.
|
||||
|
||||
## Goal
|
||||
|
||||
Phone capture of one slab → a lined-up set of Woo-compatible inventory PNGs, plus Milestone 1 calibration numbers on the reference shot, plus one annotated copy of the calibration PNG with dimension overlays.
|
||||
|
||||
## Use case
|
||||
|
||||
1. User adds a batch of one or more photos of the same slab (Setup rules from Milestone 1: green or black sheet, top-down reference expectations still apply to the calibration choice).
|
||||
2. User selects which photo in the batch is the calibration photo.
|
||||
3. App runs the Milestone 1 workflow on that calibration photo only: Mask → Length axis → Measure (length `L`, thickness, Calculate).
|
||||
4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 90% fill, transparent PNG, shorter-side floor), to every other photo in the batch.
|
||||
5. App adds a second copy of the calibration inventory PNG with dimension overlays burned in (see Dimension overlay).
|
||||
6. User sees Photos ready: inventory PNGs lined up in this order — clean calibration PNG, then the dimension-overlay copy of the calibration PNG, then the other batch photos in capture order — plus the Milestone 1 calibration details (widths, sqft, bdft, scale) presented as in Measure.
|
||||
|
||||
A batch of one photo still works: that photo is the calibration photo; the end set is the clean PNG plus the dimension-overlay copy.
|
||||
|
||||
## States (additions over Milestone 1)
|
||||
|
||||
1. Batch — add one or more photos; continue when the batch has at least one.
|
||||
2. Pick calibration — user chooses which batch photo is the reference.
|
||||
3. Mask / Length axis / Measure — Milestone 1 states on the calibration photo only.
|
||||
4. Photos ready — lineup order: clean calibration PNG → dimension-overlay copy → other batch photos in capture order; plus Milestone 1 numbers.
|
||||
|
||||
Device draft restores the last approved state, including the batch and which photo was calibration when those exist.
|
||||
|
||||
## Apply to non-calibration photos
|
||||
|
||||
For each non-calibration photo:
|
||||
|
||||
- Reuse the approved Mask sheet (Auto / Green / Black) and sensitivity from the calibration photo. Do not re-open Mask controls for that photo in this milestone.
|
||||
- Run background removal with those params; compose inventory PNG with the same crop rules as Milestone 1 Calculate.
|
||||
- If a photo yields an empty mask or fails the PNG size floor, keep it out of the ready set and show a clear per-photo error; other photos still proceed.
|
||||
|
||||
Scale, widths, sqft, and bdft stay tied to the calibration photo and user `L` / thickness. Non-calibration photos are visual inventory only.
|
||||
|
||||
## Dimension overlay
|
||||
|
||||
A second copy of the **calibration** inventory PNG is added to the collection. Overlays are black, large enough to read on phone and storefront.
|
||||
|
||||
Orientation follows the **user-approved length axis** from Milestone 1 (not the upright edges of the photo).
|
||||
|
||||
- **Length:** dimension graphic beside the wood on the right in axis-space (outside the slab edge along the length direction).
|
||||
- **Widths:** stations every 12 in along the approved length axis, starting 6 in from the **bottom** of the slab span, continuing while the station is not within 6 in of the top (stations at 6, 18, 30, … in inches along the axis while ≤ `L − 6`). Each width is drawn across the wood perpendicular to the length axis, with arrow tips on the left and right slab edges and the measurement text on that line.
|
||||
- **Bottom:** after the calibration photo’s EXIF orientation is applied, the bottom edge of that oriented photo establishes which end of the slab is bottom for width stations. Length-axis direction still follows the user-approved Milestone 1 axis; EXIF only picks which end is bottom vs top along that span.
|
||||
|
||||
## Photos-ready order
|
||||
|
||||
1. Clean calibration inventory PNG
|
||||
2. Dimension-overlay copy of the calibration PNG
|
||||
3. Remaining batch inventory PNGs in capture order
|
||||
|
||||
The clean calibration PNG remains in the set. The annotated copy is extra.
|
||||
|
||||
## Acceptance
|
||||
|
||||
On a phone UAT build:
|
||||
|
||||
- User can capture 1–N photos, pick calibration, complete Milestone 1 on that shot, and see every successful batch photo as a 3:4 transparent inventory PNG lined up.
|
||||
- Calibration measures match Milestone 1 presentation.
|
||||
- Dimension-overlay copy of the calibration PNG is present with black length and width graphics oriented to the approved axis; width stations start 6 in from EXIF-established bottom.
|
||||
- Lineup order is clean calibration → dimension overlay → other batch photos in capture order.
|
||||
- No Woo create. No inference URL.
|
||||
|
||||
## 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
|
||||
- Changing Milestone 1 mask/axis/measure math except to reuse its outputs for batch apply and overlays
|
||||
@@ -1,4 +1,4 @@
|
||||
# Product requirements
|
||||
# Product overview
|
||||
|
||||
SlabUploader creates one WooCommerce listing per wood slab for Whidbey Wood Store.
|
||||
|
||||
@@ -6,8 +6,12 @@ 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
|
||||
@@ -16,7 +20,9 @@ Photographs are taken of the slab on a green or black sheet. That is the capture
|
||||
- Title, short title, description
|
||||
- Zero or more `feat-*` tags
|
||||
|
||||
Store categories, attributes, and `fig-*` / `feat-*` tags are the filters. Measured values feed title and description templates.
|
||||
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`.
|
||||
|
||||
## Decision rights
|
||||
|
||||
@@ -30,45 +36,33 @@ Store categories, attributes, and `fig-*` / `feat-*` tags are the filters. Measu
|
||||
| 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
|
||||
|
||||
Milestone 1 runs in the browser. No backend and no inference URL.
|
||||
Handoff is the device draft: source photo, approved mask, inventory PNG, length, thickness, widths, sqft, bdft.
|
||||
Milestone 3 uses FastAPI as the Woo client. Store credentials stay on the server.
|
||||
Milestones 1 and 2 run in the browser. No backend and no inference URL on the shipped path.
|
||||
|
||||
## Create
|
||||
Milestone 1 handoff is the device draft: source photo, approved mask, inventory PNG, length, thickness, widths, sqft, bdft.
|
||||
|
||||
`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.
|
||||
Milestone 2 extends the draft with the photo batch, calibration choice, per-photo inventory PNGs, and the dimension-overlay calibration copy.
|
||||
|
||||
## Milestones
|
||||
Milestone 3 introduces FastAPI as the Woo client and inference proxy. Store credentials and inference secrets stay on the server. Spec: `docs/CREATE.md`.
|
||||
|
||||
Work is split into four milestones. Each milestone is done when its acceptance can be demonstrated.
|
||||
## Create rules (summary)
|
||||
|
||||
### Milestone 1 — Capture
|
||||
Full behavior is in `docs/CREATE.md`.
|
||||
|
||||
Spec: `docs/CAPTURE.md`. Current work.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
## Milestone status on `m3-ds`
|
||||
|
||||
Acceptance: inventory PNG and measurements visible on a phone. No Woo create.
|
||||
| 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`. |
|
||||
|
||||
### Milestone 2 — Review
|
||||
|
||||
Remaining listing fields in the app.
|
||||
Acceptance: every field in Complete listing is filled or proposed and editable.
|
||||
|
||||
### 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.
|
||||
Anything beyond `docs/CREATE.md` is out of M3 unless Ty expands scope in chat.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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
|
||||
@@ -0,0 +1,230 @@
|
||||
# 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 |
|
||||
@@ -15,3 +15,11 @@ 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.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Build the static app, then serve only the built artifacts.
|
||||
# Build context is the repo root: `docker build -f frontend/Dockerfile .`
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY deploy/nginx-m1.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>SlabUploader — Capture</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; }
|
||||
#app { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "slabuploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M2 #65 — Playwright visual evidence against local vite preview (not ubudual).
|
||||
* Artifacts land in tests/e2e/artifacts/ (png churn gitignored; README committed).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
timeout: 180_000,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
...devices['Pixel 7'],
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
outputDir: 'tests/e2e/test-results',
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { supported } from './browser';
|
||||
import { createCaptureSession, type CaptureSession } from './capture/session';
|
||||
import type { Capture } from './capture/capture.types';
|
||||
import Resume from './capture/Resume.svelte';
|
||||
import BatchPhotos from './capture/BatchPhotos.svelte';
|
||||
import PickCalibration from './capture/PickCalibration.svelte';
|
||||
import MaskPanel from './capture/MaskPanel.svelte';
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
|
||||
let cap: Capture = { k: 'batch', photos: [] };
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
||||
});
|
||||
const unsubBusy = session.busy.subscribe((b) => {
|
||||
busy = b;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void session.resumeProbe();
|
||||
});
|
||||
onDestroy(() => {
|
||||
unsubCap();
|
||||
unsubBusy();
|
||||
session.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if gate}
|
||||
<main class="hardstop">
|
||||
<h1>Unsupported browser</h1>
|
||||
<p>
|
||||
SlabUploader needs a modern browser with canvas, workers, and IndexedDB support:
|
||||
iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox.
|
||||
</p>
|
||||
<p class="detail">Missing: {gate}</p>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="app">
|
||||
{#if cap.k === 'resume'}
|
||||
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
|
||||
{:else if cap.k === 'batch'}
|
||||
<BatchPhotos
|
||||
photos={cap.photos}
|
||||
onAdd={(f) => void session.addToBatch(f)}
|
||||
onRemove={session.removeFromBatch}
|
||||
onContinue={session.continueBatch}
|
||||
/>
|
||||
{:else if cap.k === 'pickCalibration'}
|
||||
<PickCalibration
|
||||
photos={cap.photos}
|
||||
onPick={session.pickCalibration}
|
||||
onBack={session.back}
|
||||
/>
|
||||
{:else if cap.k === 'mask'}
|
||||
<MaskPanel
|
||||
state={cap}
|
||||
{busy}
|
||||
setSensitivity={session.setSensitivity}
|
||||
setSheet={session.setSheet}
|
||||
approveMask={session.approveMask}
|
||||
retakes={session.retakes}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else if cap.k === 'axis'}
|
||||
<AxisPanel
|
||||
state={cap}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
approveAxis={session.approveAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else}
|
||||
<MeasurePanel
|
||||
state={cap}
|
||||
setLength={session.setLength}
|
||||
setThickness={session.setThickness}
|
||||
calculate={session.calculate}
|
||||
back={session.back}
|
||||
{pngUrl}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #121212;
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
// Supported clients: iOS Safari, Android Chrome, PC Chrome/Edge/Firefox.
|
||||
// Gated on required API presence; anything missing → hard stop.
|
||||
export function supported(): string | null {
|
||||
if (typeof window === 'undefined') return 'window unavailable';
|
||||
if (typeof createImageBitmap !== 'function') return 'createImageBitmap';
|
||||
if (typeof Worker === 'undefined') return 'Worker';
|
||||
if (typeof indexedDB === 'undefined') return 'IndexedDB';
|
||||
const hasOffscreen =
|
||||
typeof OffscreenCanvas !== 'undefined' &&
|
||||
typeof OffscreenCanvas.prototype.convertToBlob === 'function';
|
||||
const hasCanvasBlob =
|
||||
typeof HTMLCanvasElement !== 'undefined' &&
|
||||
typeof HTMLCanvasElement.prototype.toBlob === 'function';
|
||||
if (!hasOffscreen && !hasCanvasBlob) return 'canvas PNG encode';
|
||||
const ua = navigator.userAgent;
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isAndroid = /Android/i.test(ua);
|
||||
const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|Edg|Firefox|FxiOS/i.test(ua);
|
||||
const isChrome = /Chrome|CriOS/i.test(ua) && !/Edg/i.test(ua);
|
||||
const isEdge = /Edg/i.test(ua);
|
||||
const isFirefox = /Firefox|FxiOS/i.test(ua);
|
||||
if (isIOS && isSafari) return null;
|
||||
if (isAndroid && isChrome) return null;
|
||||
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
||||
return 'unsupported browser';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="179" step="1"
|
||||
value={angleDeg}
|
||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
||||
<span class="val">{angleDeg}°</span>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Source } from './capture.types';
|
||||
|
||||
export let photos: Source[];
|
||||
export let onAdd: (f: File) => void;
|
||||
export let onRemove: (index: number) => void;
|
||||
export let onContinue: () => void;
|
||||
|
||||
let urls: string[] = [];
|
||||
|
||||
$: {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
urls = photos.map((p) => URL.createObjectURL(p.blob));
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
});
|
||||
|
||||
const pick = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const list = input.files;
|
||||
if (!list || list.length === 0) return;
|
||||
// Support multi-select from gallery when the browser allows it.
|
||||
for (let i = 0; i < list.length; i++) onAdd(list[i]);
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
$: canContinue = photos.length >= 1;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<ul class="setup">
|
||||
<li>Sheet is green or black, fills the frame around the slab.</li>
|
||||
<li>Slab fully on the sheet, nothing on top of it.</li>
|
||||
<li>Top-down reference shot: lens parallel to the face.</li>
|
||||
<li>Slab length along the long side of the picture.</li>
|
||||
<li>Add one or more photos of the same slab, then Continue.</li>
|
||||
</ul>
|
||||
|
||||
{#if photos.length > 0}
|
||||
<ul class="thumbs" aria-label="Batch photos">
|
||||
{#each photos as _p, i (urls[i])}
|
||||
<li class="thumb">
|
||||
<img src={urls[i]} alt={`Batch photo ${i + 1}`} />
|
||||
<button type="button" class="rm" aria-label={`Remove photo ${i + 1}`} on:click={() => onRemove(i)}>Remove</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">No photos yet. Add at least one to continue.</p>
|
||||
{/if}
|
||||
|
||||
<div class="pick">
|
||||
<label class="pick-btn">Take photo
|
||||
<input type="file" accept="image/*" capture="environment" on:change={pick} />
|
||||
</label>
|
||||
<label class="pick-btn">Choose from gallery
|
||||
<input type="file" accept="image/*" multiple on:change={pick} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="continue"
|
||||
type="button"
|
||||
disabled={!canContinue}
|
||||
on:click={onContinue}
|
||||
>Continue</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.setup { color: #bbb; line-height: 1.6; font-size: 0.95rem; }
|
||||
.empty { color: #888; margin: 16px 0; }
|
||||
.thumbs {
|
||||
list-style: none; padding: 0; margin: 16px 0;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px;
|
||||
}
|
||||
.thumb {
|
||||
background: #1e1e1e; border-radius: 8px; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.thumb img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; display: block; background: #000; }
|
||||
.rm {
|
||||
border: 0; background: #333; color: #ccc; padding: 8px; font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
.pick { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.pick-btn {
|
||||
background: #00e5ff; color: #002; border: 0; border-radius: 8px;
|
||||
padding: 12px 20px; font-size: 1rem; font-weight: 600;
|
||||
text-align: center; cursor: pointer;
|
||||
}
|
||||
.pick-btn input[type='file'] { display: none; }
|
||||
.continue {
|
||||
margin-top: 20px; width: 100%; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.continue:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { Capture, Sheet } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'mask' }>;
|
||||
export let busy: boolean;
|
||||
export let setSensitivity: (n: number) => void;
|
||||
export let setSheet: (s: Sheet) => void;
|
||||
export let approveMask: () => void;
|
||||
export let retakes: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
const sheets: Sheet[] = ['auto', 'green', 'black'];
|
||||
$: empty = state.overlay !== null && state.overlay.slabPx === 0;
|
||||
$: maskW = state.overlay && state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 && state.overlay ? state.overlay.bitset.length / maskW : 0;
|
||||
$: canApprove = state.overlay !== null && state.overlay.slabPx > 0;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<div class="stage-wrap">
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
maskBitset={state.overlay?.bitset ?? null}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
{#if busy}<div class="busy">Working…</div>{/if}
|
||||
{#if state.failed}<div class="warn">Mask pass failed. Adjust and retry, or retake the photo.</div>{/if}
|
||||
{#if empty}<div class="warn">No slab pixels in the mask. Adjust sensitivity or retake.</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="seg" role="group" aria-label="Sheet">
|
||||
{#each sheets as s (s)}
|
||||
<button type="button" class:active={state.control.sheet === s} on:click={() => setSheet(s)}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sens">
|
||||
<button type="button" class="thumb" aria-label="Decrease sensitivity" on:click={() => setSensitivity(state.control.sensitivity - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="100" step="1"
|
||||
value={state.control.sensitivity}
|
||||
on:input={(e) => setSensitivity(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Increase sensitivity" on:click={() => setSensitivity(state.control.sensitivity + 1)}>+</button>
|
||||
<span class="val">{state.control.sensitivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="ghost" on:click={retakes}>Retake</button>
|
||||
<button type="button" class="primary" disabled={!canApprove} on:click={approveMask}>
|
||||
Approve mask
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.stage-wrap { position: relative; }
|
||||
.busy {
|
||||
position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7);
|
||||
color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 0.85rem;
|
||||
}
|
||||
.warn { color: #ffb4a2; margin-top: 8px; font-size: 0.9rem; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.seg { display: flex; gap: 8px; }
|
||||
.seg button {
|
||||
flex: 1; padding: 12px; font-size: 1rem; border-radius: 8px;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ccc;
|
||||
}
|
||||
.seg button.active { background: #00e5ff; color: #002; font-weight: 700; border-color: #00e5ff; }
|
||||
.sens { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 2.2ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 12px; margin-top: 20px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
photosReadyPngAlt,
|
||||
type PhotosReadyLineupItem,
|
||||
} from './photosReadyLineup';
|
||||
|
||||
export let state: Extract<Capture, { k: 'measure' }>;
|
||||
export let setLength: (e: number) => void;
|
||||
export let setThickness: (e: number) => void;
|
||||
export let calculate: () => Promise<void>;
|
||||
export let back: () => void;
|
||||
export let pngUrl: string | null;
|
||||
|
||||
let calcBusy = false;
|
||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||
|
||||
let lineupUrls: string[] = [];
|
||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||
|
||||
$: photosReady = !!state.results;
|
||||
$: {
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
lineupUrls = [];
|
||||
if (state.results) {
|
||||
lineupItems = buildPhotosReadyLineup({
|
||||
inventoryPng: state.results.inventoryPng,
|
||||
overlayPng: state.results.overlayPng,
|
||||
batchApply: state.batchApply,
|
||||
sourceBelowFloor: state.results.sourceBelowFloor,
|
||||
});
|
||||
lineupUrls = lineupItems.map((it) =>
|
||||
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
|
||||
);
|
||||
} else {
|
||||
lineupItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
});
|
||||
|
||||
const positiveInches = (raw: string): number | null => {
|
||||
const inches = Number.parseFloat(raw);
|
||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||
};
|
||||
|
||||
const commitLength = () => {
|
||||
const inches = positiveInches(String(lengthIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
setLength(eighths);
|
||||
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
const commitThickness = () => {
|
||||
const inches = positiveInches(String(thicknessIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
setThickness(eighths);
|
||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
|
||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
|
||||
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
try {
|
||||
commitLength();
|
||||
commitThickness();
|
||||
await calculate();
|
||||
} finally { calcBusy = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
||||
|
||||
{#if !photosReady}
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
||||
/>
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Length L"
|
||||
inputmode="decimal"
|
||||
step="0.125"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
bind:value={lengthIn}
|
||||
on:blur={commitLength}
|
||||
on:change={commitLength}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Thickness</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Thickness"
|
||||
inputmode="decimal"
|
||||
step="0.125"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
bind:value={thicknessIn}
|
||||
on:blur={commitThickness}
|
||||
on:change={commitThickness}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={calcBusy || !canCalc}
|
||||
on:click={doCalc}
|
||||
>
|
||||
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||
</button>
|
||||
</div>
|
||||
{:else if state.results}
|
||||
<div class="results" aria-label="Photos ready">
|
||||
<div class="nums">
|
||||
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
||||
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
|
||||
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
|
||||
</div>
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{/if}
|
||||
|
||||
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||
{#each lineupItems as item, i (i)}
|
||||
{#if item.kind === 'png'}
|
||||
<li class="lineup-item png">
|
||||
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
|
||||
<span class="role">
|
||||
{#if item.role === 'calibration'}
|
||||
Clean calibration
|
||||
{:else if item.role === 'overlay'}
|
||||
Dimension overlay
|
||||
{:else}
|
||||
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="lineup-item error" role="alert">
|
||||
<span class="err-title">Photo {item.batchIndex + 1} failed</span>
|
||||
<span class="err-msg">{item.message}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||
.field { flex: 1; }
|
||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
||||
.field input {
|
||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
||||
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.results { margin-top: 8px; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.lineup {
|
||||
list-style: none; padding: 0; margin: 16px 0 0;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.lineup-item.png {
|
||||
text-align: center;
|
||||
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
|
||||
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0;
|
||||
background-color: #eee; border-radius: 8px; padding: 8px;
|
||||
}
|
||||
.lineup-item.png img { max-width: 100%; border-radius: 4px; display: block; margin: 0 auto; }
|
||||
.role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; }
|
||||
.lineup-item.error {
|
||||
border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px;
|
||||
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
|
||||
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
|
||||
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
|
||||
.png-wrap img { max-width: 100%; border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
import { fitDisplay } from './fitDisplay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
// Geometry must use the same EXIF-oriented bitmap space as the mask worker (#72).
|
||||
export let blob: Blob;
|
||||
export let maxH = 420;
|
||||
export let bbox: BBox | null = null;
|
||||
export let maskBitset: Uint8Array | null = null;
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
|
||||
let parentW = 0;
|
||||
let decodeGen = 0;
|
||||
let decoding = false;
|
||||
|
||||
const applyFit = () => {
|
||||
if (!canvas || !imgW) return;
|
||||
if (parentW <= 0 && stage) parentW = stage.clientWidth;
|
||||
const fit = fitDisplay(parentW, imgW, imgH, maxH);
|
||||
if (!fit) return;
|
||||
canvas.style.width = `${fit.fitW}px`;
|
||||
canvas.style.height = `${fit.fitH}px`;
|
||||
};
|
||||
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas) return;
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
off.height = maskH;
|
||||
const octx = off.getContext('2d')!;
|
||||
const d = octx.createImageData(maskW, maskH);
|
||||
const tile = 8;
|
||||
for (let p = 0; p < maskW * maskH; p++) {
|
||||
if (!maskBitset[p]) {
|
||||
const x = p % maskW;
|
||||
const y = (p / maskW) | 0;
|
||||
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
|
||||
d.data[p * 4] = light ? 220 : 180;
|
||||
d.data[p * 4 + 1] = light ? 220 : 180;
|
||||
d.data[p * 4 + 2] = light ? 220 : 180;
|
||||
d.data[p * 4 + 3] = 220;
|
||||
}
|
||||
}
|
||||
octx.putImageData(d, 0, 0);
|
||||
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
if (bbox && bbox.corners.length === 4) {
|
||||
const c = bbox.corners;
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c[0][0], c[0][1]);
|
||||
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';
|
||||
ctx.lineWidth = Math.max(3, canvas.width / 300);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a[0], a[1]);
|
||||
ctx.lineTo(b[0], b[1]);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ff3d71';
|
||||
ctx.beginPath();
|
||||
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||
const r = Math.max(10, canvas.width / 60);
|
||||
ctx.moveTo(b[0], b[1]);
|
||||
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
|
||||
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
const redraw = async () => {
|
||||
if (!blob || !canvas) return;
|
||||
const gen = ++decodeGen;
|
||||
decoding = true;
|
||||
let bmp: ImageBitmap | null = null;
|
||||
try {
|
||||
bmp = await decodeOrientedBitmap(blob);
|
||||
if (gen !== decodeGen || !canvas) return;
|
||||
imgW = bmp.width;
|
||||
imgH = bmp.height;
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
applyFit();
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
paintOverlays(ctx);
|
||||
} finally {
|
||||
if (bmp) bmp.close();
|
||||
if (gen === decodeGen) decoding = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (blob || bbox || maskBitset) void tick().then(redraw);
|
||||
|
||||
onMount(() => {
|
||||
if (stage) parentW = stage.clientWidth;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
const w = e.contentRect.width;
|
||||
if (w === parentW) return;
|
||||
parentW = w;
|
||||
applyFit();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {
|
||||
decodeGen += 1;
|
||||
});
|
||||
</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>
|
||||
|
||||
<style>
|
||||
.stage { width: 100%; display: flex; justify-content: center; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; }
|
||||
.loading { padding: 40px; color: #999; }
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Source } from './capture.types';
|
||||
|
||||
export let photos: Source[];
|
||||
export let onPick: (index: number) => void;
|
||||
export let onBack: () => void;
|
||||
|
||||
let urls: string[] = [];
|
||||
|
||||
$: {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
urls = photos.map((p) => URL.createObjectURL(p.blob));
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Pick calibration photo</h1>
|
||||
<p class="hint">
|
||||
Choose which photo is the top-down reference for length and thickness.
|
||||
Mask, length axis, and Measure run on that photo only.
|
||||
</p>
|
||||
|
||||
<ul class="thumbs" aria-label="Calibration candidates">
|
||||
{#each photos as _p, i (urls[i])}
|
||||
<li class="thumb">
|
||||
<img src={urls[i]} alt={`Photo ${i + 1}`} />
|
||||
<button
|
||||
type="button"
|
||||
class="pick"
|
||||
aria-label={`Use photo ${i + 1} as calibration`}
|
||||
on:click={() => onPick(i)}
|
||||
>Use as calibration</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<button class="back" type="button" on:click={onBack}>Back</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.hint { color: #bbb; line-height: 1.5; font-size: 0.95rem; }
|
||||
.thumbs {
|
||||
list-style: none; padding: 0; margin: 16px 0;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px;
|
||||
}
|
||||
.thumb {
|
||||
background: #1e1e1e; border-radius: 8px; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.thumb img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; display: block; background: #000; }
|
||||
.pick {
|
||||
border: 0; background: #00e5ff; color: #002; padding: 10px 8px;
|
||||
font-size: 0.9rem; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.back {
|
||||
margin-top: 8px; width: 100%; padding: 12px; font-size: 1rem; border-radius: 10px;
|
||||
border: 1px solid #444; background: transparent; color: #ccc; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
export let onContinue: () => void;
|
||||
export let onFresh: () => void;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Resume?</h1>
|
||||
<p>You have an in-progress slab on this device.</p>
|
||||
<div class="row">
|
||||
<button class="primary" type="button" on:click={onContinue}>Continue</button>
|
||||
<button class="ghost" type="button" on:click={onFresh}>Start fresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; text-align: center; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
p { color: #aaa; }
|
||||
.row { display: flex; gap: 12px; justify-content: center; margin-top: 24px; }
|
||||
.primary {
|
||||
flex: 1; max-width: 200px; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost {
|
||||
flex: 1; max-width: 200px; padding: 14px; border-radius: 10px; border: 1px solid #444;
|
||||
background: transparent; color: #ccc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
// Domain model for the capture flow (M1 + M2 batch).
|
||||
// The Capture tagged union is the state machine: each branch carries exactly the
|
||||
// data its state can hold, so an illegal state is a type error.
|
||||
|
||||
// Integer eighths of an inch; divide by 8 only at display.
|
||||
export type Eighths = number;
|
||||
|
||||
export const inToEighths = (inches: number): Eighths => Math.round(inches * 8);
|
||||
export const eighthsToIn = (e: Eighths): number => e / 8;
|
||||
|
||||
// Source: all geometry, measurement, and PNG work uses source pixels, never the
|
||||
// CSS size of the preview.
|
||||
export interface Source {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export type Sheet = 'auto' | 'green' | 'black';
|
||||
export interface MaskParams {
|
||||
sheet: Sheet;
|
||||
sensitivity: number; // 0–100, step 1
|
||||
}
|
||||
|
||||
// Axis-aligned box, source px.
|
||||
export interface ABox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// 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
|
||||
angleDeg: number; // long-side angle, degrees, [0, 180)
|
||||
}
|
||||
|
||||
// The approved mask is the footprint for axis, measurement, and the inventory PNG.
|
||||
export interface Mask {
|
||||
params: MaskParams;
|
||||
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
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
// corners[3] → corners[0] (the long side).
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
min: Eighths;
|
||||
max: Eighths;
|
||||
avg: Eighths;
|
||||
every6In: Eighths[];
|
||||
}
|
||||
|
||||
export interface Measures {
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
scale: number; // px per inch
|
||||
widths: Widths;
|
||||
sqft: number; // 2 dp
|
||||
bdft: number; // 2 dp
|
||||
inventoryPng: Blob;
|
||||
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
|
||||
overlayPng?: Blob;
|
||||
sourceBelowFloor: boolean;
|
||||
}
|
||||
|
||||
// Result of applying the calibration mask to one batch sibling. The
|
||||
// calibration slot is null because its inventory PNG lives in Measures.
|
||||
export type BatchPhotoResult =
|
||||
| null
|
||||
| { status: 'ok'; inventoryPng: Blob }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||
// M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration.
|
||||
export type Capture =
|
||||
| { k: 'resume'; draft: Draft }
|
||||
| { k: 'batch'; photos: Source[] }
|
||||
| { k: 'pickCalibration'; photos: Source[] }
|
||||
| {
|
||||
k: 'mask';
|
||||
source: Source;
|
||||
control: MaskParams; // immediate control value (what the UI shows)
|
||||
overlay: Mask | null; // last settled mask; null until first pass
|
||||
failed: boolean;
|
||||
}
|
||||
| {
|
||||
k: 'axis';
|
||||
source: Source;
|
||||
approvedMask: Mask; // frozen footprint
|
||||
axis: Axis; // live axis.bbox being nudged
|
||||
}
|
||||
| {
|
||||
k: 'measure';
|
||||
source: Source;
|
||||
approvedMask: Mask;
|
||||
axis: Axis; // frozen
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Measures | null;
|
||||
/** Capture-order sibling results from the post-Calculate batch pass. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
};
|
||||
|
||||
// Placeholder geometry for batch-only drafts (no mask yet).
|
||||
export const EMPTY_BBOX: BBox = {
|
||||
corners: [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
],
|
||||
longSidePx: 0,
|
||||
angleDeg: 0,
|
||||
};
|
||||
export const EMPTY_ABOX: ABox = { x: 0, y: 0, w: 0, h: 0 };
|
||||
export const DEFAULT_MASK_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50 };
|
||||
|
||||
// Device draft (IndexedDB): one in-progress slab. Small by design — the bitset
|
||||
// and PNG are re-derived from stored data, never stored second-hand.
|
||||
// M2 extends with `batch` (capture-order photo blobs). Legacy M1 drafts omit it.
|
||||
export interface Draft {
|
||||
version: 1;
|
||||
stage: 'batch' | 'pickCalibration' | 'mask' | 'axis' | 'measure'; // last approved / persisted state
|
||||
/** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */
|
||||
batch?: Source[];
|
||||
/** Index into batch of the user-picked calibration photo. Set once picked. */
|
||||
calibrationIndex?: number;
|
||||
source: Source; // calibration / active photo; for stage==='batch'|'pickCalibration' = photos[0] placeholder
|
||||
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
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||
inventoryPng: Blob | null; // stored so Continue can show Measure results
|
||||
/** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */
|
||||
overlayPng?: Blob | null;
|
||||
/** Capture-order batch apply results; blobs are retained for the #64 lineup. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
}
|
||||
|
||||
// Worker protocol. Job ids increase; the worker drops anything older than the
|
||||
// current id, so a stale result can never blank a newer one.
|
||||
export type WorkerIn =
|
||||
| { id: number; kind: 'init'; blob: Blob }
|
||||
| { id: number; kind: 'mask'; sheet: Sheet; sensitivity: number }
|
||||
| { id: number; kind: 'invalidate' };
|
||||
|
||||
export type WorkerOut =
|
||||
| { id: number; kind: 'decoded'; w: number; h: number }
|
||||
| {
|
||||
id: number;
|
||||
kind: 'ready';
|
||||
slabPx: number;
|
||||
bitset: Uint8Array;
|
||||
bbox: BBox;
|
||||
slabBounds: ABox;
|
||||
}
|
||||
| { id: number; kind: 'failed'; reason: string };
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
decodeOrientedBitmap,
|
||||
ORIENTED_BITMAP_OPTIONS,
|
||||
} from '../decodeOrientedBitmap.ts';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const fixturePath = join(here, '../../tests/fixtures/set1-48x2/face-a.jpeg');
|
||||
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
afterEach(() => {
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
});
|
||||
|
||||
/** Minimal EXIF Orientation + largest SOF size for the orient-8 fixture. */
|
||||
function readJpegOrientAndSof(buf: Buffer): { orientation: number; sofW: number; sofH: number } {
|
||||
let orientation = 1;
|
||||
const exif = buf.indexOf(Buffer.from('Exif\0\0'));
|
||||
if (exif >= 0) {
|
||||
const tiff = exif + 6;
|
||||
const le = buf[tiff] === 0x49;
|
||||
const u16 = (o: number) => (le ? buf.readUInt16LE(o) : buf.readUInt16BE(o));
|
||||
const u32 = (o: number) => (le ? buf.readUInt32LE(o) : buf.readUInt32BE(o));
|
||||
const ifd = tiff + u32(tiff + 4);
|
||||
const n = u16(ifd);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const e = ifd + 2 + k * 12;
|
||||
const tag = u16(e);
|
||||
const typ = u16(e + 2);
|
||||
if (tag === 0x0112) {
|
||||
orientation = typ === 3 ? u16(e + 8) : u32(e + 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sofW = 0;
|
||||
let sofH = 0;
|
||||
let i = 0;
|
||||
while (i < buf.length - 9) {
|
||||
if (buf[i] === 0xff && buf[i + 1] >= 0xc0 && buf[i + 1] <= 0xc3) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
if (w * h > sofW * sofH) {
|
||||
sofW = w;
|
||||
sofH = h;
|
||||
}
|
||||
const segLen = buf.readUInt16BE(i + 2);
|
||||
i += 2 + segLen;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { orientation, sofW, sofH };
|
||||
}
|
||||
|
||||
/** Orientation 5–8 swap width/height for display. */
|
||||
function orientedSize(sofW: number, sofH: number, orientation: number): { w: number; h: number } {
|
||||
if (orientation >= 5 && orientation <= 8) return { w: sofH, h: sofW };
|
||||
return { w: sofW, h: sofH };
|
||||
}
|
||||
|
||||
test('fixture face-a.jpeg is EXIF Orientation 8 (stored landscape → oriented portrait) (#72)', () => {
|
||||
const buf = readFileSync(fixturePath);
|
||||
const { orientation, sofW, sofH } = readJpegOrientAndSof(buf);
|
||||
assert.equal(orientation, 8, 'matched-set JPEGs carry Orientation 8');
|
||||
assert.equal(sofW, 4000);
|
||||
assert.equal(sofH, 2256);
|
||||
const { w, h } = orientedSize(sofW, sofH, orientation);
|
||||
assert.equal(w, 2256);
|
||||
assert.equal(h, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap always requests imageOrientation from-image (#72)', async () => {
|
||||
let seen: ImageBitmapOptions | undefined;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
seen = opts;
|
||||
return { width: 2256, height: 4000, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const blob = new Blob([readFileSync(fixturePath)], { type: 'image/jpeg' });
|
||||
const bmp = await decodeOrientedBitmap(blob);
|
||||
assert.deepEqual(seen, ORIENTED_BITMAP_OPTIONS);
|
||||
assert.equal(ORIENTED_BITMAP_OPTIONS.imageOrientation, 'from-image');
|
||||
assert.equal(bmp.width, 2256);
|
||||
assert.equal(bmp.height, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap falls back to bare createImageBitmap if option rejected (#72)', async () => {
|
||||
let calls = 0;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
calls += 1;
|
||||
if (opts && 'imageOrientation' in opts) {
|
||||
throw new TypeError('imageOrientation unsupported');
|
||||
}
|
||||
return { width: 90, height: 160, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const bmp = await decodeOrientedBitmap(new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }));
|
||||
assert.equal(calls, 2, 'retry bare after option rejection');
|
||||
assert.equal(bmp.width, 90);
|
||||
assert.equal(bmp.height, 160);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap: Chromium orients fixture to 2256×4000 (#72)', async (t) => {
|
||||
let chromium: typeof import('@playwright/test').chromium;
|
||||
try {
|
||||
({ chromium } = await import('@playwright/test'));
|
||||
} catch {
|
||||
t.skip('playwright not available');
|
||||
return;
|
||||
}
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const buf = readFileSync(fixturePath);
|
||||
const b64 = buf.toString('base64');
|
||||
// Inline the same contract as decodeOrientedBitmap (worker-safe, no bundler).
|
||||
const dims = await page.evaluate(async (b64jpeg: string) => {
|
||||
const bin = atob(b64jpeg);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
const blob = new Blob([arr], { type: 'image/jpeg' });
|
||||
const opts = { imageOrientation: 'from-image' as const };
|
||||
let bmp: ImageBitmap;
|
||||
try {
|
||||
bmp = await createImageBitmap(blob, opts);
|
||||
} catch {
|
||||
bmp = await createImageBitmap(blob);
|
||||
}
|
||||
const out = { w: bmp.width, h: bmp.height };
|
||||
bmp.close();
|
||||
return out;
|
||||
}, b64);
|
||||
assert.equal(dims.w, 2256, `oriented width (got ${dims.w})`);
|
||||
assert.equal(dims.h, 4000, `oriented height (got ${dims.h})`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fitDisplay } from './fitDisplay.ts';
|
||||
|
||||
test('parentW=0 → null (no size until laid out)', () => {
|
||||
assert.equal(fitDisplay(0, 1000, 750, 420), null);
|
||||
assert.equal(fitDisplay(-5, 1000, 750, 420), null);
|
||||
});
|
||||
|
||||
test('280 wide, 4:3 landscape → contain, under the height cap', () => {
|
||||
const fit = fitDisplay(280, 1600, 1200, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitW, 280);
|
||||
assert.ok(Math.abs(fit.fitH - 210) < 1e-9);
|
||||
assert.ok(fit.fitH <= 420);
|
||||
});
|
||||
|
||||
test('portrait under maxH never stretches: height capped, width shrinks', () => {
|
||||
// 1000 wide source, 3000 tall. Full-width fit would be 900 tall > 420.
|
||||
const fit = fitDisplay(400, 1000, 3000, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitH, 420);
|
||||
assert.ok(Math.abs(fit.fitW - 140) < 1e-9);
|
||||
// aspect preserved: fitW/fitH === imgW/imgH
|
||||
assert.ok(Math.abs(fit.fitW / fit.fitH - 1000 / 3000) < 1e-9);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// Contain-fit of an image into a parent width with a height cap. Pure and
|
||||
// display-only; all measurement math stays on source pixels.
|
||||
// Null when the parent is not laid out yet (clientWidth 0) — the caller must
|
||||
// not finalize CSS size until it is, to avoid a wrong-aspect flash.
|
||||
export function fitDisplay(
|
||||
parentW: number, imgW: number, imgH: number, maxH: number,
|
||||
): { fitW: number; fitH: number } | null {
|
||||
if (parentW <= 0 || imgW <= 0 || imgH <= 0) return null;
|
||||
const fitW = parentW;
|
||||
let fitH = (imgH / imgW) * fitW;
|
||||
if (fitH > maxH) {
|
||||
// Cap height and shrink width to preserve aspect; never stretch.
|
||||
fitH = maxH;
|
||||
return { fitW: (fitH * imgW) / imgH, fitH };
|
||||
}
|
||||
return { fitW, fitH };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
import { inToEighths } from './capture.types';
|
||||
|
||||
const panelSrc = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
|
||||
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)),
|
||||
'Length L must be a type=number input',
|
||||
);
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)),
|
||||
'Thickness must be a type=number input',
|
||||
);
|
||||
assert.equal(
|
||||
panelSrc.includes('class="stepper"'),
|
||||
false,
|
||||
'stepper-only UI is the UAT fail; primary control must be typed input',
|
||||
);
|
||||
assert.equal(
|
||||
/<span class="v">\{fmtEighths\(state\.lengthEighths\)\}<\/span>/.test(panelSrc),
|
||||
false,
|
||||
'Length must not be a read-only fmtEighths span',
|
||||
);
|
||||
});
|
||||
|
||||
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||
assert.equal(inToEighths(12), 96);
|
||||
assert.equal(inToEighths(12.1), 97);
|
||||
assert.equal(inToEighths(0.06), 0);
|
||||
assert.equal(inToEighths(0.07), 1);
|
||||
assert.equal(inToEighths(1.999), 16);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
import type { BatchPhotoResult } from './capture.types';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
type PhotosReadyLineupItem,
|
||||
} from './photosReadyLineup';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function png(label: string): Blob {
|
||||
return new Blob([label], { type: 'image/png' });
|
||||
}
|
||||
|
||||
function jpeg(label: string): Blob {
|
||||
return new Blob([label], { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function roles(items: PhotosReadyLineupItem[]): string[] {
|
||||
return items.map((it) =>
|
||||
it.kind === 'png' ? `png:${it.role}` : `error:${it.batchIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- pure helper: lineup order + error slots (#64) ---
|
||||
|
||||
test('test(photos): lineup is clean cal → overlay → other batch PNGs in capture order (#64)', () => {
|
||||
const clean = png('clean-cal');
|
||||
const overlay = png('overlay-cal');
|
||||
const sib0 = png('sib-0');
|
||||
const sib2 = png('sib-2');
|
||||
const batchApply: BatchPhotoResult[] = [
|
||||
{ status: 'ok', inventoryPng: sib0 },
|
||||
null, // calibration at index 1
|
||||
{ status: 'ok', inventoryPng: sib2 },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply,
|
||||
});
|
||||
|
||||
assert.deepEqual(roles(lineup), [
|
||||
'png:calibration',
|
||||
'png:overlay',
|
||||
'png:batch',
|
||||
'png:batch',
|
||||
]);
|
||||
assert.equal(lineup[0].kind, 'png');
|
||||
if (lineup[0].kind === 'png') {
|
||||
assert.equal(lineup[0].blob, clean);
|
||||
assert.equal(lineup[0].role, 'calibration');
|
||||
}
|
||||
assert.equal(lineup[1].kind, 'png');
|
||||
if (lineup[1].kind === 'png') {
|
||||
assert.equal(lineup[1].blob, overlay);
|
||||
assert.equal(lineup[1].role, 'overlay');
|
||||
}
|
||||
assert.equal(lineup[2].kind, 'png');
|
||||
if (lineup[2].kind === 'png') {
|
||||
assert.equal(lineup[2].blob, sib0);
|
||||
assert.equal(lineup[2].batchIndex, 0);
|
||||
}
|
||||
assert.equal(lineup[3].kind, 'png');
|
||||
if (lineup[3].kind === 'png') {
|
||||
assert.equal(lineup[3].blob, sib2);
|
||||
assert.equal(lineup[3].batchIndex, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): single-photo batch lineup is clean + overlay only (#64)', () => {
|
||||
const clean = png('only-clean');
|
||||
const overlay = png('only-overlay');
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply: [null],
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay']);
|
||||
assert.ok(lineup.every((it) => it.kind === 'png'));
|
||||
});
|
||||
|
||||
test('test(photos): batchApply errors become clear error slots (empty mask / floor) (#64)', () => {
|
||||
const clean = png('clean');
|
||||
const overlay = png('overlay');
|
||||
const ok = png('ok-sib');
|
||||
const batchApply: BatchPhotoResult[] = [
|
||||
null,
|
||||
{ status: 'error', message: 'empty mask: no slab pixels detected' },
|
||||
{ status: 'ok', inventoryPng: ok },
|
||||
{ status: 'error', message: 'PNG size floor: shorter side is below 800 px' },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply,
|
||||
});
|
||||
|
||||
assert.deepEqual(roles(lineup), [
|
||||
'png:calibration',
|
||||
'png:overlay',
|
||||
'error:1',
|
||||
'png:batch',
|
||||
'error:3',
|
||||
]);
|
||||
|
||||
const err1 = lineup[2];
|
||||
assert.equal(err1.kind, 'error');
|
||||
if (err1.kind === 'error') {
|
||||
assert.match(err1.message, /empty mask/i);
|
||||
assert.equal(err1.batchIndex, 1);
|
||||
}
|
||||
const err3 = lineup[4];
|
||||
assert.equal(err3.kind, 'error');
|
||||
if (err3.kind === 'error') {
|
||||
assert.match(err3.message, /floor|800/i);
|
||||
}
|
||||
|
||||
// Successful sibling still appears after cal+overlay, in capture order among siblings
|
||||
const okItem = lineup[3];
|
||||
assert.equal(okItem.kind, 'png');
|
||||
if (okItem.kind === 'png') {
|
||||
assert.equal(okItem.blob, ok);
|
||||
assert.equal(okItem.batchIndex, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)', () => {
|
||||
const clean = png('clean');
|
||||
const overlay = png('overlay');
|
||||
const thumb = jpeg('source-thumb');
|
||||
// Dishonest proxy would pass source thumbs; helper must only emit image/png blobs for png slots
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply: [
|
||||
null,
|
||||
{ status: 'ok', inventoryPng: png('sib') },
|
||||
],
|
||||
});
|
||||
for (const it of lineup) {
|
||||
if (it.kind === 'png') {
|
||||
assert.equal(it.blob.type, 'image/png', 'lineup PNG slots must be inventory PNGs');
|
||||
assert.notEqual(it.blob, thumb);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('below'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
|
||||
sourceBelowFloor: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
|
||||
});
|
||||
|
||||
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
|
||||
assert.deepEqual(buildPhotosReadyLineup({}), []);
|
||||
assert.deepEqual(
|
||||
buildPhotosReadyLineup({ inventoryPng: null, overlayPng: null, batchApply: undefined }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
// --- light UI / session wiring (#64) ---
|
||||
|
||||
test('test(photos): MeasurePanel shows Photos ready lineup + M1 numbers + batch errors (#64)', () => {
|
||||
const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8');
|
||||
assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate');
|
||||
assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper');
|
||||
assert.match(panel, /Widths/, 'M1 widths still visible');
|
||||
assert.match(panel, /Sqft/, 'M1 sqft still visible');
|
||||
assert.match(panel, /Bdft/, 'M1 bdft still visible');
|
||||
assert.match(panel, /Scale/, 'M1 scale still visible');
|
||||
// Dishonest proxies
|
||||
assert.ok(
|
||||
!/lineup[\s\S]{0,200}state\.source\.blob/.test(panel),
|
||||
'must not lineup source thumbs',
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/error|batchApply|message/i,
|
||||
'per-photo batchApply errors must surface in the panel',
|
||||
);
|
||||
assert.match(panel, /Back/, 'Back must still work from Photos ready');
|
||||
});
|
||||
|
||||
test('test(photos): App still routes measure results through Measure (or PhotosReady) with Back (#64)', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
// Stay on Measure with results, or dedicated panel — either is fine
|
||||
assert.ok(
|
||||
/MeasurePanel/.test(app) || /PhotosReady/.test(app),
|
||||
'App must render Photos-ready end screen',
|
||||
);
|
||||
// Must not invent a Mask loop for the lineup
|
||||
assert.ok(
|
||||
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||
'App must not #each batch into Mask for Photos ready',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { BatchPhotoResult } from './capture.types';
|
||||
|
||||
/** One slot in the Photos-ready end screen (#64 / docs/PHOTOS.md). */
|
||||
export type PhotosReadyLineupItem =
|
||||
| {
|
||||
kind: 'png';
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
blob: Blob;
|
||||
/** Capture-order index for non-calibration batch inventory PNGs. */
|
||||
batchIndex?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
batchIndex: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface PhotosReadyLineupInput {
|
||||
inventoryPng?: Blob | null;
|
||||
overlayPng?: Blob | null;
|
||||
batchApply?: BatchPhotoResult[] | null;
|
||||
/** When true, omit the clean calibration PNG (M1 floor warn path). */
|
||||
sourceBelowFloor?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Photos-ready lineup:
|
||||
* 1. Clean calibration inventory PNG
|
||||
* 2. Dimension-overlay copy
|
||||
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
|
||||
* with error slots for empty mask / floor failures.
|
||||
*/
|
||||
export function buildPhotosReadyLineup(
|
||||
input: PhotosReadyLineupInput,
|
||||
): PhotosReadyLineupItem[] {
|
||||
const items: PhotosReadyLineupItem[] = [];
|
||||
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
|
||||
|
||||
if (inventoryPng && !sourceBelowFloor) {
|
||||
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
|
||||
}
|
||||
if (overlayPng) {
|
||||
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
|
||||
}
|
||||
|
||||
if (batchApply) {
|
||||
for (let i = 0; i < batchApply.length; i++) {
|
||||
const slot = batchApply[i];
|
||||
if (slot === null) continue; // calibration lives in results.inventoryPng
|
||||
if (slot.status === 'ok') {
|
||||
items.push({
|
||||
kind: 'png',
|
||||
role: 'batch',
|
||||
blob: slot.inventoryPng,
|
||||
batchIndex: i,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
kind: 'error',
|
||||
batchIndex: i,
|
||||
message: slot.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string {
|
||||
if (item.role === 'calibration') return 'Calibration inventory PNG';
|
||||
if (item.role === 'overlay') return 'Calibration dimension-overlay PNG';
|
||||
const n = (item.batchIndex ?? 0) + 1;
|
||||
return `Batch inventory PNG ${n}`;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Draft, Source } from './capture.types';
|
||||
import { createCaptureSession, type DraftStore } from './session';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function tinyJpeg(label: string): File {
|
||||
// Minimal valid-ish JPEG bytes are unnecessary; Blob type is what session stores.
|
||||
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]);
|
||||
return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function memStore(): DraftStore & { peek: () => Draft | null; reset: () => void } {
|
||||
let mem: Draft | null = null;
|
||||
return {
|
||||
loadDraft: async () => mem,
|
||||
saveDraft: async (d) => {
|
||||
mem = d;
|
||||
},
|
||||
clearDraft: async () => {
|
||||
mem = null;
|
||||
},
|
||||
peek: () => mem,
|
||||
reset: () => {
|
||||
mem = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let store: ReturnType<typeof memStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = memStore();
|
||||
});
|
||||
|
||||
test('session starts on batch with zero photos (replaces single addPhoto)', () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'batch');
|
||||
if (cap.k === 'batch') assert.equal(cap.photos.length, 0);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('addToBatch accumulates photos and stays on batch', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addToBatch(tinyJpeg('a'));
|
||||
await s.addToBatch(tinyJpeg('b'));
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'batch');
|
||||
if (cap.k === 'batch') {
|
||||
assert.equal(cap.photos.length, 2);
|
||||
assert.ok(cap.photos[0].blob instanceof Blob);
|
||||
assert.ok(cap.photos[1].blob instanceof Blob);
|
||||
assert.notEqual(cap.photos[0].blob, cap.photos[1].blob);
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('continueBatch is a no-op when batch is empty', () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
s.continueBatch();
|
||||
assert.equal(get(s.capture).k, 'batch');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('continueBatch with ≥1 photo advances to pickCalibration (not Mask)', async () => {
|
||||
// #60: Continue must not silently Mask the first photo — user picks calibration next.
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addToBatch(tinyJpeg('a'));
|
||||
await s.addToBatch(tinyJpeg('b'));
|
||||
s.continueBatch();
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'pickCalibration');
|
||||
if (cap.k === 'pickCalibration') {
|
||||
assert.equal(cap.photos.length, 2);
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('adding photos persists batch blobs in device draft', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addToBatch(tinyJpeg('a'));
|
||||
await s.addToBatch(tinyJpeg('b'));
|
||||
// allow async persist
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const d = store.peek();
|
||||
assert.ok(d, 'draft must exist after adding photos');
|
||||
assert.equal(d!.stage, 'batch');
|
||||
const batch = d!.batch ?? [];
|
||||
assert.equal(batch.length, 2, 'draft must store both batch blobs (not a UI-only list)');
|
||||
assert.ok(batch[0].blob instanceof Blob);
|
||||
assert.ok(batch[1].blob instanceof Blob);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('refresh: resumeProbe + Continue restores the batch photos', async () => {
|
||||
const s1 = createCaptureSession({ draft: store });
|
||||
await s1.addToBatch(tinyJpeg('a'));
|
||||
await s1.addToBatch(tinyJpeg('b'));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
s1.dispose();
|
||||
|
||||
const s2 = createCaptureSession({ draft: store });
|
||||
await s2.resumeProbe();
|
||||
assert.equal(get(s2.capture).k, 'resume');
|
||||
s2.continueDraft();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const cap = get(s2.capture);
|
||||
assert.equal(cap.k, 'batch');
|
||||
if (cap.k === 'batch') {
|
||||
assert.equal(cap.photos.length, 2);
|
||||
assert.ok(cap.photos.every((p: Source) => p.blob instanceof Blob));
|
||||
}
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('Batch UI: AddPhoto/Batch panel supports multi-add and Continue when ≥1', () => {
|
||||
// Prefer BatchPhotos.svelte; fall back to AddPhoto.svelte if renamed later.
|
||||
const candidates = ['BatchPhotos.svelte', 'AddPhoto.svelte'].map((n) => join(here, n));
|
||||
let src = '';
|
||||
let found = '';
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
src = readFileSync(p, 'utf8');
|
||||
found = p;
|
||||
break;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
assert.ok(src, 'expected BatchPhotos.svelte or AddPhoto.svelte');
|
||||
assert.match(src, /Continue|continue/i, `${found} must expose Continue`);
|
||||
assert.match(src, /photos|batch/i, `${found} must talk about batch/photos`);
|
||||
// Must allow adding more than once (not a one-shot that leaves the screen).
|
||||
const fileInputs = src.match(/type="file"/g) ?? [];
|
||||
assert.ok(fileInputs.length >= 1, 'must have file picker(s)');
|
||||
assert.ok(
|
||||
/addToBatch|onAdd|onPicked/.test(src) || /on:change/.test(src),
|
||||
'must wire photo add',
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy addPhoto alias still appends to batch (no silent single-photo-only path)', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addPhoto(tinyJpeg('only'));
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'batch', 'addPhoto must not jump straight to mask anymore');
|
||||
if (cap.k === 'batch') assert.equal(cap.photos.length, 1);
|
||||
s.dispose();
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Draft, Mask, MaskParams, Source, WorkerIn, WorkerOut } from './capture.types';
|
||||
import { createCaptureSession, type DraftStore } from './session';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function tinyJpeg(label: string): File {
|
||||
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]);
|
||||
return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function memStore(): DraftStore & { peek: () => Draft | null } {
|
||||
let mem: Draft | null = null;
|
||||
return {
|
||||
loadDraft: async () => mem,
|
||||
saveDraft: async (d) => {
|
||||
mem = d;
|
||||
},
|
||||
clearDraft: async () => {
|
||||
mem = null;
|
||||
},
|
||||
peek: () => mem,
|
||||
};
|
||||
}
|
||||
|
||||
let store: ReturnType<typeof memStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = memStore();
|
||||
});
|
||||
|
||||
/** Controllable FakeWorker for #62 batch-apply paths. */
|
||||
type MaskBehavior = {
|
||||
w: number;
|
||||
h: number;
|
||||
/** slabPx; 0 ⇒ empty mask */
|
||||
slabPx: number;
|
||||
slabBounds?: { x: number; y: number; w: number; h: number };
|
||||
};
|
||||
|
||||
class FakeMaskWorker {
|
||||
onmessage: ((ev: MessageEvent<WorkerOut>) => void) | null = null;
|
||||
static inits: Blob[] = [];
|
||||
static maskCalls: { sheet: string; sensitivity: number; blob: Blob | null }[] = [];
|
||||
static lastInitBlob: Blob | null = null;
|
||||
/** Per-init blob → behavior. Default: full mask at LARGE dims (above PNG floor). */
|
||||
static behaviorFor: (blob: Blob) => MaskBehavior = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
|
||||
constructor(_url: URL | string, _opts?: WorkerOptions) {}
|
||||
|
||||
postMessage(msg: WorkerIn) {
|
||||
if (msg.kind === 'init') {
|
||||
FakeMaskWorker.inits.push(msg.blob);
|
||||
FakeMaskWorker.lastInitBlob = msg.blob;
|
||||
const id = msg.id;
|
||||
const b = FakeMaskWorker.behaviorFor(msg.blob);
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: { id, kind: 'decoded', w: b.w, h: b.h } } as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'mask') {
|
||||
const id = msg.id;
|
||||
const blob = FakeMaskWorker.lastInitBlob;
|
||||
FakeMaskWorker.maskCalls.push({
|
||||
sheet: msg.sheet,
|
||||
sensitivity: msg.sensitivity,
|
||||
blob,
|
||||
});
|
||||
const b = blob ? FakeMaskWorker.behaviorFor(blob) : { w: 40, h: 20, slabPx: 0 };
|
||||
const bitset = new Uint8Array(Math.max(1, b.w * b.h)).fill(b.slabPx > 0 ? 1 : 0);
|
||||
const bbox = {
|
||||
corners: [
|
||||
[b.w, 0],
|
||||
[b.w, b.h],
|
||||
[0, b.h],
|
||||
[0, 0],
|
||||
] as [number, number][],
|
||||
longSidePx: b.w,
|
||||
angleDeg: 0,
|
||||
};
|
||||
const slabBounds = b.slabBounds ?? { x: 0, y: 0, w: b.w, h: b.h };
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
id,
|
||||
kind: 'ready',
|
||||
slabPx: b.slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds,
|
||||
},
|
||||
} as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
const RealWorker = globalThis.Worker;
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
function installFakes() {
|
||||
FakeMaskWorker.inits = [];
|
||||
FakeMaskWorker.maskCalls = [];
|
||||
FakeMaskWorker.lastInitBlob = null;
|
||||
FakeMaskWorker.behaviorFor = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
(globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (blob: ImageBitmapSource) => {
|
||||
const b = blob instanceof Blob ? FakeMaskWorker.behaviorFor(blob) : { w: 1000, h: 900 };
|
||||
return {
|
||||
width: b.w,
|
||||
height: b.h,
|
||||
close() {},
|
||||
} as ImageBitmap;
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobals() {
|
||||
if (RealWorker === undefined) {
|
||||
delete (globalThis as unknown as { Worker?: unknown }).Worker;
|
||||
} else {
|
||||
globalThis.Worker = RealWorker;
|
||||
}
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobals();
|
||||
});
|
||||
|
||||
async function settleOverlay(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
ms = 800,
|
||||
): Promise<Mask> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < ms) {
|
||||
const cap = get(s.capture);
|
||||
if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error('mask overlay never settled');
|
||||
}
|
||||
|
||||
async function driveToMeasure(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
photos: File[],
|
||||
calIndex: number,
|
||||
maskParams: Partial<MaskParams> = { sheet: 'green', sensitivity: 50 },
|
||||
) {
|
||||
for (const p of photos) await s.addToBatch(p);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(calIndex);
|
||||
if (maskParams.sheet) s.setSheet(maskParams.sheet);
|
||||
if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity);
|
||||
// setSheet already runs a pass; if only sensitivity, force via setSheet after
|
||||
if (!maskParams.sheet) s.setSheet('auto');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.approveAxis();
|
||||
s.setLength(320); // 40 in
|
||||
s.setThickness(16); // 2 in
|
||||
}
|
||||
|
||||
type BatchApplySlot =
|
||||
| null
|
||||
| { status: 'ok'; inventoryPng: Blob }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
function batchApplyOf(cap: ReturnType<typeof get<ReturnType<typeof createCaptureSession>['capture']>>): BatchApplySlot[] | undefined {
|
||||
if (cap.k !== 'measure') return undefined;
|
||||
return (cap as { batchApply?: BatchApplySlot[] }).batchApply;
|
||||
}
|
||||
|
||||
// --- #62: apply approved mask + crop to non-calibration batch photos after Calculate ---
|
||||
|
||||
test('#62: Calculate applies frozen mask params to every non-calibration batch photo', async () => {
|
||||
installFakes();
|
||||
const encodeCalls: { widthPx: number; heightPx: number; sheet: string; sensitivity: number }[] = [];
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
encodeCalls.push({
|
||||
widthPx: input.source.widthPx,
|
||||
heightPx: input.source.heightPx,
|
||||
sheet: (input.mask as Mask).params?.sheet ?? '?',
|
||||
sensitivity: (input.mask as Mask).params?.sensitivity ?? -1,
|
||||
});
|
||||
// Distinctive PNG blob so tests cannot confuse with source JPEG thumbs
|
||||
return {
|
||||
blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b'); // calibration
|
||||
const c = tinyJpeg('c');
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'black', sensitivity: 72 });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure', 'must stay on Measure — do not reopen Mask');
|
||||
const apply = batchApplyOf(cap);
|
||||
assert.ok(Array.isArray(apply), 'measure.batchApply must exist after Calculate');
|
||||
assert.equal(apply!.length, 3, 'one slot per batch photo');
|
||||
assert.equal(apply![1], null, 'calibration index is null (uses results.inventoryPng)');
|
||||
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'batch[0] applied ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'batch[2] applied ok');
|
||||
if (apply![0]?.status === 'ok') {
|
||||
assert.equal(apply![0].inventoryPng.type, 'image/png');
|
||||
assert.notEqual(apply![0].inventoryPng, a, 'must not proxy source thumb as inventory PNG');
|
||||
}
|
||||
if (apply![2]?.status === 'ok') {
|
||||
assert.equal(apply![2].inventoryPng.type, 'image/png');
|
||||
assert.notEqual(apply![2].inventoryPng, c);
|
||||
}
|
||||
|
||||
// Frozen params: every sibling mask call used calibration sheet/sensitivity
|
||||
const siblingBlobs = new Set([a, c]);
|
||||
const siblingMasks = FakeMaskWorker.maskCalls.filter((m) => m.blob && siblingBlobs.has(m.blob as File));
|
||||
assert.ok(siblingMasks.length >= 2, 'mask worker must run on non-calibration photos');
|
||||
for (const m of siblingMasks) {
|
||||
assert.equal(m.sheet, 'black', 'reuse approved sheet');
|
||||
assert.equal(m.sensitivity, 72, 'reuse approved sensitivity');
|
||||
}
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: empty mask on one sibling → per-photo error; others still proceed', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // will empty-mask
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // ok
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === a) {
|
||||
return { w: 1000, h: 900, slabPx: 0, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['ok-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.equal(apply![1], null);
|
||||
assert.ok(apply![0] && apply![0].status === 'error', 'empty mask → error');
|
||||
if (apply![0]?.status === 'error') {
|
||||
assert.match(apply![0].message, /empty|mask/i, 'clear per-photo empty-mask error');
|
||||
}
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'other photos still proceed');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // ok
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // below floor
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === c) {
|
||||
return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
|
||||
return {
|
||||
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowFloor: below,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error');
|
||||
if (apply![2]?.status === 'error') {
|
||||
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are visual)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await driveToMeasure(s, [a, b], 1, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure');
|
||||
if (cap.k === 'measure') {
|
||||
assert.ok(cap.results);
|
||||
assert.equal(typeof cap.results!.scale, 'number');
|
||||
assert.ok(cap.results!.scale > 0);
|
||||
assert.equal(typeof cap.results!.sqft, 'number');
|
||||
assert.equal(typeof cap.results!.bdft, 'number');
|
||||
assert.ok(cap.results!.widths);
|
||||
// batchApply ok slots must NOT carry measure fields
|
||||
const apply = (cap as { batchApply?: BatchApplySlot[] }).batchApply;
|
||||
assert.ok(apply);
|
||||
const slot = apply![0];
|
||||
assert.ok(slot && slot.status === 'ok');
|
||||
if (slot && slot.status === 'ok') {
|
||||
assert.equal(
|
||||
'scale' in slot,
|
||||
false,
|
||||
'non-cal apply must not attach scale',
|
||||
);
|
||||
assert.equal('sqft' in slot, false);
|
||||
assert.equal('bdft' in slot, false);
|
||||
assert.equal('widths' in slot, false);
|
||||
}
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: draft persists batchApply after Calculate (for #64 UI later)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
const c = tinyJpeg('c');
|
||||
await driveToMeasure(s, [a, b, c], 0, { sheet: 'black' }); // cal = first
|
||||
await s.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const d = store.peek();
|
||||
assert.ok(d);
|
||||
assert.equal(d!.stage, 'measure');
|
||||
assert.ok(Array.isArray(d!.batchApply), 'draft.batchApply must be persisted');
|
||||
assert.equal(d!.batchApply!.length, 3);
|
||||
assert.equal(d!.batchApply![0], null, 'calibration slot null on draft');
|
||||
assert.ok(d!.batchApply![1] && (d!.batchApply![1] as { status: string }).status === 'ok');
|
||||
assert.ok(d!.batchApply![2] && (d!.batchApply![2] as { status: string }).status === 'ok');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mask)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const only = tinyJpeg('only');
|
||||
await driveToMeasure(s, [only], 0, { sheet: 'auto' });
|
||||
const initsBefore = FakeMaskWorker.inits.length;
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.deepEqual(apply, [null]);
|
||||
|
||||
// No extra mask inits for non-existent siblings after Calculate
|
||||
const siblingInits = FakeMaskWorker.inits.slice(initsBefore).filter((blob) => blob !== only);
|
||||
assert.equal(siblingInits.length, 0, 'must not invent Mask work for absent siblings');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: Calculate does not re-enter Mask / does not open Mask controls for siblings', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['x'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
assert.equal(get(s.capture).k, 'measure');
|
||||
|
||||
// Session source must never loop enterMaskOnSource over batch after Calculate
|
||||
const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8');
|
||||
assert.ok(
|
||||
/batchApply|applyBatch|apply.*non-?calibrat/i.test(sessionSrc),
|
||||
'session must implement batch apply after Calculate (#62)',
|
||||
);
|
||||
// Dishonest proxy: must not set k:'mask' inside calculate for each photo
|
||||
const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/);
|
||||
assert.ok(calcBody, 'calculate() must exist');
|
||||
assert.ok(
|
||||
!/set\(\s*\{\s*k:\s*'mask'/.test(calcBody![0]),
|
||||
'calculate must not reopen Mask state for siblings',
|
||||
);
|
||||
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.ok(
|
||||
!/\{\s*#each\s+[^}]*batchApply[^}]*\}[\s\S]*MaskPanel/i.test(app) &&
|
||||
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||
'App must not #each photos into MaskPanel',
|
||||
);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: resume restores batchApply alongside calibration results', async () => {
|
||||
installFakes();
|
||||
const s1 = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['resume-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await driveToMeasure(s1, [a, b], 1, { sheet: 'green' });
|
||||
await s1.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
s1.dispose();
|
||||
|
||||
FakeMaskWorker.inits = [];
|
||||
const s2 = createCaptureSession({ draft: store } as Parameters<typeof createCaptureSession>[0]);
|
||||
await s2.resumeProbe();
|
||||
s2.continueDraft();
|
||||
const start = Date.now();
|
||||
let cap = get(s2.capture);
|
||||
while (cap.k !== 'measure' && Date.now() - start < 1500) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
cap = get(s2.capture);
|
||||
}
|
||||
assert.equal(cap.k, 'measure');
|
||||
const apply = batchApplyOf(cap);
|
||||
assert.ok(apply, 'restored measure must include batchApply');
|
||||
assert.equal(apply!.length, 2);
|
||||
assert.equal(apply![1], null);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok');
|
||||
if (apply![0]?.status === 'ok') {
|
||||
assert.equal(apply![0].inventoryPng.type, 'image/png');
|
||||
}
|
||||
s2.dispose();
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Draft, Mask, Source, WorkerIn, WorkerOut } from './capture.types';
|
||||
import { createCaptureSession, type DraftStore } from './session';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function tinyJpeg(label: string): File {
|
||||
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]);
|
||||
return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function memStore(): DraftStore & { peek: () => Draft | null; reset: () => void } {
|
||||
let mem: Draft | null = null;
|
||||
return {
|
||||
loadDraft: async () => mem,
|
||||
saveDraft: async (d) => {
|
||||
mem = d;
|
||||
},
|
||||
clearDraft: async () => {
|
||||
mem = null;
|
||||
},
|
||||
peek: () => mem,
|
||||
reset: () => {
|
||||
mem = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let store: ReturnType<typeof memStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = memStore();
|
||||
});
|
||||
|
||||
test('continueBatch with ≥1 photo goes to pickCalibration (not mask — no first-photo proxy)', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addToBatch(tinyJpeg('a'));
|
||||
await s.addToBatch(tinyJpeg('b'));
|
||||
s.continueBatch();
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'pickCalibration', 'Continue must open pick-calibration, not skip to Mask');
|
||||
if (cap.k === 'pickCalibration') {
|
||||
assert.equal(cap.photos.length, 2);
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('single-photo batch: Continue still opens pickCalibration so user confirms the only photo', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addToBatch(tinyJpeg('only'));
|
||||
s.continueBatch();
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'pickCalibration');
|
||||
if (cap.k === 'pickCalibration') {
|
||||
assert.equal(cap.photos.length, 1);
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('pickCalibration(index) enters Mask on that Source only (not always first)', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
const c = tinyJpeg('c');
|
||||
await s.addToBatch(a);
|
||||
await s.addToBatch(b);
|
||||
await s.addToBatch(c);
|
||||
s.continueBatch();
|
||||
assert.equal(get(s.capture).k, 'pickCalibration');
|
||||
|
||||
s.pickCalibration(1);
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'mask');
|
||||
if (cap.k === 'mask') {
|
||||
// Chosen photo must be the second batch blob, not photos[0].
|
||||
assert.equal(cap.source.blob, b, 'Mask source must be the user-picked photo');
|
||||
assert.notEqual(cap.source.blob, a, 'must not silently use first photo');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('single-photo pick enters Mask on that photo', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
const only = tinyJpeg('only');
|
||||
await s.addToBatch(only);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(0);
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'mask');
|
||||
if (cap.k === 'mask') {
|
||||
assert.equal(cap.source.blob, only);
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('pickCalibration out of range is a no-op', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addToBatch(tinyJpeg('a'));
|
||||
s.continueBatch();
|
||||
s.pickCalibration(-1);
|
||||
assert.equal(get(s.capture).k, 'pickCalibration');
|
||||
s.pickCalibration(99);
|
||||
assert.equal(get(s.capture).k, 'pickCalibration');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('draft persists pickCalibration stage with batch photos', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
await s.addToBatch(tinyJpeg('a'));
|
||||
await s.addToBatch(tinyJpeg('b'));
|
||||
s.continueBatch();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const d = store.peek();
|
||||
assert.ok(d, 'draft must exist on pickCalibration');
|
||||
assert.equal(d!.stage, 'pickCalibration');
|
||||
assert.equal((d!.batch ?? []).length, 2);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('draft remembers calibration choice after pick', async () => {
|
||||
const s = createCaptureSession({ draft: store });
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await s.addToBatch(a);
|
||||
await s.addToBatch(b);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(1);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const d = store.peek();
|
||||
assert.ok(d);
|
||||
assert.equal(d!.stage, 'mask');
|
||||
assert.equal(d!.calibrationIndex, 1, 'draft must store which batch photo is calibration');
|
||||
assert.equal(d!.source.blob, b, 'draft.source must be the chosen calibration photo');
|
||||
assert.equal((d!.batch ?? []).length, 2, 'full batch still stored');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('refresh: resumeProbe + Continue restores pickCalibration with batch', async () => {
|
||||
const s1 = createCaptureSession({ draft: store });
|
||||
await s1.addToBatch(tinyJpeg('a'));
|
||||
await s1.addToBatch(tinyJpeg('b'));
|
||||
s1.continueBatch();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
s1.dispose();
|
||||
|
||||
const s2 = createCaptureSession({ draft: store });
|
||||
await s2.resumeProbe();
|
||||
assert.equal(get(s2.capture).k, 'resume');
|
||||
s2.continueDraft();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const cap = get(s2.capture);
|
||||
assert.equal(cap.k, 'pickCalibration');
|
||||
if (cap.k === 'pickCalibration') {
|
||||
assert.equal(cap.photos.length, 2);
|
||||
assert.ok(cap.photos.every((p: Source) => p.blob instanceof Blob));
|
||||
}
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('refresh: resume after pick restores Mask on the chosen calibration photo', async () => {
|
||||
const s1 = createCaptureSession({ draft: store });
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await s1.addToBatch(a);
|
||||
await s1.addToBatch(b);
|
||||
s1.continueBatch();
|
||||
s1.pickCalibration(1);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
s1.dispose();
|
||||
|
||||
const s2 = createCaptureSession({ draft: store });
|
||||
await s2.resumeProbe();
|
||||
s2.continueDraft();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
const cap = get(s2.capture);
|
||||
assert.equal(cap.k, 'mask');
|
||||
if (cap.k === 'mask') {
|
||||
// Blob identity may round-trip as a new Blob with same bytes; compare size/type content.
|
||||
assert.ok(cap.source.blob instanceof Blob);
|
||||
const chosen = new Uint8Array(await b.arrayBuffer());
|
||||
const got = new Uint8Array(await cap.source.blob.arrayBuffer());
|
||||
assert.deepEqual(got, chosen, 'restored Mask source must be the picked calibration photo');
|
||||
}
|
||||
// calibrationIndex still on draft
|
||||
const d = store.peek();
|
||||
assert.equal(d?.calibrationIndex, 1);
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('PickCalibration UI: thumbs over batch + pick action (not auto-first)', () => {
|
||||
const path = join(here, 'PickCalibration.svelte');
|
||||
let src = '';
|
||||
try {
|
||||
src = readFileSync(path, 'utf8');
|
||||
} catch {
|
||||
/* missing = fail */
|
||||
}
|
||||
assert.ok(src, 'expected PickCalibration.svelte');
|
||||
assert.match(src, /calibrat/i, 'UI must mention calibration');
|
||||
assert.match(src, /photos|thumb/i, 'UI must show batch thumbs');
|
||||
assert.ok(
|
||||
/onPick|pickCalibration|on:click/.test(src),
|
||||
'UI must wire a per-photo pick action',
|
||||
);
|
||||
// Must not hardcode always-first without user interaction.
|
||||
assert.ok(
|
||||
!/photos\[0\].*mask|always.*first/i.test(src) || /onPick|on:click/.test(src),
|
||||
'must require user pick',
|
||||
);
|
||||
});
|
||||
|
||||
test('App wires pickCalibration state to PickCalibration panel', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.match(app, /PickCalibration/, 'App must import/render PickCalibration');
|
||||
assert.match(app, /pickCalibration/, 'App must branch on pickCalibration state');
|
||||
});
|
||||
|
||||
// --- #61 M2-4: wire M1 mask/axis/measure onto the calibration pick ---
|
||||
|
||||
const MASK_W = 40;
|
||||
const MASK_H = 20;
|
||||
|
||||
/** Fake Worker so Node can drive Mask → Axis → Measure without canvas decode. */
|
||||
class FakeMaskWorker {
|
||||
onmessage: ((ev: MessageEvent<WorkerOut>) => void) | null = null;
|
||||
static inits: Blob[] = [];
|
||||
|
||||
constructor(_url: URL | string, _opts?: WorkerOptions) {}
|
||||
|
||||
postMessage(msg: WorkerIn) {
|
||||
if (msg.kind === 'init') {
|
||||
FakeMaskWorker.inits.push(msg.blob);
|
||||
const id = msg.id;
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: { id, kind: 'decoded', w: MASK_W, h: MASK_H } } as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'mask') {
|
||||
const id = msg.id;
|
||||
const bitset = new Uint8Array(MASK_W * MASK_H).fill(1);
|
||||
const bbox = {
|
||||
corners: [
|
||||
[MASK_W, 0],
|
||||
[MASK_W, MASK_H],
|
||||
[0, MASK_H],
|
||||
[0, 0],
|
||||
] as [number, number][],
|
||||
longSidePx: MASK_W,
|
||||
angleDeg: 0,
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
id,
|
||||
kind: 'ready',
|
||||
slabPx: MASK_W * MASK_H,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds: { x: 0, y: 0, w: MASK_W, h: MASK_H },
|
||||
},
|
||||
} as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// invalidate: no-op
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
const RealWorker = globalThis.Worker;
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
function installFakeMaskWorker() {
|
||||
FakeMaskWorker.inits = [];
|
||||
(globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker;
|
||||
}
|
||||
|
||||
function restoreWorkerGlobals() {
|
||||
if (RealWorker === undefined) {
|
||||
delete (globalThis as unknown as { Worker?: unknown }).Worker;
|
||||
} else {
|
||||
globalThis.Worker = RealWorker;
|
||||
}
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
}
|
||||
|
||||
async function settleOverlay(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
ms = 800,
|
||||
): Promise<Mask> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < ms) {
|
||||
const cap = get(s.capture);
|
||||
if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error('mask overlay never settled');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreWorkerGlobals();
|
||||
});
|
||||
|
||||
test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob only', async () => {
|
||||
installFakeMaskWorker();
|
||||
const s = createCaptureSession({ draft: store });
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
const c = tinyJpeg('c');
|
||||
await s.addToBatch(a);
|
||||
await s.addToBatch(b);
|
||||
await s.addToBatch(c);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(1);
|
||||
|
||||
let cap = get(s.capture);
|
||||
assert.equal(cap.k, 'mask');
|
||||
if (cap.k === 'mask') {
|
||||
assert.equal(cap.source.blob, b, 'Mask must open on picked photo index 1');
|
||||
assert.notEqual(cap.source.blob, a);
|
||||
}
|
||||
|
||||
// Kick an immediate mask pass (skip 2s sensitivity debounce).
|
||||
s.setSheet('green');
|
||||
await settleOverlay(s);
|
||||
|
||||
// Worker must have been inited only for the calibration blob — not a, not c.
|
||||
assert.ok(FakeMaskWorker.inits.length >= 1, 'mask worker must init');
|
||||
for (const blob of FakeMaskWorker.inits) {
|
||||
assert.equal(blob, b, 'Mask worker must never init on non-calibration batch photos');
|
||||
}
|
||||
|
||||
s.approveMask();
|
||||
cap = get(s.capture);
|
||||
assert.equal(cap.k, 'axis', 'approve Mask → Length axis');
|
||||
if (cap.k === 'axis') {
|
||||
assert.equal(cap.source.blob, b, 'Axis must keep the calibration source');
|
||||
}
|
||||
|
||||
s.approveAxis();
|
||||
cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure', 'approve Axis → Measure');
|
||||
if (cap.k === 'measure') {
|
||||
assert.equal(cap.source.blob, b, 'Measure must keep the calibration source');
|
||||
}
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#61: Calculate produces inventory PNG + M1 measures for calibration source only', async () => {
|
||||
installFakeMaskWorker();
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async () =>
|
||||
({
|
||||
width: MASK_W,
|
||||
height: MASK_H,
|
||||
close() {},
|
||||
}) as ImageBitmap;
|
||||
|
||||
const s = createCaptureSession({ draft: store });
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await s.addToBatch(a);
|
||||
await s.addToBatch(b);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(1);
|
||||
s.setSheet('black');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.approveAxis();
|
||||
|
||||
// L = 40 in (320 eighths), thickness = 2 in (16 eighths) — scale ≈ longest_axis_px / L
|
||||
s.setLength(320);
|
||||
s.setThickness(16);
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure');
|
||||
if (cap.k === 'measure') {
|
||||
assert.equal(cap.source.blob, b);
|
||||
assert.ok(cap.results, 'Calculate must produce results');
|
||||
const r = cap.results!;
|
||||
assert.ok(r.inventoryPng instanceof Blob, 'clean inventory PNG blob');
|
||||
assert.equal(typeof r.scale, 'number');
|
||||
assert.ok(r.scale > 0, 'scale px/in > 0');
|
||||
assert.equal(typeof r.sqft, 'number');
|
||||
assert.equal(typeof r.bdft, 'number');
|
||||
// CAPTURE.md: bdft = sqft × thickness_in
|
||||
const thicknessIn = 16 / 8;
|
||||
assert.ok(
|
||||
Math.abs(r.bdft - r.sqft * thicknessIn) < 0.011,
|
||||
`bdft ${r.bdft} must equal sqft×thickness (${r.sqft}*${thicknessIn})`,
|
||||
);
|
||||
assert.ok(r.widths, 'widths present');
|
||||
assert.equal(r.lengthEighths, 320);
|
||||
assert.equal(r.thicknessEighths, 16);
|
||||
assert.equal(cap.source.widthPx, MASK_W, 'measure source dims from calibration decode');
|
||||
}
|
||||
|
||||
// Pre-#62: Mask UI path used only the calibration blob. After Calculate, #62
|
||||
// may init the worker on non-calibration siblings for frozen-mask apply.
|
||||
const preApplyInits = FakeMaskWorker.inits.filter((blob) => blob === b || blob === a);
|
||||
assert.ok(preApplyInits.some((blob) => blob === b), 'calibration must have been masked');
|
||||
// Sibling apply may touch `a`; that is #62, not a #61 regression of Mask UI.
|
||||
assert.ok(
|
||||
FakeMaskWorker.inits.filter((blob) => blob === b).length >= 1,
|
||||
'calibration blob must appear in worker inits',
|
||||
);
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#61: other batch photos stay in draft.batch untouched by mask approve', async () => {
|
||||
installFakeMaskWorker();
|
||||
const s = createCaptureSession({ draft: store });
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
const c = tinyJpeg('c');
|
||||
await s.addToBatch(a);
|
||||
await s.addToBatch(b);
|
||||
await s.addToBatch(c);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(1);
|
||||
s.setSheet('auto');
|
||||
await settleOverlay(s);
|
||||
|
||||
const before = store.peek();
|
||||
assert.ok(before);
|
||||
assert.equal((before!.batch ?? []).length, 3);
|
||||
assert.equal(before!.batch![0].blob, a);
|
||||
assert.equal(before!.batch![1].blob, b);
|
||||
assert.equal(before!.batch![2].blob, c);
|
||||
|
||||
s.approveMask();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const after = store.peek();
|
||||
assert.ok(after);
|
||||
assert.equal(after!.stage, 'axis');
|
||||
assert.equal(after!.calibrationIndex, 1);
|
||||
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
|
||||
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
|
||||
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
|
||||
assert.equal(after!.batch![1].blob, b);
|
||||
assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots');
|
||||
|
||||
// Approve axis too — batch still untouched.
|
||||
s.approveAxis();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const meas = store.peek();
|
||||
assert.equal(meas!.stage, 'measure');
|
||||
assert.equal((meas!.batch ?? []).length, 3);
|
||||
assert.equal(meas!.batch![0].blob, a);
|
||||
assert.equal(meas!.batch![2].blob, c);
|
||||
assert.equal(meas!.calibrationIndex, 1);
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#61: draft resume through measure restores calibration source (not photos[0])', async () => {
|
||||
installFakeMaskWorker();
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async () =>
|
||||
({
|
||||
width: MASK_W,
|
||||
height: MASK_H,
|
||||
close() {},
|
||||
}) as ImageBitmap;
|
||||
|
||||
const s1 = createCaptureSession({ draft: store });
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b');
|
||||
await s1.addToBatch(a);
|
||||
await s1.addToBatch(b);
|
||||
s1.continueBatch();
|
||||
s1.pickCalibration(1);
|
||||
s1.setSheet('green');
|
||||
await settleOverlay(s1);
|
||||
s1.approveMask();
|
||||
s1.approveAxis();
|
||||
s1.setLength(320);
|
||||
s1.setThickness(16);
|
||||
await s1.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
s1.dispose();
|
||||
|
||||
FakeMaskWorker.inits = [];
|
||||
const s2 = createCaptureSession({ draft: store });
|
||||
await s2.resumeProbe();
|
||||
assert.equal(get(s2.capture).k, 'resume');
|
||||
s2.continueDraft();
|
||||
// rederiveMask via FakeWorker
|
||||
const start = Date.now();
|
||||
let cap = get(s2.capture);
|
||||
while (cap.k !== 'measure' && Date.now() - start < 1500) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
cap = get(s2.capture);
|
||||
}
|
||||
assert.equal(cap.k, 'measure', 'Continue must restore Measure');
|
||||
if (cap.k === 'measure') {
|
||||
assert.equal(cap.source.blob, b, 'restored Measure source is calibration pick');
|
||||
assert.ok(cap.results, 'results restored');
|
||||
}
|
||||
assert.equal(store.peek()?.calibrationIndex, 1);
|
||||
// Resume rederive may re-init worker on calibration source only.
|
||||
for (const blob of FakeMaskWorker.inits) {
|
||||
assert.equal(blob, b, 'resume must not mask non-calibration batch photos');
|
||||
}
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.match(app, /MaskPanel/);
|
||||
assert.match(app, /AxisPanel/);
|
||||
assert.match(app, /MeasurePanel/);
|
||||
// Dishonest proxy: must not iterate batch photos into Mask UI.
|
||||
assert.ok(
|
||||
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||
'App must not #each batch photos into MaskPanel',
|
||||
);
|
||||
const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8');
|
||||
assert.ok(
|
||||
!/for\s*\([^)]*batch[^)]*\)[\s\S]{0,120}(enterMaskOnSource|approveMask|k:\s*'mask')/.test(
|
||||
sessionSrc,
|
||||
),
|
||||
'session must not loop Mask over every batch photo in this issue',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Draft, Mask, MaskParams, WorkerIn, WorkerOut } from './capture.types';
|
||||
import { createCaptureSession, type DraftStore } from './session';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function tinyJpeg(label: string): File {
|
||||
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]);
|
||||
return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function memStore(): DraftStore & { peek: () => Draft | null } {
|
||||
let mem: Draft | null = null;
|
||||
return {
|
||||
loadDraft: async () => mem,
|
||||
saveDraft: async (d) => {
|
||||
mem = d;
|
||||
},
|
||||
clearDraft: async () => {
|
||||
mem = null;
|
||||
},
|
||||
peek: () => mem,
|
||||
};
|
||||
}
|
||||
|
||||
let store: ReturnType<typeof memStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = memStore();
|
||||
});
|
||||
|
||||
type MaskBehavior = {
|
||||
w: number;
|
||||
h: number;
|
||||
slabPx: number;
|
||||
slabBounds?: { x: number; y: number; w: number; h: number };
|
||||
};
|
||||
|
||||
class FakeMaskWorker {
|
||||
onmessage: ((ev: MessageEvent<WorkerOut>) => void) | null = null;
|
||||
static inits: Blob[] = [];
|
||||
static maskCalls: { sheet: string; sensitivity: number; blob: Blob | null }[] = [];
|
||||
static lastInitBlob: Blob | null = null;
|
||||
static behaviorFor: (blob: Blob) => MaskBehavior = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
|
||||
constructor(_url: URL | string, _opts?: WorkerOptions) {}
|
||||
|
||||
postMessage(msg: WorkerIn) {
|
||||
if (msg.kind === 'init') {
|
||||
FakeMaskWorker.inits.push(msg.blob);
|
||||
FakeMaskWorker.lastInitBlob = msg.blob;
|
||||
const id = msg.id;
|
||||
const b = FakeMaskWorker.behaviorFor(msg.blob);
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: { id, kind: 'decoded', w: b.w, h: b.h } } as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'mask') {
|
||||
const id = msg.id;
|
||||
const blob = FakeMaskWorker.lastInitBlob;
|
||||
FakeMaskWorker.maskCalls.push({
|
||||
sheet: msg.sheet,
|
||||
sensitivity: msg.sensitivity,
|
||||
blob,
|
||||
});
|
||||
const b = blob ? FakeMaskWorker.behaviorFor(blob) : { w: 40, h: 20, slabPx: 0 };
|
||||
const bitset = new Uint8Array(Math.max(1, b.w * b.h)).fill(b.slabPx > 0 ? 1 : 0);
|
||||
const bbox = {
|
||||
corners: [
|
||||
[b.w, 0],
|
||||
[b.w, b.h],
|
||||
[0, b.h],
|
||||
[0, 0],
|
||||
] as [number, number][],
|
||||
longSidePx: b.w,
|
||||
angleDeg: 0,
|
||||
};
|
||||
const slabBounds = b.slabBounds ?? { x: 0, y: 0, w: b.w, h: b.h };
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
id,
|
||||
kind: 'ready',
|
||||
slabPx: b.slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds,
|
||||
},
|
||||
} as MessageEvent<WorkerOut>);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
const RealWorker = globalThis.Worker;
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
function installFakes() {
|
||||
FakeMaskWorker.inits = [];
|
||||
FakeMaskWorker.maskCalls = [];
|
||||
FakeMaskWorker.lastInitBlob = null;
|
||||
FakeMaskWorker.behaviorFor = () => ({
|
||||
w: 1000,
|
||||
h: 900,
|
||||
slabPx: 1000 * 900,
|
||||
slabBounds: { x: 0, y: 0, w: 1000, h: 900 },
|
||||
});
|
||||
(globalThis as unknown as { Worker: typeof FakeMaskWorker }).Worker = FakeMaskWorker;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (blob: ImageBitmapSource) => {
|
||||
const b = blob instanceof Blob ? FakeMaskWorker.behaviorFor(blob) : { w: 1000, h: 900 };
|
||||
return { width: b.w, height: b.h, close() {} } as ImageBitmap;
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobals() {
|
||||
if (RealWorker === undefined) {
|
||||
delete (globalThis as unknown as { Worker?: unknown }).Worker;
|
||||
} else {
|
||||
globalThis.Worker = RealWorker;
|
||||
}
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobals();
|
||||
});
|
||||
|
||||
async function settleOverlay(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
ms = 2500,
|
||||
): Promise<Mask> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < ms) {
|
||||
const cap = get(s.capture);
|
||||
if (cap.k === 'mask' && cap.overlay && cap.overlay.slabPx > 0) return cap.overlay;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error('mask overlay never settled');
|
||||
}
|
||||
|
||||
async function driveToMeasure(
|
||||
s: ReturnType<typeof createCaptureSession>,
|
||||
photos: File[],
|
||||
calIndex: number,
|
||||
maskParams: Partial<MaskParams> = { sheet: 'green', sensitivity: 50 },
|
||||
) {
|
||||
for (const p of photos) await s.addToBatch(p);
|
||||
s.continueBatch();
|
||||
s.pickCalibration(calIndex);
|
||||
if (typeof maskParams.sensitivity === 'number') s.setSensitivity(maskParams.sensitivity);
|
||||
// setSheet runs an immediate pass (after sensitivity so the pass sees frozen params)
|
||||
s.setSheet(maskParams.sheet ?? 'auto');
|
||||
await settleOverlay(s);
|
||||
s.approveMask();
|
||||
s.approveAxis();
|
||||
s.setLength(320); // 40 in
|
||||
s.setThickness(16); // 2 in
|
||||
}
|
||||
|
||||
test('test(photos): Calculate stores overlayPng on results, clean inventory untouched (#63)', async () => {
|
||||
installFakes();
|
||||
let encodeN = 0;
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => {
|
||||
encodeN++;
|
||||
return {
|
||||
blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
const a = tinyJpeg('a');
|
||||
const b = tinyJpeg('b'); // cal
|
||||
await driveToMeasure(s, [a, b], 1, { sheet: 'green' });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure');
|
||||
if (cap.k !== 'measure') return;
|
||||
assert.ok(cap.results, 'results present');
|
||||
assert.ok(cap.results!.inventoryPng, 'clean cal PNG in results.inventoryPng');
|
||||
assert.equal(cap.results!.inventoryPng.type, 'image/png');
|
||||
|
||||
const overlay = (cap.results as { overlayPng?: Blob }).overlayPng;
|
||||
assert.ok(overlay, 'results.overlayPng must exist after Calculate (#63)');
|
||||
assert.equal(overlay!.type, 'image/png');
|
||||
assert.notEqual(overlay, cap.results!.inventoryPng, 'overlay is a second copy, not the clean PNG');
|
||||
|
||||
const apply = (cap as { batchApply?: unknown[] }).batchApply;
|
||||
assert.ok(Array.isArray(apply));
|
||||
assert.equal(apply![1], null, 'batchApply[cal] stays null');
|
||||
assert.ok(apply![0] && (apply![0] as { status: string }).status === 'ok', 'siblings unchanged');
|
||||
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('test(photos): single-photo batch still yields clean + overlayPng (#63)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [tinyJpeg('only')], 0, { sheet: 'auto' });
|
||||
await s.calculate();
|
||||
|
||||
const cap = get(s.capture);
|
||||
assert.equal(cap.k, 'measure');
|
||||
if (cap.k !== 'measure') return;
|
||||
assert.ok(cap.results!.inventoryPng);
|
||||
assert.ok((cap.results as { overlayPng?: Blob }).overlayPng, 'overlay even for batch of one');
|
||||
assert.deepEqual((cap as { batchApply?: unknown[] }).batchApply, [null]);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', async () => {
|
||||
installFakes();
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [tinyJpeg('a'), tinyJpeg('b')], 0, { sheet: 'black' });
|
||||
await s.calculate();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const d = store.peek();
|
||||
assert.ok(d);
|
||||
assert.equal(d!.stage, 'measure');
|
||||
assert.ok(d!.inventoryPng, 'draft keeps clean inventory');
|
||||
assert.ok(
|
||||
(d as { overlayPng?: Blob | null }).overlayPng,
|
||||
'draft.overlayPng must be persisted for #64 lineup',
|
||||
);
|
||||
assert.equal(d!.batchApply![0], null, 'cal slot still null on draft');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('test(photos): dishonest proxies absent — no raw-photo overlay / no cal-slot stash (#63)', async () => {
|
||||
const sessionSrc = readFileSync(join(here, 'session.ts'), 'utf8');
|
||||
const overlaySrcPath = join(here, '..', 'png', 'overlay.ts');
|
||||
let overlaySrc = '';
|
||||
try {
|
||||
overlaySrc = readFileSync(overlaySrcPath, 'utf8');
|
||||
} catch {
|
||||
overlaySrc = '';
|
||||
}
|
||||
assert.ok(
|
||||
/overlayPng|encodeOverlay|dimensionOverlay|buildOverlayPlan/i.test(sessionSrc) ||
|
||||
overlaySrc.length > 0,
|
||||
'session or png/overlay must implement dimension overlay (#63)',
|
||||
);
|
||||
const calcBody = sessionSrc.match(/async calculate\(\)[\s\S]*?(?=\n [a-zA-Z]+\(|\n \})/);
|
||||
assert.ok(calcBody, 'calculate() must exist');
|
||||
assert.ok(
|
||||
!/batchApply\s*\[\s*calIndex\s*\]\s*=\s*\{[^}]*overlay/i.test(calcBody![0]),
|
||||
'must not put overlay into batchApply[cal]',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type {
|
||||
BatchPhotoResult, Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||
} from './capture.types';
|
||||
import {
|
||||
DEFAULT_MASK_PARAMS, EMPTY_ABOX, EMPTY_BBOX, eighthsToIn,
|
||||
} from './capture.types';
|
||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||
import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
|
||||
export interface DraftStore {
|
||||
loadDraft(): Promise<Draft | null>;
|
||||
saveDraft(d: Draft): Promise<void>;
|
||||
clearDraft(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CaptureSession {
|
||||
capture: import('svelte/store').Readable<Capture>;
|
||||
busy: import('svelte/store').Readable<boolean>;
|
||||
resumeProbe(): Promise<void>;
|
||||
continueDraft(): void;
|
||||
startFresh(): void;
|
||||
/** Append a photo to the batch; stays on batch. */
|
||||
addToBatch(file: File): Promise<void>;
|
||||
/** Alias: same as addToBatch (no longer jumps to mask). */
|
||||
addPhoto(file: File): Promise<void>;
|
||||
removeFromBatch(index: number): void;
|
||||
/** Advance when batch has ≥1 photo → pickCalibration. */
|
||||
continueBatch(): void;
|
||||
/** User picks which batch photo is the calibration reference → Mask on that Source. */
|
||||
pickCalibration(index: number): void;
|
||||
retakes(): void;
|
||||
setSensitivity(n: number): void;
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
calculate(): Promise<void>;
|
||||
back(): void;
|
||||
getInventoryUrl(): string | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface SessionOpts {
|
||||
draft?: DraftStore;
|
||||
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
|
||||
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
|
||||
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
|
||||
}
|
||||
|
||||
const defaultDraft: DraftStore = { loadDraft, saveDraft, clearDraft };
|
||||
|
||||
const batchPhotosOf = (d: Draft): Source[] =>
|
||||
d.batch && d.batch.length > 0 ? d.batch : [d.source];
|
||||
|
||||
// The one writer. Owns the worker port, the single debounce timer, and the draft.
|
||||
// Every transition to an approved branch persists; no explicit "save".
|
||||
export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const drafts = opts.draft ?? defaultDraft;
|
||||
const encodeInventory = opts.encodeInventory ?? encodeInventoryPng;
|
||||
const encodeOverlay = opts.encodeOverlay ?? encodeOverlayPng;
|
||||
const capture = writable<Capture>({ k: 'batch', photos: [] });
|
||||
const busy = writable(false);
|
||||
let state: Capture = { k: 'batch', photos: [] };
|
||||
let batchPhotos: Source[] = [];
|
||||
let calibrationIndex: number | null = null;
|
||||
let worker: Worker | null = null;
|
||||
let jobId = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let passStarted = 0;
|
||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pngUrl: string | null = null;
|
||||
let resumed = false;
|
||||
|
||||
const set = (next: Capture) => {
|
||||
state = next;
|
||||
capture.set(next);
|
||||
};
|
||||
|
||||
const teardownWorker = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (worker) {
|
||||
try {
|
||||
worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn);
|
||||
worker.terminate();
|
||||
} catch {
|
||||
/* worker unavailable */
|
||||
}
|
||||
worker = null;
|
||||
}
|
||||
if (pngUrl) {
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeWorker = (blob: Blob) => {
|
||||
if (typeof Worker === 'undefined') return;
|
||||
if (worker) worker.terminate();
|
||||
try {
|
||||
worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (e: MessageEvent<WorkerOut>) => onWorkerOut(e.data);
|
||||
worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn);
|
||||
} catch {
|
||||
worker = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onWorkerOut = (msg: WorkerOut) => {
|
||||
if (msg.id < jobId - 1) return; // stale
|
||||
if (msg.kind === 'decoded') {
|
||||
if (state.k === 'mask') {
|
||||
set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } });
|
||||
}
|
||||
schedulePass();
|
||||
return;
|
||||
}
|
||||
busy.set(false);
|
||||
if (busyTimer) {
|
||||
clearTimeout(busyTimer);
|
||||
busyTimer = null;
|
||||
}
|
||||
if (state.k !== 'mask') return;
|
||||
if (msg.kind === 'failed') {
|
||||
set({ ...state, failed: true });
|
||||
return;
|
||||
}
|
||||
const mask: Mask = {
|
||||
params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity },
|
||||
slabPx: msg.slabPx,
|
||||
bitset: msg.bitset,
|
||||
bbox: msg.bbox,
|
||||
slabBounds: msg.slabBounds,
|
||||
};
|
||||
set({ ...state, overlay: mask, failed: false });
|
||||
};
|
||||
|
||||
const runPass = () => {
|
||||
if (state.k !== 'mask' || !worker) return;
|
||||
passStarted = Date.now();
|
||||
busyTimer = setTimeout(() => busy.set(true), BUSY_MS);
|
||||
worker.postMessage({
|
||||
id: ++jobId,
|
||||
kind: 'mask',
|
||||
sheet: state.control.sheet,
|
||||
sensitivity: state.control.sensitivity,
|
||||
} satisfies WorkerIn);
|
||||
};
|
||||
|
||||
// The 2.0s debounce: re-armed on every control change; a sheet change
|
||||
// recomputes immediately (spec allows), so it clears the pending pass.
|
||||
const schedulePass = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(runPass, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const draftFromState = (): Draft | null => {
|
||||
const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds });
|
||||
if (state.k === 'batch') {
|
||||
if (state.photos.length === 0) return null;
|
||||
return {
|
||||
version: 1,
|
||||
stage: 'batch',
|
||||
batch: state.photos,
|
||||
source: state.photos[0],
|
||||
maskParams: DEFAULT_MASK_PARAMS,
|
||||
mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX },
|
||||
lengthEighths: 0,
|
||||
thicknessEighths: 0,
|
||||
results: null,
|
||||
inventoryPng: null,
|
||||
};
|
||||
}
|
||||
if (state.k === 'pickCalibration') {
|
||||
return {
|
||||
version: 1,
|
||||
stage: 'pickCalibration',
|
||||
batch: state.photos,
|
||||
source: state.photos[0],
|
||||
maskParams: DEFAULT_MASK_PARAMS,
|
||||
mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX },
|
||||
lengthEighths: 0,
|
||||
thicknessEighths: 0,
|
||||
results: null,
|
||||
inventoryPng: null,
|
||||
};
|
||||
}
|
||||
const calIdx = calibrationIndex ?? undefined;
|
||||
if (state.k === 'axis') {
|
||||
return {
|
||||
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,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
if (state.k === 'measure') {
|
||||
const { inventoryPng: _drop, overlayPng: _dropOverlay, ...rest } = state.results ?? ({} as Measures);
|
||||
return {
|
||||
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,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
overlayPng: state.results?.overlayPng ?? null,
|
||||
batchApply: state.batchApply,
|
||||
};
|
||||
}
|
||||
if (state.k === 'mask' && state.overlay) {
|
||||
return {
|
||||
version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.overlay.params,
|
||||
mask: strip(state.overlay),
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
// Mask before first overlay settles: still persist calibration choice + batch.
|
||||
if (state.k === 'mask') {
|
||||
return {
|
||||
version: 1, stage: 'mask', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.control,
|
||||
mask: { slabPx: 0, bbox: EMPTY_BBOX, slabBounds: EMPTY_ABOX },
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const persist = async () => {
|
||||
const d = draftFromState();
|
||||
if (d) await drafts.saveDraft(d);
|
||||
else if (state.k === 'batch' && state.photos.length === 0) await drafts.clearDraft();
|
||||
};
|
||||
|
||||
// Re-derive the mask bitset from a stored photo + frozen params. The decoded
|
||||
// dimensions are returned because batch Sources begin with dimensions unknown.
|
||||
const rederiveMaskDetailed = (
|
||||
source: Source,
|
||||
params: MaskParams,
|
||||
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||
): Promise<{ mask: Mask; widthPx: number; heightPx: number }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (typeof Worker === 'undefined') {
|
||||
reject(new Error('Worker unavailable'));
|
||||
return;
|
||||
}
|
||||
const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||
let id = 0;
|
||||
let widthPx = source.widthPx;
|
||||
let heightPx = source.heightPx;
|
||||
const fail = (reason: string) => {
|
||||
w.terminate();
|
||||
reject(new Error(reason));
|
||||
};
|
||||
w.onmessage = (e: MessageEvent<WorkerOut>) => {
|
||||
const msg = e.data;
|
||||
if (msg.kind === 'decoded') {
|
||||
widthPx = msg.w;
|
||||
heightPx = msg.h;
|
||||
w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'failed') {
|
||||
fail(msg.reason);
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'ready') {
|
||||
w.terminate();
|
||||
resolve({
|
||||
widthPx,
|
||||
heightPx,
|
||||
mask: {
|
||||
params,
|
||||
slabPx: msg.slabPx || stub.slabPx,
|
||||
bitset: msg.bitset,
|
||||
bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
|
||||
slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
|
||||
});
|
||||
|
||||
const rederiveMask = (
|
||||
source: Source,
|
||||
params: MaskParams,
|
||||
stub: Omit<Mask, 'bitset' | 'params'>,
|
||||
): Promise<Mask> => rederiveMaskDetailed(source, params, stub).then((r) => r.mask);
|
||||
|
||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
batchPhotos = batchPhotosOf(d);
|
||||
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
|
||||
if (d.stage === 'batch') {
|
||||
teardownWorker();
|
||||
calibrationIndex = null;
|
||||
set({ k: 'batch', photos: [...batchPhotos] });
|
||||
return;
|
||||
}
|
||||
if (d.stage === 'pickCalibration') {
|
||||
teardownWorker();
|
||||
calibrationIndex = null;
|
||||
set({ k: 'pickCalibration', photos: [...batchPhotos] });
|
||||
return;
|
||||
}
|
||||
const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
|
||||
if (d.stage === 'mask') {
|
||||
teardownWorker();
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false });
|
||||
makeWorker(source.blob);
|
||||
return;
|
||||
}
|
||||
teardownWorker();
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? {
|
||||
...d.results,
|
||||
inventoryPng: d.inventoryPng ?? new Blob(),
|
||||
overlayPng: d.overlayPng ?? undefined,
|
||||
}
|
||||
: null,
|
||||
batchApply: d.batchApply,
|
||||
});
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
makeWorker(source.blob);
|
||||
}
|
||||
};
|
||||
|
||||
const enterMaskOnSource = (source: Source) => {
|
||||
teardownWorker();
|
||||
set({
|
||||
k: 'mask',
|
||||
source,
|
||||
control: { ...DEFAULT_MASK_PARAMS },
|
||||
overlay: null,
|
||||
failed: false,
|
||||
});
|
||||
makeWorker(source.blob);
|
||||
void persist();
|
||||
};
|
||||
|
||||
return {
|
||||
capture,
|
||||
busy,
|
||||
|
||||
async resumeProbe() {
|
||||
if (resumed) return;
|
||||
resumed = true;
|
||||
const d = await drafts.loadDraft();
|
||||
if (d) set({ k: 'resume', draft: d });
|
||||
},
|
||||
|
||||
continueDraft() {
|
||||
if (state.k !== 'resume') return;
|
||||
void restoreFromDraft(state.draft);
|
||||
},
|
||||
|
||||
async startFresh() {
|
||||
if (state.k !== 'resume') return;
|
||||
teardownWorker();
|
||||
batchPhotos = [];
|
||||
calibrationIndex = null;
|
||||
await drafts.clearDraft();
|
||||
set({ k: 'batch', photos: [] });
|
||||
},
|
||||
|
||||
async addToBatch(file: File) {
|
||||
if (state.k !== 'batch') return;
|
||||
const photo: Source = { widthPx: 0, heightPx: 0, blob: file };
|
||||
batchPhotos = [...state.photos, photo];
|
||||
set({ k: 'batch', photos: batchPhotos });
|
||||
await persist();
|
||||
},
|
||||
|
||||
async addPhoto(file: File) {
|
||||
// M2: addPhoto is an alias for addToBatch (no single-photo-only shortcut).
|
||||
if (state.k !== 'batch') {
|
||||
teardownWorker();
|
||||
batchPhotos = [];
|
||||
set({ k: 'batch', photos: [] });
|
||||
}
|
||||
if (state.k !== 'batch') return;
|
||||
const photo: Source = { widthPx: 0, heightPx: 0, blob: file };
|
||||
batchPhotos = [...state.photos, photo];
|
||||
set({ k: 'batch', photos: batchPhotos });
|
||||
await persist();
|
||||
},
|
||||
|
||||
removeFromBatch(index: number) {
|
||||
if (state.k !== 'batch') return;
|
||||
if (index < 0 || index >= state.photos.length) return;
|
||||
batchPhotos = state.photos.filter((_, i) => i !== index);
|
||||
set({ k: 'batch', photos: batchPhotos });
|
||||
void persist();
|
||||
},
|
||||
|
||||
continueBatch() {
|
||||
if (state.k !== 'batch' || state.photos.length === 0) return;
|
||||
batchPhotos = [...state.photos];
|
||||
calibrationIndex = null;
|
||||
teardownWorker();
|
||||
set({ k: 'pickCalibration', photos: [...batchPhotos] });
|
||||
void persist();
|
||||
},
|
||||
|
||||
// #61: enter M1 Mask on the chosen calibration Source only — never loop batch.
|
||||
pickCalibration(index: number) {
|
||||
if (state.k !== 'pickCalibration') return;
|
||||
if (index < 0 || index >= state.photos.length) return;
|
||||
batchPhotos = [...state.photos];
|
||||
calibrationIndex = index;
|
||||
enterMaskOnSource(batchPhotos[index]);
|
||||
},
|
||||
|
||||
retakes() {
|
||||
teardownWorker();
|
||||
calibrationIndex = null;
|
||||
// Return to the batch (photos kept) so the user can add/replace before continuing.
|
||||
set({ k: 'batch', photos: [...batchPhotos] });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setSensitivity(n: number) {
|
||||
if (state.k !== 'mask') return;
|
||||
const s = Math.max(0, Math.min(100, Math.round(n)));
|
||||
set({ ...state, control: { ...state.control, sensitivity: s } });
|
||||
schedulePass();
|
||||
},
|
||||
|
||||
setSheet(s: Sheet) {
|
||||
if (state.k !== 'mask') return;
|
||||
set({ ...state, control: { ...state.control, sheet: s } });
|
||||
runPass();
|
||||
},
|
||||
|
||||
// #61: Axis uses the same calibration source; draft.batch siblings stay untouched.
|
||||
approveMask() {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
const overlay = state.overlay;
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
||||
void persist();
|
||||
},
|
||||
|
||||
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) } });
|
||||
void persist();
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.axis.bbox },
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
},
|
||||
|
||||
setLength(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setThickness(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
// #62: Calculate the calibration photo, then apply its frozen mask params
|
||||
// to siblings. This stays in Measure; siblings never enter Mask controls.
|
||||
async calculate() {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
// CAPTURE.md: inventory is image/png even when encode is unavailable.
|
||||
pngBlob = new Blob([], { type: 'image/png' });
|
||||
}
|
||||
bitmap.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
|
||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||
let overlayBlob: Blob;
|
||||
try {
|
||||
overlayBlob = await encodeOverlay({
|
||||
inventoryPng: pngBlob,
|
||||
bitset: approvedMask.bitset,
|
||||
widthPx: source.widthPx,
|
||||
heightPx: source.heightPx,
|
||||
bbox: axis.bbox,
|
||||
scale: base.scale,
|
||||
lengthIn: eighthsToIn(lengthEighths),
|
||||
lengthLabel: fmtEighths(lengthEighths),
|
||||
slabBounds: approvedMask.slabBounds,
|
||||
source,
|
||||
});
|
||||
} catch {
|
||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||
// return a distinct image/png so callers never confuse it with clean.
|
||||
overlayBlob = new Blob(['overlay'], { type: 'image/png' });
|
||||
}
|
||||
const results: Measures = {
|
||||
...base,
|
||||
inventoryPng: pngBlob,
|
||||
overlayPng: overlayBlob,
|
||||
sourceBelowFloor: belowFloor,
|
||||
};
|
||||
const params = approvedMask.params;
|
||||
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||
? calibrationIndex
|
||||
: 0;
|
||||
const batch = batchPhotos.length ? batchPhotos : [source];
|
||||
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
||||
status: 'error',
|
||||
message: 'Batch photo was not processed',
|
||||
}));
|
||||
|
||||
// Process serially so each worker owns exactly one sibling photo and a
|
||||
// failing decode/mask/encode cannot prevent later siblings from running.
|
||||
for (let index = 0; index < batch.length; index++) {
|
||||
if (index === calIndex) continue;
|
||||
const photo = batch[index];
|
||||
try {
|
||||
const derived = await rederiveMaskDetailed(photo, params, {
|
||||
slabPx: 0,
|
||||
bbox: EMPTY_BBOX,
|
||||
slabBounds: EMPTY_ABOX,
|
||||
});
|
||||
if (derived.mask.slabPx === 0) {
|
||||
throw new Error('empty mask: no slab pixels detected');
|
||||
}
|
||||
const siblingSource: Source = {
|
||||
...photo,
|
||||
widthPx: derived.widthPx,
|
||||
heightPx: derived.heightPx,
|
||||
};
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
try {
|
||||
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
|
||||
} finally {
|
||||
siblingBitmap.close();
|
||||
}
|
||||
if (encoded.sourceBelowFloor) {
|
||||
throw new Error('PNG size floor: shorter side is below 800 px');
|
||||
}
|
||||
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||
batchApply[index] = { status: 'error', message };
|
||||
}
|
||||
}
|
||||
|
||||
set({ ...state, results, batchApply });
|
||||
void persist();
|
||||
},
|
||||
|
||||
back() {
|
||||
if (state.k === 'pickCalibration') {
|
||||
calibrationIndex = null;
|
||||
set({ k: 'batch', photos: [...batchPhotos] });
|
||||
void persist();
|
||||
} else if (state.k === 'mask') {
|
||||
teardownWorker();
|
||||
calibrationIndex = null;
|
||||
set({ k: 'pickCalibration', photos: [...batchPhotos] });
|
||||
void persist();
|
||||
} else if (state.k === 'axis') {
|
||||
// back to mask keeps the last approved mask as the live overlay
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
||||
}
|
||||
},
|
||||
|
||||
getInventoryUrl() {
|
||||
return pngUrl;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
teardownWorker();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Display helper: format eighths as inches with fractions.
|
||||
export function fmtEighths(e: Eighths): string {
|
||||
const whole = Math.floor(eighthsToIn(e));
|
||||
const rem = e - whole * 8;
|
||||
if (rem === 0) return `${whole}"`;
|
||||
const frac: Record<number, string> = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' };
|
||||
const part = frac[rem] ?? `${rem}/8`;
|
||||
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared decode for capture photos so PhotoStage, mask worker, inventory, and
|
||||
* calculate all share one EXIF-oriented pixel space.
|
||||
*
|
||||
* Matched-set / DJI JPEGs often carry EXIF Orientation 8 (stored landscape,
|
||||
* display portrait). `new Image()` and bare `createImageBitmap(blob)` can
|
||||
* disagree across browsers → pink axis arrow space ≠ mask/overlay bitset space.
|
||||
*
|
||||
* Contract: always request `imageOrientation: 'from-image'`. If the runtime
|
||||
* rejects that option (older engines), fall back to bare `createImageBitmap`;
|
||||
* engines that already apply EXIF by default still match Image/canvas size.
|
||||
* Engines that never apply EXIF may still diverge after fallback — document
|
||||
* that path rather than inventing a software rotate.
|
||||
*/
|
||||
export const ORIENTED_BITMAP_OPTIONS: ImageBitmapOptions = {
|
||||
imageOrientation: 'from-image',
|
||||
};
|
||||
|
||||
export async function decodeOrientedBitmap(blob: Blob): Promise<ImageBitmap> {
|
||||
try {
|
||||
return await createImageBitmap(blob, ORIENTED_BITMAP_OPTIONS);
|
||||
} catch (first) {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
throw first;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { get, set, del } from 'idb-keyval';
|
||||
import type { Draft } from '../capture/capture.types';
|
||||
|
||||
const KEY = 'slabuploader-draft';
|
||||
|
||||
export async function loadDraft(): Promise<Draft | null> {
|
||||
try {
|
||||
const d = await get<Draft>(KEY);
|
||||
return d && d.version === 1 ? d : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDraft(d: Draft): Promise<void> {
|
||||
await set(KEY, d);
|
||||
}
|
||||
|
||||
export async function clearDraft(): Promise<void> {
|
||||
try {
|
||||
await del(KEY);
|
||||
} catch {
|
||||
// idb unavailable: draft persistence is best-effort, app state is not
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
|
||||
import { measure } from '../measure/measure.ts';
|
||||
|
||||
// 100×20 horizontal slab
|
||||
const W = 100, H = 20;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
|
||||
|
||||
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
|
||||
const sx = maskSpanAlong(bitset, W, H, 1, 0);
|
||||
const sy = maskSpanAlong(bitset, W, H, 0, 1);
|
||||
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
|
||||
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
|
||||
});
|
||||
|
||||
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||
const prev = minAreaRect(bitset, W, H);
|
||||
assert.ok(prev, 'minAreaRect returned a box');
|
||||
const rotated = orientedRect(prev, 45);
|
||||
assert.ok(Math.abs(rotated.angleDeg - 45) < 1e-9);
|
||||
assert.equal(rotated.longSidePx, prev.longSidePx);
|
||||
// The long side (angleDeg 0 → +x) must be preserved by the rotation.
|
||||
const longOf = (c) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]);
|
||||
assert.ok(Math.abs(longOf(rotated.corners) - longOf(prev.corners)) < 1e-9);
|
||||
assert.ok(Math.abs(longOf(prev.corners) - prev.longSidePx) < 1e-9);
|
||||
// angle normalization to [0, 180)
|
||||
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||
});
|
||||
|
||||
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
||||
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
||||
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
||||
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
||||
const TW = 20, TH = 100;
|
||||
const thin = new Uint8Array(TW * TH).fill(1);
|
||||
const thinBox = minAreaRect(thin, TW, TH);
|
||||
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
||||
const rotated = orientedRect(thinBox, 0);
|
||||
const lengthEighths = 24; // 3 in
|
||||
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
|
||||
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
|
||||
// far from the old longSidePx/3 ≈ 33.
|
||||
const ex = rotated.corners[0][0] - rotated.corners[3][0];
|
||||
const ey = rotated.corners[0][1] - rotated.corners[3][1];
|
||||
const elen = Math.hypot(ex, ey);
|
||||
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
|
||||
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
|
||||
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { ABox, BBox } from '../capture/capture.types';
|
||||
|
||||
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (bitset[row + x]) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return { x: 0, y: 0, w: 0, h: 0 };
|
||||
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
// Convex hull (Andrew's monotone chain). Points are [x, y].
|
||||
export function convexHull(pts: [number, number][]): [number, number][] {
|
||||
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (p.length < 3) return p;
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: [number, number][] = [];
|
||||
for (const pt of p) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
|
||||
lower.push(pt);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = p.length - 1; i >= 0; i--) {
|
||||
const pt = p[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
|
||||
upper.push(pt);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
// Rotating calipers: true minimum-area bounding rectangle of the mask.
|
||||
export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | null {
|
||||
const pts: [number, number][] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) if (bitset[row + x]) pts.push([x, y]);
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const hull = convexHull(pts);
|
||||
|
||||
let best = { area: Infinity, cx: 0, cy: 0, ux: 1, uy: 0, hw: 0, hh: 0 };
|
||||
const n = hull.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
const ex = hull[j][0] - hull[i][0];
|
||||
const ey = hull[j][1] - hull[i][1];
|
||||
const len = Math.hypot(ex, ey);
|
||||
if (len === 0) continue;
|
||||
const ux = ex / len, uy = ey / len;
|
||||
const vx = -uy, vy = ux;
|
||||
let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const dx = hull[k][0] - hull[i][0];
|
||||
const dy = hull[k][1] - hull[i][1];
|
||||
const u = dx * ux + dy * uy;
|
||||
const v = dx * vx + dy * vy;
|
||||
if (u < minU) minU = u;
|
||||
if (u > maxU) maxU = u;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
const rw = maxU - minU;
|
||||
const rh = maxV - minV;
|
||||
const area = rw * rh;
|
||||
if (area < best.area) {
|
||||
best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
// long-side direction: the edge with the larger extent
|
||||
const lu = best.hw >= best.hh ? best.ux : -best.uy;
|
||||
const lv = best.hw >= best.hh ? best.uy : best.ux;
|
||||
const su = best.hw >= best.hh ? -best.uy : best.ux;
|
||||
const sv = best.hw >= best.hh ? -best.ux : -best.uy;
|
||||
const halfL = best.hw >= best.hh ? best.hw : best.hh;
|
||||
const halfS = best.hw >= best.hh ? best.hh : best.hw;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]);
|
||||
}
|
||||
const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
|
||||
return { corners, longSidePx: halfL * 2, angleDeg };
|
||||
}
|
||||
|
||||
// Rotate the long side to a new angle, keeping center and half-extents.
|
||||
// Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the
|
||||
// long side (the axis arrow direction).
|
||||
export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
||||
const a = ((angleDeg % 180) + 180) % 180;
|
||||
const halfL = prev.longSidePx / 2;
|
||||
const halfS = Math.hypot(prev.corners[0][0] - prev.corners[1][0], prev.corners[0][1] - prev.corners[1][1]) / 2;
|
||||
// corners[i] is a corner point: average of all four = center
|
||||
const cx = (prev.corners[0][0] + prev.corners[1][0] + prev.corners[2][0] + prev.corners[3][0]) / 4;
|
||||
const cy = (prev.corners[0][1] + prev.corners[1][1] + prev.corners[2][1] + prev.corners[3][1]) / 4;
|
||||
const r = (a * Math.PI) / 180;
|
||||
const lu = Math.cos(r), lv = Math.sin(r);
|
||||
const su = -lv, sv = lu;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [aSign, bSign] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([cx + aSign * halfL * lu + bSign * halfS * su, cy + aSign * halfL * lv + bSign * halfS * sv]);
|
||||
}
|
||||
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||
}
|
||||
|
||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||
// extent in px. 0 when the mask is empty.
|
||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < min) min = p;
|
||||
if (p > max) max = p;
|
||||
}
|
||||
}
|
||||
return max < min ? 0 : max - min;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
const root = document.querySelector('#app');
|
||||
if (root) new App({ target: root });
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { blackKeep, greenKeep } from './sheetKeep.ts';
|
||||
|
||||
// --- Black sheet sensitivity (#74) ---
|
||||
|
||||
test('blackKeep: s=0 keeps dark live-edge wood (lum ~25–50), removes near-black sheet (#74)', () => {
|
||||
// Near-black sheet (lum ≈ 5)
|
||||
assert.equal(blackKeep(5, 5, 5, 0), false, 'near-black sheet removed at s=0');
|
||||
assert.equal(blackKeep(0, 0, 0, 0), false, 'pure black sheet removed at s=0');
|
||||
// Dark wood / bark / live edge
|
||||
assert.equal(blackKeep(25, 22, 18, 0), true, 'lum~22 dark wood kept at s=0');
|
||||
assert.equal(blackKeep(40, 35, 30, 0), true, 'lum~35 dark wood kept at s=0');
|
||||
assert.equal(blackKeep(50, 45, 40, 0), true, 'lum~46 wood kept at s=0');
|
||||
// Mid wood always kept
|
||||
assert.equal(blackKeep(120, 90, 60, 0), true, 'mid wood kept at s=0');
|
||||
});
|
||||
|
||||
test('blackKeep: s=100 removes more than s=0; curve monotonic (#74)', () => {
|
||||
// Dark-ish pixel that sits between floors: sheet at high s, wood at low s
|
||||
const darkish = { r: 80, g: 70, b: 60 }; // lum ≈ 72
|
||||
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 0), true, 'lum~72 kept at s=0');
|
||||
assert.equal(blackKeep(darkish.r, darkish.g, darkish.b, 100), false, 'lum~72 sheet at s=100');
|
||||
|
||||
// Near-black always sheet
|
||||
assert.equal(blackKeep(2, 2, 2, 0), false);
|
||||
assert.equal(blackKeep(2, 2, 2, 100), false);
|
||||
|
||||
// Bright wood always kept
|
||||
assert.equal(blackKeep(200, 160, 100, 0), true);
|
||||
assert.equal(blackKeep(200, 160, 100, 100), true);
|
||||
|
||||
// Monotonic: once removed, stays removed as s increases
|
||||
const probe = { r: 60, g: 55, b: 50 }; // lum ≈ 56
|
||||
let prevKeep = true;
|
||||
for (const s of [0, 25, 50, 75, 100]) {
|
||||
const keep = blackKeep(probe.r, probe.g, probe.b, s);
|
||||
if (!prevKeep) assert.equal(keep, false, `once sheet at lower s, stay sheet at s=${s}`);
|
||||
prevKeep = keep;
|
||||
}
|
||||
// s=100 must remove more pixels than s=0 for this mid-dark band
|
||||
assert.equal(blackKeep(probe.r, probe.g, probe.b, 0), true);
|
||||
assert.equal(blackKeep(probe.r, probe.g, probe.b, 100), false);
|
||||
});
|
||||
|
||||
test('greenKeep: s=0 does not eat mild wood greens; s=100 widens sheet match (#74)', () => {
|
||||
// Neutral wood
|
||||
assert.equal(greenKeep(140, 110, 80, 0), true);
|
||||
assert.equal(greenKeep(140, 110, 80, 100), true);
|
||||
// Mild green cast on wood (g - max(r,b) ≈ 5) — must keep at s=0
|
||||
assert.equal(greenKeep(100, 105, 90, 0), true, 'mild green cast kept at s=0');
|
||||
// Strong sheet green
|
||||
assert.equal(greenKeep(40, 180, 50, 0), false, 'strong green sheet removed at s=0');
|
||||
assert.equal(greenKeep(40, 180, 50, 100), false);
|
||||
// Borderline: g-max ≈ 20 — may become sheet at high s
|
||||
const border = { r: 80, g: 100, b: 70 }; // delta = 20
|
||||
assert.equal(greenKeep(border.r, border.g, border.b, 0), true, 'delta20 kept at s=0');
|
||||
assert.equal(greenKeep(border.r, border.g, border.b, 100), false, 'delta20 sheet at s=100');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Sheet-vs-slab keep predicates used by the mask worker.
|
||||
* Extracted so unit tests can cover sensitivity curves without the worker.
|
||||
*/
|
||||
|
||||
/** Keep slab pixels on a green sheet. Higher s widens the green-sheet match. */
|
||||
export function greenKeep(r: number, g: number, b: number, s: number): boolean {
|
||||
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||
// s=0 removes only strongly green sheet; increasing sensitivity widens the match.
|
||||
const isSheet = g - Math.max(r, b) > 45 - 35 * t;
|
||||
return !isSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep slab pixels on a black sheet.
|
||||
* At s=0 only near-black sheet (very low lum) is removed so dark live-edge /
|
||||
* bark (lum ~25–50) stays. The match widens monotonically toward s=100 (#74).
|
||||
*/
|
||||
export function blackKeep(r: number, g: number, b: number, s: number): boolean {
|
||||
const t = Math.max(0, Math.min(100, s)) / 100;
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
// Old floor 40 ate dark wood at minimum sensitivity. Floor 10 isolates near-black.
|
||||
const isSheet = lum < 10 + 140 * t;
|
||||
return !isSheet;
|
||||
}
|
||||
|
||||
export function autoPickSheet(
|
||||
samples: { r: number; g: number; b: number }[],
|
||||
): 'green' | 'black' {
|
||||
let gr = 0, gl = 0;
|
||||
for (const p of samples) {
|
||||
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||
}
|
||||
return gl >= gr ? 'black' : 'green';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
|
||||
import { measure } from './measure.ts';
|
||||
|
||||
// Uniform slab, 10 px/in: full W×H canvas, axis +x via minAreaRect angle 0.
|
||||
function uniform(W: number, H: number) {
|
||||
const bitset = new Uint8Array(W * H).fill(1);
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
return { bitset, box, slabPx: W * H };
|
||||
}
|
||||
|
||||
// Tapered slab, 10 px/in: tips under 6 in and past L−6 in are `tipPx` wide
|
||||
// (centered), the bulk is full canvas height.
|
||||
function tapered(W: number, H: number, lengthEighths: number, tipPx: number) {
|
||||
const inIn = W / 10;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
const half = tipPx / 2;
|
||||
for (let x = 0; x < W; x++) {
|
||||
const p = x / 10;
|
||||
const w = p < 6 || p > inIn - 6 ? tipPx : H;
|
||||
for (let y = (H - w) / 2; y < (H + w) / 2; y++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
return { bitset, box, slabPx: bitset.reduce((a, c) => a + c, 0), lengthEighths };
|
||||
}
|
||||
|
||||
test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
|
||||
const { bitset, box, slabPx, lengthEighths } = tapered(480, 100, 384, 20); // tips 2in, bulk 10in
|
||||
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx);
|
||||
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
|
||||
}
|
||||
assert.ok(m.widths.min >= 76 && m.widths.min <= 84, `min ${m.widths.min / 8}in`);
|
||||
assert.ok(m.widths.max >= 76 && m.widths.max <= 84, `max ${m.widths.max / 8}in`);
|
||||
});
|
||||
|
||||
test('uniform slab: interior stations keep the full width', () => {
|
||||
const { bitset, box, slabPx } = uniform(480, 100);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx);
|
||||
assert.equal(m.widths.every6In.length, 7);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
|
||||
}
|
||||
});
|
||||
|
||||
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(120, 100);
|
||||
const m = measure(bitset, 120, 100, box, 96, 8, slabPx);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
|
||||
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(100, 100);
|
||||
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// Display / direction: corners[3] → corners[0] is the long side.
|
||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||
return { start: bbox.corners[3], end: bbox.corners[0] };
|
||||
}
|
||||
|
||||
// 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.
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array, w: number, h: number, bbox: BBox,
|
||||
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen, uy = ey / elen;
|
||||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
||||
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) return { start: a, end: b, span: 0, ux, uy };
|
||||
return {
|
||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||
span: maxP - minP,
|
||||
ux,
|
||||
uy,
|
||||
};
|
||||
}
|
||||
|
||||
// Interior stations only (6, 12, … ≤ L−6). Tips dominate min/max on tapered
|
||||
// boards, so the first and last 6 in are excluded. Each sample is a line
|
||||
// probe perpendicular to the axis; width = max run of slab px across it.
|
||||
function sampleWidths(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
start: [number, number], ux: number, uy: number,
|
||||
scale: number, lengthIn: number,
|
||||
): Eighths[] {
|
||||
const px = -uy, py = ux;
|
||||
const stepPx = 6 * scale;
|
||||
const lastPx = lengthIn > 12 ? Math.floor((lengthIn - 6) / 6) * 6 * scale : 0;
|
||||
const out: Eighths[] = [];
|
||||
const half = Math.max(2, Math.round((w + h) / 4));
|
||||
for (let t = stepPx; t <= lastPx; t += stepPx) {
|
||||
const ox = start[0] + ux * t;
|
||||
const oy = start[1] + uy * t;
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let p = -half; p <= half; p++) {
|
||||
const x = Math.round(ox + px * p);
|
||||
const y = Math.round(oy + py * p);
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||||
if (bitset[y * w + x]) {
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) continue;
|
||||
out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function measure(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
const min = Math.min(...every6In);
|
||||
const max = Math.max(...every6In);
|
||||
const avg = Math.round(every6In.reduce((a, b) => a + b, 0) / every6In.length);
|
||||
widths = { min, max, avg, every6In };
|
||||
} else {
|
||||
widths = { min: 0, max: 0, avg: 0, every6In: [] };
|
||||
}
|
||||
const sqft = Math.round((slabPx / (scale * scale) / 144) * 100) / 100;
|
||||
const bdft = Math.round(sqft * (thicknessEighths / 8) * 100) / 100;
|
||||
return {
|
||||
lengthEighths,
|
||||
thicknessEighths,
|
||||
scale,
|
||||
widths,
|
||||
sqft,
|
||||
bdft,
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeInventoryFrame } from './inventory.ts';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const makePhoto = (w: number, h: number, paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255]) => {
|
||||
const rgba = new Uint8Array(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b, a] = paint(x, y);
|
||||
const i = (y * w + x) * 4;
|
||||
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = a;
|
||||
}
|
||||
}
|
||||
return { width: w, height: h, rgba };
|
||||
};
|
||||
|
||||
const maskFor = (bitset: Uint8Array, slabBounds: Mask['slabBounds']): Pick<Mask, 'bitset' | 'slabBounds'> =>
|
||||
({ bitset, slabBounds });
|
||||
|
||||
test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha 255', () => {
|
||||
const W = 8, H = 8;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y < 5; y++) for (let x = 1; x < 5; x++) bitset[y * W + x] = 1;
|
||||
bitset[2 * W + 2] = 0;
|
||||
bitset[3 * W + 3] = 0;
|
||||
const SLAB = [200, 40, 20, 255] as const;
|
||||
const SHEET = [0, 255, 0, 255] as const;
|
||||
const src = makePhoto(W, H, (x, y) => (bitset[y * W + x] === 1 ? [...SLAB] : [...SHEET]));
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
|
||||
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
|
||||
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width < 800, 'undersized source stays small');
|
||||
|
||||
let opaque = 0;
|
||||
let transparent = 0;
|
||||
for (let i = 0; i < out.rgba.length; i += 4) {
|
||||
const a = out.rgba[i + 3];
|
||||
if (a === 0) {
|
||||
transparent++;
|
||||
assert.equal(out.rgba[i], 0);
|
||||
assert.equal(out.rgba[i + 1], 0);
|
||||
assert.equal(out.rgba[i + 2], 0);
|
||||
continue;
|
||||
}
|
||||
opaque++;
|
||||
assert.equal(a, 255);
|
||||
assert.deepEqual(
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]],
|
||||
[SLAB[0], SLAB[1], SLAB[2]],
|
||||
'opaque pixel must be slab RGB, never sheet green',
|
||||
);
|
||||
}
|
||||
assert.ok(opaque > 0, 'slab pixels are opaque');
|
||||
assert.ok(transparent > 0, 'frame has transparent pixels');
|
||||
});
|
||||
|
||||
test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2000, heightPx: 1800 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 2000, h: 1800 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.width, 800);
|
||||
assert.equal(out.height, 1067);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
});
|
||||
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 1200, heightPx: 1000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
assert.equal(out.width, 800);
|
||||
});
|
||||
|
||||
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 100, y: 100, w: 2000, h: 1800 }),
|
||||
source,
|
||||
);
|
||||
let minx = out.width, maxx = -1, miny = out.height, maxy = -1;
|
||||
for (let y = 0; y < out.height; y++) {
|
||||
for (let x = 0; x < out.width; x++) {
|
||||
if (out.rgba[(y * out.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const xs = [minx, maxx], ys = [miny, maxy];
|
||||
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
|
||||
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
|
||||
assert.ok(Math.abs(wExtent - 720) <= 2, `width extent ${wExtent} ≈ 720 (0.9×800)`);
|
||||
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
||||
});
|
||||
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
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;
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
|
||||
export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
sourceBelowFloor: boolean;
|
||||
}
|
||||
|
||||
export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
): Frame {
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
const y0 = Math.max(0, Math.round(b.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(b.w)));
|
||||
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 outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
const rgba = new Uint8Array(outW * outH * 4);
|
||||
const xEnd = ox + dw;
|
||||
const yEnd = oy + dh;
|
||||
for (let y = 0; y < outH; y++) {
|
||||
for (let x = 0; x < outW; x++) {
|
||||
if (x + 0.5 < ox || x + 0.5 >= xEnd || y + 0.5 < oy || y + 0.5 >= yEnd) continue;
|
||||
const sx = Math.floor(x0 + (x + 0.5 - ox) / scale);
|
||||
const sy = Math.floor(y0 + (y + 0.5 - oy) / scale);
|
||||
if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue;
|
||||
if (mask.bitset[sy * source.widthPx + sx] !== 1) continue;
|
||||
const si = (sy * source.widthPx + sx) * 4;
|
||||
const di = (y * outW + x) * 4;
|
||||
rgba[di] = src.rgba[si];
|
||||
rgba[di + 1] = src.rgba[si + 1];
|
||||
rgba[di + 2] = src.rgba[si + 2];
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
||||
}
|
||||
|
||||
export async function encodeInventoryPng(
|
||||
input: InventoryInput,
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
const { bitmap, mask, source } = input;
|
||||
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||
const srcCtx = srcCanvas.getContext('2d')!;
|
||||
srcCtx.drawImage(bitmap, 0, 0);
|
||||
const img = srcCtx.getImageData(0, 0, source.widthPx, source.heightPx);
|
||||
const frame = composeInventoryFrame(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0);
|
||||
const blob = await out.convertToBlob({ type: 'image/png' });
|
||||
return { blob, sourceBelowFloor: frame.sourceBelowFloor };
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
if (!(lengthIn > 6)) return [];
|
||||
const last = lengthIn - 6;
|
||||
const out: number[] = [];
|
||||
for (let s = 6; s <= last + 1e-9; s += 12) out.push(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXIF-oriented photo bottom = larger canvas y. Pick which end of the approved
|
||||
* length-axis span is bottom; return unit from bottom → top along that span.
|
||||
*/
|
||||
export function pickBottomTowardTop(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): {
|
||||
bottom: [number, number];
|
||||
top: [number, number];
|
||||
ux: number;
|
||||
uy: number;
|
||||
} {
|
||||
// Larger y is closer to the bottom edge of the oriented photo.
|
||||
// Tie-break (horizontal axis): prefer larger x for a stable choice.
|
||||
const startIsBottom =
|
||||
start[1] > end[1] || (start[1] === end[1] && start[0] >= end[0]);
|
||||
const bottom = startIsBottom ? start : end;
|
||||
const top = startIsBottom ? end : start;
|
||||
const dx = top[0] - bottom[0];
|
||||
const dy = top[1] - bottom[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
return { bottom, top, ux: dx / len, uy: dy / len };
|
||||
}
|
||||
|
||||
/** Screen-space right of travel (canvas y↓): (ux,uy) → (−uy, ux). */
|
||||
export function axisRightUnit(ux: number, uy: number): [number, number] {
|
||||
return [-uy, ux];
|
||||
}
|
||||
|
||||
export interface OverlayWidthMark {
|
||||
stationIn: number;
|
||||
center: [number, number];
|
||||
left: [number, number];
|
||||
right: [number, number];
|
||||
/** Axis-left of the slab, outside the wood edge (opposite of length dim). */
|
||||
labelAt: [number, number];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OverlayLengthMark {
|
||||
a: [number, number];
|
||||
b: [number, number];
|
||||
labelAt: [number, number];
|
||||
label: string;
|
||||
/** True when the line sits outside the slab on the axis-right side. */
|
||||
outside: boolean;
|
||||
}
|
||||
|
||||
export interface OverlayPlan {
|
||||
stroke: '#000000';
|
||||
fontPx: number;
|
||||
length: OverlayLengthMark;
|
||||
widths: OverlayWidthMark[];
|
||||
}
|
||||
|
||||
export interface OverlayPlanInput {
|
||||
bitset: Uint8Array;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
bbox: BBox;
|
||||
scale: number; // px per inch
|
||||
lengthIn: number;
|
||||
lengthLabel: string;
|
||||
/** Map source px → inventory PNG px (identity OK for unit tests). */
|
||||
mapToInventory: (x: number, y: number) => [number, number];
|
||||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||||
inventoryW?: number;
|
||||
inventoryH?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Min across-width as a fraction of max bin width for an axial bin to count as a tip.
|
||||
* #71 used 0.32 (excluded fringe); #73 lowers to ~0.15 so tapered live-edge tips remain.
|
||||
*/
|
||||
export const BODY_WIDTH_FRAC = 0.15;
|
||||
|
||||
/**
|
||||
* Axial tip stations from across-width profile (#71/#73).
|
||||
* Thin fringe whiskers past the visible body survive 0.2%/99.8% percentiles;
|
||||
* require each axial bin to have meaningful across-width before it counts as a tip.
|
||||
*/
|
||||
function widthAwareAxialRange(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
ux: number,
|
||||
uy: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
): { minP: number; maxP: number } | null {
|
||||
const rx = -uy;
|
||||
const ry = ux;
|
||||
const binMin = new Map<number, number>();
|
||||
const binMax = new Map<number, number>();
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const along = x * ux + y * uy;
|
||||
const across = (x - cx) * rx + (y - cy) * ry;
|
||||
const bin = Math.round(along);
|
||||
const prevMin = binMin.get(bin);
|
||||
if (prevMin === undefined || across < prevMin) binMin.set(bin, across);
|
||||
const prevMax = binMax.get(bin);
|
||||
if (prevMax === undefined || across > prevMax) binMax.set(bin, across);
|
||||
}
|
||||
}
|
||||
if (binMin.size === 0) return null;
|
||||
let maxWidth = 0;
|
||||
const widths: { bin: number; width: number }[] = [];
|
||||
for (const [bin, minA] of binMin) {
|
||||
const maxA = binMax.get(bin)!;
|
||||
const width = maxA - minA + 1;
|
||||
widths.push({ bin, width });
|
||||
if (width > maxWidth) maxWidth = width;
|
||||
}
|
||||
// Body threshold: exclude ~10% fringe whiskers; keep 15–25% live-edge tapers (#73).
|
||||
const thresh = Math.max(4, maxWidth * BODY_WIDTH_FRAC);
|
||||
widths.sort((a, b) => a.bin - b.bin);
|
||||
let tipLo = widths[0].bin;
|
||||
let tipHi = widths[widths.length - 1].bin;
|
||||
for (const { bin, width } of widths) {
|
||||
if (width >= thresh) {
|
||||
tipLo = bin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = widths.length - 1; i >= 0; i--) {
|
||||
if (widths[i].width >= thresh) {
|
||||
tipHi = widths[i].bin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { minP: tipLo, maxP: tipHi };
|
||||
}
|
||||
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
bbox: BBox,
|
||||
): {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
/** Actual mask pixels nearest centerline at tip stations. */
|
||||
tipStart: [number, number];
|
||||
tipEnd: [number, number];
|
||||
ux: number;
|
||||
uy: number;
|
||||
span: number;
|
||||
} {
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen;
|
||||
const uy = ey / elen;
|
||||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
||||
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
// Width-aware tips (#71). Fall back to robust percentiles if profile empty.
|
||||
const aware = widthAwareAxialRange(bitset, w, h, ux, uy, cx, cy);
|
||||
let minP: number;
|
||||
let maxP: number;
|
||||
if (aware) {
|
||||
minP = aware.minP;
|
||||
maxP = aware.maxP;
|
||||
} else {
|
||||
const projs: number[] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
projs.push(x * ux + y * uy);
|
||||
}
|
||||
}
|
||||
if (projs.length === 0) return { start: a, end: b, tipStart: a, tipEnd: b, ux, uy, span: 0 };
|
||||
projs.sort((p, q) => p - q);
|
||||
const lo = Math.min(Math.floor(projs.length * 0.002), projs.length - 1);
|
||||
const hi = Math.max(Math.floor(projs.length * 0.998), lo);
|
||||
minP = projs[lo];
|
||||
maxP = projs[hi];
|
||||
}
|
||||
// Tip pixel at each station: closest to the axis centerline
|
||||
let tipStart: [number, number] = a;
|
||||
let tipEnd: [number, number] = b;
|
||||
let bestStart = Infinity;
|
||||
let bestEnd = Infinity;
|
||||
const stationTol = Math.max(1.5, (maxP - minP) * 0.001);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
const across = Math.abs((x - cx) * -uy + (y - cy) * ux);
|
||||
if (Math.abs(p - minP) <= stationTol && across < bestStart) {
|
||||
bestStart = across;
|
||||
tipStart = [x, y];
|
||||
}
|
||||
if (Math.abs(p - maxP) <= stationTol && across < bestEnd) {
|
||||
bestEnd = across;
|
||||
tipEnd = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||
tipStart,
|
||||
tipEnd,
|
||||
ux,
|
||||
uy,
|
||||
span: maxP - minP,
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe slab extent along unit (px, py) through origin (ox, oy). */
|
||||
function probeExtent(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
ox: number,
|
||||
oy: number,
|
||||
px: number,
|
||||
py: number,
|
||||
): { minP: number; maxP: number } | null {
|
||||
const half = Math.max(2, Math.round((w + h) / 4));
|
||||
let minP = Infinity;
|
||||
let maxP = -Infinity;
|
||||
for (let p = -half; p <= half; p++) {
|
||||
const x = Math.round(ox + px * p);
|
||||
const y = Math.round(oy + py * p);
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||||
if (bitset[y * w + x]) {
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) return null;
|
||||
return { minP, maxP };
|
||||
}
|
||||
|
||||
function fmtInches(inches: number): string {
|
||||
const whole = Math.floor(inches + 1e-9);
|
||||
const rem = Math.round((inches - whole) * 8);
|
||||
if (rem === 0) return `${whole}"`;
|
||||
const frac: Record<number, string> = {
|
||||
1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '',
|
||||
};
|
||||
if (rem === 8) return `${whole + 1}"`;
|
||||
const part = frac[rem] ?? `${rem}/8`;
|
||||
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
|
||||
}
|
||||
|
||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const {
|
||||
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||||
inventoryW, inventoryH,
|
||||
} = input;
|
||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
||||
// Re-orient span ends so EXIF bottom establishes station origin; axis still
|
||||
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
|
||||
const oriented = pickBottomTowardTop(seg.start, seg.end);
|
||||
const { bottom, top, ux, uy } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
// Consistent smaller readable font (#71). Cap by inventory size so high
|
||||
// source scale (≈80 px/in) does not dominate the PNG.
|
||||
const fontPx = (() => {
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
return Math.max(12, Math.min(16, Math.round(Math.min(inventoryW, inventoryH) * 0.016)));
|
||||
}
|
||||
return Math.max(12, Math.min(16, Math.round(scale * 0.18)));
|
||||
})();
|
||||
// Inset for stroke + arrowhead so visual tips sit on/inside edges (#71).
|
||||
const lineW = Math.max(2, Math.round(fontPx / 10));
|
||||
const arrowHead = Math.max(7, fontPx * 0.32);
|
||||
// Width tips retain stroke/arrowhead protection. Length endpoints need only
|
||||
// half-stroke protection: larger arrowhead-derived inset visibly under-reached tips (#73).
|
||||
const widthEdgeInset = Math.max(lineW / 2 + 0.5, arrowHead * 0.15);
|
||||
const lengthEdgeInset = Math.max(0.5, lineW / 2);
|
||||
const pad = Math.max(10, Math.min(28, fontPx * 1.1));
|
||||
const stations = overlayWidthStations(lengthIn);
|
||||
const widths: OverlayWidthMark[] = [];
|
||||
for (const stationIn of stations) {
|
||||
const t = stationIn * scale;
|
||||
const ox = bottom[0] + ux * t;
|
||||
const oy = bottom[1] + uy * t;
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (!extent) continue;
|
||||
// left/right on mask edges, then inset in inventory space for stroke/arrowhead
|
||||
const leftMapped = mapToInventory(ox + rx * extent.minP, oy + ry * extent.minP);
|
||||
const rightMapped = mapToInventory(ox + rx * extent.maxP, oy + ry * extent.maxP);
|
||||
const wdx = rightMapped[0] - leftMapped[0];
|
||||
const wdy = rightMapped[1] - leftMapped[1];
|
||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||
const wux = wdx / wlen;
|
||||
const wuy = wdy / wlen;
|
||||
const left: [number, number] = [leftMapped[0] + wux * widthEdgeInset, leftMapped[1] + wuy * widthEdgeInset];
|
||||
const right: [number, number] = [rightMapped[0] - wux * widthEdgeInset, rightMapped[1] - wuy * widthEdgeInset];
|
||||
// Width number sits axis-left outside the slab (past left tip).
|
||||
const labelOff = extent.minP - pad - fontPx * 0.85;
|
||||
const labelSrcW: [number, number] = [ox + rx * labelOff, oy + ry * labelOff];
|
||||
const widthIn = (extent.maxP - extent.minP + 1) / scale;
|
||||
widths.push({
|
||||
stationIn,
|
||||
center: mapToInventory(ox, oy),
|
||||
left,
|
||||
right,
|
||||
labelAt: mapToInventory(labelSrcW[0], labelSrcW[1]),
|
||||
label: fmtInches(widthIn),
|
||||
});
|
||||
}
|
||||
|
||||
// Length graphic: beside wood on axis-right, outside max right extent.
|
||||
let maxRight = 0;
|
||||
const samples = Math.max(8, Math.round(lengthIn));
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = (i / samples) * (lengthIn * scale);
|
||||
const ox = bottom[0] + ux * t;
|
||||
const oy = bottom[1] + uy * t;
|
||||
const extent = probeExtent(bitset, widthPx, heightPx, ox, oy, rx, ry);
|
||||
if (extent && extent.maxP > maxRight) maxRight = extent.maxP;
|
||||
}
|
||||
const outward = maxRight + pad;
|
||||
// Classic dimensioning (#70 + #71): width-aware centerline tip stations offset by
|
||||
// pure axis-right (rx,ry)*outward. Length stays || approved (ux,uy); no screen-slide.
|
||||
// Axial inset (inventory space) so arrowhead tips sit on footprint, not past.
|
||||
const aMapped = mapToInventory(bottom[0] + rx * outward, bottom[1] + ry * outward);
|
||||
const bMapped = mapToInventory(top[0] + rx * outward, top[1] + ry * outward);
|
||||
const ldx0 = bMapped[0] - aMapped[0];
|
||||
const ldy0 = bMapped[1] - aMapped[1];
|
||||
const llen0 = Math.hypot(ldx0, ldy0) || 1;
|
||||
const lux = ldx0 / llen0;
|
||||
const luy = ldy0 / llen0;
|
||||
const aInv: [number, number] = [aMapped[0] + lux * lengthEdgeInset, aMapped[1] + luy * lengthEdgeInset];
|
||||
const bInv: [number, number] = [bMapped[0] - lux * lengthEdgeInset, bMapped[1] - luy * lengthEdgeInset];
|
||||
const midInv: [number, number] = [(aInv[0] + bInv[0]) / 2, (aInv[1] + bInv[1]) / 2];
|
||||
// Length label further axis-right of dim line so glyphs stay off the wood (#71).
|
||||
const mapOrigin = mapToInventory(0, 0);
|
||||
const mapR = mapToInventory(rx, ry);
|
||||
const irx = mapR[0] - mapOrigin[0];
|
||||
const iry = mapR[1] - mapOrigin[1];
|
||||
const irlen = Math.hypot(irx, iry) || 1;
|
||||
const nrx = irx / irlen;
|
||||
const nry = iry / irlen;
|
||||
const labelAt: [number, number] = [
|
||||
midInv[0] + nrx * (fontPx * 0.85 + 6),
|
||||
midInv[1] + nry * (fontPx * 0.85 + 6),
|
||||
];
|
||||
|
||||
const lengthMark: OverlayLengthMark = {
|
||||
a: aInv,
|
||||
b: bInv,
|
||||
labelAt,
|
||||
label: lengthLabel,
|
||||
outside: true,
|
||||
};
|
||||
|
||||
// Clamp all labels inside inventory PNG so nothing clips (#71).
|
||||
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
|
||||
const margin = Math.max(8, fontPx * 0.65);
|
||||
const clamp = (pt: [number, number], growLeft: boolean, text: string): [number, number] => {
|
||||
let [x, y] = pt;
|
||||
const glyphW = Math.max(fontPx * 1.2, text.length * fontPx * 0.55);
|
||||
if (growLeft) {
|
||||
// textAlign right — glyphs extend left of anchor
|
||||
x = Math.max(margin + glyphW, Math.min(inventoryW - margin, x));
|
||||
} else {
|
||||
x = Math.max(margin, Math.min(inventoryW - margin, x));
|
||||
}
|
||||
y = Math.max(margin, Math.min(inventoryH - margin, y));
|
||||
return [x, y];
|
||||
};
|
||||
lengthMark.labelAt = clamp(lengthMark.labelAt, false, lengthLabel);
|
||||
for (const w of widths) {
|
||||
const growLeft = w.labelAt[0] <= w.center[0];
|
||||
w.labelAt = clamp(w.labelAt, growLeft, w.label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stroke: '#000000',
|
||||
fontPx,
|
||||
length: lengthMark,
|
||||
widths,
|
||||
};
|
||||
}
|
||||
|
||||
/** Draw black dimension graphics onto an existing canvas (inventory PNG size). */
|
||||
export function drawOverlayPlan(
|
||||
ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D,
|
||||
plan: OverlayPlan,
|
||||
): void {
|
||||
const { stroke, fontPx } = plan;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.lineWidth = Math.max(2, Math.round(fontPx / 10));
|
||||
// Butt caps: round caps extend past endpoints and overshoot wood edges (#71).
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.font = `bold ${fontPx}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const arrow = (from: [number, number], to: [number, number]) => {
|
||||
const dx = to[0] - from[0];
|
||||
const dy = to[1] - from[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const head = Math.max(7, fontPx * 0.32);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from[0], from[1]);
|
||||
ctx.lineTo(to[0], to[1]);
|
||||
ctx.stroke();
|
||||
// arrow tips at both ends
|
||||
for (const [sx, sy, dir] of [
|
||||
[from[0], from[1], 1],
|
||||
[to[0], to[1], -1],
|
||||
] as const) {
|
||||
const px = -uy;
|
||||
const py = ux;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.lineTo(sx + dir * ux * head + px * head * 0.4, sy + dir * uy * head + py * head * 0.4);
|
||||
ctx.lineTo(sx + dir * ux * head - px * head * 0.4, sy + dir * uy * head - py * head * 0.4);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
// Length — grow text further axis-right of the dim line (away from wood)
|
||||
arrow(plan.length.a, plan.length.b);
|
||||
{
|
||||
const [lax, lay] = plan.length.a;
|
||||
const [lbx, lby] = plan.length.b;
|
||||
const midX = (lax + lbx) / 2;
|
||||
const midY = (lay + lby) / 2;
|
||||
const outwardX = plan.length.labelAt[0] - midX;
|
||||
const outwardY = plan.length.labelAt[1] - midY;
|
||||
// Prefer left/right align so glyphs extend away from wood when offset is mostly ±x
|
||||
if (Math.abs(outwardX) >= Math.abs(outwardY)) {
|
||||
ctx.textAlign = outwardX >= 0 ? 'left' : 'right';
|
||||
} else {
|
||||
ctx.textAlign = 'center';
|
||||
}
|
||||
}
|
||||
ctx.fillText(plan.length.label, plan.length.labelAt[0], plan.length.labelAt[1]);
|
||||
|
||||
// Widths — grow text away from wood (labelAt is axis-left of left tip)
|
||||
for (const w of plan.widths) {
|
||||
arrow(w.left, w.right);
|
||||
// labelAt is on the axis-left side of the station; align so glyphs extend further out
|
||||
const towardLeft = w.labelAt[0] <= w.center[0];
|
||||
ctx.textAlign = towardLeft ? 'right' : 'left';
|
||||
ctx.fillText(w.label, w.labelAt[0], w.labelAt[1]);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export interface InventoryMap {
|
||||
mapToInventory: (x: number, y: number) => [number, number];
|
||||
outW: number;
|
||||
outH: number;
|
||||
}
|
||||
|
||||
/** Same crop math as composeInventoryFrame — source → inventory PNG pixels. */
|
||||
export function inventorySourceMap(
|
||||
slabBounds: { x: number; y: number; w: number; h: number },
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
): InventoryMap {
|
||||
const FILL = 0.9;
|
||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(slabBounds.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
return {
|
||||
outW,
|
||||
outH,
|
||||
mapToInventory: (sx, sy) => [ox + (sx - x0) * scale, oy + (sy - y0) * scale],
|
||||
};
|
||||
}
|
||||
|
||||
export interface EncodeOverlayInput {
|
||||
/** Clean calibration inventory PNG bytes. */
|
||||
inventoryPng: Blob;
|
||||
bitset: Uint8Array;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
bbox: BBox;
|
||||
scale: number;
|
||||
lengthIn: number;
|
||||
lengthLabel: string;
|
||||
slabBounds: { x: number; y: number; w: number; h: number };
|
||||
source: { widthPx: number; heightPx: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Burn black dimension overlays onto a copy of the clean calibration inventory PNG.
|
||||
* Returns a new image/png blob; never mutates the clean inventory.
|
||||
*/
|
||||
export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob> {
|
||||
const bmp = await createImageBitmap(input.inventoryPng);
|
||||
try {
|
||||
const outW = bmp.width;
|
||||
const outH = bmp.height;
|
||||
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 plan = buildOverlayPlan({
|
||||
bitset: input.bitset,
|
||||
widthPx: input.widthPx,
|
||||
heightPx: input.heightPx,
|
||||
bbox: input.bbox,
|
||||
scale: input.scale,
|
||||
lengthIn: input.lengthIn,
|
||||
lengthLabel: input.lengthLabel,
|
||||
mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
} finally {
|
||||
bmp.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
||||
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let data: Uint8ClampedArray | null = null;
|
||||
let w = 0, h = 0;
|
||||
let current = 0; // highest job id issued by main; older replies are dropped
|
||||
|
||||
function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
|
||||
let sheet = params.sheet;
|
||||
if (sheet === 'auto') {
|
||||
const samples: { r: number; g: number; b: number }[] = [];
|
||||
for (let y = 0; y < h; y += 16)
|
||||
for (let x = 0; x < w; x += 16) {
|
||||
const i = (y * w + x) * 4;
|
||||
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
|
||||
}
|
||||
sheet = autoPickSheet(samples);
|
||||
}
|
||||
const keep = sheet === 'green' ? greenKeep : blackKeep;
|
||||
const s = Math.max(0, Math.min(100, params.sensitivity));
|
||||
const bitset = new Uint8Array(w * h);
|
||||
let slabPx = 0;
|
||||
for (let i = 0, p = 0; i < data.length; i += 4, p++) {
|
||||
if (keep(data[i], data[i + 1], data[i + 2], s)) {
|
||||
bitset[p] = 1;
|
||||
slabPx++;
|
||||
}
|
||||
}
|
||||
if (slabPx === 0) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
const bbox = minAreaRect(bitset, w, h);
|
||||
if (!bbox) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
return { id, kind: 'ready', slabPx, bitset, bbox, slabBounds: slabBounds(bitset, w, h) };
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerIn>) => {
|
||||
const msg = e.data;
|
||||
if (msg.id < current) return; // superseded by a newer job
|
||||
current = msg.id;
|
||||
if (msg.kind === 'init') {
|
||||
if (bitmap) {
|
||||
bitmap.close();
|
||||
bitmap = null;
|
||||
}
|
||||
data = null;
|
||||
w = h = 0;
|
||||
decodeOrientedBitmap(msg.blob)
|
||||
.then((bmp) => {
|
||||
w = bmp.width;
|
||||
h = bmp.height;
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
data = ctx.getImageData(0, 0, w, h).data;
|
||||
bitmap = bmp;
|
||||
self.postMessage({ id: msg.id, kind: 'decoded', w, h } as WorkerOut);
|
||||
})
|
||||
.catch((err) => self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut));
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'invalidate') {
|
||||
if (bitmap) {
|
||||
bitmap.close();
|
||||
bitmap = null;
|
||||
}
|
||||
data = null;
|
||||
w = h = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const out = runMask({ sheet: msg.sheet, sensitivity: msg.sensitivity }, msg.id);
|
||||
if (out.kind === 'ready') {
|
||||
self.postMessage(out, [out.bitset.buffer]);
|
||||
} else {
|
||||
self.postMessage(out);
|
||||
}
|
||||
} catch (err) {
|
||||
self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut);
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,40 @@
|
||||
# E2E visual artifacts (#65)
|
||||
|
||||
Screenshots from `npx playwright test` for Milestone 2 Photos-ready evidence.
|
||||
|
||||
## How to run (local box — not ubudual :10880)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npx playwright install chromium # once per machine
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Or against an already-running preview:
|
||||
|
||||
```bash
|
||||
npm run build && npm run preview -- --host 127.0.0.1 --port 4173
|
||||
# other terminal:
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=31, thickness=2.5). Black sheet — e2e selects **Black** and may nudge sensitivity.
|
||||
|
||||
## Expected screenshots (this folder)
|
||||
|
||||
| File | Step |
|
||||
|---|---|
|
||||
| `01-batch.png` | Batch with fixtures loaded |
|
||||
| `02-pick-calibration.png` | Pick calibration |
|
||||
| `03-mask.png` | Mask (Black sheet) ready to approve |
|
||||
| `04-axis.png` | Length axis |
|
||||
| `05-measure.png` | Measure with L / thickness filled |
|
||||
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
||||
|
||||
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
||||
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
||||
|
||||
## Unit tests
|
||||
|
||||
`npm test` must still pass separately from Playwright.
|
||||
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 466 KiB |
@@ -0,0 +1,11 @@
|
||||
# Last green e2e run (#71)
|
||||
|
||||
- **When:** 2026-09-15 9:55 PM PT (`2026-09-16T04:55:14Z`)
|
||||
- **Fixture:** `tests/fixtures/set1-48x2/` (L=48, thickness=2)
|
||||
- **Server:** local `vite preview` at `http://127.0.0.1:4173` (not ubudual)
|
||||
- **Result:** 1 passed (~8s test)
|
||||
- **Mask:** Black sheet — Approve enabled
|
||||
- **Fix under test:** width-aware tip stations; stroke/arrowhead edge inset; inventory-capped font; labels clamped in PNG (#71); #70 axis-parallel kept
|
||||
- **Lineup:** Clean calibration → Dimension overlay → Batch photo 2
|
||||
|
||||
Full-resolution PNGs are produced under `../` on each run (gitignored). These JPEGs are commit-sized evidence for review.
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.resolve(__dirname, '../fixtures/set1-48x2');
|
||||
const ARTIFACTS = path.resolve(__dirname, 'artifacts');
|
||||
const FACE_A = path.join(FIXTURES, 'face-a.jpeg');
|
||||
const FACE_B = path.join(FIXTURES, 'face-b.jpeg');
|
||||
|
||||
const shot = async (page: Page, name: string) => {
|
||||
fs.mkdirSync(ARTIFACTS, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(ARTIFACTS, name),
|
||||
fullPage: true,
|
||||
});
|
||||
};
|
||||
|
||||
/** Discard any device draft so we always start on Batch. */
|
||||
const ensureFreshBatch = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
// resumeProbe is async onMount — wait for either Resume or Batch.
|
||||
const resumeHeading = page.getByRole('heading', { name: /resume/i });
|
||||
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
|
||||
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
|
||||
if (await resumeHeading.isVisible().catch(() => false)) {
|
||||
await page.getByRole('button', { name: /start fresh/i }).click();
|
||||
}
|
||||
await expect(batchHeading).toBeVisible();
|
||||
};
|
||||
|
||||
/**
|
||||
* Black-sheet fixtures: Auto may work, but UAT note says prefer Black + sensitivity
|
||||
* tweaks. Do not invent inference — only UI sheet/sensitivity controls.
|
||||
*/
|
||||
const prepareMaskForApprove = async (page: Page) => {
|
||||
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
|
||||
// Sheet change recomputes immediately (session.setSheet → runPass).
|
||||
await page.getByRole('button', { name: 'Black', exact: true }).click();
|
||||
|
||||
const approve = page.getByRole('button', { name: 'Approve mask' });
|
||||
// First overlay after decode is debounced 2s; Black pass is immediate but
|
||||
// 4K fixtures still need decode + worker time.
|
||||
try {
|
||||
await expect(approve).toBeEnabled({ timeout: 45_000 });
|
||||
return;
|
||||
} catch {
|
||||
/* try sensitivity ladder */
|
||||
}
|
||||
|
||||
// Higher sensitivity widens black sheet match; if still empty, try lower.
|
||||
for (const target of [60, 70, 40, 30, 80]) {
|
||||
const slider = page.locator('input[type="range"]');
|
||||
await slider.fill(String(target));
|
||||
// Sensitivity is debounced 2s — wait past debounce + worker.
|
||||
await page.waitForTimeout(2800);
|
||||
if (await approve.isEnabled()) return;
|
||||
}
|
||||
|
||||
await expect(approve, 'Mask approve stayed disabled after Black + sensitivity tweaks').toBeEnabled({
|
||||
timeout: 15_000,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
|
||||
|
||||
await ensureFreshBatch(page);
|
||||
|
||||
// Gallery multi-input (not the capture=environment camera input).
|
||||
const gallery = page.locator('input[type="file"][multiple]');
|
||||
await gallery.setInputFiles([FACE_A, FACE_B]);
|
||||
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByAltText('Batch photo 2')).toBeVisible();
|
||||
await shot(page, '01-batch.png');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Pick calibration photo' })).toBeVisible();
|
||||
await shot(page, '02-pick-calibration.png');
|
||||
|
||||
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
|
||||
await prepareMaskForApprove(page);
|
||||
await shot(page, '03-mask.png');
|
||||
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
|
||||
await shot(page, '04-axis.png');
|
||||
await page.getByRole('button', { name: 'Approve axis' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
|
||||
await page.getByLabel('Length L').fill('48');
|
||||
await page.getByLabel('Thickness').fill('2');
|
||||
// blur/change commits eighths into session before Calculate.
|
||||
await page.getByLabel('Length L').blur();
|
||||
await page.getByLabel('Thickness').blur();
|
||||
await shot(page, '05-measure.png');
|
||||
|
||||
const calc = page.getByRole('button', { name: 'Calculate' });
|
||||
await expect(calc).toBeEnabled();
|
||||
await calc.click();
|
||||
|
||||
// Calculate encodes calibration PNG + overlay + sibling batch apply.
|
||||
await expect(page.getByRole('heading', { name: 'Photos ready' })).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(page.getByLabel('Photos ready')).toBeVisible();
|
||||
await expect(page.getByText('Clean calibration')).toBeVisible();
|
||||
await expect(page.getByText('Dimension overlay')).toBeVisible();
|
||||
// Sibling face-b should appear as Batch photo 2 (capture order).
|
||||
await expect(page.getByText(/Batch photo 2|Photo 2 failed/)).toBeVisible();
|
||||
|
||||
await shot(page, '06-photos-ready.png');
|
||||
|
||||
// Write a short run log for parent AI visual review (not a substitute for eyes).
|
||||
const logPath = path.join(ARTIFACTS, 'run-log.txt');
|
||||
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
|
||||
const lines = [
|
||||
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
|
||||
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
|
||||
`finished: ${new Date().toISOString()}`,
|
||||
`artifacts:`,
|
||||
...shots.map((s) => {
|
||||
const p = path.join(ARTIFACTS, s);
|
||||
const st = fs.existsSync(p) ? fs.statSync(p) : null;
|
||||
return ` - ${s} (${st ? `${st.size} bytes` : 'MISSING'})`;
|
||||
}),
|
||||
`assertions: Photos ready heading; Clean calibration; Dimension overlay; batch sibling slot`,
|
||||
];
|
||||
fs.writeFileSync(logPath, lines.join('\n') + '\n', 'utf8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
# Test photo fixtures
|
||||
|
||||
Matched sets from Ty for Milestone 2 Photos (Playwright + calibration). Black sheet.
|
||||
|
||||
## set1-48x2 — L = 48 in, thickness = 2 in
|
||||
Curly / fiddleback bookmatch pair.
|
||||
- `face-a.jpeg`
|
||||
- `face-b.jpeg`
|
||||
|
||||
## set2-31x2p5 — L = 31 in, thickness = 2.5 in
|
||||
Shorter organic / burl pair.
|
||||
- `face-a.jpeg`
|
||||
- `face-b.jpeg`
|
||||
|
||||
## m1-dji (optional M1 UAT sample)
|
||||
- `162332.JPG` — live-edge on black; historically used on ubudual M1 UAT.
|
||||
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 768 KiB |
|
After Width: | Height: | Size: 884 KiB |
|
After Width: | Height: | Size: 856 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import sveltePreprocess from 'svelte-preprocess';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ preprocess: sveltePreprocess() })],
|
||||
worker: { format: 'es' },
|
||||
});
|
||||