Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc40c66c44 | |||
| f505be0bbf | |||
| 685acc10dd | |||
| 73e28180c8 | |||
| 8f8e0132e6 | |||
| 5651a38058 | |||
| 88278a55e8 | |||
| 7e0157e45b | |||
| c25e3f4927 | |||
| 076eb17db6 | |||
| 1464cac5f7 | |||
| 1ff082f77c | |||
| a801a757f8 | |||
| 85786f16f2 | |||
| c54870f5b9 | |||
| 1b5ad112cc | |||
| df87d97a7d | |||
| 76f30262f3 | |||
| 610b0883fe | |||
| 89bc7d4ee9 | |||
| 0dd5d255ec | |||
| b55344ec05 | |||
| 4ea2618475 | |||
| d7fc424b69 | |||
| b841b16cc0 | |||
| ba30a340e6 | |||
| 0c263c6563 | |||
| 6caa4f62e7 | |||
| 2ac0f6f48e | |||
| f5b84f1ad7 | |||
| 65589ac4b4 | |||
| 087557f89f | |||
| 60700b47fb | |||
| 52240083b3 | |||
| d20e3d9077 | |||
| 0c00d25cc7 | |||
| 96a697a50a | |||
| 67aa3d6b0d | |||
| 8ee3da9dcf | |||
| 1a50fad0a5 | |||
| e61303e2d8 | |||
| bdd03afce4 | |||
| e5acef4c9b | |||
| 204bc5e960 | |||
| 191b507934 | |||
| 0329052d37 | |||
| e1b928ccbd | |||
| 76fcb36a2b | |||
| 5a1800df91 | |||
| cd326d1ec9 | |||
| 502314b338 | |||
| 0612ce66a6 | |||
| 7b7adbdb34 | |||
| d9af8420bc | |||
| fe82133ca4 | |||
| 693719e85f | |||
| 41401a7266 | |||
| 7287a55707 | |||
| 81be17d5cd | |||
| cec03c5bd3 | |||
| e36979617b | |||
| c8d6d5eda0 | |||
| 1133201f30 |
@@ -43,3 +43,6 @@ frontend/tests/e2e/test-results/
|
||||
frontend/tests/e2e/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
|
||||
.uat-data/
|
||||
.venv-overnight/
|
||||
|
||||
@@ -14,6 +14,32 @@
|
||||
|
||||
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
|
||||
|
||||
## Build & test (verified commands)
|
||||
|
||||
Frontend (`frontend/`, Svelte 4 + Vite + TS, no router):
|
||||
|
||||
```
|
||||
cd frontend && npm install
|
||||
npm run test # node --import tsx --test src/**/*.test.ts (unit)
|
||||
npm run build # tsc --noEmit && vite build (type-check + build)
|
||||
npm run test:e2e # playwright test (see Pitfalls: mocks the API)
|
||||
```
|
||||
|
||||
Backend (`backend/`, FastAPI + stdlib sqlite, no ORM, deps pinned in `backend/requirements.txt`):
|
||||
|
||||
```
|
||||
cd backend && python -m pytest tests/ -q
|
||||
uvicorn backend.app:app --host 127.0.0.1 --port 18081
|
||||
```
|
||||
|
||||
No Makefile, no CI workflows, no Gitea CI — nothing runs in a pipeline; the only automation is the local commands above plus the human UAT walk (`deploy/uat_walk.sh`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- The LIVE FastAPI routes in `backend/*.py` are the API source of truth; `docs/contracts/API.md` is aspirational and can drift (it describes a polling model the backend never shipped). Read the router, not the doc, before wiring a client.
|
||||
- Frontend is one Svelte app, no router: `App.svelte` switches on a `writable` flow store. Screens live under `frontend/src/<area>/`.
|
||||
- Commit subjects: `feat(scope): …` / `fix(scope): …` / `test(scope): …` / `chore(scope): …`, often citing a requirement ID or Gitea number.
|
||||
|
||||
## Gates (must pass before claiming work complete)
|
||||
|
||||
Run from repo root:
|
||||
@@ -25,6 +51,14 @@ python tools/check_coverage.py
|
||||
|
||||
Exit 0 on both = pass.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- E2E mocks the API: `tests/e2e/*.spec.ts` use Playwright `page.route('**/api/v1/**')` with fixture ids — there is **no live backend** under e2e. A green `test:e2e` is not proof the real backend works; that's the human `deploy/uat_walk.sh` (real Woo, never a mock — QUALITY-105).
|
||||
- Ports on the 5060 UAT host (192.168.1.202): FastAPI `18083`, FE `18082`/`10881` are typically already up (compose on `deploy/docker-compose.yml`). Check before binding; don't assume `18081` is free.
|
||||
- `SLAB_UAT_UNMASK_SECRETS=1` makes `GET /settings` return decrypted Woo/inference secrets — dev-test only, never set in prod (SETTINGS-75 masking is the default).
|
||||
- Never hardcode live Woo category/term ids into app code (QUALITY-105 / METADATA-5); resolve groupers by slug (`species`, `wcat`) at runtime.
|
||||
- `tsc` won't catch Svelte template errors; `npm run build` (which runs `tsc --noEmit`) is the real type gate for the frontend.
|
||||
|
||||
## Pointers (read these for everything else)
|
||||
|
||||
- Docs map and shipped status: `docs/README.md`
|
||||
|
||||
+24
-3
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
||||
from . import settings as settings_mod
|
||||
|
||||
__version__ = "0.5.0-m3-s0"
|
||||
__version__ = "0.5.0-m3"
|
||||
|
||||
# --- Structured JSON logging (stdlib only) ---
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||
@@ -155,7 +155,11 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
|
||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
|
||||
|
||||
#84: woo_reachable is a real authenticated probe (signal only — store outage
|
||||
does not fail health).
|
||||
"""
|
||||
db_ok = probe_writable()
|
||||
img_ok = probe_images_writable()
|
||||
if not (db_ok and img_ok):
|
||||
@@ -164,10 +168,27 @@ async def health() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
"woo_reachable": False, # S0 stub; S4 will implement real check
|
||||
"woo_reachable": _probe_woo_reachable(),
|
||||
}
|
||||
|
||||
|
||||
def _probe_woo_reachable() -> bool:
|
||||
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
|
||||
|
||||
Storage health never depends on this result. Missing creds → False.
|
||||
"""
|
||||
try:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
if not base or not username or not enc_password:
|
||||
return False
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
# One lightweight authenticated call (categories page 1).
|
||||
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
|
||||
return code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/v1/meta")
|
||||
async def meta() -> dict[str, Any]:
|
||||
"""Per API.md §3."""
|
||||
|
||||
@@ -79,6 +79,21 @@ _M3_TABLES = [
|
||||
]
|
||||
|
||||
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Idempotent column additions for volumes created before a column existed.
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
|
||||
so new columns must be added here. Each guard is a no-op once the column is
|
||||
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
|
||||
"""
|
||||
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
|
||||
if photo_cols and "role" not in photo_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
|
||||
"CHECK(role IN ('calibration','overlay','batch'))"
|
||||
)
|
||||
|
||||
|
||||
def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
||||
conn = get_connection(db_path)
|
||||
schema_path = _get_schema_path()
|
||||
@@ -98,6 +113,10 @@ def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
||||
conn.executescript(schema)
|
||||
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
|
||||
conn.commit()
|
||||
# Safe to run every boot: no-op once the columns exist.
|
||||
with _lock:
|
||||
_migrate(conn)
|
||||
conn.commit()
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ CREATE TABLE IF NOT EXISTS slab_photos (
|
||||
slab_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
|
||||
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
|
||||
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
|
||||
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
|
||||
original_blob BLOB,
|
||||
processed_blob BLOB,
|
||||
width_px INTEGER,
|
||||
|
||||
+787
-109
@@ -1,10 +1,15 @@
|
||||
"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2
|
||||
content prose, test-inference, inference-off server description, inference_log.
|
||||
Cites METADATA-35..115, SETTINGS-30/45/90.
|
||||
|
||||
overnight-whole-app: Call 1 POST is hard-disabled (410 call1_disabled). Call 2 stays.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -13,25 +18,139 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from PIL import Image
|
||||
|
||||
from .db import get_connection
|
||||
from .crypto import decrypt
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
# METADATA-35: Call 1 (and Call 2) send clean processed photos at 1024 px long edge or less.
|
||||
_INFER_MAX_LONG_EDGE = 1024
|
||||
|
||||
|
||||
def fit_long_edge_png(png_bytes: bytes, max_long_edge: int = _INFER_MAX_LONG_EDGE) -> bytes:
|
||||
"""Downscale a PNG so max(width, height) <= max_long_edge. Never upscale. Keep alpha.
|
||||
|
||||
Used only for the inference request payload; stored processed_blob / Woo media stay full size.
|
||||
Unreadable or non-image bytes are returned unchanged so a bad blob cannot crash Call 1.
|
||||
"""
|
||||
if not png_bytes or max_long_edge < 1:
|
||||
return png_bytes
|
||||
try:
|
||||
im = Image.open(io.BytesIO(png_bytes))
|
||||
im.load()
|
||||
except Exception:
|
||||
return png_bytes
|
||||
w, h = im.size
|
||||
long_edge = max(w, h)
|
||||
if long_edge <= max_long_edge:
|
||||
# Already within budget; re-encode only if we need a stable PNG (caller always base64s).
|
||||
return png_bytes
|
||||
scale = max_long_edge / float(long_edge)
|
||||
nw = max(1, int(round(w * scale)))
|
||||
nh = max(1, int(round(h * scale)))
|
||||
# Preserve alpha: convert palette/etc. to RGBA when needed, LANCZOS downscale.
|
||||
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
||||
im = im.convert("RGBA")
|
||||
out = im.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
out.save(buf, format="PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _image_part_from_processed(png_bytes: bytes) -> dict:
|
||||
"""Build one OpenAI-style image_url part from a processed PNG (METADATA-35 sized)."""
|
||||
fitted = fit_long_edge_png(png_bytes)
|
||||
b64 = base64.b64encode(fitted).decode()
|
||||
return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||
|
||||
# Locked Call 1 seed (#86 / METADATA-40): expert helper + closed Woo lists only.
|
||||
CALL1_SEED = (
|
||||
"Classify the slab's species, wood categories, edge type, figure, and grade from the "
|
||||
"supplied photos, choosing only from the supplied synced taxonomy lists, and return the "
|
||||
"result fields as JSON: species_id, wood_category_ids, edge_type_term_id, figure_term_ids, "
|
||||
"grade_term_id, character, inclusions, voids, checks. Report a confidence (0-1) for each "
|
||||
"classified field. Map free-text observations to the supplied feature-tag list only. "
|
||||
"Invent no category or term."
|
||||
"You are a wood identification expert for retail lumber slabs. Judge only from the photos "
|
||||
"and the LISTS below. Use list ids only — never invent a name or id.\n"
|
||||
"\n"
|
||||
"Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, "
|
||||
"(5) feature tags. Decide wood categories before edge type — edge rules depend on product form.\n"
|
||||
"\n"
|
||||
"For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). "
|
||||
"Prefer null or [] over a weak guess.\n"
|
||||
"\n"
|
||||
"Species — which tree species is this slab. Match grain, color, and figure to the species "
|
||||
"list. If more than one entry is plausible, return only the single highest-confidence match "
|
||||
"(one id). Null only when none are credible.\n"
|
||||
"\n"
|
||||
"Wood categories — product form / how it will be sold (live edge, dimensional, cookies & "
|
||||
"burl slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; "
|
||||
"often one, sometimes more. [] if none fit. Finish this field before edge type.\n"
|
||||
"\n"
|
||||
"Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.\n"
|
||||
" Meanings (match the closest list name):\n"
|
||||
" • Square / square edge — both long edges milled straight (2 milled long edges).\n"
|
||||
" • Live one side / Live Edge 1 side — one long edge milled, the other natural bark or "
|
||||
"waney (1 milled long edge).\n"
|
||||
" • Live both / Live edge 2 sides — neither long edge milled; both natural "
|
||||
"(0 milled long edges).\n"
|
||||
" • Wild edge — use when the three mill-count cases do not fit, including cookies and burl "
|
||||
"cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.\n"
|
||||
"\n"
|
||||
"Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids "
|
||||
"from figure_terms that clearly apply.\n"
|
||||
"\n"
|
||||
"Feature tags — defects and character marks the store tags for shoppers (knots, checks, "
|
||||
"inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].\n"
|
||||
"\n"
|
||||
"Return one JSON object with exactly these keys:\n"
|
||||
'{"species_id": <id|null>, "species_confidence": <0-1>, '
|
||||
'"wood_category_ids": [<id>,...], "wood_category_confidence": <0-1>, '
|
||||
'"edge_type_term_id": <id|null>, "edge_confidence": <0-1>, '
|
||||
'"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, '
|
||||
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
|
||||
)
|
||||
CALL2_SEED = (
|
||||
"Write the long product description for the slab from the supplied photos and listing "
|
||||
"facts. The name and short description are already built, so do not repeat them. Use the "
|
||||
"brand voice and geo context given. The dimensions in the fact block are exact: use those "
|
||||
"strings and invent no numbers. Write prose a premium lumber buyer would read."
|
||||
"Write listing copy for this slab for woodworkers choosing stock for a project. "
|
||||
"Audience: makers matching wood to a tabletop, bar, bench, or similar build — practical, "
|
||||
"material/figure/spec-forward. No lifestyle or marketing hype "
|
||||
"(avoid empty phrases like elevate, stunning, dramatic visual contrast).\n"
|
||||
"You write BOTH the short description and the long description.\n"
|
||||
"\n"
|
||||
"PLAIN TEXT ONLY (short AND long):\n"
|
||||
"- Output plain text only. No HTML tags (<p>, <ul>, <li>, <strong>, <br>, etc.). "
|
||||
"No markdown bold/headers that look like markup (**bold**, # headers).\n"
|
||||
"- Structure the long description with newlines and \"- \" bullets when helpful.\n"
|
||||
"\n"
|
||||
"Rules that always apply:\n"
|
||||
"- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.\n"
|
||||
"- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade, "
|
||||
"moisture, or features.\n"
|
||||
"- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay "
|
||||
"neutral. Never invent return, warranty, or shipping policies.\n"
|
||||
"- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.\n"
|
||||
"- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece "
|
||||
"suits (tabletop, bar, etc.) without ad-copy fluff.\n"
|
||||
"- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED "
|
||||
"ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description "
|
||||
"(no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no \"Full specs\" "
|
||||
"section, no \"Maple Slab\" attribute-header block). You may weave 1–2 key traits into "
|
||||
"prose or benefit bullets naturally.\n"
|
||||
"\n"
|
||||
"SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):\n"
|
||||
"1. Lead with a practical outcome for a maker (what they can build / how the stock helps), "
|
||||
"not lifestyle hype and not a bare species list.\n"
|
||||
"2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).\n"
|
||||
"3. Close with a trust signal or concrete reason to choose this piece.\n"
|
||||
"\n"
|
||||
"LONG DESCRIPTION (plain text only — newlines and \"- \" bullets; no HTML, no markdown "
|
||||
"markup). Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute "
|
||||
"list / product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK:\n"
|
||||
"1. Hook — one sentence on what the piece suits and its standout usable trait.\n"
|
||||
"2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail. "
|
||||
"Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.\n"
|
||||
"3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present; "
|
||||
"otherwise a short neutral close with no invented policies.\n"
|
||||
"\n"
|
||||
"Return ONE JSON object only (no prose outside JSON) with exactly these keys:\n"
|
||||
'{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}'
|
||||
)
|
||||
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
|
||||
_THRESHOLD = 0.7
|
||||
@@ -52,19 +171,53 @@ def _hash(text: str) -> str:
|
||||
|
||||
|
||||
def _seed_prompts(conn) -> None:
|
||||
"""Ensure each call has a prompt row and the shipped seed revision.
|
||||
|
||||
If the shipped seed hash is new and the active revision is still seed-authored
|
||||
(or missing), activate the new seed so deploys pick up CALL1_SEED / CALL2_SEED
|
||||
updates without clobbering an owner-activated API revision.
|
||||
"""
|
||||
for name, seed in _SEEDS.items():
|
||||
if conn.execute("SELECT 1 FROM prompts WHERE name=?", (name,)).fetchone():
|
||||
continue
|
||||
cur = conn.execute(
|
||||
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
|
||||
(name, None, _now(), _now()),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
cur2 = conn.execute(
|
||||
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)",
|
||||
(pid, seed, _hash(seed), _now(), "seed"),
|
||||
)
|
||||
conn.execute("UPDATE prompts SET active_revision_id=? WHERE id=?", (cur2.lastrowid, pid))
|
||||
prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone()
|
||||
if prow is None:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
|
||||
(name, None, _now(), _now()),
|
||||
)
|
||||
pid = cur.lastrowid
|
||||
active_id = None
|
||||
else:
|
||||
pid = prow[0]
|
||||
active_id = prow[1]
|
||||
h = _hash(seed)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_revisions WHERE prompt_id=? AND content_hash=?",
|
||||
(pid, h),
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
cur2 = conn.execute(
|
||||
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(pid, seed, h, _now(), "seed"),
|
||||
)
|
||||
seed_rev_id = cur2.lastrowid
|
||||
else:
|
||||
seed_rev_id = existing[0]
|
||||
# Activate shipped seed when nothing active, or active is still a seed revision.
|
||||
activate = False
|
||||
if active_id is None:
|
||||
activate = True
|
||||
else:
|
||||
act = conn.execute(
|
||||
"SELECT created_by FROM prompt_revisions WHERE id=?", (active_id,)
|
||||
).fetchone()
|
||||
if act is None or act[0] == "seed":
|
||||
activate = True
|
||||
if activate and active_id != seed_rev_id:
|
||||
conn.execute(
|
||||
"UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?",
|
||||
(seed_rev_id, _now(), pid),
|
||||
)
|
||||
|
||||
|
||||
def _active_prompt(conn, call: str) -> tuple[int, str] | None:
|
||||
@@ -185,62 +338,86 @@ def _enabled(conn, key: str) -> bool:
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call1")
|
||||
async def call1(slab_id: str) -> dict:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if not _enabled(conn, "inference_enabled"):
|
||||
return JSONResponse(status_code=409, content=_error("inference_disabled", "Inference is off"))
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||
client = InferenceClient(base, key, model)
|
||||
act = _active_prompt(conn, "call1")
|
||||
prompt_text = act[1] if act else CALL1_SEED
|
||||
# Build vision message with processed photos (1024px) + taxonomy lists.
|
||||
photos = conn.execute(
|
||||
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts = []
|
||||
for p in photos:
|
||||
image_parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"},
|
||||
})
|
||||
tax = _taxonomy_lists(conn)
|
||||
text = (f"{prompt_text}\n\nSYNCED TAXONOMY (only legal choices):\n{json.dumps(tax)}\n\n"
|
||||
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
|
||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = client.chat(messages)
|
||||
except InferenceConnectionError as e:
|
||||
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
|
||||
except InferenceError as e:
|
||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
parsed = _parse_json(raw)
|
||||
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency)
|
||||
# Apply confidence gate (METADATA-45).
|
||||
suggestions = _gate(parsed)
|
||||
return {"ok": True, "suggestions": suggestions}
|
||||
"""Call 1 OUT (overnight-whole-app): hard-disabled. Call 2 stays.
|
||||
|
||||
Route retained so clients get an explicit 410 instead of a half-wired path.
|
||||
Prompt seed / revision APIs for call1 remain for docs & future re-enable.
|
||||
"""
|
||||
_ = slab_id # unused while disabled
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=_error(
|
||||
"call1_disabled",
|
||||
"Call 1 (vision classification) is disabled on this branch. Use Call 2 for description text.",
|
||||
{"call": 1, "disabled": True},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _feat_display_name(name: str) -> str:
|
||||
"""Strip leading feat- / feat_ for the model (#86); Woo id stays authoritative."""
|
||||
n = (name or "").strip()
|
||||
low = n.lower()
|
||||
for prefix in ("feat-", "feat_"):
|
||||
if low.startswith(prefix):
|
||||
return n[len(prefix):].strip() or n
|
||||
return n
|
||||
|
||||
|
||||
def _taxonomy_lists(conn) -> dict:
|
||||
out = {"species_leaves": [], "edge_terms": [], "figure_terms": [], "grade_terms": [],
|
||||
"thickness_terms": [], "moisture_terms": [], "feat_tags": []}
|
||||
for r in conn.execute("SELECT id,name FROM woo_categories WHERE parent_id IS NULL").fetchall():
|
||||
out["species_leaves"].append({"id": r[0], "name": r[1]})
|
||||
for attr, key in (("Edge Type", "edge_terms"), ("Figure", "figure_terms"), ("Grade", "grade_terms"),
|
||||
("Thickness", "thickness_terms"), ("Moisture", "moisture_terms")):
|
||||
"""Closed Call 1 lists only (#86 / METADATA-40).
|
||||
|
||||
Keys match the seed prompt. No grade/thickness/moisture (owner fields).
|
||||
Feature tag names are shown without the feat- prefix; ids are Woo/local tag ids.
|
||||
Groupers resolved by slug only (QUALITY-105).
|
||||
"""
|
||||
out: dict[str, list] = {
|
||||
"species": [],
|
||||
"wood_categories": [],
|
||||
"edge_terms": [],
|
||||
"figure_terms": [],
|
||||
"feature_tags": [],
|
||||
}
|
||||
# species = leaves under the 'species' grouper
|
||||
sp = conn.execute("SELECT id FROM woo_categories WHERE slug='species'").fetchone()
|
||||
if sp:
|
||||
for r in conn.execute(
|
||||
"SELECT id,name FROM woo_categories WHERE parent_id=?", (sp[0],)
|
||||
).fetchall():
|
||||
out["species"].append({"id": r[0], "name": r[1]})
|
||||
# wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers).
|
||||
wc = conn.execute("SELECT id FROM woo_categories WHERE slug='wcat'").fetchone()
|
||||
if wc:
|
||||
kids = [r[0] for r in conn.execute(
|
||||
"SELECT id FROM woo_categories WHERE parent_id=?", (wc[0],)
|
||||
).fetchall()]
|
||||
leaf_ids = []
|
||||
for cid in kids:
|
||||
has_kids = conn.execute(
|
||||
"SELECT 1 FROM woo_categories WHERE parent_id=? LIMIT 1", (cid,)
|
||||
).fetchone()
|
||||
if has_kids:
|
||||
leaf_ids.extend(r[0] for r in conn.execute(
|
||||
"SELECT id FROM woo_categories WHERE parent_id=?", (cid,)
|
||||
).fetchall())
|
||||
else:
|
||||
leaf_ids.append(cid)
|
||||
if leaf_ids:
|
||||
q = ",".join("?" * len(leaf_ids))
|
||||
for r in conn.execute(
|
||||
"SELECT id,name FROM woo_categories WHERE id IN (%s)" % q, leaf_ids
|
||||
).fetchall():
|
||||
out["wood_categories"].append({"id": r[0], "name": r[1]})
|
||||
for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms")):
|
||||
rows = conn.execute(
|
||||
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id WHERE a.name=?",
|
||||
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id "
|
||||
"WHERE lower(a.name)=lower(?)",
|
||||
(attr,),
|
||||
).fetchall()
|
||||
out[key] = [{"id": r[0], "name": r[1]} for r in rows]
|
||||
for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall():
|
||||
out["feat_tags"].append({"id": r[0], "name": r[1]})
|
||||
out["feature_tags"].append({"id": r[0], "name": _feat_display_name(r[1])})
|
||||
return out
|
||||
|
||||
|
||||
@@ -257,58 +434,452 @@ def _parse_json(raw: str) -> dict:
|
||||
|
||||
|
||||
def _gate(parsed: dict) -> dict:
|
||||
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45)."""
|
||||
def keep(field: str, conf_key: str) -> Any:
|
||||
conf = parsed.get(conf_key)
|
||||
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45).
|
||||
|
||||
Confidence is read from the flat '<field>_confidence' keys; as a safety net it also
|
||||
understands the nested 'confidence: {<field>: n}' shape some models emit, so a shape
|
||||
wobble cannot silently nuke every suggestion. Grade is never suggested (METADATA-40).
|
||||
Free-text observation bags are dropped — feature tags are ids only (#86).
|
||||
"""
|
||||
conf_key = {
|
||||
"species_id": "species_confidence",
|
||||
"wood_category_ids": "wood_category_confidence",
|
||||
"edge_type_term_id": "edge_confidence",
|
||||
"figure_term_ids": "figure_confidence",
|
||||
"feature_tag_ids": "feature_tag_confidence",
|
||||
}
|
||||
conf_obj = parsed.get("confidence") if isinstance(parsed.get("confidence"), dict) else {}
|
||||
|
||||
def _conf(field: str) -> Any:
|
||||
k = conf_key.get(field, f"{field}_confidence")
|
||||
if k in conf_obj:
|
||||
return conf_obj[k]
|
||||
return parsed.get(k)
|
||||
|
||||
def keep(field: str) -> Any:
|
||||
conf = _conf(field)
|
||||
if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed:
|
||||
return parsed[field]
|
||||
return None
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
if v := keep("species_id", "species_confidence"):
|
||||
if v := keep("species_id"):
|
||||
out["species_id"] = v
|
||||
if v := keep("wood_category_ids", "wood_category_confidence"):
|
||||
if v := keep("wood_category_ids"):
|
||||
out["wood_category_ids"] = v
|
||||
if v := keep("edge_type_term_id", "edge_confidence"):
|
||||
if v := keep("edge_type_term_id"):
|
||||
out["edge_type_term_id"] = v
|
||||
if v := keep("figure_term_ids", "figure_confidence"):
|
||||
if v := keep("figure_term_ids"):
|
||||
out["figure_term_ids"] = v
|
||||
if v := keep("grade_term_id", "grade_confidence"):
|
||||
out["grade_term_id"] = v
|
||||
for k in ("character", "inclusions", "voids", "checks"):
|
||||
if parsed.get(k):
|
||||
out[k] = parsed[k]
|
||||
if v := keep("feature_tag_ids"):
|
||||
out["feature_tag_ids"] = v
|
||||
# grade / free-text bags intentionally omitted (METADATA-40, #86).
|
||||
return out
|
||||
|
||||
|
||||
def _json_list(val: Any) -> list:
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _term_name(terms: dict, term_id: Any) -> str:
|
||||
if not term_id:
|
||||
return ""
|
||||
t = terms.get(int(term_id) if not isinstance(term_id, int) and str(term_id).isdigit() else term_id) or {}
|
||||
try:
|
||||
from .taxonomy import _decode_name
|
||||
return _decode_name(t.get("name") or "")
|
||||
except Exception:
|
||||
return str(t.get("name") or "")
|
||||
|
||||
|
||||
def _cat_name(cats: dict, cat_id: Any) -> str:
|
||||
if not cat_id:
|
||||
return ""
|
||||
c = cats.get(int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id) or {}
|
||||
try:
|
||||
from .taxonomy import _decode_name
|
||||
return _decode_name(c.get("name") or "")
|
||||
except Exception:
|
||||
return str(c.get("name") or "")
|
||||
|
||||
|
||||
def _parent_path(cats: dict, cat_id: Any) -> str:
|
||||
"""Parent-category path for a wood-category leaf (METADATA-60)."""
|
||||
if not cat_id:
|
||||
return ""
|
||||
cid = int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id
|
||||
leaf = cats.get(cid) or {}
|
||||
parts: list[str] = []
|
||||
pid = leaf.get("parent_id")
|
||||
seen = set()
|
||||
while pid and pid not in seen:
|
||||
seen.add(pid)
|
||||
parent = cats.get(pid) or {}
|
||||
name = _cat_name(cats, pid)
|
||||
if name:
|
||||
parts.append(name)
|
||||
pid = parent.get("parent_id")
|
||||
parts.reverse()
|
||||
leaf_name = _cat_name(cats, cid)
|
||||
if leaf_name:
|
||||
parts.append(leaf_name)
|
||||
return " > ".join(parts)
|
||||
|
||||
|
||||
def _feat_names(conn, feat_ids: list) -> list[str]:
|
||||
if not feat_ids:
|
||||
return []
|
||||
q = ",".join("?" * len(feat_ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, name FROM woo_tags WHERE id IN ({q})", feat_ids
|
||||
).fetchall()
|
||||
by_id = {r[0]: _feat_display_name(r[1]) for r in rows}
|
||||
return [by_id[i] for i in feat_ids if i in by_id]
|
||||
|
||||
|
||||
def build_declared_attributes(conn, slab: dict) -> str:
|
||||
"""Structured DECLARED ATTRIBUTES from the slab for Call 2 (METADATA-60).
|
||||
|
||||
Names only — no hardcoded Curl≠Curly bridges. Primary = first wood category
|
||||
when not otherwise known; secondary = the rest.
|
||||
"""
|
||||
cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()}
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
wood_ids = _json_list(slab.get("wood_category_ids"))
|
||||
primary_id = wood_ids[0] if wood_ids else None
|
||||
secondary_ids = wood_ids[1:] if len(wood_ids) > 1 else []
|
||||
figure_ids = _json_list(slab.get("figure_term_ids"))
|
||||
feat_ids = _json_list(slab.get("feat_tag_ids"))
|
||||
lines = [
|
||||
"DECLARED ATTRIBUTES (confirmed in review — use these names; do not invent taxonomy):",
|
||||
f"species: {_cat_name(cats, slab.get('species_id')) or '(none)'}",
|
||||
f"primary wood category: {_cat_name(cats, primary_id) or '(none)'}",
|
||||
f"primary path: {_parent_path(cats, primary_id) or '(none)'}",
|
||||
f"secondary wood categories: {', '.join(_cat_name(cats, i) for i in secondary_ids) or '(none)'}",
|
||||
f"edge: {_term_name(terms, slab.get('edge_type_term_id')) or '(none)'}",
|
||||
f"figure: {', '.join(_term_name(terms, i) for i in figure_ids) or '(none)'}",
|
||||
f"grade: {_term_name(terms, slab.get('grade_term_id')) or '(none)'}",
|
||||
f"moisture: {_term_name(terms, slab.get('moisture_term_id')) or '(none)'}",
|
||||
f"features: {', '.join(_feat_names(conn, feat_ids)) or '(none)'}",
|
||||
]
|
||||
title = (slab.get("title") or "").strip()
|
||||
if title:
|
||||
lines.append(f"title: {title}")
|
||||
# short_description is Call 2 output — do not feed a prior value back into the prompt.
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_dimension_fact_block(slab: dict) -> str:
|
||||
"""Exact dimension strings — invent no numbers (METADATA-65/90)."""
|
||||
return (
|
||||
"DIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
||||
f"length={slab.get('length_in')}in thickness={slab.get('thickness_in')}in "
|
||||
f"avg_width={slab.get('width_avg_in')}in width_min={slab.get('width_min_in')}in "
|
||||
f"width_max={slab.get('width_max_in')}in bdft={slab.get('bdft')}"
|
||||
)
|
||||
|
||||
|
||||
def assemble_call2_text(conn, slab: dict, prompt_text: str | None = None) -> str:
|
||||
"""Assemble the Call 2 text payload: seed + declared attrs + dims + brand/geo.
|
||||
|
||||
Shared by View-prompt GET and POST .../inference/call2 so both show the same text.
|
||||
"""
|
||||
seed = prompt_text
|
||||
if seed is None:
|
||||
act = _active_prompt(conn, "call2")
|
||||
seed = act[1] if act else CALL2_SEED
|
||||
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
||||
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
||||
brand_s = brand[0] if brand and brand[0] else ""
|
||||
geo_s = geo[0] if geo and geo[0] else ""
|
||||
return (
|
||||
f"{seed}\n\n"
|
||||
f"{build_declared_attributes(conn, slab)}\n\n"
|
||||
f"{build_dimension_fact_block(slab)}\n\n"
|
||||
f"Brand voice: {brand_s}\n"
|
||||
f"Geo: {geo_s}"
|
||||
)
|
||||
|
||||
|
||||
def assemble_call2_photo_slots(conn, slab_id: str, *, for_wire: bool = False) -> tuple[list[dict], list[dict]]:
|
||||
"""Up to 8 processed PNGs for Call 2.
|
||||
|
||||
Returns (image_parts for the model, photo_slots without base64 for View prompt / log).
|
||||
Slot list: seq, id, bytes, long_edge_px of the *fitted* infer payload.
|
||||
"""
|
||||
photos = conn.execute(
|
||||
"SELECT id, seq, processed_blob FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts: list[dict] = []
|
||||
photo_slots: list[dict] = []
|
||||
for p in photos:
|
||||
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
|
||||
pid = p["id"] if hasattr(p, "keys") else p[0]
|
||||
seq = p["seq"] if hasattr(p, "keys") else p[1]
|
||||
fitted = fit_long_edge_png(blob)
|
||||
if for_wire:
|
||||
image_parts.append(_image_part_from_processed(blob))
|
||||
meta = _photo_meta_row(pid, seq, fitted)
|
||||
photo_slots.append({
|
||||
"seq": meta["seq"],
|
||||
"id": meta["photo_id"],
|
||||
"bytes": meta["bytes"],
|
||||
"long_edge_px": meta["infer_long_edge_px"],
|
||||
})
|
||||
return image_parts, photo_slots
|
||||
|
||||
|
||||
def assemble_call2(conn, slab_id: str) -> dict[str, Any] | None:
|
||||
"""Full Call 2 assembly used by View prompt and the live POST.
|
||||
|
||||
Returns None if the slab is missing. Does not call the model.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
slab = dict(row)
|
||||
for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"):
|
||||
if slab.get(f) is not None and isinstance(slab[f], str):
|
||||
try:
|
||||
slab[f] = json.loads(slab[f])
|
||||
except (ValueError, TypeError):
|
||||
slab[f] = []
|
||||
act = _active_prompt(conn, "call2")
|
||||
prompt_text = act[1] if act else CALL2_SEED
|
||||
rev_id = act[0] if act else None
|
||||
text = assemble_call2_text(conn, slab, prompt_text)
|
||||
image_parts, photo_slots = assemble_call2_photo_slots(conn, slab_id, for_wire=True)
|
||||
# photo_meta for the inference log (same order as image_parts)
|
||||
photo_meta = [
|
||||
{
|
||||
"photo_id": s["id"],
|
||||
"seq": s["seq"],
|
||||
"bytes": s["bytes"],
|
||||
"infer_long_edge_px": s["long_edge_px"],
|
||||
"label": f"photo seq={s['seq']} id={s['id']}",
|
||||
}
|
||||
for s in photo_slots
|
||||
]
|
||||
return {
|
||||
"text": text,
|
||||
"photos": photo_slots,
|
||||
"image_parts": image_parts,
|
||||
"photo_meta": photo_meta,
|
||||
"prompt_revision_id": rev_id,
|
||||
"slab": slab,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/slabs/{slab_id}/inference/call2/prompt")
|
||||
async def call2_prompt(slab_id: str) -> dict:
|
||||
"""View prompt: exact Call 2 text + photo slot list (no base64, no model call)."""
|
||||
conn = get_connection()
|
||||
assembled = assemble_call2(conn, slab_id)
|
||||
if assembled is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
return {
|
||||
"text": assembled["text"],
|
||||
"photos": assembled["photos"],
|
||||
"prompt_revision_id": assembled["prompt_revision_id"],
|
||||
}
|
||||
|
||||
|
||||
# Labels that mark a Woo/taxonomy/dimension metadata dump when used as "- Label: …".
|
||||
_ATTR_DUMP_LABELS = (
|
||||
"Category",
|
||||
"Primary",
|
||||
"Secondary",
|
||||
"Species",
|
||||
"Figure",
|
||||
"Edge",
|
||||
"Grade",
|
||||
"Moisture",
|
||||
"Features",
|
||||
"Feature",
|
||||
"Title",
|
||||
"Length",
|
||||
"Thickness",
|
||||
"Width",
|
||||
"Avg Width",
|
||||
"Average Width",
|
||||
"Width Min",
|
||||
"Width Max",
|
||||
"Min Width",
|
||||
"Max Width",
|
||||
"Board Feet",
|
||||
"Bdft",
|
||||
"BF",
|
||||
"Dimensions",
|
||||
"Dims",
|
||||
)
|
||||
_ATTR_DUMP_LABEL_RE = re.compile(
|
||||
r"(?i)^\s*-\s*(?:"
|
||||
+ "|".join(re.escape(l) for l in _ATTR_DUMP_LABELS)
|
||||
+ r")\s*:",
|
||||
)
|
||||
_ATTR_DUMP_HEADER_RE = re.compile(
|
||||
r"(?i)^\s*(?:Full\s+specs?|Specifications?|Attributes?|Attribute\s+list|"
|
||||
r"[A-Za-z][A-Za-z0-9 /&-]{0,40}\s+Slab)\s*$",
|
||||
)
|
||||
|
||||
|
||||
def _strip_attribute_dump(value: str) -> str:
|
||||
"""Remove a labeled Category:/Length:/Board Feet: metadata dump from long desc.
|
||||
|
||||
Specs live in taxonomy/dims on the product; a trailing (or mid-body) block that
|
||||
re-lists them as "- Label: value" lines drifts from Woo fields. Keeps Hook +
|
||||
Benefits (+ trust close) prose around the dump.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
lines = value.split("\n")
|
||||
n = len(lines)
|
||||
is_attr = [_ATTR_DUMP_LABEL_RE.match(ln) is not None for ln in lines]
|
||||
|
||||
# Find contiguous runs of ≥3 attribute bullets (optionally preceded by a header).
|
||||
runs: list[tuple[int, int]] = []
|
||||
i = 0
|
||||
while i < n:
|
||||
if not is_attr[i]:
|
||||
i += 1
|
||||
continue
|
||||
start = i
|
||||
while i < n and is_attr[i]:
|
||||
i += 1
|
||||
end = i # exclusive
|
||||
if end - start >= 3:
|
||||
# Absorb a single preceding header / "Maple Slab" title line.
|
||||
hdr = start - 1
|
||||
if hdr >= 0 and lines[hdr].strip() and _ATTR_DUMP_HEADER_RE.match(lines[hdr]):
|
||||
# Do not swallow a benefits bullet that happens to match poorly.
|
||||
if not is_attr[hdr]:
|
||||
start = hdr
|
||||
# Absorb blank lines immediately before the block.
|
||||
while start > 0 and not lines[start - 1].strip():
|
||||
start -= 1
|
||||
runs.append((start, end))
|
||||
|
||||
if not runs:
|
||||
return value.strip()
|
||||
|
||||
# Prefer the last run (typical trailing dump); drop all qualifying runs.
|
||||
keep = [True] * n
|
||||
for start, end in runs:
|
||||
for j in range(start, end):
|
||||
keep[j] = False
|
||||
out_lines = [ln for j, ln in enumerate(lines) if keep[j]]
|
||||
text = "\n".join(out_lines)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _html_to_plain(value: str) -> str:
|
||||
"""Defensive: strip HTML/markup so Review fields never show raw tags.
|
||||
|
||||
Converts common block tags to newlines and <li> to "- " bullets, then removes
|
||||
remaining tags and unescapes entities. Already-plain text is returned cleaned.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
t = value
|
||||
# Normalize common break / list / paragraph boundaries before dropping tags.
|
||||
t = re.sub(r"(?is)<br\s*/?>", "\n", t)
|
||||
t = re.sub(r"(?is)</\s*(p|div|h[1-6]|tr|table)\s*>", "\n", t)
|
||||
t = re.sub(r"(?is)<\s*li[^>]*>", "- ", t)
|
||||
t = re.sub(r"(?is)</\s*(ul|ol)\s*>", "\n", t)
|
||||
t = re.sub(r"(?is)<[^>]+>", "", t)
|
||||
t = html.unescape(t)
|
||||
# Drop leftover markdown-ish bold/headers that look like markup in Review.
|
||||
t = re.sub(r"(?m)^#{1,6}\s+", "", t)
|
||||
t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t)
|
||||
t = re.sub(r"[ \t]+\n", "\n", t)
|
||||
t = re.sub(r"\n{3,}", "\n\n", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
def _plain_long_desc(value: str) -> str:
|
||||
"""HTML→plain then drop labeled attribute/metadata dumps from long description."""
|
||||
return _strip_attribute_dump(_html_to_plain(value))
|
||||
|
||||
|
||||
def parse_call2_response(raw: str) -> tuple[str | None, str]:
|
||||
"""Split Call 2 model output into (short_description, description).
|
||||
|
||||
Preferred shape is a JSON object with short_description + description.
|
||||
Both fields are forced to plain text (HTML stripped) before return.
|
||||
Long description also drops a trailing/mid labeled attribute dump
|
||||
(Category:/Length:/Board Feet: …) so specs stay on Woo fields.
|
||||
If JSON is missing or incomplete, treat the whole body as description
|
||||
(stripped) and leave short_description None (caller keeps any prior short).
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return None, ""
|
||||
# Strip common markdown fences around JSON.
|
||||
body = text
|
||||
if body.startswith("```"):
|
||||
lines = body.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
body = "\n".join(lines).strip()
|
||||
try:
|
||||
obj = json.loads(body)
|
||||
except (ValueError, TypeError):
|
||||
# Try to find a JSON object embedded in prose.
|
||||
start = body.find("{")
|
||||
end = body.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
obj = json.loads(body[start : end + 1])
|
||||
except (ValueError, TypeError):
|
||||
return None, _plain_long_desc(text)
|
||||
else:
|
||||
return None, _plain_long_desc(text)
|
||||
if not isinstance(obj, dict):
|
||||
return None, _plain_long_desc(text)
|
||||
short = obj.get("short_description")
|
||||
desc = obj.get("description")
|
||||
if isinstance(short, str):
|
||||
short = _html_to_plain(short) or None
|
||||
else:
|
||||
short = None
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
return short, _plain_long_desc(desc)
|
||||
# JSON without a usable description — fall back to raw (stripped).
|
||||
return short, _plain_long_desc(text)
|
||||
|
||||
|
||||
@router.post("/slabs/{slab_id}/inference/call2")
|
||||
async def call2(slab_id: str) -> dict:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if not _enabled(conn, "content_llm_enabled"):
|
||||
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
|
||||
base, key, model = _inference_creds(conn)
|
||||
if not base or not key:
|
||||
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
||||
assembled = assemble_call2(conn, slab_id)
|
||||
if assembled is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
client = InferenceClient(base, key, model)
|
||||
act = _active_prompt(conn, "call2")
|
||||
prompt_text = act[1] if act else CALL2_SEED
|
||||
photos = conn.execute(
|
||||
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
image_parts = [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"}}
|
||||
for p in photos
|
||||
]
|
||||
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
||||
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
||||
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
||||
f"length={row['length_in']}in thickness={row['thickness_in']}in "
|
||||
f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in "
|
||||
f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}")
|
||||
text = assembled["text"]
|
||||
image_parts = assembled["image_parts"]
|
||||
photo_meta = assembled["photo_meta"]
|
||||
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -318,12 +889,25 @@ async def call2(slab_id: str) -> dict:
|
||||
except InferenceError as e:
|
||||
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency)
|
||||
# Store as description, mark content_source=llm.
|
||||
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||
(raw.strip(), _now(), slab_id))
|
||||
_log_call(
|
||||
conn, slab_id, 2, assembled["prompt_revision_id"], model, messages, raw, latency, photo_meta,
|
||||
)
|
||||
short, description = parse_call2_response(raw)
|
||||
if short:
|
||||
conn.execute(
|
||||
"UPDATE slabs SET short_description=?, description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||
(short, description, _now(), slab_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
||||
(description, _now(), slab_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "description": raw.strip()}
|
||||
out: dict[str, Any] = {"ok": True, "description": description}
|
||||
if short:
|
||||
out["short_description"] = short
|
||||
return out
|
||||
|
||||
|
||||
def server_description(slab: dict) -> str:
|
||||
@@ -336,12 +920,106 @@ def server_description(slab: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _photo_meta_row(photo_id: str, seq: int, blob: bytes) -> dict[str, Any]:
|
||||
"""Metadata for one photo slot in the inference log (#87)."""
|
||||
edge = None
|
||||
try:
|
||||
im = Image.open(io.BytesIO(blob))
|
||||
im.load()
|
||||
edge = max(im.size)
|
||||
except Exception:
|
||||
edge = None
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"seq": seq,
|
||||
"bytes": len(blob) if blob is not None else 0,
|
||||
"infer_long_edge_px": edge,
|
||||
"label": f"photo seq={seq} id={photo_id}",
|
||||
}
|
||||
|
||||
|
||||
def _redact_request_for_log(messages: list, photo_meta: list[dict] | None = None) -> list:
|
||||
"""Replace image data-URLs with photo refs for inference_log (#87 / METADATA-66).
|
||||
|
||||
Wire messages still carry base64 for the model; only the logged copy is redacted.
|
||||
Text parts are kept in full. Image parts become type=image_ref using photo_meta by
|
||||
image-part index (same order as built from slab_photos).
|
||||
"""
|
||||
meta = list(photo_meta or [])
|
||||
out: list = []
|
||||
img_i = 0
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
out.append(msg)
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
out.append(msg)
|
||||
continue
|
||||
new_parts: list = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
new_parts.append(part)
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
new_parts.append({"type": "text", "text": part.get("text", "")})
|
||||
continue
|
||||
if ptype == "image_url":
|
||||
url = ""
|
||||
iu = part.get("image_url")
|
||||
if isinstance(iu, dict):
|
||||
url = str(iu.get("url") or "")
|
||||
if url.startswith("data:image") or "base64," in url:
|
||||
ref = {
|
||||
"type": "image_ref",
|
||||
"photo_id": None,
|
||||
"seq": None,
|
||||
"bytes": None,
|
||||
"infer_long_edge_px": None,
|
||||
"label": f"photo slot={img_i}",
|
||||
}
|
||||
if img_i < len(meta):
|
||||
m = meta[img_i]
|
||||
ref.update({
|
||||
"photo_id": m.get("photo_id"),
|
||||
"seq": m.get("seq"),
|
||||
"bytes": m.get("bytes"),
|
||||
"infer_long_edge_px": m.get("infer_long_edge_px"),
|
||||
"label": m.get("label") or ref["label"],
|
||||
})
|
||||
new_parts.append(ref)
|
||||
img_i += 1
|
||||
else:
|
||||
# Non-data URL (rare): keep URL string only, never invent bytes
|
||||
new_parts.append({
|
||||
"type": "image_ref",
|
||||
"url": url[:200],
|
||||
"label": f"external image slot={img_i}",
|
||||
})
|
||||
img_i += 1
|
||||
continue
|
||||
new_parts.append(part)
|
||||
out.append({**msg, "content": new_parts})
|
||||
return out
|
||||
|
||||
|
||||
_LOG_PAYLOAD_CAP = 200_000
|
||||
|
||||
|
||||
def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str,
|
||||
request: list, response: str, latency_ms: int) -> None:
|
||||
request: list, response: str, latency_ms: int,
|
||||
photo_meta: list[dict] | None = None) -> None:
|
||||
"""Append inference_log with redacted request (no image bytes) — #87 / METADATA-66."""
|
||||
redacted = _redact_request_for_log(request, photo_meta)
|
||||
payload = json.dumps(redacted, ensure_ascii=False)
|
||||
if len(payload) > _LOG_PAYLOAD_CAP:
|
||||
# Safety net only; after redaction normal calls are far under the cap.
|
||||
payload = payload[: _LOG_PAYLOAD_CAP - 20] + "\n/* truncated */"
|
||||
conn.execute(
|
||||
"INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(slab_id, call, rev_id, model, json.dumps(request)[:200000], response, latency_ms, _now()),
|
||||
(slab_id, call, rev_id, model, payload, response, latency_ms, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -361,4 +1039,4 @@ async def test_inference() -> dict:
|
||||
except InferenceError as e:
|
||||
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
|
||||
detail = f"reachable in {int((time.time()-t0)*1000)}ms"
|
||||
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}
|
||||
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}
|
||||
+25
-6
@@ -26,7 +26,7 @@ def _get_slab(conn, slab_id: str):
|
||||
|
||||
def _get_photo(conn, slab_id: str, photo_id: str):
|
||||
row = conn.execute(
|
||||
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq "
|
||||
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role "
|
||||
"FROM slab_photos WHERE id=? AND slab_id=?",
|
||||
(photo_id, slab_id),
|
||||
).fetchone()
|
||||
@@ -44,6 +44,7 @@ async def upload_photo(
|
||||
photo_id: str = Form(...),
|
||||
seq: int | None = Form(None),
|
||||
is_calibration: int = Form(0),
|
||||
role: str = Form("batch"),
|
||||
original: UploadFile = File(...),
|
||||
processed: UploadFile | None = File(None),
|
||||
) -> dict:
|
||||
@@ -52,6 +53,8 @@ async def upload_photo(
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
if is_calibration not in (0, 1):
|
||||
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
|
||||
if role not in ("calibration", "overlay", "batch"):
|
||||
return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch"))
|
||||
orig_bytes = await original.read()
|
||||
proc_bytes = await processed.read() if processed is not None else None
|
||||
if not orig_bytes:
|
||||
@@ -60,14 +63,14 @@ async def upload_photo(
|
||||
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
|
||||
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, original_blob, processed_blob, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(photo_id, slab_id, seq_val, is_calibration, orig_bytes, proc_bytes, _now()),
|
||||
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
row = _get_photo(conn, slab_id, photo_id)
|
||||
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
|
||||
"original_retained": True, "processed_retained": row["processed_blob"] is not None}
|
||||
"role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -108,9 +111,25 @@ async def list_photos(slab_id: str) -> list[dict]:
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
rows = conn.execute(
|
||||
"SELECT id, seq, is_calibration, width_px, height_px, "
|
||||
"SELECT id, seq, is_calibration, role, width_px, height_px, "
|
||||
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
|
||||
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.delete("/{slab_id}/photos/{photo_id}", status_code=204)
|
||||
async def delete_photo(slab_id: str, photo_id: str):
|
||||
"""#90: drop a photo the owner removed in Review (keep/remove edit). The
|
||||
remaining photos are re-numbered by the client via re-upload."""
|
||||
conn = get_connection()
|
||||
if _get_slab(conn, slab_id) is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
cur = conn.execute(
|
||||
"DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
|
||||
return Response(status_code=204)
|
||||
|
||||
+50
-30
@@ -16,7 +16,14 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .db import get_connection
|
||||
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
|
||||
from .woo import (
|
||||
WooAuthError,
|
||||
WooClient,
|
||||
WooConnectionError,
|
||||
WooCredentialsMissing,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
)
|
||||
from .review import compute_ready, _slab_dict
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
@@ -72,21 +79,24 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
|
||||
if tags:
|
||||
payload["tags"] = [{"id": t} for t in tags]
|
||||
# Five locked attributes by synced term name.
|
||||
# Five locked attributes: resolve the attribute row from the synced cache by
|
||||
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
|
||||
# then fall back to case-insensitive name. Term *options* are always the
|
||||
# names of the selected synced terms.
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
|
||||
built_attrs = []
|
||||
for field, attr_name, pos in (
|
||||
("edge_type_term_id", "Edge Type", 0),
|
||||
("figure_term_ids", "Figure", 1),
|
||||
("grade_term_id", "Grade", 2),
|
||||
("thickness_term_id", "Thickness", 3),
|
||||
("moisture_term_id", "Moisture", 4),
|
||||
for field, attr_slug, attr_name, pos in (
|
||||
("edge_type_term_id", "edge-type", "Edge Type", 0),
|
||||
("figure_term_ids", "pa_figure", "Figure", 1),
|
||||
("grade_term_id", "pa_grade", "Grade", 2),
|
||||
("thickness_term_id", "pa_thickness", "Thickness", 3),
|
||||
("moisture_term_id", "pa_moisture", "Moisture", 4),
|
||||
):
|
||||
raw = slab.get(field)
|
||||
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
|
||||
options = [terms[t]["name"] for t in term_ids if t in terms]
|
||||
attr_id = _attr_id_by_name(attrs, attr_name)
|
||||
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
|
||||
if attr_id and options:
|
||||
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
|
||||
if built_attrs:
|
||||
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def _attr_id_by_name(attrs: dict, name: str) -> int | None:
|
||||
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
|
||||
"""Match a locked attribute against the synced Woo set by slug, then name."""
|
||||
slug_l = slug.lower().lstrip()
|
||||
# Accept both "figure" and "pa_figure" style slugs from Woo.
|
||||
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
|
||||
for a in attrs.values():
|
||||
if a.get("name", "").lower() == name.lower():
|
||||
s = (a.get("slug") or "").lower()
|
||||
if s in slug_alts:
|
||||
return a["id"]
|
||||
name_l = name.lower()
|
||||
for a in attrs.values():
|
||||
if (a.get("name") or "").lower() == name_l:
|
||||
return a["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||
"""Return (code, plain message, recoverable)."""
|
||||
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
|
||||
if isinstance(e, WooCredentialsMissing):
|
||||
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
||||
if isinstance(e, WooAuthError):
|
||||
return "woo_auth", "The store rejected the credentials. Check the app password.", False
|
||||
if isinstance(e, WooForbiddenError):
|
||||
# WAF / security plugin block — retrying the same request will not help.
|
||||
return (
|
||||
"woo_forbidden",
|
||||
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
|
||||
False,
|
||||
)
|
||||
if isinstance(e, WooConnectionError):
|
||||
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
|
||||
return "woo_failed", "The store create failed. You can retry.", True
|
||||
@@ -150,9 +176,16 @@ async def publish(slab_id: str) -> dict:
|
||||
code, msg, rec = _classify(e)
|
||||
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
|
||||
# 4/5. Upload processed PNGs, then POST product.
|
||||
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
|
||||
# its dimension-overlay copy, then the remaining batch photos. The Review
|
||||
# photo strip stores the owner's keep order (leftmost = primary), so the
|
||||
# overlay (which can sit after a moved batch photo) is re-ordered here by
|
||||
# role rather than by stored seq. Ties fall back to stored seq.
|
||||
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
|
||||
photos = conn.execute(
|
||||
"SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq",
|
||||
"SELECT id, processed_blob, role FROM slab_photos "
|
||||
"WHERE slab_id=? AND processed_blob IS NOT NULL "
|
||||
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
|
||||
(slab_id,),
|
||||
).fetchall()
|
||||
uploaded_ids: list[int] = []
|
||||
@@ -202,26 +235,13 @@ def _create_status(conn) -> str:
|
||||
|
||||
|
||||
def _sku_exists(client: WooClient, sku: str) -> bool:
|
||||
code, _h, body = client._request(
|
||||
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
|
||||
"GET", None,
|
||||
{"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
|
||||
)
|
||||
if code != 200:
|
||||
raise WooError(f"sku lookup returned {code}")
|
||||
return len(json.loads(body)) > 0
|
||||
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return len(client.get_products_by_sku(sku)) > 0
|
||||
|
||||
|
||||
def _create_product(client: WooClient, payload: dict) -> int:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
code, _h, resp = client._request(
|
||||
f"{client.base_url}/wp-json/wc/v3/products",
|
||||
"POST", body,
|
||||
{"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooError(f"product create returned {code}")
|
||||
return int(json.loads(resp)["id"])
|
||||
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return client.create_product(payload)
|
||||
|
||||
|
||||
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
||||
|
||||
@@ -5,4 +5,5 @@ python-multipart==0.0.9
|
||||
pytest==8.3.3
|
||||
httpx==0.27.2
|
||||
cryptography==43.0.1
|
||||
Pillow==11.3.0
|
||||
# stdlib only for sqlite3, logging, json, contextvars, threading
|
||||
|
||||
+95
-37
@@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35.
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -34,6 +35,7 @@ _REQUIRED = [
|
||||
"sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||
"width_min_in", "width_max_in", "width_avg_in",
|
||||
"species_id", "wood_category_ids", "edge_type_term_id",
|
||||
"figure_term_ids", # UX requires ≥1 figure (Create-desc gate + ready)
|
||||
"grade_term_id", "thickness_term_id", "moisture_term_id",
|
||||
"price", "price_source", "title", "short_description", "description",
|
||||
"content_source",
|
||||
@@ -44,6 +46,10 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _split_length(length_in: float) -> tuple[int, float]:
|
||||
ft = int(length_in // 12)
|
||||
rem = round(length_in - ft * 12, 1)
|
||||
@@ -66,14 +72,16 @@ def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
|
||||
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
|
||||
|
||||
|
||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
|
||||
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
||||
)
|
||||
figure = figure.strip() if figure else ""
|
||||
live_edge = ""
|
||||
if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"):
|
||||
live_edge = "Live Edge " if slab.get("edge_type_term_id") else ""
|
||||
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
|
||||
# "Live Edge" is a product-form flag driven by the *synced* edge term
|
||||
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
|
||||
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
|
||||
title = pattern.format(
|
||||
figure=figure + " " if figure else "",
|
||||
species=species_name,
|
||||
@@ -84,8 +92,17 @@ def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf:
|
||||
return re.sub(r"\s+", " ", title).strip()
|
||||
|
||||
|
||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
||||
title = build_title(slab, figure, species_name, primary_leaf)
|
||||
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
|
||||
"""True when the synced edge term name/slug indicates live edge."""
|
||||
if not edge_term_id:
|
||||
return False
|
||||
term = terms.get(edge_term_id) or {}
|
||||
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
|
||||
return "live" in blob
|
||||
|
||||
|
||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
title = build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
ft, rem = _split_length(slab["length_in"])
|
||||
length_str = f"{ft}ft {_fmt1(rem)}in"
|
||||
bdft = round(slab["bdft"], 1)
|
||||
@@ -114,8 +131,20 @@ def missing_required(slab: dict) -> list[str]:
|
||||
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
|
||||
|
||||
|
||||
# REVIEW-45: SKU is 3 to 24 chars, alphanumeric and hyphen only. The middle tier
|
||||
# (not the DB CHECK) is the business-logic arbiter here; the DB constraint is a backstop.
|
||||
_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$")
|
||||
|
||||
|
||||
def sku_valid(sku: Any) -> bool:
|
||||
return isinstance(sku, str) and bool(_SKU_RE.match(sku))
|
||||
|
||||
|
||||
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
|
||||
missing = missing_required(slab)
|
||||
# A present-but-malformed SKU is not ready (business rule, enforced in the middle tier).
|
||||
if "sku" not in missing and not sku_valid(slab.get("sku")):
|
||||
missing.append("sku")
|
||||
return (len(missing) == 0), missing
|
||||
|
||||
|
||||
@@ -130,6 +159,13 @@ def price_suggestion(conn, slab: dict) -> float | None:
|
||||
|
||||
@router.post("/{slab_id}/review")
|
||||
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
||||
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
|
||||
|
||||
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
|
||||
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
|
||||
win (METADATA-50). Assembled values are written back to the slab so the lock
|
||||
sticks for publish.
|
||||
"""
|
||||
payload = payload or {}
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
@@ -138,46 +174,68 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
slab = _slab_dict(row)
|
||||
# Apply any review edits (primary category declaration + prose).
|
||||
editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"}
|
||||
for k in editable:
|
||||
if k in payload and payload[k] is not None:
|
||||
if k == "primary_wood_category_id":
|
||||
continue # not stored as a column; derived
|
||||
slab[k] = payload[k]
|
||||
|
||||
# --- Deterministic review: report ready/missing + the deterministic
|
||||
# title/short_description built from the current metadata. The client's
|
||||
# "Create title" / "Create short desc" buttons call this endpoint and write the
|
||||
# returned strings into the fields (#89: no separate lock action).
|
||||
# #80: persist owner overrides (price/sku) from the payload so the review
|
||||
# endpoint is self-contained. title/short_description are NOT overridden here —
|
||||
# they are produced by the deterministic build below, not persisted from the
|
||||
# payload (so the review POST can't clobber the owner's Create workflow).
|
||||
cats, terms = _taxonomy_lookup(conn)
|
||||
# Primary wood category: declared, else first wood_category_ids, else first category.
|
||||
primary_id = payload.get("primary_wood_category_id")
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
if not primary_id:
|
||||
primary_id = wood_cats[0] if wood_cats else None
|
||||
primary_leaf = cats.get(primary_id, {})
|
||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
|
||||
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
|
||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else ""
|
||||
figure_term_ids = slab.get("figure_term_ids") or []
|
||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
||||
|
||||
# Deterministic copy (only if not already overridden by owner).
|
||||
if not payload.get("title"):
|
||||
if primary_leaf and species_name:
|
||||
slab["title"] = build_title(slab, figure, species_name, primary_leaf)
|
||||
if not payload.get("short_description"):
|
||||
if primary_leaf and species_name:
|
||||
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf)
|
||||
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks.
|
||||
suggested = price_suggestion(conn, slab)
|
||||
if payload.get("price") is not None:
|
||||
price = payload["price"]
|
||||
elif slab.get("price") not in (None, 0):
|
||||
price = slab["price"]
|
||||
else:
|
||||
price = suggested
|
||||
ready, missing = compute_ready({**slab, "price": price})
|
||||
# #80: persist owner overrides from the payload (price/sku only).
|
||||
overrides: dict[str, Any] = {}
|
||||
for k in ("price", "sku"):
|
||||
if k in payload and payload[k] is not None:
|
||||
overrides[k] = payload[k]
|
||||
if overrides:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in overrides)
|
||||
conn.execute(
|
||||
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
|
||||
[*overrides.values(), _now(), slab_id],
|
||||
)
|
||||
conn.commit()
|
||||
slab.update(overrides)
|
||||
|
||||
# Deterministic title / short_description, re-derived from the current
|
||||
# metadata on every review (the Create button's source of truth).
|
||||
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
|
||||
title = (
|
||||
build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
if (primary_leaf and species_name)
|
||||
else slab.get("title") or None
|
||||
)
|
||||
short_desc = (
|
||||
build_short_description(slab, figure, species_name, primary_leaf, terms)
|
||||
if (primary_leaf and species_name)
|
||||
else slab.get("short_description") or None
|
||||
)
|
||||
|
||||
price = _resolve_price(conn, slab, payload)
|
||||
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
||||
return {
|
||||
"ready": ready,
|
||||
"missing": missing,
|
||||
"title": slab.get("title"),
|
||||
"short_description": slab.get("short_description"),
|
||||
"price": price,
|
||||
"price_suggestion": suggested,
|
||||
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
|
||||
"price": price, "price_suggestion": price_suggestion(conn, slab),
|
||||
"primary_wood_category_id": primary_id,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
|
||||
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
|
||||
else the stored price, else the suggestion (None if no rate)."""
|
||||
suggested = price_suggestion(conn, slab)
|
||||
if payload.get("price") is not None:
|
||||
return payload["price"]
|
||||
if slab.get("price") not in (None, 0):
|
||||
return slab["price"]
|
||||
return suggested
|
||||
|
||||
+139
-18
@@ -13,6 +13,7 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from .db import get_connection
|
||||
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
@@ -42,6 +43,20 @@ TAXONOMY_TABLES = {
|
||||
"fig_tag_ids": "woo_tags",
|
||||
"feat_tag_ids": "woo_tags",
|
||||
}
|
||||
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
|
||||
FIELD_LABELS = {
|
||||
"species_id": "Species",
|
||||
"wood_category_ids": "Wood category",
|
||||
"edge_type_term_id": "Edge type",
|
||||
"figure_term_ids": "Figure",
|
||||
"grade_term_id": "Grade",
|
||||
"thickness_term_id": "Thickness",
|
||||
"moisture_term_id": "Moisture",
|
||||
"fig_tag_ids": "Figure tags",
|
||||
"feat_tag_ids": "Feature tags",
|
||||
}
|
||||
PRICE_SOURCES = {"recommendation", "override", "manual"}
|
||||
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
@@ -64,6 +79,12 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
# #89: server-managed fields that a plain client PUT must not set directly.
|
||||
# (metadata_locked was dropped — the deterministic title/short-desc are built by
|
||||
# the review endpoint and written back by the client's Create buttons.)
|
||||
CLIENT_FORBIDDEN_FIELDS = set()
|
||||
|
||||
|
||||
def _row_to_slab(row) -> Dict[str, Any]:
|
||||
d = dict(row)
|
||||
for f in JSON_ARRAY_FIELDS:
|
||||
@@ -95,21 +116,117 @@ def _check_taxonomy(conn, table: str, val: Any) -> bool:
|
||||
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
|
||||
|
||||
|
||||
def _current_options(conn, table: str) -> list[dict[str, Any]]:
|
||||
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
|
||||
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
|
||||
def _normalize_tax_val(val: Any) -> Any:
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return val
|
||||
|
||||
|
||||
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
|
||||
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
|
||||
for field, table in TAXONOMY_TABLES.items():
|
||||
if field not in payload or payload[field] is None:
|
||||
continue
|
||||
val = _normalize_tax_val(payload[field])
|
||||
if not _check_taxonomy(conn, table, val):
|
||||
label = FIELD_LABELS.get(field, field)
|
||||
return _err(
|
||||
"stale_field",
|
||||
f"{label} is no longer in the store taxonomy",
|
||||
{"field": field, "current_options": _current_options(conn, table)},
|
||||
status=422,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
|
||||
if "price_source" in payload and payload["price_source"] is not None:
|
||||
if payload["price_source"] not in PRICE_SOURCES:
|
||||
return _err(
|
||||
"validation",
|
||||
"price_source is not a known value",
|
||||
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
|
||||
status=400,
|
||||
)
|
||||
if "inference_status" in payload and payload["inference_status"] is not None:
|
||||
if payload["inference_status"] not in INFERENCE_STATUSES:
|
||||
return _err(
|
||||
"validation",
|
||||
"inference_status is not a known value",
|
||||
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
|
||||
status=400,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Capture-only fields required to create a slab. The Review screen fills the
|
||||
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
|
||||
# editable until create"), so create must accept a capture-math payload.
|
||||
# client_rev is server-managed (set to 1 at create) — never required from the client.
|
||||
REQUIRED_CREATE_FIELDS = [
|
||||
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
|
||||
"width_min_in", "width_max_in", "width_avg_in",
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
missing = [f for f in ALLOWED_FIELDS if f not in payload]
|
||||
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
|
||||
if missing:
|
||||
return _err("validation", "missing fields", {"fields": missing}, status=400)
|
||||
# #83: reject unknown keys (client sent a field that is not client-settable).
|
||||
# The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped
|
||||
# — but a 400 tells the client its payload is malformed, not that the slab
|
||||
# was created with the extra keys silently ignored.
|
||||
unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"]
|
||||
if unknown:
|
||||
return _err("validation", "unknown fields", {"fields": unknown}, status=400)
|
||||
# Business rule (middle tier, not the DB): an empty/blank SKU at create means
|
||||
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
|
||||
# governs present SKUs, and NULL is the honest "absent" representation.
|
||||
sku = payload.get("sku")
|
||||
if isinstance(sku, str) and sku.strip() == "":
|
||||
payload = {**payload, "sku": None}
|
||||
enum_err = _validate_enums(payload)
|
||||
if enum_err is not None:
|
||||
return enum_err
|
||||
conn = get_connection()
|
||||
if _sku_taken(conn, payload["sku"]):
|
||||
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||
if tax_err is not None:
|
||||
return tax_err
|
||||
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
|
||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
|
||||
# Resolve it from the measured thickness against the live synced Woo bands and
|
||||
# overwrite whatever the client sent (the UI has no band control).
|
||||
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
|
||||
if band is not None:
|
||||
payload = {**payload, "thickness_term_id": band}
|
||||
now = _now_iso()
|
||||
data = _encode(payload)
|
||||
cols = [f for f in ALLOWED_FIELDS if f in data] + ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
|
||||
vals = [data[f] for f in cols if f in data] + ["calibrated", 1, 1, None, now, now]
|
||||
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
|
||||
server_cols = ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
|
||||
server_vals = ["calibrated", 1, 1, None, now, now]
|
||||
cols = allowed_cols + server_cols
|
||||
# One value per col, in cols order: data values for the allowed fields, then the 6 server values.
|
||||
vals = [data[f] for f in allowed_cols] + server_vals
|
||||
ph = ",".join(["?"] * len(cols))
|
||||
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
|
||||
conn.commit()
|
||||
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
|
||||
try:
|
||||
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# QUALITY-120: never surface a driver stack to the owner.
|
||||
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
|
||||
return _row_to_slab(row)
|
||||
|
||||
@@ -134,6 +251,13 @@ async def get_slab(id: str) -> Dict[str, Any]:
|
||||
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if "client_rev" not in payload:
|
||||
return _err("validation", "client_rev required", status=400)
|
||||
# #89: server-managed fields a plain PUT must not set.
|
||||
for field in CLIENT_FORBIDDEN_FIELDS:
|
||||
if field in payload:
|
||||
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
|
||||
enum_err = _validate_enums(payload)
|
||||
if enum_err is not None:
|
||||
return enum_err
|
||||
conn = get_connection()
|
||||
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
||||
if current is None:
|
||||
@@ -143,19 +267,16 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return _err("revision_conflict", "client_rev mismatch",
|
||||
{"current_server_rev": current["server_rev"]}, status=409)
|
||||
if "sku" in payload and payload["sku"] != current["sku"]:
|
||||
if _sku_taken(conn, payload["sku"], exclude_id=id):
|
||||
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
|
||||
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||
for field, table in TAXONOMY_TABLES.items():
|
||||
if field in payload and payload[field] is not None:
|
||||
val = payload[field]
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
val = json.loads(val)
|
||||
except (ValueError, TypeError):
|
||||
val = []
|
||||
if not _check_taxonomy(conn, table, val):
|
||||
return _err("stale_field", f"{field} not in current taxonomy",
|
||||
{"current_options": val}, status=422)
|
||||
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||
if tax_err is not None:
|
||||
return tax_err
|
||||
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
|
||||
eff_thickness = payload.get("thickness_in", current["thickness_in"])
|
||||
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
|
||||
if band is not None:
|
||||
payload = {**payload, "thickness_term_id": band}
|
||||
data = _encode(payload)
|
||||
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
|
||||
if not cols:
|
||||
|
||||
+25
-7
@@ -1,11 +1,14 @@
|
||||
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
|
||||
|
||||
Rebuild happens in one transaction; on any failure the prior cache is kept.
|
||||
Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a
|
||||
stable hash of the stored subset, recorded only when it changes.
|
||||
Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen
|
||||
(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored
|
||||
subset, recorded only when it changes. Display names are HTML-unescaped so
|
||||
Woo entities like ``&`` store as ``&``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -16,8 +19,23 @@ def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _decode_name(name: str | None) -> str:
|
||||
"""Decode HTML entities Woo puts in taxonomy display names."""
|
||||
return html.unescape(name or "")
|
||||
|
||||
|
||||
def _scoped_tags(tags: list[dict]) -> list[dict]:
|
||||
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
|
||||
"""Keep product tags whose slug starts with fig- or feat- (hyphen required).
|
||||
|
||||
Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl.
|
||||
Filtering by name prefix kept 0 live tags; slug is authoritative.
|
||||
"""
|
||||
out = []
|
||||
for t in tags:
|
||||
slug = t.get("slug") or ""
|
||||
if slug.startswith("fig-") or slug.startswith("feat-"):
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_category(c: dict) -> tuple:
|
||||
@@ -34,7 +52,7 @@ def _normalize_category(c: dict) -> tuple:
|
||||
woo_id = c.get("woo_id", c.get("id"))
|
||||
if woo_id is not None:
|
||||
woo_id = int(woo_id)
|
||||
return (c["id"], c["name"], c["slug"], parent_id, woo_id)
|
||||
return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id)
|
||||
|
||||
|
||||
def _subset_hash(categories: list[dict], attributes: list[dict],
|
||||
@@ -90,16 +108,16 @@ def sync(client: WooClient, conn) -> dict:
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
|
||||
[(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes],
|
||||
)
|
||||
for attr_id, terms in terms_by_attr.items():
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
|
||||
[(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms],
|
||||
[(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||
[(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags],
|
||||
[(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags],
|
||||
)
|
||||
|
||||
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
|
||||
|
||||
+537
-24
@@ -58,39 +58,206 @@ def test_prompt_seed_and_revision(env):
|
||||
assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None
|
||||
|
||||
|
||||
def test_call1_confidence_gate(env, monkeypatch):
|
||||
def test_call1_seed_is_locked_expert_prompt(env):
|
||||
"""#86: shipped Call 1 seed is the locked expert prompt (no free-text bags, no grade)."""
|
||||
client, _conn = env
|
||||
from backend.inference import CALL1_SEED
|
||||
|
||||
body = client.get("/api/v1/prompts/call1").json()
|
||||
assert body["active_content"] == CALL1_SEED
|
||||
assert "wood identification expert" in CALL1_SEED
|
||||
assert "feature_tag_ids" in CALL1_SEED
|
||||
assert "Decide wood categories before edge type" in CALL1_SEED
|
||||
assert "highest-confidence" in CALL1_SEED
|
||||
assert "Wild edge" in CALL1_SEED
|
||||
assert "cookies and burl" in CALL1_SEED.lower() or "cookies and burl" in CALL1_SEED
|
||||
assert "grade_term_id" not in CALL1_SEED
|
||||
assert '"character"' not in CALL1_SEED
|
||||
assert '"inclusions"' not in CALL1_SEED
|
||||
assert "character\":" not in CALL1_SEED.replace(" ", "")
|
||||
|
||||
|
||||
def test_call2_seed_has_short_long_structure(env):
|
||||
"""Call 2 seed: plain-text short+long, woodworker tone, JSON dual output (not brand voice)."""
|
||||
client, _conn = env
|
||||
from backend.inference import CALL2_SEED
|
||||
|
||||
body = client.get("/api/v1/prompts/call2").json()
|
||||
assert body["active_content"] == CALL2_SEED
|
||||
assert "SHORT DESCRIPTION" in CALL2_SEED
|
||||
assert "LONG DESCRIPTION" in CALL2_SEED
|
||||
assert "PLAIN TEXT ONLY" in CALL2_SEED
|
||||
assert "plain text only" in CALL2_SEED.lower()
|
||||
assert "No HTML tags" in CALL2_SEED
|
||||
assert "woodworkers choosing stock" in CALL2_SEED
|
||||
assert "elevate" in CALL2_SEED # banned-phrase guidance
|
||||
assert "stunning" in CALL2_SEED
|
||||
assert "Lead with a practical outcome" in CALL2_SEED
|
||||
assert "Hook" in CALL2_SEED
|
||||
assert "Brand voice and Geography sections when provided" in CALL2_SEED
|
||||
assert "Whidbey" not in CALL2_SEED
|
||||
assert "Pacific Northwest" not in CALL2_SEED
|
||||
assert '"short_description"' in CALL2_SEED
|
||||
assert '"description"' in CALL2_SEED
|
||||
assert "<HTML long description>" not in CALL2_SEED
|
||||
assert "plain text long description" in CALL2_SEED
|
||||
# No Full-specs / attribute metadata dump in long desc (specs live on Woo fields).
|
||||
assert "Hook + Benefits only" in CALL2_SEED
|
||||
assert "Do NOT restate DECLARED" in CALL2_SEED
|
||||
assert 'no "Full specs"' in CALL2_SEED or "Do NOT add a Full specs" in CALL2_SEED
|
||||
assert "Category:/Figure:/Edge:" in CALL2_SEED
|
||||
assert "1–2 key traits" in CALL2_SEED
|
||||
assert "labeled catalog" in CALL2_SEED or "labeled metadata list" in CALL2_SEED
|
||||
# Must not instruct a Full-specs numbered part (old 4-part structure).
|
||||
assert "3. Full specs" not in CALL2_SEED
|
||||
assert "Four parts, in order" not in CALL2_SEED
|
||||
|
||||
|
||||
def test_taxonomy_lists_call1_closed_shape():
|
||||
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
|
||||
import tempfile, os, shutil
|
||||
tmp = tempfile.mkdtemp(prefix="tax_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ.setdefault("WOO_BASE_URL", "https://x.com")
|
||||
os.environ.setdefault(
|
||||
"SLAB_AES_KEY",
|
||||
base64.b64encode(b"0123456789abcdef0123456789abcdef").decode(),
|
||||
)
|
||||
db_mod._reset_for_tests()
|
||||
conn = db_mod.init_db()
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (10,'Species','species',NULL,10)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Walnut','walnut',10,11)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (20,'Wcat','wcat',NULL,20)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (21,'Live edge wood','liveedge',20,21)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (6,'Edge type','pa_etype',6)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Figure','pa_fig',8)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Grade','pa_gr',9)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Thickness','pa_thk',10)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (7,'Moisture','pa_mst',7)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (321,6,'Square edge','sqe',321)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (328,8,'Plain','pl',328)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (342,9,'Fancy','fnc',342)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (350,10,'1\"- 1 1/2\"','4-6qtr',350)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,7,'Kiln dried','kd',326)")
|
||||
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)")
|
||||
conn.execute("INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (2,'fig-curl','fig-curl',2)")
|
||||
conn.commit()
|
||||
from backend.inference import _taxonomy_lists
|
||||
|
||||
tax = _taxonomy_lists(conn)
|
||||
assert set(tax.keys()) == {"species", "wood_categories", "edge_terms", "figure_terms", "feature_tags"}
|
||||
assert tax["species"] == [{"id": 11, "name": "Walnut"}]
|
||||
assert tax["wood_categories"] == [{"id": 21, "name": "Live edge wood"}]
|
||||
assert tax["edge_terms"] == [{"id": 321, "name": "Square edge"}]
|
||||
assert tax["figure_terms"] == [{"id": 328, "name": "Plain"}]
|
||||
# feat- prefix stripped for the model; fig- tags are not feature_tags
|
||||
assert tax["feature_tags"] == [{"id": 1, "name": "knot"}]
|
||||
assert "grade_terms" not in tax
|
||||
assert "thickness_terms" not in tax
|
||||
assert "moisture_terms" not in tax
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def test_gate_feature_tag_ids_not_free_text():
|
||||
"""#86: gate keeps feature_tag_ids at threshold; drops free-text bags and grade."""
|
||||
from backend.inference import _gate
|
||||
|
||||
parsed = {
|
||||
"species_id": 11, "species_confidence": 0.9,
|
||||
"wood_category_ids": [21], "wood_category_confidence": 0.8,
|
||||
"edge_type_term_id": 321, "edge_confidence": 0.85,
|
||||
"figure_term_ids": [328, 335], "figure_confidence": 0.75,
|
||||
"feature_tag_ids": [1], "feature_tag_confidence": 0.8,
|
||||
"grade_term_id": 342, "grade_confidence": 0.99,
|
||||
"character": ["rustic"], "inclusions": ["bark"], "voids": [], "checks": ["end"],
|
||||
}
|
||||
out = _gate(parsed)
|
||||
assert out["species_id"] == 11
|
||||
assert out["wood_category_ids"] == [21]
|
||||
assert out["edge_type_term_id"] == 321
|
||||
assert out["figure_term_ids"] == [328, 335]
|
||||
assert out["feature_tag_ids"] == [1]
|
||||
assert "grade_term_id" not in out
|
||||
assert "character" not in out
|
||||
assert "inclusions" not in out
|
||||
assert "voids" not in out
|
||||
assert "checks" not in out
|
||||
# below threshold feature tags dropped
|
||||
low = {**parsed, "feature_tag_confidence": 0.2}
|
||||
assert "feature_tag_ids" not in _gate(low)
|
||||
|
||||
|
||||
def test_call1_hard_disabled_410(env, monkeypatch):
|
||||
"""overnight-whole-app: Call 1 OUT — POST always 410, never chats the model."""
|
||||
client, conn = env
|
||||
# enable inference
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
return json.dumps({
|
||||
"species_id": 1, "species_confidence": 0.9,
|
||||
"wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold
|
||||
"grade_term_id": 7, "grade_confidence": 0.3, # below
|
||||
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
|
||||
})
|
||||
called["n"] += 1
|
||||
return "{}"
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 200, r.text
|
||||
s = r.json()["suggestions"]
|
||||
assert s.get("species_id") == 1
|
||||
assert s.get("figure_term_ids") == [2]
|
||||
assert "wood_category_ids" not in s # gated out
|
||||
assert "grade_term_id" not in s # gated out
|
||||
# inference_log written with call=1
|
||||
assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1
|
||||
assert r.status_code == 410, r.text
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "call1_disabled"
|
||||
assert called["n"] == 0
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_stores_description(env, monkeypatch):
|
||||
def test_call2_stores_short_and_description(env, monkeypatch):
|
||||
"""Call 2 persists both fields as plain text (HTML from model is stripped)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','ready',1,1,'t','t')")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','old short','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
import json as _json
|
||||
|
||||
payload = _json.dumps({
|
||||
"short_description": "Kiln-dried walnut live-edge stock for a dining table — clear midspan, usable width. Ready to finish.",
|
||||
"description": "<p><strong>Dining table stock.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>",
|
||||
})
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: payload)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "Dining table stock" in body["description"]
|
||||
assert "kiln dried" in body["description"]
|
||||
assert "<p>" not in body["description"]
|
||||
assert "<strong>" not in body["description"]
|
||||
assert body["short_description"].startswith("Kiln-dried walnut")
|
||||
row = conn.execute(
|
||||
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
|
||||
).fetchone()
|
||||
assert row["short_description"].startswith("Kiln-dried walnut")
|
||||
assert "<p>" not in row["description"]
|
||||
assert "- Stable:" in row["description"] or "Stable:" in row["description"]
|
||||
assert row["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
|
||||
"""Non-JSON Call 2 body becomes plain-text description; prior short is left alone."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
|
||||
"status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','keep me','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
@@ -98,18 +265,120 @@ def test_call2_stores_description(env, monkeypatch):
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>A fine walnut slab.</p>")
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["description"] == "<p>A fine walnut slab.</p>"
|
||||
row = conn.execute("SELECT description, content_source FROM slabs WHERE id='s1'").fetchone()
|
||||
assert row["description"] == "<p>A fine walnut slab.</p>"
|
||||
assert r.json()["description"] == "A fine walnut slab."
|
||||
assert "short_description" not in r.json()
|
||||
row = conn.execute(
|
||||
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
|
||||
).fetchone()
|
||||
assert row["short_description"] == "keep me"
|
||||
assert row["description"] == "A fine walnut slab."
|
||||
assert row["content_source"] == "llm"
|
||||
|
||||
|
||||
def test_inference_off_call1_409(env):
|
||||
def test_parse_call2_response_json_and_fenced():
|
||||
from backend.inference import parse_call2_response, _html_to_plain
|
||||
import json as _json
|
||||
|
||||
short, desc = parse_call2_response(
|
||||
_json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "})
|
||||
)
|
||||
assert short == "Benefit first."
|
||||
assert desc == "Long"
|
||||
fenced = "```json\n" + _json.dumps({"short_description": "S", "description": "<p>D</p>"}) + "\n```"
|
||||
short2, desc2 = parse_call2_response(fenced)
|
||||
assert short2 == "S"
|
||||
assert desc2 == "D"
|
||||
short3, desc3 = parse_call2_response("<p>only long</p>")
|
||||
assert short3 is None
|
||||
assert desc3 == "only long"
|
||||
|
||||
# Defensive strip: lists become "- " bullets; entities unescaped; no tags left.
|
||||
plain = _html_to_plain(
|
||||
"<p>Hook.</p><ul><li><strong>Stable:</strong> kiln dried</li>"
|
||||
"<li>Live edge</li></ul><p>Trust & ship.</p>"
|
||||
)
|
||||
assert "<" not in plain
|
||||
assert "Hook." in plain
|
||||
assert "- Stable: kiln dried" in plain
|
||||
assert "- Live edge" in plain
|
||||
assert "Trust & ship." in plain
|
||||
# Already-plain text is unchanged aside from trim.
|
||||
assert _html_to_plain(" Kiln-dried walnut for a bar top. ") == "Kiln-dried walnut for a bar top."
|
||||
|
||||
|
||||
def test_strip_attribute_dump_trailing_and_header():
|
||||
"""Defensive: drop labeled Category:/Length: dumps from long description."""
|
||||
from backend.inference import _strip_attribute_dump, parse_call2_response
|
||||
import json as _json
|
||||
|
||||
hook_benefits = (
|
||||
"Kiln-dried maple suited to a compact bar top.\n"
|
||||
"- Stable stock: kiln dried, ready to mill.\n"
|
||||
"- Live edge on one side for a natural apron.\n"
|
||||
"- Character grade with usable burl figure."
|
||||
)
|
||||
dump = (
|
||||
"Maple Slab\n"
|
||||
"- Category: Live edge wood\n"
|
||||
"- Figure: Burl, Curl, Figured\n"
|
||||
"- Edge: Live Edge 1 side\n"
|
||||
"- Grade: Character\n"
|
||||
"- Moisture: Kiln dried\n"
|
||||
"- Length: 45.0in\n"
|
||||
"- Board Feet: 12.4"
|
||||
)
|
||||
trust = "Ships from the mill; ask about returns in Brand voice."
|
||||
full = f"{hook_benefits}\n\n{dump}\n\n{trust}"
|
||||
cleaned = _strip_attribute_dump(full)
|
||||
assert "Category:" not in cleaned
|
||||
assert "Board Feet:" not in cleaned
|
||||
assert "Maple Slab" not in cleaned
|
||||
assert "Kiln-dried maple" in cleaned
|
||||
assert "Live edge on one side" in cleaned
|
||||
assert "Ships from the mill" in cleaned
|
||||
|
||||
# Mid-body Full specs header also stripped; benefits bullets without Label: kept.
|
||||
mid = (
|
||||
"Hook line.\n"
|
||||
"- Good for a bench seat.\n"
|
||||
"\n"
|
||||
"Full specs\n"
|
||||
"- Species: Maple\n"
|
||||
"- Length: 45.0in\n"
|
||||
"- Thickness: 2.0in\n"
|
||||
"\n"
|
||||
"Neutral trust close."
|
||||
)
|
||||
mid_clean = _strip_attribute_dump(mid)
|
||||
assert "Full specs" not in mid_clean
|
||||
assert "Species:" not in mid_clean
|
||||
assert "Good for a bench seat" in mid_clean
|
||||
assert "Neutral trust close" in mid_clean
|
||||
|
||||
# Fewer than 3 attr lines: leave alone (not a dump).
|
||||
short_list = "Hook.\n- Length: 45.0in\n- Thickness: 2in\nTrust."
|
||||
assert _strip_attribute_dump(short_list) == short_list.strip()
|
||||
|
||||
# parse_call2_response applies strip on description (not short).
|
||||
payload = _json.dumps({
|
||||
"short_description": "Short with - Length: 1in - Width: 2in - Thickness: 3in ok",
|
||||
"description": full,
|
||||
})
|
||||
short, desc = parse_call2_response(payload)
|
||||
assert short and "Length: 1in" in short # short untouched by attr-dump strip
|
||||
assert "Category:" not in desc
|
||||
assert "Board Feet:" not in desc
|
||||
assert "Kiln-dried maple" in desc
|
||||
|
||||
|
||||
def test_call1_disabled_even_when_inference_off(env):
|
||||
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01','calibrated',1,1,'t','t')")
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 409 # inference_enabled default off
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
|
||||
|
||||
def test_server_description_inference_off(env):
|
||||
@@ -126,3 +395,247 @@ def test_test_inference_unconfigured(env):
|
||||
r = client.post("/api/v1/admin/test-inference")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is False and r.json()["mode"] == "fail"
|
||||
|
||||
|
||||
def _rgba_png(w: int, h: int) -> bytes:
|
||||
"""RGBA PNG: opaque body + transparent top-left block (capture-like alpha)."""
|
||||
from PIL import Image
|
||||
import io
|
||||
im = Image.new("RGBA", (w, h), (10, 20, 30, 255))
|
||||
for y in range(min(80, h)):
|
||||
for x in range(min(80, w)):
|
||||
im.putpixel((x, y), (0, 0, 0, 0))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_fit_long_edge_png_downscales_over_1024():
|
||||
"""METADATA-35: Call 1 images long edge <= 1024; never upscale; keep alpha."""
|
||||
from backend.inference import fit_long_edge_png
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
src = _rgba_png(800, 1067) # inventory 3:4, long edge 1067
|
||||
out = fit_long_edge_png(src, max_long_edge=1024)
|
||||
im = Image.open(io.BytesIO(out)).convert("RGBA")
|
||||
assert max(im.size) == 1024
|
||||
assert min(im.size) < 1024
|
||||
w, h = im.size
|
||||
assert abs((w / h) - (800 / 1067)) < 0.02
|
||||
# Transparent block still transparent near origin after downscale
|
||||
assert im.getpixel((0, 0))[3] == 0
|
||||
assert im.getpixel((5, 5))[3] == 0
|
||||
|
||||
|
||||
def test_fit_long_edge_png_leaves_small_unchanged_dims():
|
||||
from backend.inference import fit_long_edge_png
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
src = _rgba_png(400, 300)
|
||||
out = fit_long_edge_png(src, max_long_edge=1024)
|
||||
im = Image.open(io.BytesIO(out))
|
||||
assert im.size == (400, 300)
|
||||
|
||||
|
||||
def test_redact_request_for_log_strips_data_urls_keeps_text():
|
||||
"""#87: log sanitizer replaces data-URL images with photo refs; keeps text."""
|
||||
from backend.inference import _redact_request_for_log
|
||||
|
||||
b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64 * 20}"}},
|
||||
{"type": "text", "text": "LISTS:\n{\"species\":[]}\n\nSKU=X length_in=1 thickness_in=2"},
|
||||
],
|
||||
}]
|
||||
meta = [
|
||||
{"photo_id": "p1", "seq": 1, "bytes": 100, "infer_long_edge_px": 64},
|
||||
{"photo_id": "p2", "seq": 2, "bytes": 200, "infer_long_edge_px": 128},
|
||||
]
|
||||
out = _redact_request_for_log(messages, meta)
|
||||
blob = json.dumps(out)
|
||||
assert "data:image" not in blob
|
||||
assert "base64" not in blob.lower()
|
||||
assert "LISTS:" in blob
|
||||
assert "SKU=X" in blob
|
||||
parts = out[0]["content"]
|
||||
assert parts[0]["type"] == "image_ref"
|
||||
assert parts[0]["photo_id"] == "p1"
|
||||
assert parts[0]["seq"] == 1
|
||||
assert parts[1]["photo_id"] == "p2"
|
||||
assert parts[2]["type"] == "text"
|
||||
assert "LISTS:" in parts[2]["text"]
|
||||
|
||||
|
||||
def test_call1_log_skipped_while_disabled(env):
|
||||
"""#87 Call 1 log path dormant while Call 1 is hard-disabled (overnight-whole-app)."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
|
||||
)
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_call2_log_redacts_images(env, monkeypatch):
|
||||
"""#87: Call 2 log also redacts image data-URLs."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut','ready',1,1,'t','t')"
|
||||
)
|
||||
png = _rgba_png(200, 200)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p2','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>ok</p>")
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = conn.execute(
|
||||
"SELECT request_payload FROM inference_log WHERE call=2 ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()["request_payload"]
|
||||
assert "data:image" not in payload
|
||||
assert "image_ref" in payload
|
||||
assert "p2" in payload
|
||||
assert "DIMENSION FACT BLOCK" in payload
|
||||
|
||||
|
||||
def test_call1_sends_long_edge_dormant_while_disabled(env):
|
||||
"""METADATA-35 Call 1 sizing path dormant while Call 1 is hard-disabled."""
|
||||
client, _conn = env
|
||||
r = client.post("/api/v1/slabs/missing/inference/call1")
|
||||
assert r.status_code == 410
|
||||
assert r.json()["error"]["code"] == "call1_disabled"
|
||||
|
||||
|
||||
def test_assemble_call2_includes_declared_attrs_and_dims(env):
|
||||
"""Call 2 text = seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; shared assembler."""
|
||||
client, conn = env
|
||||
conn.execute(
|
||||
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES "
|
||||
"(10,'Species','species',NULL,10),"
|
||||
"(11,'Black Walnut','black-walnut',10,11),"
|
||||
"(20,'Wcat','wcat',NULL,20),"
|
||||
"(21,'Slabs & Lumber','snl',20,21),"
|
||||
"(22,'Live edge wood','liveedge',21,22)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES "
|
||||
"(6,'Edge type','pa_etype',6),(8,'Figure','pa_fig',8),"
|
||||
"(9,'Grade','pa_gr',9),(7,'Moisture','pa_mst',7)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES "
|
||||
"(321,6,'Live Edge 1 side','le1',321),"
|
||||
"(328,8,'Curl','curl',328),"
|
||||
"(342,9,'Fancy','fnc',342),"
|
||||
"(326,7,'Kiln dried','kd',326)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
|
||||
"('brand_voice','Warm and precise.',NULL),"
|
||||
"('geo_context','Pacific Northwest mill.',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,moisture_term_id,"
|
||||
"feat_tag_ids,title,short_description,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',114.0,1.75,24.3,18.0,24.3,18.4,11,?,321,?,342,326,?,"
|
||||
"'Figured Black Walnut Live Edge Slab','short line','ready',1,1,'t','t')",
|
||||
(json.dumps([22]), json.dumps([328]), json.dumps([1])),
|
||||
)
|
||||
png = _rgba_png(200, 200)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p9','s1',1,?, 't')",
|
||||
(png,),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import assemble_call2, assemble_call2_text, CALL2_SEED
|
||||
|
||||
assembled = assemble_call2(conn, "s1")
|
||||
assert assembled is not None
|
||||
text = assembled["text"]
|
||||
assert "DECLARED ATTRIBUTES" in text
|
||||
assert "species: Black Walnut" in text
|
||||
assert "primary wood category: Live edge wood" in text
|
||||
assert "edge: Live Edge 1 side" in text
|
||||
assert "figure: Curl" in text
|
||||
assert "grade: Fancy" in text
|
||||
assert "moisture: Kiln dried" in text
|
||||
assert "features: knot" in text
|
||||
assert "DIMENSION FACT BLOCK" in text
|
||||
assert "length=114.0in" in text
|
||||
assert "Brand voice: Warm and precise." in text
|
||||
assert "Geo: Pacific Northwest mill." in text
|
||||
assert CALL2_SEED.split("\n")[0] in text
|
||||
assert "SHORT DESCRIPTION" in text
|
||||
assert "LONG DESCRIPTION" in text
|
||||
assert "short_description" in CALL2_SEED
|
||||
assert '"description"' in CALL2_SEED or "\"description\"" in CALL2_SEED
|
||||
# Prior short must not be fed back into the prompt (Call 2 writes it).
|
||||
assert "short_description: short line" not in text
|
||||
assert "Write BOTH" in CALL2_SEED or "BOTH the short" in CALL2_SEED
|
||||
# No raw base64 in View-prompt shape
|
||||
assert "photos" in assembled
|
||||
assert assembled["photos"][0]["id"] == "p9"
|
||||
assert assembled["photos"][0]["seq"] == 1
|
||||
assert assembled["photos"][0]["bytes"] > 0
|
||||
assert "base64" not in json.dumps(assembled["photos"])
|
||||
# View prompt GET mirrors assembler (no model)
|
||||
r = client.get("/api/v1/slabs/s1/inference/call2/prompt")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["text"] == text
|
||||
assert body["photos"][0]["id"] == "p9"
|
||||
assert "data:image" not in json.dumps(body)
|
||||
|
||||
|
||||
def test_call2_post_uses_same_assembler_text(env, monkeypatch):
|
||||
"""POST call2 sends the same assembled text View prompt returns."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
|
||||
"('brand_voice','voice',NULL),('geo_context','geo',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
|
||||
"title,status,server_rev,client_rev,created_at,updated_at) "
|
||||
"VALUES ('s1','SLAB-01',100,2,20,18,22,18,'Walnut Slab','ready',1,1,'t','t')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
|
||||
(_rgba_png(64, 64),),
|
||||
)
|
||||
conn.commit()
|
||||
from backend.inference import InferenceClient, assemble_call2
|
||||
|
||||
expected = assemble_call2(conn, "s1")["text"]
|
||||
captured = {}
|
||||
|
||||
def fake_chat(self, messages):
|
||||
captured["text"] = messages[0]["content"][-1]["text"]
|
||||
return "<p>ok</p>"
|
||||
|
||||
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
|
||||
r = client.post("/api/v1/slabs/s1/inference/call2")
|
||||
assert r.status_code == 200, r.text
|
||||
assert captured["text"] == expected
|
||||
assert "DECLARED ATTRIBUTES" in captured["text"]
|
||||
assert "DIMENSION FACT BLOCK" in captured["text"]
|
||||
|
||||
@@ -34,13 +34,16 @@ PNG_ORIG = b"\x89PNG-orig-bytes"
|
||||
PNG_PROC = b"\x89PNG-proc-bytes"
|
||||
|
||||
|
||||
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0):
|
||||
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
|
||||
files = {"original": ("orig.png", original, "image/png")}
|
||||
if processed is not None:
|
||||
files["processed"] = ("proc.png", processed, "image/png")
|
||||
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
|
||||
if seq is not None:
|
||||
data["seq"] = str(seq)
|
||||
return client.post(
|
||||
f"/api/v1/slabs/{slab_id}/photos",
|
||||
data={"photo_id": photo_id, "is_calibration": str(is_calibration)},
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
@@ -90,9 +93,43 @@ def test_reupload_replaces(photos_env):
|
||||
|
||||
def test_list_photos(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "pa", is_calibration=1)
|
||||
_multipart(client, "s1", "pb")
|
||||
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
|
||||
_multipart(client, "s1", "pb", role="overlay")
|
||||
r = client.get("/api/v1/slabs/s1/photos")
|
||||
assert r.status_code == 200
|
||||
ids = [row["id"] for row in r.json()]
|
||||
assert "pa" in ids and "pb" in ids
|
||||
|
||||
|
||||
def test_upload_stores_and_returns_role(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "s1", "r1", role="overlay")
|
||||
assert r.status_code == 201
|
||||
assert r.json()["role"] == "overlay"
|
||||
r = client.get("/api/v1/slabs/s1/photos")
|
||||
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
|
||||
assert row["role"] == "overlay"
|
||||
|
||||
|
||||
def test_upload_rejects_bad_role(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "s1", "r2", role="not-a-role")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"]["code"] == "validation"
|
||||
|
||||
|
||||
def test_delete_photo(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "d1")
|
||||
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
|
||||
r = client.delete("/api/v1/slabs/s1/photos/d1")
|
||||
assert r.status_code == 204
|
||||
# Gone from the list and blob no longer served.
|
||||
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
|
||||
assert "d1" not in ids
|
||||
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
|
||||
|
||||
|
||||
def test_delete_photo_not_found(photos_env):
|
||||
client, _ = photos_env
|
||||
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
|
||||
|
||||
@@ -45,6 +45,21 @@ def env(monkeypatch):
|
||||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
|
||||
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
|
||||
for tid, name, slug in [
|
||||
(347, 'Under 1/2"', 'xthin'), (348, '1/2" - 3/4"', '2-3qtr'),
|
||||
(349, '3/4" - 1"', '3-4qtr'), (350, '1"- 1 1/2"', '4-6qtr'),
|
||||
(351, '1 1/2" - 2"', '6-8qtr'), (352, '2" - 3"', '8-12qtr'),
|
||||
(353, 'Over 3"', '12qtr'),
|
||||
]:
|
||||
conn.execute(
|
||||
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
|
||||
(tid, name, slug, tid),
|
||||
)
|
||||
# Moisture keeps its own attribute so the five locked attributes stay distinct.
|
||||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
|
||||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
|
||||
# creds
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
|
||||
@@ -119,6 +134,109 @@ def test_publish_success(env, monkeypatch):
|
||||
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
|
||||
|
||||
|
||||
def test_publish_sends_derived_thickness_band(env, monkeypatch):
|
||||
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
|
||||
|
||||
The owner has no thickness-band control, so nothing client-side carries it: the
|
||||
server derives it from the measurement on create/update, and publish must send the
|
||||
live synced term name on the product payload. This pins that chain end to end.
|
||||
"""
|
||||
client, conn = env
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75,
|
||||
"sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
|
||||
})
|
||||
assert create.status_code == 201, create.text
|
||||
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
|
||||
assert create.json()["thickness_term_id"] == 351
|
||||
|
||||
put = client.put("/api/v1/slabs/s1", json={
|
||||
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
|
||||
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
|
||||
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
|
||||
"description": "D", "content_source": "manual",
|
||||
})
|
||||
assert put.status_code == 200, put.text
|
||||
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
||||
conn.commit()
|
||||
|
||||
products = []
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
products.append(json.loads(data))
|
||||
return 201, {}, json.dumps({"id": 999}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
from backend.woo import WooClient
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(products) == 1, "expected exactly one product create"
|
||||
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
|
||||
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
|
||||
assert attrs[9]["options"] == ['1 1/2" - 2"']
|
||||
|
||||
|
||||
def test_publish_woo_image_order_by_role(env, monkeypatch):
|
||||
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
|
||||
# role, not by stored seq (the Review strip stores the owner's keep order).
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
|
||||
# overlay(3). The publish query must reorder to cal, overlay, batch.
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
|
||||
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
|
||||
conn.commit()
|
||||
media_calls = []
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
||||
media_calls.append(data)
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": 999}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
from backend.woo import WooClient
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(media_calls) == 3
|
||||
# The photo id is embedded in the multipart filename (slab.py publish
|
||||
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
|
||||
# batch (PUBLISH-45), not stored seq.
|
||||
def photo_id(body: bytes) -> str:
|
||||
import re
|
||||
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
|
||||
return m.group(1).decode() if m else ""
|
||||
|
||||
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
|
||||
|
||||
|
||||
def test_uat_draft(env, monkeypatch):
|
||||
client, conn = env
|
||||
s = _full_slab(sku="SLAB-UAT-1")
|
||||
@@ -182,3 +300,63 @@ def test_partial_media_failure_502(env, monkeypatch):
|
||||
# sync_log written, status failed
|
||||
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
|
||||
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
|
||||
|
||||
|
||||
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
|
||||
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
seen = []
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
|
||||
if "/products?sku=" in url:
|
||||
return 200, {}, b"[]"
|
||||
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
||||
return 201, {}, json.dumps({"id": 42}).encode()
|
||||
return 200, {}, b"[]"
|
||||
|
||||
from backend.woo import WooClient, _USER_AGENT
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 200, r.text
|
||||
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
|
||||
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
|
||||
assert sku_hops and create_hops
|
||||
for hop in sku_hops + create_hops:
|
||||
assert hop["headers"].get("User-Agent") == _USER_AGENT
|
||||
|
||||
|
||||
def test_publish_403_is_unrecoverable(env, monkeypatch):
|
||||
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
|
||||
client, conn = env
|
||||
s = _full_slab()
|
||||
_insert_slab(conn, s)
|
||||
conn.commit()
|
||||
from backend.woo import WooClient, WooForbiddenError
|
||||
|
||||
def fake_request(url, method, data, headers):
|
||||
raise WooForbiddenError("request forbidden (403)")
|
||||
|
||||
orig_init = WooClient.__init__
|
||||
|
||||
def patched_init(self, *a, **k):
|
||||
orig_init(self, *a, **k)
|
||||
self._request = fake_request
|
||||
|
||||
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
||||
r = client.post("/api/v1/slabs/s1/publish")
|
||||
assert r.status_code == 502
|
||||
body = r.json()
|
||||
assert body["error"]["code"] == "woo_forbidden"
|
||||
assert body["error"]["detail"]["recoverable"] is False
|
||||
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
|
||||
|
||||
@@ -55,6 +55,8 @@ def test_title_live_edge_no_dims(env):
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
# #89: the review always returns the deterministic title/short_description
|
||||
# built from the current metadata (the Create button's source of truth).
|
||||
r = env[0].post("/api/v1/slabs/s1/review", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
@@ -134,5 +136,87 @@ def test_knife_blank_carries_dims(env):
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
# #89: the deterministic build runs on every review.
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
|
||||
|
||||
|
||||
def test_review_always_reports_deterministic_title(env):
|
||||
"""#89: the review always returns the deterministic title/short_description
|
||||
built from the current metadata. The client's Create button writes these into
|
||||
the fields; the server does not persist them from the review payload."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["title"] == "Figured Black Walnut Live Edge Slab"
|
||||
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
|
||||
# The review does NOT persist title/short_description from the payload.
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["title"] is None
|
||||
assert got["short_description"] is None
|
||||
|
||||
|
||||
def test_review_no_species_reports_stored_title(env):
|
||||
"""With no species, there is no deterministic pattern; the review reports the
|
||||
stored title (None here) and lists title as missing."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["title"] is None
|
||||
assert "title" in body["missing"]
|
||||
|
||||
|
||||
def test_review_persists_owner_overrides(env):
|
||||
"""#80: regular review persists price/sku overrides from the payload to the DB,
|
||||
so the client doesn't need a separate PUT for each. (title/short_description
|
||||
are NOT persisted here — they come from the deterministic build, not the payload.)"""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
# Review with owner overrides for price + sku.
|
||||
r = client.post("/api/v1/slabs/s1/review", json={
|
||||
"price": 250.0,
|
||||
"sku": "SLAB-99",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["price"] == 250.0
|
||||
# The overrides are persisted: GET reflects them.
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["price"] == 250.0
|
||||
assert got["sku"] == "SLAB-99"
|
||||
# server_rev was incremented (the UPDATE fired).
|
||||
assert got["server_rev"] == 2
|
||||
|
||||
|
||||
def test_ready_requires_figure_term_ids(env):
|
||||
"""UX requires ≥1 figure — figure_term_ids is in _REQUIRED."""
|
||||
from backend.review import missing_required, compute_ready
|
||||
|
||||
slab = _slab(figure_term_ids=[])
|
||||
miss = missing_required(slab)
|
||||
assert "figure_term_ids" in miss
|
||||
ready, missing = compute_ready({**slab, "price": 1.0, "price_source": "manual",
|
||||
"title": "T", "short_description": "S",
|
||||
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||
"thickness_term_id": 1, "moisture_term_id": 1})
|
||||
assert ready is False
|
||||
assert "figure_term_ids" in missing
|
||||
|
||||
ready2, missing2 = compute_ready({
|
||||
**slab,
|
||||
"figure_term_ids": [1],
|
||||
"price": 1.0, "price_source": "manual",
|
||||
"title": "T", "short_description": "S",
|
||||
"edge_type_term_id": 1, "grade_term_id": 1,
|
||||
"thickness_term_id": 1, "moisture_term_id": 1,
|
||||
})
|
||||
# May still miss other fields depending on fixture; figure must not be listed
|
||||
assert "figure_term_ids" not in missing2
|
||||
|
||||
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
|
||||
# tags
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
|
||||
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
|
||||
# live Woo attribute, because the derivation parses them.
|
||||
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
|
||||
for tid, name, slug in [
|
||||
(347, 'Under 1/2"', 'xthin'),
|
||||
(348, '1/2" - 3/4"', '2-3qtr'),
|
||||
(349, '3/4" - 1"', '3-4qtr'),
|
||||
(350, '1"- 1 1/2"', '4-6qtr'),
|
||||
(351, '1 1/2" - 2"', '6-8qtr'),
|
||||
(352, '2" - 3"', '8-12qtr'),
|
||||
(353, 'Over 3"', '12qtr'),
|
||||
]:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
|
||||
"VALUES (?, 2, ?, ?, ?)",
|
||||
(tid, name, slug, tid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@pytest.fixture
|
||||
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
|
||||
assert body[key] == payload[key]
|
||||
|
||||
|
||||
def test_create_derives_thickness_band(client, db):
|
||||
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
|
||||
|
||||
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
|
||||
the UI has no band control, so the server's derivation is authoritative.
|
||||
"""
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
"thickness_term_id": 1,
|
||||
}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
|
||||
|
||||
|
||||
def test_update_rebands_when_thickness_changes(client, db):
|
||||
"""A thickness edit moves the slab to the band the new measurement rounds into."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
})
|
||||
assert create.status_code == 201
|
||||
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
|
||||
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
|
||||
})
|
||||
assert put.status_code == 200
|
||||
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
|
||||
|
||||
|
||||
def test_update_ignores_client_supplied_band(client, db):
|
||||
"""A stale client band cannot pin the slab to the wrong band."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = client.post("/api/v1/slabs", json={
|
||||
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
|
||||
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
})
|
||||
assert create.status_code == 201
|
||||
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
|
||||
put = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
|
||||
})
|
||||
assert put.status_code == 200
|
||||
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
|
||||
|
||||
|
||||
def test_duplicate_sku(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
@@ -134,6 +200,30 @@ def test_get_not_found(client, db):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
|
||||
def test_update_omitting_species_id_preserves_it(client, db):
|
||||
"""Review PUTs omit species_id after Measure owns it — must not null the column."""
|
||||
slab_id = str(uuid.uuid4())
|
||||
create = {
|
||||
"id": slab_id, "sku": "", "length_in": 48, "thickness_in": 2, "sqft": 6.25, "bdft": 12.5,
|
||||
"width_min_in": 10, "width_max_in": 12, "width_avg_in": 11, "species_id": 1,
|
||||
}
|
||||
r = client.post("/api/v1/slabs", json=create)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["species_id"] == 1
|
||||
rev = r.json()["server_rev"]
|
||||
# PUT like Review commitReview: wood cats only, no species_id key.
|
||||
r2 = client.put(f"/api/v1/slabs/{slab_id}", json={
|
||||
"client_rev": rev,
|
||||
"wood_category_ids": [2],
|
||||
"title": None,
|
||||
"short_description": None,
|
||||
})
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["species_id"] == 1
|
||||
assert r2.json()["wood_category_ids"] == [2]
|
||||
|
||||
|
||||
def test_update_success(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
@@ -180,6 +270,76 @@ def test_soft_delete(client, db):
|
||||
assert resp_get.status_code == 404
|
||||
|
||||
|
||||
def test_create_empty_sku_stored_as_null(client, db):
|
||||
# Business rule (middle tier): empty SKU at create = "not set yet" -> stored as NULL,
|
||||
# not rejected by the DB CHECK. This is the UAT "Continue to Review" path.
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["sku"] is None
|
||||
# A second empty-SKU slab is allowed (NULLs are not unique-conflicts).
|
||||
resp2 = client.post("/api/v1/slabs", json={**payload, "id": str(uuid.uuid4())})
|
||||
assert resp2.status_code == 201
|
||||
|
||||
|
||||
def test_create_null_sku_allowed(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": None, "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["sku"] is None
|
||||
|
||||
|
||||
def test_gate_reads_flat_confidence_keys(client, db):
|
||||
# Regression: the gate's confidence keys are 'species_confidence' / 'edge_confidence'
|
||||
# etc., NOT f'{field}_confidence' (field 'species_id' would give the wrong key). A
|
||||
# high-confidence response must surface its picks; low-confidence ones must not.
|
||||
from backend.inference import _gate, _parse_json
|
||||
good = '```json\n{"species_id": 284, "species_confidence": 0.85, "wood_category_ids": [251], "wood_category_confidence": 1.0, "edge_type_term_id": 323, "edge_confidence": 0.95, "figure_term_ids": [332], "figure_confidence": 0.9, "feature_tag_ids": [1], "feature_tag_confidence": 0.8, "character": [], "inclusions": ["bark"], "voids": [], "checks": []}\n```'
|
||||
out = _gate(_parse_json(good))
|
||||
assert out["species_id"] == 284
|
||||
assert out["wood_category_ids"] == [251]
|
||||
assert out["edge_type_term_id"] == 323
|
||||
assert out["figure_term_ids"] == [332]
|
||||
assert out["feature_tag_ids"] == [1]
|
||||
# grade must never be gated in (METADATA-40)
|
||||
assert "grade_term_id" not in out
|
||||
# free-text bags dropped (#86)
|
||||
assert "character" not in out
|
||||
assert "inclusions" not in out
|
||||
# low-confidence fields are dropped (METADATA-45)
|
||||
low = '```json\n{"species_id": 284, "species_confidence": 0.4, "wood_category_ids": [251], "wood_category_confidence": 0.8, "edge_type_term_id": 323, "edge_confidence": 0.2, "figure_term_ids": [332], "figure_confidence": 0.9}\n```'
|
||||
out2 = _gate(_parse_json(low))
|
||||
assert out2["wood_category_ids"] == [251]
|
||||
assert out2["figure_term_ids"] == [332]
|
||||
assert "species_id" not in out2
|
||||
assert "edge_type_term_id" not in out2
|
||||
|
||||
|
||||
def test_ready_gate_rejects_malformed_sku(client, db):
|
||||
# REVIEW-45 enforced in the middle tier: a present-but-out-of-range/char-set SKU is not ready.
|
||||
from backend.review import compute_ready, sku_valid
|
||||
base = {"length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual"}
|
||||
# valid
|
||||
assert sku_valid("AB-1") is True
|
||||
ready, missing = compute_ready({**base, "sku": "AB-1"})
|
||||
assert ready is True and "sku" not in missing
|
||||
# too short
|
||||
assert sku_valid("AB") is False
|
||||
ready, missing = compute_ready({**base, "sku": "AB"})
|
||||
assert ready is False and "sku" in missing
|
||||
# bad char
|
||||
ready, missing = compute_ready({**base, "sku": "AB!"})
|
||||
assert ready is False and "sku" in missing
|
||||
# too long
|
||||
ready, missing = compute_ready({**base, "sku": "A" * 25})
|
||||
assert ready is False and "sku" in missing
|
||||
# missing (None)
|
||||
ready, missing = compute_ready({**base, "sku": None})
|
||||
assert ready is False and "sku" in missing
|
||||
|
||||
|
||||
def test_delete_published_conflict(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
@@ -190,3 +350,59 @@ def test_delete_published_conflict(client, db):
|
||||
conn.commit()
|
||||
resp = client.delete(f"/api/v1/slabs/{slab_id}")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_create_rejects_unknown_fields(client, db):
|
||||
# #83: POST /slabs with extra (non-ALLOWED_FIELDS) keys → 400, not a silent drop.
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5, "bogus_field": 42}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["error"]["code"] == "validation"
|
||||
assert "bogus_field" in body["error"]["detail"]["fields"]
|
||||
|
||||
|
||||
def test_update_stale_field_returns_live_options(client, db):
|
||||
# #82: stale_field returns LIVE taxonomy options (id+name), not the submitted id.
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
}
|
||||
assert client.post("/api/v1/slabs", json=payload).status_code == 201
|
||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["error"]["code"] == "stale_field"
|
||||
opts = body["error"]["detail"]["current_options"]
|
||||
assert isinstance(opts, list) and opts
|
||||
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
|
||||
assert 999 not in [o["id"] for o in opts]
|
||||
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
|
||||
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
|
||||
base = {
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||
"status": "published", # server-managed → unknown field 400
|
||||
}
|
||||
r = client.post("/api/v1/slabs", json=base)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "status" in r.json()["error"]["detail"]["fields"]
|
||||
# Bad taxonomy on create
|
||||
r2 = client.post("/api/v1/slabs", json={
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "species_id": 999,
|
||||
})
|
||||
assert r2.status_code == 422
|
||||
assert r2.json()["error"]["code"] == "stale_field"
|
||||
# Bad enum
|
||||
r3 = client.post("/api/v1/slabs", json={
|
||||
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
|
||||
})
|
||||
assert r3.status_code == 400
|
||||
assert r3.json()["error"]["code"] == "validation"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Thickness band resolution (METADATA-15 / QUALITY-95).
|
||||
|
||||
The owner never picks the Woo thickness band in the UI — the server derives it
|
||||
from the measured thickness_in against the live synced bands. These tests pin the
|
||||
derivation on the real live band names (from the UAT taxonomy) and on the
|
||||
quarter-inch slug fallback.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.thickness_band import (
|
||||
parse_inch_token,
|
||||
resolve_thickness_band_id,
|
||||
term_bounds,
|
||||
)
|
||||
|
||||
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
|
||||
LIVE_TERMS = [
|
||||
{"id": 347, "name": "Under 1/2\"", "slug": "xthin"},
|
||||
{"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"},
|
||||
{"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"},
|
||||
{"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"},
|
||||
{"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"},
|
||||
{"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"},
|
||||
{"id": 353, "name": "Over 3\"", "slug": "12qtr"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("1 1/2", 1.5),
|
||||
("3/4", 0.75),
|
||||
("2", 2.0),
|
||||
("1.75", 1.75),
|
||||
("2\"", 2.0),
|
||||
("1 1/2\"", 1.5),
|
||||
],
|
||||
)
|
||||
def test_parse_inch_token(text, expected):
|
||||
assert parse_inch_token(text) == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_parse_inch_token_rejects_junk():
|
||||
assert parse_inch_token("") is None
|
||||
assert parse_inch_token(" ") is None
|
||||
|
||||
|
||||
def test_term_bounds_from_live_names():
|
||||
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
|
||||
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
|
||||
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
|
||||
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
|
||||
|
||||
|
||||
def test_term_bounds_falls_back_to_qtr_slug():
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
|
||||
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"thickness,expected_id",
|
||||
[
|
||||
(0.25, 347), # Under 1/2"
|
||||
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
|
||||
(0.625, 348),
|
||||
(0.75, 349), # boundary rounds UP
|
||||
(0.875, 349),
|
||||
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
|
||||
(1.25, 350),
|
||||
(1.5, 351),
|
||||
(1.75, 351), # the UAT sample thickness
|
||||
(2.0, 352),
|
||||
(2.75, 352),
|
||||
(3.0, 353),
|
||||
(4.0, 353),
|
||||
],
|
||||
)
|
||||
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
|
||||
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
|
||||
|
||||
|
||||
def test_resolve_handles_missing_inputs():
|
||||
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
|
||||
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
|
||||
assert resolve_thickness_band_id(1.0, []) is None
|
||||
# A term with no parseable name/slug is skipped, never guessed at.
|
||||
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
|
||||
|
||||
|
||||
def test_resolve_is_id_order_independent():
|
||||
"""The result must not depend on the sync order of the terms."""
|
||||
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351
|
||||
@@ -95,12 +95,29 @@ def test_401_raises_auth_error(db_dir):
|
||||
c.get_categories()
|
||||
|
||||
|
||||
def test_scoped_tags_by_slug_not_name():
|
||||
"""SETTINGS-51: keep fig-/feat- by slug prefix with hyphen; human names stay."""
|
||||
tags = [
|
||||
{"id": 1, "name": "Curl", "slug": "fig-curl"},
|
||||
{"id": 2, "name": "Knot", "slug": "feat-knot"},
|
||||
{"id": 3, "name": "fig-looks-like", "slug": "other"}, # name prefix alone — drop
|
||||
{"id": 4, "name": "Figured", "slug": "figured"}, # no hyphen — drop
|
||||
{"id": 5, "name": "other", "slug": "other-tag"},
|
||||
]
|
||||
kept = taxonomy_mod._scoped_tags(tags)
|
||||
assert {t["id"] for t in kept} == {1, 2}
|
||||
|
||||
|
||||
def test_sync_populates_scoped(db_dir):
|
||||
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
|
||||
# Live Woo: human names + fig-/feat- slugs. Name-prefix filter would keep 0.
|
||||
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||
{"id": 1, "name": "Cookies & Burl Slices", "slug": "cookies-burl", "parent_id": None, "woo_id": 10},
|
||||
]).encode())
|
||||
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||
{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20},
|
||||
{"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21},
|
||||
{"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped
|
||||
{"id": 2, "name": "Curl", "slug": "fig-curl", "woo_id": 20},
|
||||
{"id": 3, "name": "Knot", "slug": "feat-knot", "woo_id": 21},
|
||||
{"id": 4, "name": "fig-fake", "slug": "other-tag", "woo_id": 22}, # name looks scoped; slug not — drop
|
||||
{"id": 5, "name": "other-tag", "slug": "other-tag-2", "woo_id": 23},
|
||||
]).encode())
|
||||
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode())
|
||||
terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode())
|
||||
@@ -108,8 +125,11 @@ def test_sync_populates_scoped(db_dir):
|
||||
result = taxonomy_mod.sync(c, db_mod.get_connection())
|
||||
conn = db_mod.get_connection()
|
||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1]
|
||||
tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")}
|
||||
assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped
|
||||
assert conn.execute("SELECT name FROM woo_categories WHERE id=1").fetchone()[0] == "Cookies & Burl Slices"
|
||||
tag_rows = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT id, name, slug FROM woo_tags")}
|
||||
assert set(tag_rows) == {2, 3} # fig- + feat- by slug; name-only fig-fake dropped
|
||||
assert tag_rows[2] == ("Curl", "fig-curl")
|
||||
assert tag_rows[3] == ("Knot", "feat-knot")
|
||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6]
|
||||
assert result["taxonomy_anchor"]
|
||||
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
|
||||
|
||||
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
|
||||
synced band term. We map thickness_in into the first half-open band [low, high)
|
||||
that contains it, derived only from the live synced term names/slugs — never a
|
||||
hardcoded Woo term id.
|
||||
|
||||
Round-up semantics: bands are half-open on the upper bound, so a value that sits
|
||||
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
|
||||
value (1"–1 1/2"), not the band that ends there (3/4"–1").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# Mixed number / fraction / decimal inch token, optional trailing quote.
|
||||
_INCH_RE = re.compile(
|
||||
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
|
||||
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
|
||||
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
|
||||
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_inch_token(text: str) -> Optional[float]:
|
||||
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
|
||||
t = (text or "").strip().rstrip("\"'").strip()
|
||||
if not t:
|
||||
return None
|
||||
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
|
||||
if not m:
|
||||
return None
|
||||
if m.group("a") is not None:
|
||||
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
|
||||
if m.group("n2") is not None:
|
||||
return float(m.group("n2")) / float(m.group("d2"))
|
||||
if m.group("dec") is not None:
|
||||
return float(m.group("dec"))
|
||||
return None
|
||||
|
||||
|
||||
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
|
||||
"""Return half-open [low, high) inches from a Woo thickness term name."""
|
||||
raw = (name or "").strip()
|
||||
low_s = raw.lower()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# Under X / less than X / < X
|
||||
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
|
||||
if m:
|
||||
hi = parse_inch_token(m.group(1))
|
||||
if hi is not None:
|
||||
return (0.0, hi)
|
||||
|
||||
# Over X / above X / > X / X+
|
||||
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
|
||||
if m:
|
||||
lo = parse_inch_token(m.group(1))
|
||||
if lo is not None:
|
||||
return (lo, float("inf"))
|
||||
m = re.match(r"^(.+?)\+\s*$", raw)
|
||||
if m:
|
||||
lo = parse_inch_token(m.group(1))
|
||||
if lo is not None:
|
||||
return (lo, float("inf"))
|
||||
|
||||
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
|
||||
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
lo = parse_inch_token(parts[0])
|
||||
hi = parse_inch_token(parts[1])
|
||||
if lo is not None and hi is not None and hi > lo:
|
||||
return (lo, hi)
|
||||
|
||||
# Single value band treated as [v, v+epsilon) — rare
|
||||
only = parse_inch_token(raw)
|
||||
if only is not None:
|
||||
return (only, only + 1e-9)
|
||||
return None
|
||||
|
||||
|
||||
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
|
||||
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
|
||||
s = (slug or "").strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if s in {"xthin", "under", "thin"}:
|
||||
return (0.0, 0.5)
|
||||
m = _QTR_RANGE_RE.match(s)
|
||||
if m:
|
||||
lo = int(m.group(1)) * 0.25
|
||||
hi = int(m.group(2)) * 0.25
|
||||
if hi > lo:
|
||||
return (lo, hi)
|
||||
m = _QTR_MIN_RE.match(s)
|
||||
if m:
|
||||
lo = int(m.group(1)) * 0.25
|
||||
return (lo, float("inf"))
|
||||
return None
|
||||
|
||||
|
||||
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
|
||||
"""Bounds for one synced thickness term; name first, slug fallback."""
|
||||
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
|
||||
str(term.get("slug") or "")
|
||||
)
|
||||
|
||||
|
||||
def resolve_thickness_band_id(
|
||||
thickness_in: float | int | None,
|
||||
terms: Iterable[dict[str, Any]],
|
||||
) -> Optional[int]:
|
||||
"""Pick the synced thickness term id for thickness_in, or None if unknown.
|
||||
|
||||
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
|
||||
"""
|
||||
if thickness_in is None:
|
||||
return None
|
||||
try:
|
||||
t = float(thickness_in)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if t < 0:
|
||||
return None
|
||||
|
||||
scored: list[tuple[float, float, int]] = [] # (low, high, id)
|
||||
for term in terms:
|
||||
b = term_bounds(term)
|
||||
if b is None:
|
||||
continue
|
||||
tid = term.get("id")
|
||||
if tid is None:
|
||||
continue
|
||||
lo, hi = b
|
||||
scored.append((lo, hi, int(tid)))
|
||||
|
||||
if not scored:
|
||||
return None
|
||||
|
||||
# Prefer the tightest band that contains t under half-open [low, high).
|
||||
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
|
||||
if matches:
|
||||
matches.sort() # tightest span first
|
||||
return matches[0][3]
|
||||
|
||||
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
|
||||
scored.sort(key=lambda x: x[0])
|
||||
for lo, hi, tid in scored:
|
||||
if t < hi:
|
||||
return tid
|
||||
return scored[-1][2]
|
||||
|
||||
|
||||
def load_thickness_terms(conn) -> list[dict[str, Any]]:
|
||||
"""Live Thickness attribute terms from the synced Woo cache."""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
|
||||
("thickness", "thickness"),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return []
|
||||
attr_id = row[0]
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
|
||||
(attr_id,),
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
|
||||
+38
-13
@@ -98,14 +98,7 @@ class WooClient:
|
||||
|
||||
def _get(self, path: str) -> tuple[int, dict, bytes]:
|
||||
url = f"{self.base_url}/wp-json/wc/v3{path}"
|
||||
return self._request(
|
||||
url, "GET", None,
|
||||
{
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
},
|
||||
)
|
||||
return self._request(url, "GET", None, self._auth_headers())
|
||||
|
||||
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
||||
page = 1
|
||||
@@ -132,6 +125,42 @@ class WooClient:
|
||||
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
|
||||
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
|
||||
|
||||
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
|
||||
h = {
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
}
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
|
||||
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
|
||||
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
|
||||
code, _h, body = self._request(
|
||||
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
|
||||
"GET",
|
||||
None,
|
||||
self._auth_headers(),
|
||||
)
|
||||
if code != 200:
|
||||
raise WooServerError(f"sku lookup returned {code}")
|
||||
return list(json.loads(body))
|
||||
|
||||
def create_product(self, payload: dict[str, Any]) -> int:
|
||||
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
code, _h, resp = self._request(
|
||||
f"{self.base_url}/wp-json/wc/v3/products",
|
||||
"POST",
|
||||
body,
|
||||
self._auth_headers({"Content-Type": "application/json"}),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"product create returned {code}")
|
||||
return int(json.loads(resp)["id"])
|
||||
|
||||
def upload_media(self, png_bytes: bytes, filename: str) -> int:
|
||||
"""POST a PNG to WordPress core media; return the attachment id."""
|
||||
boundary = "----slabuploader-media-boundary"
|
||||
@@ -143,11 +172,7 @@ class WooClient:
|
||||
url = f"{self.base_url}/wp-json/wp/v2/media"
|
||||
code, _headers, resp = self._request(
|
||||
url, "POST", body,
|
||||
{
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"User-Agent": _USER_AGENT,
|
||||
},
|
||||
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"media upload returned {code}")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Port block `10880`–`10889` on ubudual5060 is allocated to this project.
|
||||
`:10880` is Ty's local Node/SvelteKit and must never be bound by project deploys.
|
||||
The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can reach it.
|
||||
Check to see if you are running on ubudual5060 (192.168.1.202) before attempting SSH.
|
||||
|
||||
## Dev-test environment (M2 current, M3 incoming)
|
||||
|
||||
@@ -23,3 +24,204 @@ The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can
|
||||
Future deploys use a **separate** container name and a free port in `10880`–`10889`
|
||||
(other than `10880`), bound `0.0.0.0`.
|
||||
Do not put a host name in the product spec.
|
||||
|
||||
## Deploy records
|
||||
|
||||
### 2026-09-26 ~12:01 PT — Measure Zoom/Pan vs Rotate + drop dead ↻ on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`f505be0`** (`fix(capture): Zoom/Pan vs Rotate gesture modes; drop dead ↻ overlay`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-CsENRLHF.js`
|
||||
- UX: Measure stage segmented **Zoom / Pan** (default) vs **Rotate**; pinch/pan exclusive of two-finger twist; removed bottom-center ↻ that overlapped tick B
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~11:47 PT — Publish preview before Woo draft POST on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`73e2818`** (`feat(publish): Publish preview before Woo draft POST`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-Co2F8tsa.js`
|
||||
- UX: Review → Continue to Publish → glanceable preview (thumbs, SKU/title/price, taxonomy/dims, short+long desc) → Publish / Back to Review
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~11:08 PT — Review UI nits (margins, buttons, moisture, live validation) on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`5651a38`** (`fix(review): UI nits — margins, buttons, moisture, live validation`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-C1jF-uuB.js`
|
||||
- Nits: post-publish no Back; removed white Slabs footer control; Review/App padding; unified `.btn-sec`; moisture kd + live input validation
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~10:12 PT — Review View prompt + Call 2 declared attrs / Create-desc gate on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`1ff082f`** (`feat(review): View prompt + Call 2 declared attrs, Create-desc gate`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **fastapi + frontend** rebuilt/recreated
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-BJxrob4c.js`
|
||||
- Call 1 still **410** `call1_disabled`; `GET .../inference/call2/prompt` live (View prompt)
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
### 2026-09-26 ~09:42 PT — Review metadata: slug tags, decode names, no fig picker / AI labels on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`85786f1`** (`fix(review): slug-scoped tags, decode names, drop fig picker & AI labels`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **fastapi + frontend** rebuilt/recreated
|
||||
- Taxonomy re-sync: `POST /api/v1/admin/taxonomy/sync` → 14 tags (11 `fig-*`, 3 `feat-*`); cats decode `&` → `&`
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-DA24IrWT.js`
|
||||
- Call 1 still **410** `call1_disabled`
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
|
||||
|
||||
### 2026-09-26 ~09:12 PT — PNG size floor warn 300–799 / block <300 on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`1b5ad11`** (`fix(capture): warn 300–799px PNG floor; hard-block only below 300`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend only** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-Cgc1wxKP.js`
|
||||
- Call 1 still **410** `call1_disabled`
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
|
||||
|
||||
### 2026-09-26 ~08:58 PT — fix Create title species carry-forward on :10890
|
||||
|
||||
- Source: `overnight-whole-app` @ **`76f3026`** (`fix(review): carry Measure species into Create title/short-desc`)
|
||||
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend only** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` → ok; Tailscale `100.64.0.9:10890` → ok; root `/` → 200; asset `index-4mcBthul.js`
|
||||
- Call 1 still **410** `call1_disabled`
|
||||
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
|
||||
|
||||
### 2026-09-25 ~22:06 PT — overnight-whole-app SEPARATE stack on :10890/:10891
|
||||
|
||||
- **Intentional ports**: frontend **10890**, fastapi **10891** (1089x range; avoids fighting 10881/10882).
|
||||
- Host: `ubudual5060` / LAN `192.168.1.202` / Tailscale `100.64.0.9`
|
||||
- Checkout: `~/slab-deploy` @ **`6caa4f6`** branch `overnight-whole-app`
|
||||
- Compose: host-local untracked `deploy/override-overnight.yml` as **standalone** file (not merged with base); project **`-p slab-overnight`**
|
||||
- Containers: `slab-overnight-frontend-1` / `slab-overnight-fastapi-1`
|
||||
- Binds: `0.0.0.0:10890→80`, `0.0.0.0:10891→8000` (+ loopback 18090/18091)
|
||||
- Volumes: **dedicated** `slab-overnight_slab_overnight_db` / `_images` (did not reuse `deploy_slab_*`)
|
||||
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
|
||||
- Health: `GET http://127.0.0.1:10890/api/health` and Tailscale → `{"status":"ok","version":"0.5.0-m3","woo_reachable":false}` (fresh DB; Woo app password not yet in Settings). Root `/` → 200.
|
||||
- Call 1: `POST /api/v1/slabs/{id}/inference/call1` → **410** `call1_disabled`
|
||||
- URLs for Ty: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
|
||||
- Note: an earlier mistaken recreate put overnight bits onto `deploy-*` :10881/:10882; that stack was then **left running untouched**. Canonical overnight prop is **10890/10891**.
|
||||
- Box Cloudflare quick tunnel stopped; box local :18181/:18182 left running.
|
||||
|
||||
### 2026-09-25 ~22:05 PT — overnight-whole-app on ubudual5060 :10881/:10882
|
||||
|
||||
- Host: `ubudual5060` / LAN `192.168.1.202` / Tailscale `100.64.0.9`
|
||||
- Checkout: `~/slab-deploy` branch `overnight-whole-app` @ **`6caa4f6`** (`fix(uat): allow Cloudflare quick-tunnel Host on vite preview`)
|
||||
- Compose project: `deploy` → containers `deploy-frontend-1` / `deploy-fastapi-1` only
|
||||
- Ports: `0.0.0.0:10881→80`, `0.0.0.0:10882→8000` (+ loopback 18082/18083 from host `.env`); **did not** touch 10880 / `slabuploader-m1` (18080) / greenfield 10884–10885 / m4-review 10883
|
||||
- Build: on UAT host (`docker compose -f deploy/docker-compose.yml --env-file deploy/.env build` then `up -d`, **no `-v`**); volumes `deploy_slab_db` / `deploy_slab_images` preserved
|
||||
- Health: `GET /api/health` → `{"status":"ok","version":"0.5.0-m3","woo_reachable":true}` from 127.0.0.1 and from Tailscale `100.64.0.9`; root `/` → 200
|
||||
- Call 1: `POST /api/v1/slabs/{id}/inference/call1` → **410** `call1_disabled`
|
||||
- URLs for Ty: Tailscale `http://100.64.0.9:10881` · LAN `http://192.168.1.202:10881`
|
||||
- Box Cloudflare quick tunnel (`cloudflared tunnel --url http://127.0.0.1:18181`) stopped; box local :18181/:18182 left running
|
||||
|
||||
### 2026-09-25 ~17:50 PT — overnight-whole-app box UAT (Grok Bot computer)
|
||||
|
||||
- Branch: `overnight-whole-app` (main @ 0c00d25 + cherry-picked #99 tape-line `b44e231`→`323f29b`→`be2ea6c` + Call 1 OUT).
|
||||
- **Frontend**: vite preview `0.0.0.0:18181` (proxy `/api` → FastAPI). Never touch `:10880`.
|
||||
- **Backend**: uvicorn `0.0.0.0:18182` (`backend.app:app`), local SQLite under `.uat-data/` (gitignored).
|
||||
- Env: `WOO_BASE_URL` from `~/.config/slabuploader/woo.env` (password not logged); `SLAB_AES_KEY` from local file (not in repo).
|
||||
- Call 1: `POST .../inference/call1` → **410 call1_disabled**. Call 2 path retained.
|
||||
- Health: `/api/health` ok; `woo_reachable` may be false from this box (network) — draft-only smoke.
|
||||
- Partner Woo UAT containers: **not touched**.
|
||||
|
||||
|
||||
### 2026-09-21 02:41 PDT — #90 wood primary category + derived thickness band
|
||||
|
||||
- Source ref: `0329052` on `issue-89-metadata-lock` (pushed to Gitea; pulled into `~/slab-deploy`).
|
||||
- Images (built on `ubudual5060`): `deploy-frontend:latest` `1c169c29f131`,
|
||||
`deploy-fastapi:latest` `32671659639b`.
|
||||
- Compose: `deploy/docker-compose.yml` + host-local (untracked) `deploy/override-m3.yml`.
|
||||
- URL: `http://192.168.1.202:10881` (LAN) · `http://100.64.0.9:10881` (Tailscale).
|
||||
- `matches HEAD`: **yes**.
|
||||
- Sample data preserved: **yes** — 24 slabs before and after `up -d` (no `-v`, no volume removal).
|
||||
- Verified on the live stack: served asset `assets/index-tlFib3RM.js` (sha256 `3b665746ec81f28e…`)
|
||||
contains the new markers and no `#thickness` select; `POST /api/v1/slabs` at `thickness_in 1.0`
|
||||
stored band `350` (`1"- 1 1/2"`); `PUT` at `2.75` re-banded to `352`; a client-sent stale band id
|
||||
was ignored; `POST /review` no longer lists thickness as missing; the Review screen in a real
|
||||
browser (LAN URL) shows the wood-category MultiSelect closed summary with names + count, the
|
||||
`Primary wood category` radio set switching on click, and the band as read-only text.
|
||||
- Not exercised: the Woo publish path. Rows created **before** this deploy keep `thickness_term_id`
|
||||
NULL until their next save (the band is derived on write, so any Review commit fills it in).
|
||||
|
||||
### 2026-09-21 03:12 PDT — follow-up: thickness band omitted entirely from Review
|
||||
|
||||
- Commit: `204bc5e` (fix #90: band removed from UI, not merely read-only; publish test added).
|
||||
- Rebuilt `deploy-frontend:latest` and recreated the container on 10881.
|
||||
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement".
|
||||
- Sample data unchanged (still 25 slabs post-recreate).
|
||||
- All gates (unit, build, requirements, coverage, m3-create e2e) pass.
|
||||
|
||||
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
|
||||
|
||||
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
|
||||
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
|
||||
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
|
||||
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
|
||||
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
|
||||
deterministic title/short_description are now built by the review endpoint on every call; the
|
||||
owner's **Create title / Create short desc** buttons fire the build and write the result
|
||||
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
|
||||
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
|
||||
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
|
||||
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
|
||||
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
|
||||
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
|
||||
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
|
||||
`woo_reachable:true`.
|
||||
- Sample data preserved: **yes** — 34 slabs before/after.
|
||||
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
|
||||
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
|
||||
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
|
||||
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
|
||||
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
|
||||
not exercised this round.
|
||||
|
||||
### 2026-09-21 12:10 PDT — redeploy: `main` after merge `8ee3da9` (reconciled PR #97 + branch)
|
||||
|
||||
- Source: `main` @ `8ee3da9` (merge of `work/merge-89-90` into main, which itself reconciled
|
||||
main's PR #97 client-side `thicknessBand.ts` + `syncSelects`/`box-sizing` against the branch's
|
||||
server-side `thickness_band.py` + Create-string #89). `matches HEAD`: **yes**.
|
||||
- Resolution outcome in the deployed bundle: band is **server-side** — `Review.svelte` no longer
|
||||
imports `thicknessBand` and the read-only band display is gone; PR #97's `syncSelects()` (3× in
|
||||
source) + `box-sizing` CSS preserved; `metadata_locked` still dropped; Create-string + photo
|
||||
strip + wood primary live.
|
||||
- Rebuilt both images from `main`; recreated the stack (`down --remove-orphans` + `up -d`, no `-v`).
|
||||
LAN binds 10881/10882 intact.
|
||||
- Bundle: `assets/index-BScrgd8o.js` (new; differs from pre-merge `YPAqx609`; contains
|
||||
`create-title`, **zero** `thicknessBand` references). API `/api/health` `woo_reachable:true`.
|
||||
- Sample data preserved: **yes** — 35 slabs (one added upstream since last deploy).
|
||||
- Live verification (LAN URL, real DB): `POST /review` on slab `ec4088da…` →
|
||||
`title:'Curl Black Walnut Slab'`, `ready:True`.
|
||||
- Gates at merge time: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
|
||||
- Branches `work/merge-89-90` and `issue-89-metadata-lock` deleted (local + remote); remote has
|
||||
only `main`.
|
||||
|
||||
### 2026-09-21 12:34 PDT — Review mount: restore dropped lineup/woodPrimary imports
|
||||
|
||||
- Source: uncommitted working tree on `main` (parent `67aa3d6`). `matches HEAD`: **no**.
|
||||
Diff: `frontend/src/create/review/Review.svelte` +5 lines restoring imports of
|
||||
`keptInOrder`/`moveItem`/`missingMinimums`/`roleLabel`/`resolvePhotoUrl`/`LineupItem`
|
||||
from `lineup.ts` and `nextPrimary` from `woodPrimary.ts`. The morning merge kept the
|
||||
call sites and dropped the imports → `ReferenceError` on Review mount → "Could not load the slab."
|
||||
- Rebuilt `deploy-frontend` only (`compose build frontend` + `up -d frontend`, no `-v`).
|
||||
Image `56b35f6cdcdf`. FastAPI container left running.
|
||||
- URL: http://192.168.1.202:10881 (LAN) · loopback 127.0.0.1:18082
|
||||
- Bundle: `assets/index-BVCyM130.js` (replaced `index-ck41xD02.js`). Served JS has **0**
|
||||
`nextPrimary`/`keptInOrder` (minified locals) vs 3 unresolved `nextPrimary` in the broken bundle.
|
||||
- Sample data preserved: **yes** — 47 slabs before rebuild; 48 after live Capture→Review probe
|
||||
(created `ef1f4101-b1d2-459d-b9a0-842e7650ea3c`, sku NULL, status calibrated, 48×2 in; not published).
|
||||
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
|
||||
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
|
||||
GET slab/taxonomy/settings 200, review 200.
|
||||
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
|
||||
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
|
||||
|
||||
@@ -16,7 +16,9 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: frontend/Dockerfile
|
||||
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"]
|
||||
ports:
|
||||
- "127.0.0.1:${FRONTEND_PORT:-18080}:80"
|
||||
- "0.0.0.0:10881:80"
|
||||
depends_on: [fastapi]
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -34,7 +36,9 @@ services:
|
||||
volumes:
|
||||
- slab_db:/data
|
||||
- slab_images:/images
|
||||
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"]
|
||||
ports:
|
||||
- "127.0.0.1:${FASTAPI_PORT:-18081}:8000"
|
||||
- "0.0.0.0:10882:8000"
|
||||
mem_limit: 2g
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
|
||||
# multipart to /api/v1/slabs/{id}/photos. nginx's default client_max_body_size
|
||||
# is 1MB, which 413s on real multi-MB photos. Allow large photo bodies.
|
||||
client_max_body_size 25m;
|
||||
|
||||
# Docker embedded DNS for service name resolution (fastapi)
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
|
||||
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
||||
| CAPTURE-95 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-100 | Crop is 3:4 portrait, slab centered, 90 percent fill at the widest and tallest extremes, on a transparent background. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-105 | After crop the shorter side is at least 800 px when the source allows; otherwise warn and require a new photo, and never upscale. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-105 | After crop the shorter side targets 800 px when the source allows; warn (still encode/include) when 300–799 px; hard-block below 300 px; never upscale. | shipped | frontend/src/png/inventory.test.ts |
|
||||
| CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.batchApply.test.ts |
|
||||
| CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.batchApply.test.ts |
|
||||
| CAPTURE-120 | The dimension overlay is black and oriented to the approved length axis, with the length graphic beside the wood on the right in axis space. | shipped | frontend/src/png/overlay.test.ts |
|
||||
@@ -62,9 +62,9 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| METADATA-20 | Missing attributes or terms are skipped, never created. | M3 | — |
|
||||
| METADATA-25 | Species, character, length, and free-text thickness are not product attributes. | M3 | — |
|
||||
| METADATA-30 | Product tags come from the synced Woo set and are all optional; no hardcoded prefixes, and the bare fig tag is kept. | M3 | — |
|
||||
| METADATA-100 | Call 1 free-text observations (character, inclusions, voids, checks) are mapped to existing synced feat-* tags only; WooCommerce is the authority for feat-* definitions. | M3 | — |
|
||||
| METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | — |
|
||||
| METADATA-40 | Call 1 returns species, wood categories, edge, figure, grade, and feature-tag suggestions constrained to the synced lists, each with confidence. | M3 | — |
|
||||
| METADATA-100 | Call 1 returns feature suggestions as feature_tag_ids chosen only from the synced feat-* tag set (display names without the feat- prefix); WooCommerce is the authority for those tags. No free-text observation bags. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-40 | Call 1 returns species, wood categories, edge, figure (multi), and feature-tag id suggestions constrained to the synced LISTS, each with confidence. Grade is not suggested by Call 1 (owner judgment). Wood category is decided before edge; cookies/burl prefer Wild edge when present. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-45 | Call 1 confidence at or above the threshold (default 0.7) pre-fills review fields; below it the field stays empty for the owner to fill by hand or re-run Call 1; suggestions are always editable. | M3 | — |
|
||||
| METADATA-50 | Typed values always win over inferred values. | M3 | — |
|
||||
| METADATA-85 | The primary wood category selects the Call 2 prompt and the title and short-description patterns. | M3 | — |
|
||||
@@ -75,7 +75,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
|
||||
| METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — |
|
||||
| METADATA-70 | With inference off, the server produces the long description. | M3 | — |
|
||||
| METADATA-66 | Every content generation, from a deterministic pattern or from the model, is logged with its inputs, the model request and response when a model was used, and the final stored content. | M3 | — |
|
||||
| METADATA-66 | Every content generation, from a deterministic pattern or from the model, is logged with its inputs, the model request and response when a model was used, and the final stored content. The logged request keeps full text and photo refs only — never base64 image payloads. | M3 | backend/tests/test_inference.py |
|
||||
| METADATA-75 | Inference never blocks create and never auto-publishes. | M3 | — |
|
||||
| METADATA-105 | Inference prompts are stored in the database with all revisions; each call uses the active revision, the revision history is retained, and the revision used is recorded with the call. | M3 | — |
|
||||
| METADATA-106 | The prompt revision interface is a REST API: append a revision, activate a revision, and read the active prompt with its revision list. There is no prompt editing UI. | M3 | — |
|
||||
@@ -133,7 +133,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — |
|
||||
| SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — |
|
||||
| SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — |
|
||||
| SETTINGS-51 | Taxonomy sync keeps only the store's figure and feature product-tag families; all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | — |
|
||||
| SETTINGS-51 | Taxonomy sync keeps only product tags whose slug starts with fig- or feat- (hyphen required); all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | backend/tests/test_woo.py |
|
||||
| SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — |
|
||||
| SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — |
|
||||
| SETTINGS-65 | woo_create_status defaults to draft. | M3 | — |
|
||||
@@ -199,7 +199,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
||||
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
|
||||
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
|
||||
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
|
||||
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — |
|
||||
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py |
|
||||
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
|
||||
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
|
||||
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
|
||||
|
||||
@@ -121,7 +121,10 @@ hard stop. Switch to a supported browser.
|
||||
3. **Wood categories.** Pick at least one from the synced list.
|
||||
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
|
||||
exactly one grade.
|
||||
5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into.
|
||||
5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness
|
||||
up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish
|
||||
that the Woo product's Thickness attribute is the band the captured thickness rounds up into
|
||||
(`QUALITY-95`).
|
||||
6. **Moisture.** Keep the default (kiln-dried) or change it.
|
||||
7. **fig-* tags.** Optional; pick any from the synced list.
|
||||
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
|
||||
|
||||
@@ -123,7 +123,7 @@ On Calculate, the app builds the inventory image from the approved mask:
|
||||
1. Keep slab pixels from the source photo. Removed pixels are transparent.
|
||||
2. Place the slab in a 3:4 portrait frame, centered.
|
||||
3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
|
||||
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
||||
4. After that crop, shorter side targets ≥ 800px when the source can supply it. If the slab bbox shorter side is 300–799 px, warn but still encode and include the photo. Below 300 px is a hard block (exclude / retake). No upscale from an undersized source.
|
||||
5. Encode as PNG with alpha.
|
||||
|
||||
## Device draft
|
||||
@@ -147,6 +147,6 @@ The calibration photo is the measurement reference (`CAPTURE-25`, `CAPTURE-115`)
|
||||
| Alpha | Kept through encode and upload |
|
||||
| Aspect | 3:4 portrait |
|
||||
| Layout | Slab centered, 90% fill at the widest and tallest points |
|
||||
| Size | After crop, shorter side ≥ 800px when the source can supply it |
|
||||
| Size | After crop, shorter side targets ≥ 800px when the source can supply it |
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
Shorter side 300–799 px: warn but still encode/include. Below 300 px: hard block (exclude / retake). No upscale from an undersized source.
|
||||
|
||||
@@ -78,7 +78,7 @@ default off (`SETTINGS-90`). Species rates are one flat value per synced leaf (`
|
||||
test-woo returns ok and detail, fails closed on missing credentials or non-HTTPS, and always
|
||||
resyncs taxonomy on success (`SETTINGS-80`, `SETTINGS-40`). test-inference returns ok, mode
|
||||
(stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only
|
||||
(`SETTINGS-45`). Taxonomy sync keeps only fig-* and feat-* tag families (`SETTINGS-51`), rebuilds
|
||||
(`SETTINGS-45`). Taxonomy sync keeps only tags whose **slug** starts with `fig-` or `feat-` (`SETTINGS-51`), rebuilds
|
||||
in one transaction, keeps the previous cache on failure (`SETTINGS-50`), and bumps the anchor only
|
||||
on a real stored-subset change (`SETTINGS-55`). The client re-pulls when the anchor advances, and
|
||||
publish refreshes when the last sync is over an hour old (`SETTINGS-60`).
|
||||
|
||||
@@ -48,7 +48,7 @@ For each non-calibration photo:
|
||||
|
||||
- Reuse the approved Mask sheet (Auto / Green / Black) and sensitivity from the calibration photo. Do not re-open Mask controls for that photo in this milestone.
|
||||
- Run background removal with those params; compose inventory PNG with the same crop rules as Milestone 1 Calculate.
|
||||
- If a photo yields an empty mask or fails the PNG size floor, keep it out of the ready set and show a clear per-photo error; other photos still proceed.
|
||||
- If a photo yields an empty mask or fails the PNG size block floor (<300 px shorter side), keep it out of the ready set and show a clear per-photo error; other photos still proceed. A warn-band size (300–799 px) still includes the photo with a clear per-photo warn.
|
||||
|
||||
Scale, widths, sqft, and bdft stay tied to the calibration photo and user `L` / thickness. Non-calibration photos are visual inventory only.
|
||||
|
||||
|
||||
@@ -144,9 +144,9 @@ block, and brand/geo context; no provider thread id and no raw Call 1 result. Re
|
||||
and never touches dimensions. Cites METADATA-55, METADATA-65.
|
||||
|
||||
Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`,
|
||||
`edge_type_term_id`, `figure_term_ids`, `figure_confidence`, `grade_term_id`, `grade_confidence`,
|
||||
`character`, `inclusions`, `voids`, `checks`. Free-text observations map to synced `feat-*` tags
|
||||
only.
|
||||
`wood_category_confidence`, `edge_type_term_id`, `edge_confidence`, `figure_term_ids`,
|
||||
`figure_confidence`, `feature_tag_ids`, `feature_tag_confidence`. Feature tags are ids from the
|
||||
synced `feat-*` set only (no free-text bags). Grade is never suggested.
|
||||
|
||||
Call 2 result field: `description`. The name and short description are assembled by the server from
|
||||
the primary wood category's patterns, not returned by the call.
|
||||
@@ -170,7 +170,7 @@ Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLIS
|
||||
## 8. Taxonomy
|
||||
|
||||
- `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus
|
||||
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only fig-* and feat-* tag families are kept.
|
||||
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only tags whose slug starts with `fig-` or `feat-` are kept.
|
||||
These lists are the only legal picker options.
|
||||
- `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets
|
||||
`taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and
|
||||
|
||||
@@ -109,7 +109,7 @@ Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55.
|
||||
- `woo_categories` — species and wood-category leaves only; parents group and are never assigned.
|
||||
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
|
||||
Grade, Thickness, Moisture, with their terms.
|
||||
- `woo_tags` — only the fig-* and feat-* families; other store tags are ignored.
|
||||
- `woo_tags` — only tags whose slug starts with `fig-` or `feat-`; other store tags are ignored.
|
||||
|
||||
Rebuild is one transaction; on failure the previous cache is kept.
|
||||
|
||||
@@ -160,6 +160,14 @@ Append-only; never deleted on publish. Columns: `id`, `slab_id`, `woo_product_id
|
||||
Append-only audit. Columns: `id`, `slab_id`, `call` (1 | 2), `prompt_revision_id` FK, `model`,
|
||||
`request_payload`, `response_payload`, `latency_ms`, `created_at`.
|
||||
|
||||
`request_payload` is auditable JSON of the model request **without media bytes**
|
||||
(`METADATA-66`). Text parts (active prompt, LISTS / fact block, SKU/dims) are stored in full.
|
||||
Each vision attachment is an `image_ref` (`photo_id`, `seq`, `bytes`, optional
|
||||
`infer_long_edge_px`, `label`) pointing at `slab_photos` — never a
|
||||
`data:image/...;base64,...` URL. The wire request to the inference API still includes images;
|
||||
only the log is redacted. A byte cap remains as a safety net after redaction; normal Call 1/2
|
||||
rows are far under it.
|
||||
|
||||
## 10. Status transitions
|
||||
|
||||
Cites APP-5.
|
||||
|
||||
+118
-22
@@ -1,5 +1,11 @@
|
||||
# Prompts contract
|
||||
|
||||
> **overnight-whole-app / Call 1 OUT:** Call 1 (vision classification) is hard-disabled.
|
||||
> `POST /api/v1/slabs/{id}/inference/call1` returns **410 call1_disabled**. UI triggers
|
||||
> (Review "Suggest with AI", GenerateText classify) are removed. Call 2
|
||||
> (`POST .../inference/call2`, Review "Generate description") stays invokable.
|
||||
> Call 1 seed text and prompt-revision APIs remain for documentation / future re-enable.
|
||||
|
||||
Status: SPEC · 2026-09-18.
|
||||
Owns prompt storage, prompt inputs, and the two inference calls.
|
||||
|
||||
@@ -46,20 +52,23 @@ true; also on demand or retry. With inference off it is skipped and the fields a
|
||||
|
||||
**Inputs.**
|
||||
- The clean processed photos at 1024 px long edge or less.
|
||||
- The synced taxonomy: species leaves, Edge Type, Figure, Grade, Thickness, Moisture terms, each
|
||||
with its Woo id. These are the only legal choices.
|
||||
- Closed LISTS from the synced Woo cache only: `species`, `wood_categories`, `edge_terms`,
|
||||
`figure_terms`, `feature_tags` (each with id + display name). Grade, thickness, and moisture are
|
||||
not sent to Call 1.
|
||||
- User metadata: SKU, length, thickness.
|
||||
|
||||
**Constraints.**
|
||||
- The model may only choose values from the supplied synced lists; it never invents categories or
|
||||
terms.
|
||||
- Free-text observations (character, inclusions, voids, checks) are mapped to existing synced
|
||||
`feat-*` tags only; Woo is authoritative for those tags.
|
||||
- The model may only choose values from the supplied LISTS; it never invents categories or terms.
|
||||
- Feature tags are returned as `feature_tag_ids` from the supplied list only. Display names omit a
|
||||
leading `feat-` prefix; Woo remains authoritative for the tag set. No free-text observation bags.
|
||||
- Grade is never suggested (owner judgment).
|
||||
- Decide wood categories before edge type. Cookies and burl cuts prefer Wild edge when that term
|
||||
exists in the list.
|
||||
|
||||
**Result fields.** `species_id` + `species_confidence`; `wood_category_ids`;
|
||||
`edge_type_term_id`; `figure_term_ids` + `figure_confidence`; `grade_term_id` +
|
||||
`grade_confidence`; `character`, `inclusions`, `voids`, `checks`. Grade, edge, thickness, and
|
||||
moisture are singular.
|
||||
**Result fields.** `species_id` + `species_confidence`; `wood_category_ids` +
|
||||
`wood_category_confidence`; `edge_type_term_id` + `edge_confidence`; `figure_term_ids` +
|
||||
`figure_confidence` (multi allowed); `feature_tag_ids` + `feature_tag_confidence`. Edge is singular;
|
||||
figure and feature tags are multi.
|
||||
|
||||
**Pre-fill.** At or above the threshold (default 0.7) the field is pre-filled and remains
|
||||
editable; below it the field stays empty and is not shown as a low-confidence guess. Typed values
|
||||
@@ -87,9 +96,14 @@ No provider thread id is used, and Call 1's raw prompt and result are not sent (
|
||||
fact block (`METADATA-65`).
|
||||
- The dimension-overlay image is never sent (`METADATA-95`).
|
||||
|
||||
**Output.** The long `description` prose. The server assembles `name` and `short_description`
|
||||
(`METADATA-55`, `METADATA-80`). The primary wood category selects the specialized prompt
|
||||
(`METADATA-85`). The owner can edit the result (`REVIEW-40`).
|
||||
**Output.** Call 2 writes **both** `short_description` (plain text, ~50 words) and
|
||||
`description` (HTML). The seed carries short/long writing structure (benefit→diff→trust short;
|
||||
Hook→Benefits→Specs→Trust long) and requires a JSON object
|
||||
`{"short_description":"...","description":"..."}`. Brand voice and geo stay Settings-injected
|
||||
at assemble time — not hardcoded in the seed. The server still assembles `name`
|
||||
deterministically (`METADATA-55`, `METADATA-80`); short may also be owner-edited or rebuilt via
|
||||
Create short desc. The primary wood category selects the specialized prompt (`METADATA-85`).
|
||||
The owner can edit the result (`REVIEW-40`).
|
||||
|
||||
## 5. Fallback and templates
|
||||
|
||||
@@ -110,16 +124,98 @@ application.
|
||||
The seed is the starting point for tuning, not a final text. Both calls are revised ad hoc through
|
||||
the §2 interface when UAT surfaces a miss.
|
||||
|
||||
**`call1` seed.** Classify the slab's species, wood categories, edge type, figure, and grade from
|
||||
the supplied photos, choosing only from the supplied synced taxonomy lists, and return the result
|
||||
fields in §3 as JSON. Report a confidence for each classified field. Map free-text observations
|
||||
(character, inclusions, voids, checks) to the supplied feature-tag list only. Invent no category or
|
||||
term.
|
||||
**`call1` seed.** Shipped text in `backend/inference.py` (`CALL1_SEED`), mirrored here for review.
|
||||
|
||||
**`call2` seed.** Write the long product description for the slab from the supplied photos and
|
||||
listing facts. The name and short description are already built, so do not repeat them. Use the
|
||||
brand voice and geo context given. The dimensions in the fact block are exact: use those strings
|
||||
and invent no numbers. Write prose a premium lumber buyer would read.
|
||||
You are a wood identification expert for retail lumber slabs. Judge only from the photos and the
|
||||
LISTS below. Use list ids only — never invent a name or id.
|
||||
|
||||
Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, (5) feature tags.
|
||||
Decide wood categories before edge type — edge rules depend on product form.
|
||||
|
||||
For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). Prefer
|
||||
null or [] over a weak guess.
|
||||
|
||||
Species — which tree species is this slab. Match grain, color, and figure to the species list. If
|
||||
more than one entry is plausible, return only the single highest-confidence match (one id). Null
|
||||
only when none are credible.
|
||||
|
||||
Wood categories — product form / how it will be sold (live edge, dimensional, cookies & burl
|
||||
slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; often one,
|
||||
sometimes more. [] if none fit. Finish this field before edge type.
|
||||
|
||||
Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.
|
||||
Meanings (match the closest list name):
|
||||
• Square / square edge — both long edges milled straight (2 milled long edges).
|
||||
• Live one side / Live Edge 1 side — one long edge milled, the other natural bark or waney
|
||||
(1 milled long edge).
|
||||
• Live both / Live edge 2 sides — neither long edge milled; both natural (0 milled long edges).
|
||||
• Wild edge — use when the three mill-count cases do not fit, including cookies and burl
|
||||
cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.
|
||||
|
||||
Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids from
|
||||
figure_terms that clearly apply.
|
||||
|
||||
Feature tags — defects and character marks the store tags for shoppers (knots, checks,
|
||||
inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].
|
||||
|
||||
Return one JSON object with exactly these keys:
|
||||
{"species_id": <id|null>, "species_confidence": <0-1>, "wood_category_ids": [<id>,...],
|
||||
"wood_category_confidence": <0-1>, "edge_type_term_id": <id|null>, "edge_confidence": <0-1>,
|
||||
"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, "feature_tag_ids": [<id>,...],
|
||||
"feature_tag_confidence": <0-1>}
|
||||
|
||||
Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos.
|
||||
|
||||
**`call2` seed.** Shipped text in `backend/inference.py` (`CALL2_SEED`), mirrored here for review.
|
||||
|
||||
Write listing copy for this slab for woodworkers choosing stock for a project. Audience:
|
||||
makers matching wood to a tabletop, bar, bench, or similar build — practical,
|
||||
material/figure/spec-forward. No lifestyle or marketing hype (avoid empty phrases like
|
||||
elevate, stunning, dramatic visual contrast).
|
||||
You write BOTH the short description and the long description.
|
||||
|
||||
PLAIN TEXT ONLY (short AND long):
|
||||
- Output plain text only. No HTML tags (`<p>`, `<ul>`, `<li>`, `<strong>`, `<br>`, etc.).
|
||||
No markdown bold/headers that look like markup (`**bold**`, `#` headers).
|
||||
- Structure the long description with newlines and `- ` bullets when helpful.
|
||||
|
||||
Rules that always apply:
|
||||
- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.
|
||||
- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade,
|
||||
moisture, or features.
|
||||
- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay
|
||||
neutral. Never invent return, warranty, or shipping policies.
|
||||
- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.
|
||||
- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece
|
||||
suits (tabletop, bar, etc.) without ad-copy fluff.
|
||||
- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED
|
||||
ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description
|
||||
(no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no "Full specs"
|
||||
section, no "Maple Slab" attribute-header block). You may weave 1–2 key traits into
|
||||
prose or benefit bullets naturally.
|
||||
|
||||
SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):
|
||||
1. Lead with a practical outcome for a maker (what they can build / how the stock helps),
|
||||
not lifestyle hype and not a bare species list.
|
||||
2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).
|
||||
3. Close with a trust signal or concrete reason to choose this piece.
|
||||
|
||||
LONG DESCRIPTION (plain text only — newlines and `- ` bullets; no HTML, no markdown markup).
|
||||
Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute list /
|
||||
product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK:
|
||||
1. Hook — one sentence on what the piece suits and its standout usable trait.
|
||||
2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail.
|
||||
Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.
|
||||
3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present;
|
||||
otherwise a short neutral close with no invented policies.
|
||||
|
||||
Return ONE JSON object only (no prose outside JSON) with exactly these keys:
|
||||
`{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}`
|
||||
|
||||
Runtime appends DECLARED ATTRIBUTES (title + taxonomy; not a prior short), DIMENSION FACT
|
||||
BLOCK, and `Brand voice:` / `Geo:` from Settings (`SETTINGS-30`). The POST handler parses the
|
||||
JSON, strips any residual HTML to plain text, drops a labeled Category:/Length:/Board Feet:
|
||||
attribute dump if the model still emits one, and persists both fields.
|
||||
|
||||
## 7. Prompt management
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
|
||||
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
|
||||
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
|
||||
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
|
||||
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
|
||||
| METADATA-10, METADATA-15, METADATA-30, QUALITY-95 | required taxonomy fields and cardinality; the thickness band is derived from the measurement, not picked |
|
||||
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
|
||||
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
|
||||
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
|
||||
@@ -55,10 +55,16 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
|
||||
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
|
||||
on edit.
|
||||
- **Required set.** Exactly one species and at least one wood-category leaf; one Edge Type, at
|
||||
least one Figure, exactly one Grade, one Thickness band, one Moisture; fig-* and feat-* tags
|
||||
optional; price; title, short_description, and description.
|
||||
least one Figure, exactly one Grade, one Moisture; fig-* and feat-* tags optional; price;
|
||||
title, short_description, and description. The **Thickness band is not an owner control** — it
|
||||
is derived deterministically from `thickness_in` (the value rounds up into the synced Woo band,
|
||||
`TV-9` / `QUALITY-95`), displayed read-only, and satisfies the readiness requirement
|
||||
(`REVIEW-5`) via that derived value.
|
||||
- **Primary category.** The owner declares one primary wood category and any secondary ones; the
|
||||
default is the first selected.
|
||||
- **Thickness band is derived and not shown.** The measured thickness bands up into the synced Woo
|
||||
band server-side (`METADATA-15`, `QUALITY-95`); Review carries no thickness-band control and no
|
||||
read-only echo of it. The derived band is stored on the slab and sent to the store at publish.
|
||||
- **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is
|
||||
no single submit-time gap list.
|
||||
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||||
rectangle (calibration length + crop)".
|
||||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||||
|
||||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||||
in from the top and the bottom — and never changes the aspect ratio.
|
||||
|
||||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||||
production workflow:
|
||||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||||
- the requirements-coverage board below the stage
|
||||
- the "was / now" pair of mini diagrams
|
||||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||||
- the window.__wf debug hook
|
||||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||||
semantics, and the single shared crop_fill.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||||
.panel{padding:10px 4px;color:var(--fg)}
|
||||
h1{font-size:1.3rem;margin:0 0 10px}
|
||||
h3{font-size:.95rem;margin:0 0 8px}
|
||||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||||
.row{display:flex;gap:10px;margin-top:16px}
|
||||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||||
/* ---- the stage: fixed line + movable slab ---- */
|
||||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||||
.tw.dragging svg{cursor:grabbing}
|
||||
.tickg{cursor:ns-resize}
|
||||
.rotg{cursor:ew-resize}
|
||||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||||
input[type=range]{flex:1}
|
||||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||||
.nums span:first-child{color:var(--mut)}
|
||||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||||
.nums .dim{color:#777}
|
||||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||||
.status.ok{background:#12210f;color:var(--ok)}
|
||||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||||
.plan code{color:#ddd;font-size:.72rem}
|
||||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||||
/* replacement two-up */
|
||||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||||
.swap .gone{color:#ff9d8a}
|
||||
/* scope lineup */
|
||||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||||
/* fields + settings */
|
||||
.fields{display:flex;gap:16px;margin-top:16px}
|
||||
.field{flex:1}
|
||||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||||
.settings .msg.ok{color:var(--ok)}
|
||||
/* requirement coverage board (outside the phone) */
|
||||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||||
.cov .where{color:#ddd}
|
||||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||||
@media (max-width:760px){
|
||||
.cov thead{display:none}
|
||||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||||
.cov tr:last-child{border-bottom:0}
|
||||
.cov td{border:0;padding:1px 0}
|
||||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||||
}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||||
<div class="flowbar" id="flowbar"></div>
|
||||
<div class="vp">
|
||||
<span>Viewport</span>
|
||||
<span id="vpval">412</span>
|
||||
<button data-w="390">390</button>
|
||||
<button data-w="412" class="on">412</button>
|
||||
<button data-w="720">720</button>
|
||||
<button data-w="1440">1440</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage"><div id="phone"></div></div>
|
||||
<div class="cov"><div class="cov-inner">
|
||||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||||
<table>
|
||||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
<script>
|
||||
// ===========================================================================
|
||||
// issue #99 — the tape line is a FIXED ENTITY.
|
||||
//
|
||||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||||
// its long axis lies on the line, tick to tick.
|
||||
// * The ticks belong to the line - they slide along it, never with the photo.
|
||||
// * L = the distance between the two ticks (the owner's tape reading).
|
||||
// ===========================================================================
|
||||
const S = {
|
||||
screen: "tape",
|
||||
// slab transform (the only movable entity)
|
||||
tilt: 14, // degrees
|
||||
zoom: 1,
|
||||
panX: 60, panY: 0,
|
||||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom - and the operator drags them onto their tape marks from there.
|
||||
tickA: 32,
|
||||
tickB: 608,
|
||||
focus: "A",
|
||||
fill: 90, // settings.crop_fill - global, default 90
|
||||
// declared values (entered on Measure, shown on both steps)
|
||||
L: "88",
|
||||
thickness: "2.125",
|
||||
savedMsg: "",
|
||||
};
|
||||
|
||||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||||
const VB = { w: 480, h: 640 };
|
||||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||||
// AXIS - the centreline running down the middle of the board, which is where
|
||||
// the tape is laid. (Not a physical side edge.)
|
||||
const SLAB = {
|
||||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||||
};
|
||||
const TICK_LEN = 26; // rifle-scope end tick, total
|
||||
const TICK_GAP = 40; // min separation between ticks
|
||||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||||
const HIT_R = 34;
|
||||
const ALIGN_TOL = 3; // stage units
|
||||
|
||||
const rad = (a) => a * Math.PI / 180;
|
||||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||||
// crop frame, which is where the crop centres the slab's long axis.
|
||||
const lineX = () => VB.w / 2;
|
||||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||||
|
||||
// slab transform: zoom + tilt about the stage centre, then pan
|
||||
function mapPoint(p) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||||
}
|
||||
function slabTransform() {
|
||||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||||
}
|
||||
// where the slab's long axis crosses a given stage y
|
||||
function axisXAt(y) {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||||
}
|
||||
const axisAngle = () => {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||||
};
|
||||
// the axis is vertical when its angle points straight down (or up) the screen
|
||||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||||
|
||||
// ---------------------------------------------------------------- the stage
|
||||
function slabSvg() {
|
||||
return `
|
||||
<g id="slab" transform="${slabTransform()}">
|
||||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||||
<g clip-path="url(#slabClip)" opacity=".8">
|
||||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
</g>
|
||||
</g>`;
|
||||
}
|
||||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||||
function fixtureSvg() {
|
||||
const x = lineX();
|
||||
const tick = (y, id) => {
|
||||
const on = S.focus === id;
|
||||
return `
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||||
};
|
||||
return `
|
||||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||||
}
|
||||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||||
function cropFrameSvg() {
|
||||
const m = cropInset();
|
||||
return `
|
||||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||||
}
|
||||
function rotateHandleSvg() {
|
||||
return `
|
||||
<g class="rotg" data-rot="1">
|
||||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||||
</g>`;
|
||||
}
|
||||
function stageSvg() {
|
||||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||||
${cropFrameSvg()}
|
||||
${slabSvg()}
|
||||
${fixtureSvg()}
|
||||
${rotateHandleSvg()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ screens
|
||||
const screens = {
|
||||
batch() {
|
||||
return `<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||||
</section>`;
|
||||
},
|
||||
mask() {
|
||||
return `<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||||
<div class="nums">
|
||||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-batch">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
tape() {
|
||||
const off = axisOffset(), ok = aligned();
|
||||
return `<section class="panel">
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<div class="tw" id="tw">${stageSvg()}</div>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||||
</div>
|
||||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||||
? "The board's long axis is on the line ✓"
|
||||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||||
</div>
|
||||
|
||||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||
|
||||
<div class="swap">
|
||||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-mask">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||||
<ul>
|
||||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
measure() {
|
||||
const mini = (tilt, marks) => `
|
||||
<svg viewBox="0 0 480 640">
|
||||
<rect width="480" height="640" fill="#16281c"/>
|
||||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||||
</g>
|
||||
</svg>`;
|
||||
return `<section class="panel">
|
||||
<h1>Photos ready</h1>
|
||||
<div class="fields">
|
||||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||||
</div>
|
||||
<div class="nums">
|
||||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||||
</div>
|
||||
<div class="plan">
|
||||
<h3>Rotation scope — req. 6</h3>
|
||||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||||
</div>
|
||||
<ol class="lineup">
|
||||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-tape">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
settings() {
|
||||
return `<section class="panel settings">
|
||||
<h1>Settings</h1>
|
||||
<div class="section">
|
||||
<h3>Crop — global</h3>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||||
<div class="croprow">
|
||||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||||
<button data-act="save-fill">Save Changes</button>
|
||||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Implementation notes</h3>
|
||||
<ul class="plan" style="border:0;padding:0;background:none">
|
||||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||||
</section>`;
|
||||
},
|
||||
};
|
||||
|
||||
// the two mini overlays for the was/now pair
|
||||
function oldOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||||
</svg>`;
|
||||
}
|
||||
function newOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- plumbing
|
||||
const phone = document.getElementById('phone');
|
||||
const flowbar = document.getElementById('flowbar');
|
||||
const STEPS = [
|
||||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||||
];
|
||||
function render() {
|
||||
phone.innerHTML = screens[S.screen]();
|
||||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||||
}
|
||||
flowbar.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-goto]');
|
||||
if (!b) return;
|
||||
S.screen = b.dataset.goto;
|
||||
render();
|
||||
});
|
||||
document.querySelector('.vp').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-w]');
|
||||
if (!b) return;
|
||||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||||
document.getElementById('vpval').textContent = b.dataset.w;
|
||||
});
|
||||
|
||||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||||
// (a full panel re-render would destroy the element under the pointer).
|
||||
function refreshStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
host.innerHTML = stageSvg();
|
||||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||
set('tiltVal', S.tilt + '\u00B0');
|
||||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||||
set('fillVal', S.fill + '%');
|
||||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||||
const st = document.getElementById('alignstatus');
|
||||
if (st) {
|
||||
const ok = aligned(), off = axisOffset();
|
||||
st.className = 'status' + (ok ? ' ok' : '');
|
||||
st.innerHTML = ok
|
||||
? "The board's long axis is on the line \u2713"
|
||||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||||
}
|
||||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||||
axisAngle: axisAngle(), aligned: aligned() };
|
||||
}
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||||
// twin, stepping about the stage centre so the slab stays where it is.
|
||||
function stepZoom(dir) {
|
||||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||||
refreshStage();
|
||||
}
|
||||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||||
function nudgeTick(dir, mult) {
|
||||
const step = (mult === undefined ? 1 : mult);
|
||||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||||
render();
|
||||
}
|
||||
|
||||
function bindStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
const toVB = (e) => {
|
||||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||||
};
|
||||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||||
function zoomAt(p, factor) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2;
|
||||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||||
if (z1 === z0) return;
|
||||
const qx = cx + (p.x - cx - S.panX) / z0;
|
||||
const qy = cy + (p.y - cy - S.panY) / z0;
|
||||
S.zoom = z1;
|
||||
S.panX = p.x - cx - z1 * (qx - cx);
|
||||
S.panY = p.y - cy - z1 * (qy - cy);
|
||||
refreshStage();
|
||||
}
|
||||
host.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||||
const dy = clamp(e.deltaY, -200, 200);
|
||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||||
}, { passive: false });
|
||||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||||
|
||||
let drag = null, tb = null, moved = false, downP = null;
|
||||
const off = { x: 0, y: 0 };
|
||||
host.addEventListener('pointerdown', (e) => {
|
||||
const tick = e.target.closest('[data-tick]');
|
||||
const rot = e.target.closest('[data-rot]');
|
||||
const p = toVB(e);
|
||||
moved = false; downP = p;
|
||||
if (tick) {
|
||||
S.focus = tick.dataset.tick;
|
||||
drag = { kind: "tick", id: S.focus };
|
||||
} else if (rot) {
|
||||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||||
} else {
|
||||
drag = { kind: "slab" };
|
||||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||||
}
|
||||
host.setPointerCapture(e.pointerId);
|
||||
host.classList.add('dragging');
|
||||
refreshStage();
|
||||
e.preventDefault();
|
||||
});
|
||||
host.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const p = toVB(e);
|
||||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||||
if (drag.kind === "tick") {
|
||||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||||
const y = clamp(p.y, 20, VB.h - 20);
|
||||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||||
} else if (drag.kind === "rot") {
|
||||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||||
} else {
|
||||
S.panX = clamp(p.x - off.x, -400, 400);
|
||||
S.panY = clamp(p.y - off.y, -400, 400);
|
||||
}
|
||||
refreshStage();
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag) return;
|
||||
const wasSlab = drag.kind === "slab";
|
||||
const startP = toVB(e);
|
||||
drag = null;
|
||||
host.classList.remove('dragging');
|
||||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||||
// dblclick both stop arriving), so track the taps ourselves.
|
||||
if (wasSlab && !moved) {
|
||||
const now = performance.now();
|
||||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||||
tb = { t: now, x: startP.x, y: startP.y };
|
||||
}
|
||||
refreshStage();
|
||||
};
|
||||
host.addEventListener('pointerup', end);
|
||||
host.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (S.screen !== "tape") return;
|
||||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!e.key.startsWith("Arrow")) return;
|
||||
const fine = e.shiftKey ? 0.1 : 1;
|
||||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
phone.addEventListener('click', (e) => {
|
||||
const t = e.target.closest('[data-act]');
|
||||
if (!t) return;
|
||||
const a = t.dataset.act;
|
||||
if (a === "tilt") tiltBy(+t.dataset.d);
|
||||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||||
else if (a === "fit") fitSlab();
|
||||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||||
});
|
||||
phone.addEventListener('input', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||||
else if (t.dataset.act === "fill-range") {
|
||||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||||
// rectangle's aspect never changes and the line's x never moves.
|
||||
S.fill = +t.value;
|
||||
const ct = cropTicks();
|
||||
S.tickA = ct.a; S.tickB = ct.b;
|
||||
refreshStage();
|
||||
}
|
||||
});
|
||||
phone.addEventListener('change', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
|
||||
@@ -6,12 +6,12 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
timeout: 180_000,
|
||||
expect: { timeout: 30_000 },
|
||||
workers: 2,
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 4_000 },
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
|
||||
+221
-86
@@ -1,20 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { supported } from './browser';
|
||||
import { supported, uuid } 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';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
import Publish from './create/publish/Publish.svelte';
|
||||
import GenerateText from './create/inference/GenerateText.svelte';
|
||||
import type { Capture, Measures } from './capture/capture.types';
|
||||
import { eighthsToIn } from './capture/capture.types';
|
||||
import { buildPhotosReadyLineup } from './capture/photosReadyLineup';
|
||||
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 MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
import Publish from './create/publish/Publish.svelte';
|
||||
import {
|
||||
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||
} from './create/flow';
|
||||
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
|
||||
import { clearDraft } from './draft/idb';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
@@ -23,28 +26,114 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
let showSettings = false;
|
||||
let showSlabs = false;
|
||||
let showPhotos = false;
|
||||
let showReview = false;
|
||||
let showPublish = false;
|
||||
let showGenerate = false;
|
||||
|
||||
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
||||
$: curFlow = $flow;
|
||||
let saving = false;
|
||||
let saveError = '';
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
||||
});
|
||||
const unsubBusy = session.busy.subscribe((b) => {
|
||||
busy = b;
|
||||
});
|
||||
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||
|
||||
onMount(() => {
|
||||
void session.resumeProbe();
|
||||
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
|
||||
// device draft into IndexedDB after the origin exists, then reload without
|
||||
// ?noload to trigger the Resume prompt deterministically.
|
||||
const boot = new URLSearchParams(window.location.search);
|
||||
if (!boot.get('noload')) void session.resumeProbe();
|
||||
});
|
||||
onDestroy(() => {
|
||||
unsubCap();
|
||||
unsubBusy();
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
// APP-15: the device draft is cleared only on publish success / "new slab".
|
||||
function startNewSlab(): void {
|
||||
void clearDraft().then(() => {
|
||||
session.startFresh();
|
||||
backToCapture();
|
||||
});
|
||||
}
|
||||
|
||||
// C2: Photos-ready → build the calibrated slab, upload the lineup PNGs, then
|
||||
// hand off to Review. The device draft is retained here (APP-10); it is
|
||||
// cleared only on publish success (APP-15).
|
||||
async function continueToReview(): Promise<void> {
|
||||
const m = session.currentMeasure();
|
||||
if (!m || !m.results) return;
|
||||
const r: Measures = m.results;
|
||||
const capture: CalibratedCapture = {
|
||||
lengthIn: eighthsToIn(m.lengthEighths),
|
||||
thicknessIn: eighthsToIn(m.thicknessEighths),
|
||||
speciesId: m.speciesId,
|
||||
sqft: r.sqft,
|
||||
bdft: r.bdft,
|
||||
widthMinIn: eighthsToIn(r.widths.min),
|
||||
widthMaxIn: eighthsToIn(r.widths.max),
|
||||
widthAvgIn: eighthsToIn(r.widths.avg),
|
||||
sourceBelowBlock: r.sourceBelowBlock,
|
||||
sourceBelowWarn: r.sourceBelowWarn,
|
||||
lineup: buildPhotosReadyLineup({
|
||||
inventoryPng: r.inventoryPng,
|
||||
overlayPng: r.overlayPng,
|
||||
batchApply: m.batchApply,
|
||||
sourceBelowBlock: r.sourceBelowBlock,
|
||||
sourceBelowWarn: r.sourceBelowWarn,
|
||||
}),
|
||||
};
|
||||
saving = true;
|
||||
saveError = '';
|
||||
startSaving();
|
||||
try {
|
||||
const id = uuid();
|
||||
await createSlab({
|
||||
id,
|
||||
sku: '',
|
||||
length_in: capture.lengthIn,
|
||||
thickness_in: capture.thicknessIn,
|
||||
sqft: capture.sqft,
|
||||
bdft: capture.bdft,
|
||||
width_min_in: capture.widthMinIn,
|
||||
width_max_in: capture.widthMaxIn,
|
||||
width_avg_in: capture.widthAvgIn,
|
||||
species_id: capture.speciesId,
|
||||
price: null,
|
||||
price_source: 'recommendation',
|
||||
content_source: 'manual',
|
||||
client_rev: 0,
|
||||
});
|
||||
// #90 / REVIEW-5: persist the full lineup with correct roles so the Review
|
||||
// strip matches in-memory blobs by role. The overlay is the dimension-drawn
|
||||
// copy — it must be persisted (its blob already has the dimensions) and is
|
||||
// the photo the owner sees with the measurement overlay. Excluding it (the
|
||||
// old `role !== 'overlay'` filter) dropped the dimension copy from Review.
|
||||
const pngItems = capture.lineup.filter((it) => it.kind === 'png');
|
||||
for (let i = 0; i < pngItems.length; i++) {
|
||||
const it = pngItems[i];
|
||||
await uploadPhoto(id, {
|
||||
photoId: uuid(),
|
||||
seq: i + 1,
|
||||
isCalibration: it.role === 'calibration',
|
||||
role: it.role,
|
||||
original: it.blob,
|
||||
// The calibration/batch PNGs have no separate processed variant; the
|
||||
// overlay blob already carries the drawn dimensions.
|
||||
processed: it.blob,
|
||||
});
|
||||
}
|
||||
enterReview(id, capture);
|
||||
} catch (e) {
|
||||
saveError =
|
||||
e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.';
|
||||
backToCapture();
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if gate}
|
||||
@@ -58,74 +147,104 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
||||
</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}
|
||||
{#if curFlow.phase === 'review'}
|
||||
<Review
|
||||
slabId={curFlow.slabId}
|
||||
capture={curFlow.capture}
|
||||
onPublish={() => {
|
||||
const f = curFlow;
|
||||
if (f.phase === 'review') enterPublish(f.slabId, f.capture);
|
||||
}}
|
||||
onBack={() => backToCapture()}
|
||||
/>
|
||||
{: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 if curFlow.phase === 'publish'}
|
||||
<Publish
|
||||
slabId={curFlow.slabId}
|
||||
onPublished={(wooId, draft) => markDone(curFlow.slabId, wooId, draft)}
|
||||
onBack={() => {
|
||||
const f = curFlow;
|
||||
if (f.phase === 'publish') backToReview(f.slabId, f.capture);
|
||||
}}
|
||||
/>
|
||||
{:else if curFlow.phase === 'done'}
|
||||
<section class="panel done" aria-label="Published">
|
||||
<h1>Published</h1>
|
||||
<p class="ok">
|
||||
{#if curFlow.draft}
|
||||
Created as a UAT draft in the store.
|
||||
{:else}
|
||||
Created in the store.
|
||||
{/if}
|
||||
</p>
|
||||
<p>Woo product #{curFlow.wooProductId} · photos and device draft cleared.</p>
|
||||
<div class="row">
|
||||
<button type="button" class="primary" data-testid="new-slab" on:click={startNewSlab}>
|
||||
Start a new slab
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{:else if curFlow.phase === 'saving'}
|
||||
<section class="panel" aria-busy="true">
|
||||
<h1>Saving slab…</h1>
|
||||
</section>
|
||||
{:else}
|
||||
<MeasurePanel
|
||||
state={cap}
|
||||
setLength={session.setLength}
|
||||
setThickness={session.setThickness}
|
||||
calculate={session.calculate}
|
||||
back={session.back}
|
||||
{pngUrl}
|
||||
/>
|
||||
<!-- capture column (single slab, CAPTURE-25/30) -->
|
||||
{#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 === 'measure'}
|
||||
<MeasurePanel
|
||||
state={cap}
|
||||
setLength={session.setLength}
|
||||
setThickness={session.setThickness}
|
||||
setSpecies={session.setSpecies}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
setTape={session.setTape}
|
||||
calculate={session.calculate}
|
||||
back={session.back}
|
||||
{pngUrl}
|
||||
onContinue={saving ? undefined : () => void continueToReview()}
|
||||
/>
|
||||
{/if}
|
||||
{#if saving}
|
||||
<div class="row"><span class="busy" aria-live="polite">Saving slab…</span></div>
|
||||
{/if}
|
||||
{#if saveError}
|
||||
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div style="margin-top:1rem;display:flex;gap:8px">
|
||||
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
||||
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
||||
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
||||
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
|
||||
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
|
||||
<button on:click={() => (showGenerate = !showGenerate)}>Generate (dev)</button>
|
||||
</div>
|
||||
{#if showSettings}
|
||||
|
||||
<!-- Settings only (dev Slabs list removed — unused white control). Hidden after publish. -->
|
||||
{#if curFlow.phase !== 'done'}
|
||||
<div class="app-tools">
|
||||
<button type="button" class="tool" on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showSettings && curFlow.phase !== 'done'}
|
||||
<Settings />
|
||||
{/if}
|
||||
{#if showSlabs}
|
||||
<SlabList />
|
||||
{/if}
|
||||
{#if showPhotos}
|
||||
<PhotoAttach />
|
||||
{/if}
|
||||
{#if showReview}
|
||||
<Review />
|
||||
{/if}
|
||||
{#if showPublish}
|
||||
<Publish />
|
||||
{/if}
|
||||
{#if showGenerate}
|
||||
<GenerateText />
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -135,8 +254,24 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
||||
background: #121212;
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; }
|
||||
.app { padding: 12px 16px 24px; }
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
.panel { padding: 4px 0 16px; color: #eee; }
|
||||
.done .ok { color: #7fd37f; font-weight: 600; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.busy { color: #aaa; }
|
||||
.save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; }
|
||||
.app-tools { margin-top: 1rem; display: flex; gap: 8px; }
|
||||
.tool {
|
||||
box-sizing: border-box; padding: 8px 12px; border-radius: 3px; border: 1px solid #444;
|
||||
background: #1d1d1d; color: #ccc; font-size: .82rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.tool:hover { background: #242424; }
|
||||
</style>
|
||||
|
||||
+521
-18
@@ -1,45 +1,133 @@
|
||||
// Thin client for the S2 slabs endpoints. Svelte-agnostic.
|
||||
// Thin client for the SlabUploader app API. Svelte-agnostic.
|
||||
//
|
||||
// Mirrors the LIVE backend routes (backend/*.py), which are the source of truth:
|
||||
// slabs.py POST/GET/PUT/DELETE /api/v1/slabs[/{id}]
|
||||
// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed]
|
||||
// review.py POST /api/v1/slabs/{id}/review
|
||||
// publish.py POST /api/v1/slabs/{id}/publish
|
||||
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
|
||||
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
||||
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||
//
|
||||
// Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain
|
||||
// owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner).
|
||||
|
||||
export interface Slab {
|
||||
id: string;
|
||||
sku: string;
|
||||
status: string;
|
||||
price: number;
|
||||
price: number | null;
|
||||
price_source: string | null;
|
||||
length_in: number;
|
||||
thickness_in: number;
|
||||
sqft: number;
|
||||
bdft: number;
|
||||
width_min_in: number;
|
||||
width_max_in: number;
|
||||
width_avg_in: number;
|
||||
species_id: number | null;
|
||||
wood_category_ids: number[];
|
||||
edge_type_term_id: number | null;
|
||||
figure_term_ids: number[];
|
||||
grade_term_id: number | null;
|
||||
thickness_term_id: number | null;
|
||||
moisture_term_id: number | null;
|
||||
fig_tag_ids: number[];
|
||||
feat_tag_ids: number[];
|
||||
title: string | null;
|
||||
short_description: string | null;
|
||||
description: string | null;
|
||||
content_source: string | null;
|
||||
species_confidence: number | null;
|
||||
figure_confidence: number | null;
|
||||
grade_confidence: number | null;
|
||||
inference_status: string | null;
|
||||
calibration_photo_id: string | null;
|
||||
woo_product_id: number | null;
|
||||
/** Create-time optimistic token; updates compare against server_rev, not this. */
|
||||
client_rev: number;
|
||||
/** Optimistic concurrency token; send as client_rev on PUT. */
|
||||
server_rev: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Fields a client may send on create/update (subset of the row).
|
||||
export type SlabInput = Partial<
|
||||
Omit<
|
||||
Slab,
|
||||
| 'id'
|
||||
| 'status'
|
||||
| 'woo_product_id'
|
||||
| 'server_rev'
|
||||
| 'created_at'
|
||||
| 'updated_at'
|
||||
| 'inference_status'
|
||||
| 'species_confidence'
|
||||
| 'figure_confidence'
|
||||
| 'grade_confidence'
|
||||
| 'calibration_photo_id'
|
||||
>
|
||||
>;
|
||||
|
||||
export interface SlabCreateInput extends SlabInput {
|
||||
id: string; // client-generated UUIDv4 (APP-20)
|
||||
client_rev: number; // 0 on create (APP-20)
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code: string | null;
|
||||
message: string;
|
||||
detail?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string | null, message: string) {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string | null,
|
||||
message: string,
|
||||
public detail?: Record<string, unknown> | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let code: string | null = null;
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) {
|
||||
code = body.error.code ?? null;
|
||||
message = body.error.message ?? message;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
/* non-JSON body (e.g. 410 Gone) */
|
||||
}
|
||||
if (!res.ok) {
|
||||
const e = (body as { error?: ApiErrorBody } | null)?.error;
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
e?.code ?? null,
|
||||
e?.message ?? res.statusText,
|
||||
e?.detail ?? null,
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function jsonBody(v: unknown): BodyInit {
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slabs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSlab(input: SlabCreateInput): Promise<Slab> {
|
||||
return fetch('/api/v1/slabs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: jsonBody(input),
|
||||
}).then((r) => parse<Slab>(r));
|
||||
}
|
||||
|
||||
export function listSlabs(): Promise<Slab[]> {
|
||||
@@ -49,3 +137,418 @@ export function listSlabs(): Promise<Slab[]> {
|
||||
export function getSlab(id: string): Promise<Slab> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
||||
}
|
||||
|
||||
function putSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: jsonBody(patch),
|
||||
}).then((r) => parse<Slab>(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT a slab patch. `client_rev` must equal the slab's current `server_rev`.
|
||||
* On 409 revision_conflict (stale rev — e.g. review POST bumped server_rev, or
|
||||
* a concurrent save), refetch once, adopt the latest server_rev, and retry.
|
||||
* A second 409 surfaces a clear reload message.
|
||||
*/
|
||||
export async function updateSlab(
|
||||
id: string,
|
||||
patch: SlabInput & { client_rev: number },
|
||||
): Promise<Slab> {
|
||||
try {
|
||||
return await putSlab(id, patch);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError) || e.status !== 409) throw e;
|
||||
const latest = await getSlab(id);
|
||||
try {
|
||||
return await putSlab(id, { ...patch, client_rev: latest.server_rev });
|
||||
} catch (e2) {
|
||||
if (e2 instanceof ApiError && e2.status === 409) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
e2.code ?? 'revision_conflict',
|
||||
'This slab changed elsewhere. Reload the page and try again.',
|
||||
e2.detail,
|
||||
);
|
||||
}
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSlab(id: string): Promise<void> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) =>
|
||||
parse<void>(r),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Photos (lineup order = seq; calibration is is_calibration=1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PhotoRow {
|
||||
id: string;
|
||||
seq: number;
|
||||
is_calibration: number;
|
||||
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
has_original: number;
|
||||
has_processed: number;
|
||||
}
|
||||
|
||||
export interface UploadPhotoInput {
|
||||
photoId: string; // client-generated UUIDv4
|
||||
seq?: number;
|
||||
isCalibration: boolean;
|
||||
/** #90: lineup kind; defaults to batch. */
|
||||
role?: 'calibration' | 'overlay' | 'batch';
|
||||
original: Blob;
|
||||
processed?: Blob | null;
|
||||
}
|
||||
|
||||
export interface UploadPhotoResult {
|
||||
id: string;
|
||||
slab_id: string;
|
||||
seq: number;
|
||||
is_calibration: number;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
original_retained: boolean;
|
||||
processed_retained: boolean;
|
||||
}
|
||||
|
||||
export function listPhotos(slabId: string): Promise<PhotoRow[]> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) =>
|
||||
parse<PhotoRow[]>(r),
|
||||
);
|
||||
}
|
||||
|
||||
export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<UploadPhotoResult> {
|
||||
const form = new FormData();
|
||||
form.append('photo_id', input.photoId);
|
||||
if (input.seq != null) form.append('seq', String(input.seq));
|
||||
form.append('is_calibration', input.isCalibration ? '1' : '0');
|
||||
form.append('role', input.role ?? 'batch');
|
||||
const origName = input.original instanceof File ? input.original.name : 'original.png';
|
||||
form.append('original', input.original, origName);
|
||||
if (input.processed) form.append('processed', input.processed, 'processed.png');
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
}).then((r) => parse<UploadPhotoResult>(r));
|
||||
}
|
||||
|
||||
// #90: drop a photo the owner removed in Review (keep/remove edit).
|
||||
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
|
||||
method: 'DELETE',
|
||||
}).then((r) => parse<void>(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* #90: persist an edited Review photo lineup. `kept` is the owner's final
|
||||
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
|
||||
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
|
||||
* kept photo's blob is re-sent (original + processed) because the server
|
||||
* stores only the BLOB — there is no separate re-seq endpoint.
|
||||
*/
|
||||
export interface LineupPhoto {
|
||||
id: string;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
isCalibration: boolean;
|
||||
original: Blob;
|
||||
processed: Blob | null;
|
||||
}
|
||||
|
||||
export async function persistLineup(
|
||||
slabId: string,
|
||||
kept: LineupPhoto[],
|
||||
removed: string[],
|
||||
): Promise<void> {
|
||||
for (const id of removed) {
|
||||
await deletePhoto(slabId, id);
|
||||
}
|
||||
for (let i = 0; i < kept.length; i++) {
|
||||
const p = kept[i];
|
||||
await uploadPhoto(slabId, {
|
||||
photoId: p.id,
|
||||
seq: i + 1,
|
||||
isCalibration: p.isCalibration,
|
||||
role: p.role,
|
||||
original: p.original,
|
||||
processed: p.processed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function photoUrl(
|
||||
slabId: string,
|
||||
photoId: string,
|
||||
kind: 'original' | 'processed',
|
||||
): string {
|
||||
return `/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}/${kind}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review (REVIEW-*)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReviewResult {
|
||||
ready: boolean;
|
||||
missing: string[];
|
||||
title: string | null;
|
||||
short_description: string | null;
|
||||
price: number | null;
|
||||
price_suggestion: number | null;
|
||||
primary_wood_category_id: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewInput {
|
||||
primary_wood_category_id?: number;
|
||||
price?: number;
|
||||
sku?: string;
|
||||
title?: string;
|
||||
short_description?: string;
|
||||
description?: string;
|
||||
price_source?: string;
|
||||
content_source?: string;
|
||||
}
|
||||
|
||||
export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: jsonBody(payload),
|
||||
}).then((r) => parse<ReviewResult>(r));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish (PUBLISH-*)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PublishResult {
|
||||
ok: true;
|
||||
status: string;
|
||||
woo_product_id: number;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
export function publishSlab(slabId: string): Promise<PublishResult> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
|
||||
method: 'POST',
|
||||
}).then((r) => parse<PublishResult>(r));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inference (METADATA-*)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Call1Suggestions {
|
||||
species_id?: number | null;
|
||||
species_confidence?: number | null;
|
||||
wood_category_ids?: number[];
|
||||
edge_type_term_id?: number | null;
|
||||
figure_term_ids?: number[];
|
||||
figure_confidence?: number | null;
|
||||
feature_tag_ids?: number[];
|
||||
feature_tag_confidence?: number | null;
|
||||
}
|
||||
|
||||
export interface Call1Result {
|
||||
ok: boolean;
|
||||
suggestions: Call1Suggestions;
|
||||
}
|
||||
|
||||
export interface Call2Result {
|
||||
ok: boolean;
|
||||
description: string;
|
||||
/** Present when Call 2 returned a parsed short_description. */
|
||||
short_description?: string;
|
||||
}
|
||||
|
||||
/** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */
|
||||
export function inferTaxon(slabId: string): Promise<Call1Result> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
|
||||
method: 'POST',
|
||||
}).then((r) => parse<Call1Result>(r));
|
||||
}
|
||||
|
||||
export function inferContent(slabId: string): Promise<Call2Result> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, {
|
||||
method: 'POST',
|
||||
}).then((r) => parse<Call2Result>(r));
|
||||
}
|
||||
|
||||
/** View prompt: assembled Call 2 text + photo slots (no base64, no model call). */
|
||||
export interface Call2PromptPhoto {
|
||||
seq: number;
|
||||
id: string;
|
||||
bytes: number;
|
||||
long_edge_px: number | null;
|
||||
}
|
||||
|
||||
export interface Call2Prompt {
|
||||
text: string;
|
||||
photos: Call2PromptPhoto[];
|
||||
prompt_revision_id: number | null;
|
||||
}
|
||||
|
||||
export function getCall2Prompt(slabId: string): Promise<Call2Prompt> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2/prompt`).then((r) =>
|
||||
parse<Call2Prompt>(r),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Taxonomy (METADATA-5/15/30; only legal picker options)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TaxCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id: number | null;
|
||||
woo_id: number | null;
|
||||
}
|
||||
|
||||
export interface TaxTerm {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface TaxAttribute {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
terms: TaxTerm[];
|
||||
}
|
||||
|
||||
export interface TaxTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface Taxonomy {
|
||||
categories: TaxCategory[];
|
||||
attributes: TaxAttribute[];
|
||||
tags: TaxTag[];
|
||||
taxonomy_anchor: string | null;
|
||||
taxonomy_last_sync_at: string | null;
|
||||
}
|
||||
|
||||
export function getTaxonomy(): Promise<Taxonomy> {
|
||||
return fetch('/api/v1/admin/taxonomy').then((r) => parse<Taxonomy>(r));
|
||||
}
|
||||
|
||||
export function syncTaxonomy(): Promise<{
|
||||
taxonomy_anchor: string;
|
||||
taxonomy_last_sync_at: string;
|
||||
}> {
|
||||
return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST' }).then((r) => parse(r));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pricing (SETTINGS-35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PriceRule {
|
||||
id: number;
|
||||
species_id: number;
|
||||
price_per_bdft: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function listPricing(): Promise<PriceRule[]> {
|
||||
return fetch('/api/v1/admin/pricing').then((r) => parse<PriceRule[]>(r));
|
||||
}
|
||||
|
||||
export function createPricingRule(rule: {
|
||||
species_id: number;
|
||||
price_per_bdft: number;
|
||||
}): Promise<PriceRule> {
|
||||
return fetch('/api/v1/admin/pricing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: jsonBody(rule),
|
||||
}).then((r) => parse<PriceRule>(r));
|
||||
}
|
||||
|
||||
export function updatePricingRule(
|
||||
id: number,
|
||||
patch: { price_per_bdft: number; species_id?: number },
|
||||
): Promise<PriceRule> {
|
||||
return fetch(`/api/v1/admin/pricing/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: jsonBody(patch),
|
||||
}).then((r) => parse<PriceRule>(r));
|
||||
}
|
||||
|
||||
export function deletePricingRule(id: number): Promise<void> {
|
||||
return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse<void>(r));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings (SETTINGS-*)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SettingsView {
|
||||
woo_base_url: string;
|
||||
woo_wp_username: string | null;
|
||||
woo_app_password: { configured: boolean } | string;
|
||||
inference_base_url: { configured: boolean } | string;
|
||||
inference_api_key: { configured: boolean } | string;
|
||||
inference_model: string | null;
|
||||
inference_enabled: boolean;
|
||||
content_llm_enabled: boolean;
|
||||
brand_voice: string | null;
|
||||
geo_context: string | null;
|
||||
woo_create_status: string;
|
||||
taxonomy_last_sync_at: string | null;
|
||||
taxonomy_anchor: string | null;
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<SettingsView> {
|
||||
return fetch('/api/v1/settings').then((r) => parse<SettingsView>(r));
|
||||
}
|
||||
|
||||
export function updateSettings(
|
||||
patch: Partial<Record<keyof SettingsView, unknown>>,
|
||||
): Promise<SettingsView> {
|
||||
return fetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: jsonBody(patch),
|
||||
}).then((r) => parse<SettingsView>(r));
|
||||
}
|
||||
|
||||
export interface TestWooResult {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
taxonomy_anchor: string | null;
|
||||
taxonomy_last_sync_at: string | null;
|
||||
}
|
||||
|
||||
export interface TestInferenceResult {
|
||||
ok: boolean;
|
||||
mode: 'stateful' | 'stateless' | 'fail';
|
||||
vision_capable: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export function testWoo(): Promise<TestWooResult> {
|
||||
return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) =>
|
||||
parse<TestWooResult>(r),
|
||||
);
|
||||
}
|
||||
|
||||
export function testInference(): Promise<TestInferenceResult> {
|
||||
return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) =>
|
||||
parse<TestInferenceResult>(r),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, test } from 'node:test';
|
||||
import { ApiError, updateSlab, type Slab } from './slabs';
|
||||
|
||||
const SLAB_ID = 'slab-rev-1';
|
||||
|
||||
function baseSlab(server_rev: number): Slab {
|
||||
return {
|
||||
id: SLAB_ID,
|
||||
sku: 'SLAB-01',
|
||||
status: 'calibrated',
|
||||
price: 100,
|
||||
price_source: 'recommendation',
|
||||
length_in: 48,
|
||||
thickness_in: 2,
|
||||
sqft: 2.4,
|
||||
bdft: 4.8,
|
||||
width_min_in: 6,
|
||||
width_max_in: 9,
|
||||
width_avg_in: 8,
|
||||
species_id: 1,
|
||||
wood_category_ids: [2],
|
||||
edge_type_term_id: 3,
|
||||
figure_term_ids: [],
|
||||
grade_term_id: 4,
|
||||
thickness_term_id: 5,
|
||||
moisture_term_id: 6,
|
||||
fig_tag_ids: [],
|
||||
feat_tag_ids: [],
|
||||
title: 'Maple Slab',
|
||||
short_description: 'short',
|
||||
description: null,
|
||||
content_source: 'manual',
|
||||
species_confidence: null,
|
||||
figure_confidence: null,
|
||||
grade_confidence: null,
|
||||
inference_status: null,
|
||||
calibration_photo_id: null,
|
||||
woo_product_id: null,
|
||||
client_rev: 1,
|
||||
server_rev,
|
||||
created_at: 't',
|
||||
updated_at: 't',
|
||||
};
|
||||
}
|
||||
|
||||
type Call = { method: string; url: string; body: unknown };
|
||||
const calls: Call[] = [];
|
||||
let handler: (method: string, url: string, body: unknown) => { status: number; body: unknown };
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
function mockFetch() {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
calls.push({ method, url, body });
|
||||
const { status, body: resp } = handler(method, url, body);
|
||||
return new Response(JSON.stringify(resp), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
test('updateSlab succeeds on first PUT when client_rev matches', async () => {
|
||||
mockFetch();
|
||||
handler = (method, _url, body) => {
|
||||
assert.equal(method, 'PUT');
|
||||
const patch = body as { client_rev: number };
|
||||
assert.equal(patch.client_rev, 3);
|
||||
return { status: 200, body: baseSlab(4) };
|
||||
};
|
||||
const out = await updateSlab(SLAB_ID, { client_rev: 3, title: 'Maple Slab' });
|
||||
assert.equal(out.server_rev, 4);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('updateSlab on 409 refetches, adopts server_rev, retries once', async () => {
|
||||
mockFetch();
|
||||
let puts = 0;
|
||||
handler = (method, url, body) => {
|
||||
if (method === 'PUT') {
|
||||
puts += 1;
|
||||
const patch = body as { client_rev: number; title?: string };
|
||||
if (puts === 1) {
|
||||
assert.equal(patch.client_rev, 9); // stale (e.g. review POST bumped)
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: {
|
||||
code: 'revision_conflict',
|
||||
message: 'client_rev mismatch',
|
||||
detail: { current_server_rev: 10 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
assert.equal(patch.client_rev, 10); // adopted from GET
|
||||
assert.equal(patch.title, 'Maple Slab');
|
||||
return { status: 200, body: baseSlab(11) };
|
||||
}
|
||||
if (method === 'GET' && url.includes(SLAB_ID)) {
|
||||
return { status: 200, body: baseSlab(10) };
|
||||
}
|
||||
throw new Error(`unexpected ${method} ${url}`);
|
||||
};
|
||||
const out = await updateSlab(SLAB_ID, { client_rev: 9, title: 'Maple Slab' });
|
||||
assert.equal(out.server_rev, 11);
|
||||
assert.equal(puts, 2);
|
||||
assert.equal(calls.map((c) => c.method).join(','), 'PUT,GET,PUT');
|
||||
});
|
||||
|
||||
test('updateSlab second 409 surfaces a reload message', async () => {
|
||||
mockFetch();
|
||||
handler = (method) => {
|
||||
if (method === 'PUT') {
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: {
|
||||
code: 'revision_conflict',
|
||||
message: 'client_rev mismatch',
|
||||
detail: { current_server_rev: 99 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { status: 200, body: baseSlab(99) };
|
||||
};
|
||||
await assert.rejects(
|
||||
() => updateSlab(SLAB_ID, { client_rev: 1, sku: 'X' }),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof ApiError);
|
||||
assert.equal(e.status, 409);
|
||||
assert.match(e.message, /Reload the page/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -24,3 +24,25 @@ export function supported(): string | null {
|
||||
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
||||
return 'unsupported browser';
|
||||
}
|
||||
|
||||
// v4 UUID. crypto.randomUUID() is only guaranteed on secure contexts
|
||||
// (HTTPS/localhost); the UAT host is plain HTTP on a LAN IP, where it is
|
||||
// undefined. Fall back to crypto.getRandomValues (available on HTTP) so the
|
||||
// client-generated ids (APP-20) work in both contexts.
|
||||
export function uuid(): string {
|
||||
const c = globalThis.crypto;
|
||||
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
||||
if (c && typeof c.getRandomValues === 'function') {
|
||||
const b = new Uint8Array(16);
|
||||
c.getRandomValues(b);
|
||||
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,58 +1,156 @@
|
||||
<!-- DEPRECATED: tape orientation merged into MeasurePanel (overnight-whole-app). Kept for reference. -->
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let setTape: (tape: TapeTransform) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
let cropFill = getCropFill();
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
$: tape = state.axis.tape;
|
||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||
|
||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||
|
||||
const nudgeTilt = (delta: number) => {
|
||||
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
|
||||
};
|
||||
|
||||
const setTilt = (deg: number) => {
|
||||
const clamped = clampTiltDeg(deg);
|
||||
setTape({ ...tape, tiltDeg: clamped });
|
||||
// Keep legacy path warm for any callers still using setAxisAngle.
|
||||
setAxisAngle(clamped);
|
||||
};
|
||||
|
||||
const nudgeZoom = (dir: number) => {
|
||||
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
|
||||
setTape({ ...tape, zoom: z });
|
||||
};
|
||||
|
||||
const onFill = (pct: number) => {
|
||||
const fill = Math.min(1, Math.max(0.01, pct / 100));
|
||||
cropFill = setCropFill(fill);
|
||||
const ticks = defaultTicks(cropFill);
|
||||
setTape({ ...tape, ...ticks });
|
||||
};
|
||||
|
||||
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
{tape}
|
||||
{cropFill}
|
||||
onTapeChange={onTapeChange}
|
||||
/>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="179" step="1"
|
||||
value={angleDeg}
|
||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
||||
<span class="val">{angleDeg}°</span>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="-90" max="90" step="0.1"
|
||||
value={clampTiltDeg(tiltShown)}
|
||||
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||
<span class="val">{tiltShown}°</span>
|
||||
</div>
|
||||
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist (Rotate gesture mode)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="50" max="300" step="1"
|
||||
value={Math.round(tape.zoom * 100)}
|
||||
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
|
||||
<span class="val">{Math.round(tape.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div class="row tight">
|
||||
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input
|
||||
type="range" min="50" max="100" step="1"
|
||||
value={Math.round(cropFill * 100)}
|
||||
aria-label="Crop fill percent"
|
||||
on:input={(e) => onFill(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="val">{Math.round(cropFill * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">
|
||||
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
|
||||
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
|
||||
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
|
||||
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.row.tight { margin-top: 8px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.tab { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.dim { color: #777; }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { createMaskedPreviewBlob } from '../png/maskedPreview';
|
||||
import { getTaxonomy } from '../api/slabs';
|
||||
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
photosReadyPngAlt,
|
||||
@@ -13,13 +20,63 @@
|
||||
export let state: Extract<Capture, { k: 'measure' }>;
|
||||
export let setLength: (e: number) => void;
|
||||
export let setThickness: (e: number) => void;
|
||||
export let setSpecies: (id: number | null) => void;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let setTape: (tape: TapeTransform) => void;
|
||||
export let calculate: () => Promise<void>;
|
||||
export let back: () => void;
|
||||
export let pngUrl: string | null;
|
||||
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
||||
export let onContinue: (() => void) | undefined = undefined;
|
||||
|
||||
let calcBusy = false;
|
||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||
let speciesSel = state.speciesId == null ? '' : String(state.speciesId);
|
||||
let speciesOpts: { id: number; name: string }[] = [];
|
||||
let speciesLoadErr = '';
|
||||
|
||||
let cropFill = getCropFill();
|
||||
/** Exclusive stage gestures: Zoom/Pan (default) vs Rotate. Sliders stay available either way. */
|
||||
let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: tape = state.axis.tape;
|
||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||
|
||||
let maskedPreview: Blob | null = null;
|
||||
let previewError = '';
|
||||
let previewGeneration = 0;
|
||||
let previewSource: Blob | null = null;
|
||||
let previewMask: Uint8Array | null = null;
|
||||
let previewW = 0;
|
||||
let previewH = 0;
|
||||
|
||||
// Build the tape photo once from the oriented source plus the approved mask.
|
||||
// Never fall back to the raw source here: tape alignment must show the same
|
||||
// background-removed slab footprint that was approved in Mask.
|
||||
$: {
|
||||
const sourceBlob = state.source.blob;
|
||||
const bitset = state.approvedMask.bitset;
|
||||
const width = state.source.widthPx;
|
||||
const height = state.source.heightPx;
|
||||
if (sourceBlob !== previewSource || bitset !== previewMask || width !== previewW || height !== previewH) {
|
||||
previewSource = sourceBlob;
|
||||
previewMask = bitset;
|
||||
previewW = width;
|
||||
previewH = height;
|
||||
maskedPreview = null;
|
||||
previewError = '';
|
||||
const generation = ++previewGeneration;
|
||||
void createMaskedPreviewBlob(sourceBlob, bitset)
|
||||
.then((blob) => {
|
||||
if (generation === previewGeneration) maskedPreview = blob;
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation === previewGeneration) previewError = 'Could not build the background-removed preview.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let lineupUrls: string[] = [];
|
||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||
@@ -33,7 +90,8 @@
|
||||
inventoryPng: state.results.inventoryPng,
|
||||
overlayPng: state.results.overlayPng,
|
||||
batchApply: state.batchApply,
|
||||
sourceBelowFloor: state.results.sourceBelowFloor,
|
||||
sourceBelowBlock: state.results.sourceBelowBlock,
|
||||
sourceBelowWarn: state.results.sourceBelowWarn,
|
||||
});
|
||||
lineupUrls = lineupItems.map((it) =>
|
||||
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
|
||||
@@ -43,10 +101,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local select only when session speciesId changes (draft restore / setSpecies).
|
||||
// Do NOT depend on speciesSel — that fights bind:value and can clear a choice
|
||||
// before setSpecies lands (or wipe restored ids while options are still loading).
|
||||
let syncedSpeciesId: number | null | undefined = undefined;
|
||||
$: if (syncedSpeciesId !== state.speciesId) {
|
||||
syncedSpeciesId = state.speciesId;
|
||||
speciesSel = state.speciesId == null ? '' : String(state.speciesId);
|
||||
}
|
||||
|
||||
let speciesOptsReady = false;
|
||||
onMount(() => {
|
||||
void getTaxonomy()
|
||||
.then((tax) => {
|
||||
speciesOpts = speciesLeavesFromTaxonomy(tax.categories).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
}));
|
||||
speciesOptsReady = true;
|
||||
})
|
||||
.catch(() => {
|
||||
speciesLoadErr = 'Could not load species list.';
|
||||
speciesOpts = [];
|
||||
speciesOptsReady = true;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
previewGeneration += 1;
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
});
|
||||
|
||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||
|
||||
const nudgeTilt = (delta: number) => {
|
||||
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
|
||||
};
|
||||
|
||||
const setTilt = (deg: number) => {
|
||||
const clamped = clampTiltDeg(deg);
|
||||
setTape({ ...tape, tiltDeg: clamped });
|
||||
setAxisAngle(clamped);
|
||||
};
|
||||
|
||||
const nudgeZoom = (dir: number) => {
|
||||
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
|
||||
setTape({ ...tape, zoom: z });
|
||||
};
|
||||
|
||||
const onFill = (pct: number) => {
|
||||
const fill = Math.min(1, Math.max(0.01, pct / 100));
|
||||
cropFill = setCropFill(fill);
|
||||
const ticks = defaultTicks(cropFill);
|
||||
setTape({ ...tape, ...ticks });
|
||||
};
|
||||
|
||||
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
|
||||
|
||||
const positiveInches = (raw: string): number | null => {
|
||||
const inches = Number.parseFloat(raw);
|
||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||
@@ -64,14 +175,21 @@
|
||||
setThickness(eighths);
|
||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
const commitSpecies = () => {
|
||||
setSpecies(speciesSel === '' ? null : Number(speciesSel));
|
||||
};
|
||||
|
||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
|
||||
$: canCalc =
|
||||
positiveInches(String(lengthIn)) !== null
|
||||
&& positiveInches(String(thicknessIn)) !== null
|
||||
&& speciesSel !== '';
|
||||
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
try {
|
||||
commitLength();
|
||||
commitThickness();
|
||||
commitSpecies();
|
||||
await calculate();
|
||||
} finally { calcBusy = false; }
|
||||
};
|
||||
@@ -81,13 +199,106 @@
|
||||
<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}
|
||||
/>
|
||||
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
|
||||
<div class="mode-toggle" role="group" aria-label="Stage gesture mode">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn"
|
||||
class:active={gestureMode === 'zoompan'}
|
||||
aria-pressed={gestureMode === 'zoompan'}
|
||||
data-testid="gesture-mode-zoompan"
|
||||
on:click={() => (gestureMode = 'zoompan')}
|
||||
>Zoom / Pan</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn"
|
||||
class:active={gestureMode === 'rotate'}
|
||||
aria-pressed={gestureMode === 'rotate'}
|
||||
data-testid="gesture-mode-rotate"
|
||||
on:click={() => (gestureMode = 'rotate')}
|
||||
>Rotate</button>
|
||||
</div>
|
||||
<p class="nudge mode-hint">
|
||||
{#if gestureMode === 'zoompan'}
|
||||
Pinch to zoom and drag to pan. Rotation gestures are ignored — switch to Rotate or use the tilt slider.
|
||||
{:else}
|
||||
Two-finger twist to rotate (or use the tilt slider). Pan and pinch-zoom are ignored in this mode.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if maskedPreview}
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
blob={maskedPreview}
|
||||
{tape}
|
||||
{cropFill}
|
||||
{gestureMode}
|
||||
onTapeChange={onTapeChange}
|
||||
/>
|
||||
{:else}
|
||||
<div class="preview-loading" role="status">
|
||||
{previewError || 'Preparing background-removed preview…'}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="-90" max="90" step="0.1"
|
||||
value={clampTiltDeg(tiltShown)}
|
||||
on:input={(e) => setTilt(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||
<span class="val">{tiltShown}°</span>
|
||||
</div>
|
||||
<p class="nudge">±90° · slider always works · in Rotate mode: ← → tilt 1° · Shift+← → 0.1° · two-finger twist</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
|
||||
<input
|
||||
type="range" min="50" max="300" step="1"
|
||||
value={Math.round(tape.zoom * 100)}
|
||||
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
|
||||
<span class="val">{Math.round(tape.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div class="row tight">
|
||||
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input
|
||||
type="range" min="50" max="100" step="1"
|
||||
value={Math.round(cropFill * 100)}
|
||||
aria-label="Crop fill percent"
|
||||
on:input={(e) => onFill(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="val">{Math.round(cropFill * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">
|
||||
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
|
||||
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
|
||||
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
|
||||
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
@@ -120,6 +331,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field species">
|
||||
<label class="label" for="measure-species">Species</label>
|
||||
{#if !speciesOptsReady}
|
||||
<select id="measure-species" class="sel" data-testid="measure-species" aria-label="Species" disabled>
|
||||
<option value="">Loading species…</option>
|
||||
</select>
|
||||
{:else}
|
||||
<select
|
||||
id="measure-species"
|
||||
class="sel"
|
||||
data-testid="measure-species"
|
||||
aria-label="Species"
|
||||
bind:value={speciesSel}
|
||||
on:change={commitSpecies}
|
||||
>
|
||||
<option value="">Select species…</option>
|
||||
{#each speciesOpts as o (o.id)}
|
||||
<option value={String(o.id)}>{o.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if speciesLoadErr}
|
||||
<p class="species-err" role="status">{speciesLoadErr}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button
|
||||
@@ -138,9 +375,16 @@
|
||||
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
|
||||
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
|
||||
<div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div>
|
||||
</div>
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{#if state.results.sourceBelowBlock}
|
||||
<div class="warn" role="alert" data-testid="size-floor-block">
|
||||
Source shorter side is below 300px — inventory PNG blocked. Retake a larger photo.
|
||||
</div>
|
||||
{:else if state.results.sourceBelowWarn}
|
||||
<div class="warn" data-testid="size-floor-warn">
|
||||
Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||
@@ -157,6 +401,9 @@
|
||||
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.warn}
|
||||
<span class="item-warn" data-testid="lineup-warn">{item.warn}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}
|
||||
<li class="lineup-item error" role="alert">
|
||||
@@ -168,7 +415,7 @@
|
||||
</ol>
|
||||
|
||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
@@ -177,33 +424,70 @@
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
{#if onContinue}
|
||||
<button type="button" class="primary" data-testid="continue-to-review" on:click={onContinue}>
|
||||
Continue to Review
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
|
||||
.mode-toggle {
|
||||
display: flex; gap: 0; margin: 10px 0 4px;
|
||||
border: 1px solid #444; border-radius: 10px; overflow: hidden; width: fit-content; max-width: 100%;
|
||||
}
|
||||
.mode-btn {
|
||||
min-height: 44px; padding: 10px 18px; font-size: 0.95rem; font-weight: 600;
|
||||
border: 0; background: #1d1d1d; color: #aaa; cursor: pointer;
|
||||
}
|
||||
.mode-btn + .mode-btn { border-left: 1px solid #444; }
|
||||
.mode-btn.active { background: #00e5ff; color: #002; }
|
||||
.mode-hint { margin: 0 0 10px; }
|
||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
|
||||
.ctl-label .sub { font-size: 0.7rem; color: #777; }
|
||||
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||
.field { flex: 1; }
|
||||
.field.species { margin-top: 16px; flex: none; }
|
||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
||||
.field input {
|
||||
.field input, .sel {
|
||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
||||
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.species-err { color: #ffb4a2; font-size: 0.85rem; margin: 8px 0 0; }
|
||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.row.tight { margin-top: 8px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.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 { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.tab { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.dim { color: #777; }
|
||||
.results { margin-top: 8px; }
|
||||
.results .nums { font-size: 1rem; }
|
||||
.results .nums div { font-size: 1rem; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.item-warn { display: block; margin-top: 6px; color: #c07020; font-size: 0.8rem; font-weight: 600; }
|
||||
.lineup {
|
||||
list-style: none; padding: 0; margin: 16px 0 0;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { STAGE_H, STAGE_W, clampTickPair, clampTiltDeg, defaultTicks, lineX } from './tapeGeometry';
|
||||
import { fitDisplay } from './fitDisplay';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
@@ -14,15 +18,49 @@
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
/** #99 tape-line mode: fixed middle line + movable photo. */
|
||||
export let mode: 'bbox' | 'tape' = 'bbox';
|
||||
export let tape: TapeTransform | null = null;
|
||||
export let cropFill: number = getCropFill();
|
||||
export let onTapeChange: ((t: TapeTransform) => void) | null = null;
|
||||
/** When false, tape overlay is display-only (Measure step). */
|
||||
export let interactive = true;
|
||||
/**
|
||||
* Exclusive stage gestures so pinch/drag don't fight.
|
||||
* zoompan (default): pinch zoom + one-finger pan; ignore rotate gestures.
|
||||
* rotate: two-finger twist only; ignore pan/zoom from those gestures.
|
||||
* Tilt/zoom sliders outside the stage stay available either way.
|
||||
*/
|
||||
export let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
|
||||
let tapeWrap: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0;
|
||||
let parentW = 0;
|
||||
let decodeGen = 0;
|
||||
let decoding = false;
|
||||
let focusTick: 'A' | 'B' = 'A';
|
||||
|
||||
// Local editable copy while dragging; flushed via onTapeChange.
|
||||
let live: TapeTransform = tape ?? {
|
||||
tiltDeg: 0, zoom: 1, panX: 0, panY: 0, ...defaultTicks(cropFill),
|
||||
};
|
||||
$: if (tape) live = { ...tape };
|
||||
|
||||
const emit = (next: TapeTransform) => {
|
||||
live = next;
|
||||
onTapeChange?.(next);
|
||||
};
|
||||
|
||||
const applyFit = () => {
|
||||
if (!canvas || !imgW) return;
|
||||
if (mode === 'tape') {
|
||||
// Photo is sized to the logical stage; CSS transform handles pan/zoom/tilt.
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
return;
|
||||
}
|
||||
if (parentW <= 0 && stage) parentW = stage.clientWidth;
|
||||
const fit = fitDisplay(parentW, imgW, imgH, maxH);
|
||||
if (!fit) return;
|
||||
@@ -31,7 +69,7 @@
|
||||
};
|
||||
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas) return;
|
||||
if (!canvas || mode === 'tape') return;
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
@@ -62,7 +100,6 @@
|
||||
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
// The axis reads corners[3] → corners[0], the long side.
|
||||
const a = c[3];
|
||||
const b = c[0];
|
||||
ctx.strokeStyle = '#ff3d71';
|
||||
@@ -106,19 +143,185 @@
|
||||
}
|
||||
};
|
||||
|
||||
$: if (blob || bbox || maskBitset) void tick().then(redraw);
|
||||
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
|
||||
|
||||
// ---- tape pointer interactions ----
|
||||
type DragKind = null | 'pan' | 'tickA' | 'tickB';
|
||||
let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null;
|
||||
let pointers = new Map<number, { x: number; y: number }>();
|
||||
let pinchStartDist = 0;
|
||||
let pinchStartZoom = 1;
|
||||
let pinchStartAngle = 0;
|
||||
let pinchStartTilt = 0;
|
||||
|
||||
const PAN_DAMP = 0.4; // Keep pointer pan movement deliberate without changing keyboard nudges.
|
||||
const ROTATE_DAMP = 0.4; // Soften two-finger and rotation-handle tilt changes.
|
||||
const PINCH_ZOOM_DAMP = 0.4; // Blend pinch distance changes toward 1 to avoid zoom jumps.
|
||||
|
||||
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
|
||||
const el = tapeWrap;
|
||||
if (!el) return { x: 0, y: 0 };
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - r.left) / Math.max(r.width, 1)) * STAGE_W,
|
||||
y: ((clientY - r.top) / Math.max(r.height, 1)) * STAGE_H,
|
||||
};
|
||||
};
|
||||
|
||||
const hitTick = (x: number, y: number): 'A' | 'B' | null => {
|
||||
// ≥44×44 CSS px hit → ~34 stage units on a ~352px-wide stage (wireframe HIT_R).
|
||||
const HIT = 34;
|
||||
const lx = lineX();
|
||||
if (Math.hypot(x - lx, y - live.tickA) <= HIT) return 'A';
|
||||
if (Math.hypot(x - lx, y - live.tickB) <= HIT) return 'B';
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
const pts = [...pointers.values()];
|
||||
const dx = pts[1].x - pts[0].x;
|
||||
const dy = pts[1].y - pts[0].y;
|
||||
pinchStartDist = Math.hypot(dx, dy) || 1;
|
||||
pinchStartZoom = live.zoom;
|
||||
pinchStartAngle = Math.atan2(dy, dx);
|
||||
pinchStartTilt = live.tiltDeg;
|
||||
drag = null;
|
||||
return;
|
||||
}
|
||||
const p = stagePoint(e.clientX, e.clientY);
|
||||
const tick = hitTick(p.x, p.y);
|
||||
if (tick) {
|
||||
focusTick = tick;
|
||||
drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
return;
|
||||
}
|
||||
// One-finger pan only in Zoom/Pan mode (Rotate mode uses slider + two-finger twist).
|
||||
if (gestureMode === 'zoompan') {
|
||||
drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
const pts = [...pointers.values()];
|
||||
const dx = pts[1].x - pts[0].x;
|
||||
const dy = pts[1].y - pts[0].y;
|
||||
if (gestureMode === 'zoompan') {
|
||||
const dist = Math.hypot(dx, dy) || 1;
|
||||
const ratio = dist / pinchStartDist;
|
||||
const softenedRatio = 1 + (ratio - 1) * PINCH_ZOOM_DAMP;
|
||||
const z = Math.min(3, Math.max(0.5, pinchStartZoom * softenedRatio));
|
||||
emit({ ...live, zoom: z });
|
||||
} else {
|
||||
// Screen y-down atan2 delta matches CSS rotate (positive = clockwise).
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const deltaDeg = ((angle - pinchStartAngle) * 180) / Math.PI;
|
||||
emit({ ...live, tiltDeg: clampTiltDeg(pinchStartTilt + deltaDeg * ROTATE_DAMP) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!drag) return;
|
||||
const p = stagePoint(e.clientX, e.clientY);
|
||||
if (drag.kind === 'pan') {
|
||||
if (gestureMode !== 'zoompan') return;
|
||||
emit({
|
||||
...live,
|
||||
panX: drag.panX0 + (p.x - drag.x0) * PAN_DAMP,
|
||||
panY: drag.panY0 + (p.y - drag.y0) * PAN_DAMP,
|
||||
});
|
||||
} else if (drag.kind === 'tickA' || drag.kind === 'tickB') {
|
||||
const pair = drag.kind === 'tickA'
|
||||
? clampTickPair(p.y, live.tickB)
|
||||
: clampTickPair(live.tickA, p.y);
|
||||
emit({ ...live, ...pair });
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) drag = null;
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!interactive || mode !== 'tape' || gestureMode !== 'zoompan') return;
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
||||
emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) });
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!interactive || mode !== 'tape') return;
|
||||
const fine = e.shiftKey;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
if (gestureMode !== 'rotate') return;
|
||||
e.preventDefault();
|
||||
const step = fine ? 0.1 : 1;
|
||||
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
||||
emit({ ...live, tiltDeg: clampTiltDeg(live.tiltDeg + dir * step) });
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const step = fine ? 0.1 : 1;
|
||||
const dir = e.key === 'ArrowUp' ? -1 : 1;
|
||||
if (focusTick === 'A') {
|
||||
emit({ ...live, ...clampTickPair(live.tickA + dir * step, live.tickB) });
|
||||
} else {
|
||||
emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) });
|
||||
}
|
||||
} else if (e.key === '+' || e.key === '=') {
|
||||
if (gestureMode !== 'zoompan') return;
|
||||
e.preventDefault();
|
||||
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
if (gestureMode !== 'zoompan') return;
|
||||
e.preventDefault();
|
||||
emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) });
|
||||
}
|
||||
};
|
||||
|
||||
const fitPhoto = () => emit({ ...live, zoom: 1, panX: 0, panY: 0 });
|
||||
|
||||
let vbScale = 1;
|
||||
const updateVbScale = () => {
|
||||
if (!tapeWrap) return;
|
||||
vbScale = tapeWrap.clientWidth / STAGE_W || 1;
|
||||
};
|
||||
|
||||
$: inset = STAGE_H * cropMarginFraction(cropFill);
|
||||
$: photoTransform = `translate(${live.panX}px, ${live.panY}px) translate(${STAGE_W / 2}px, ${STAGE_H / 2}px) rotate(${live.tiltDeg}deg) scale(${live.zoom}) translate(${-STAGE_W / 2}px, ${-STAGE_H / 2}px)`;
|
||||
// Contain-fit the canvas bitmap into the logical stage.
|
||||
$: photoLayerStyle = imgW > 0 && imgH > 0
|
||||
? (() => {
|
||||
const scale0 = Math.min(STAGE_W / imgW, STAGE_H / imgH);
|
||||
const w = imgW * scale0;
|
||||
const h = imgH * scale0;
|
||||
const left = (STAGE_W - w) / 2;
|
||||
const top = (STAGE_H - h) / 2;
|
||||
return `position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;transform-origin:center center;`;
|
||||
})()
|
||||
: 'position:absolute;inset:0;';
|
||||
|
||||
onMount(() => {
|
||||
if (stage) parentW = stage.clientWidth;
|
||||
updateVbScale();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
const w = e.contentRect.width;
|
||||
if (mode === 'tape') {
|
||||
updateVbScale();
|
||||
}
|
||||
if (w === parentW) return;
|
||||
parentW = w;
|
||||
applyFit();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
if (tapeWrap) ro.observe(tapeWrap);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {
|
||||
@@ -126,16 +329,85 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="stage" bind:this={stage}>
|
||||
{#if imgW > 0 || decoding || blob}
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if mode === 'tape'}
|
||||
<div
|
||||
class="tape-stage"
|
||||
bind:this={stage}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label="Fixed tape line over a movable slab photo"
|
||||
on:keydown={onKeyDown}
|
||||
>
|
||||
<div
|
||||
class="tw"
|
||||
class:dragging={!!drag}
|
||||
class:mode-rotate={gestureMode === 'rotate'}
|
||||
bind:this={tapeWrap}
|
||||
role="group"
|
||||
aria-label="Tape alignment stage"
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerUp}
|
||||
on:wheel={onWheel}
|
||||
on:dblclick={() => interactive && fitPhoto()}
|
||||
>
|
||||
<div class="vb" style="width:{STAGE_W}px;height:{STAGE_H}px;transform:scale({vbScale});">
|
||||
<div class="photo-layer" style="{photoLayerStyle} transform: {photoTransform};">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
<!-- crop end bands (proposal visual; aspect stays 3:4) -->
|
||||
<div class="band top" style="height:{inset}px"></div>
|
||||
<div class="band bot" style="height:{inset}px"></div>
|
||||
<svg class="fixture" viewBox="0 0 {STAGE_W} {STAGE_H}" width={STAGE_W} height={STAGE_H} aria-hidden="true">
|
||||
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round" />
|
||||
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||
{#each [['A', live.tickA], ['B', live.tickB]] as [id, y]}
|
||||
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round" />
|
||||
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
|
||||
<circle cx={lineX()} cy={y} r="5" fill={focusTick === id ? '#ff3d71' : '#000'} stroke="#fff" stroke-width="1.5" />
|
||||
<circle class="tick-hit" cx={lineX()} cy={y} r="34" fill="transparent" />
|
||||
{/each}
|
||||
</svg>
|
||||
{#if decoding && imgW === 0}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage" bind:this={stage}>
|
||||
{#if imgW > 0 || decoding || blob}
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stage { width: 100%; display: flex; justify-content: center; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; }
|
||||
.loading { padding: 40px; color: #999; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; width: 100%; height: 100%; }
|
||||
.loading { padding: 40px; color: #999; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.tape-stage { width: 100%; outline: none; }
|
||||
.tw {
|
||||
position: relative; width: 100%; aspect-ratio: 3 / 4;
|
||||
background: #111; border: 1px solid #333; border-radius: 8px; overflow: hidden;
|
||||
touch-action: none; user-select: none; cursor: grab;
|
||||
}
|
||||
.tw.dragging { cursor: grabbing; }
|
||||
.tw.mode-rotate { cursor: ew-resize; }
|
||||
.vb {
|
||||
position: absolute; top: 0; left: 0;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vb > :global(*) { pointer-events: auto; }
|
||||
.photo-layer { will-change: transform; pointer-events: none; }
|
||||
.photo-layer canvas { border-radius: 0; background: transparent; pointer-events: none; }
|
||||
.band { position: absolute; left: 0; right: 0; background: #000; opacity: 0.3; pointer-events: none; z-index: 1; }
|
||||
.band.top { top: 0; }
|
||||
.band.bot { bottom: 0; }
|
||||
.fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
||||
.tick-hit { pointer-events: auto; cursor: ns-resize; }
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ABox {
|
||||
// Minimum-area bounding rectangle of the mask, source px.
|
||||
export interface BBox {
|
||||
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
|
||||
longSidePx: number; // geometric extent only; scale uses mask span along the axis
|
||||
longSidePx: number; // geometric extent only; scale prefers tape tick span (#99)
|
||||
angleDeg: number; // long-side angle, degrees, [0, 180)
|
||||
}
|
||||
|
||||
@@ -43,13 +43,26 @@ export interface Mask {
|
||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||
bbox: BBox;
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the crop_fill PNG crop (default 90%)
|
||||
}
|
||||
|
||||
// Tape-line calibration (#99): photo tilt/pan/zoom under a fixed middle line;
|
||||
// ticks declare L. bbox is derived for overlay/width sampling direction.
|
||||
export interface TapeLine {
|
||||
tiltDeg: number;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
/** Tick Y in logical stage units (STAGE_H = 640). */
|
||||
tickA: number;
|
||||
tickB: number;
|
||||
/** Image-space distance between ticks (px); drives scale = spanPx / L. */
|
||||
spanPx: number;
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
// corners[3] → corners[0] (the long side).
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
tape: TapeLine;
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
@@ -69,14 +82,17 @@ export interface Measures {
|
||||
inventoryPng: Blob;
|
||||
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
|
||||
overlayPng?: Blob;
|
||||
sourceBelowFloor: boolean;
|
||||
/** Shorter side of slab bbox < 300 px — hard fail / exclude clean cal. */
|
||||
sourceBelowBlock: boolean;
|
||||
/** Shorter side in 300–799 px — encode/include with warn. */
|
||||
sourceBelowWarn: boolean;
|
||||
}
|
||||
|
||||
// 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: 'ok'; inventoryPng: Blob; /** Soft size-floor concern; photo still included. */ warn?: string }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||
@@ -102,9 +118,11 @@ export type Capture =
|
||||
k: 'measure';
|
||||
source: Source;
|
||||
approvedMask: Mask;
|
||||
axis: Axis; // frozen
|
||||
axis: Axis; // editable tape until Calculate; frozen after results
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
/** Woo species leaf id; set on Measure. Persisted on draft + createSlab; Review does not edit. */
|
||||
speciesId: number | null;
|
||||
results: Measures | null;
|
||||
/** Capture-order sibling results from the post-Calculate batch pass. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
@@ -138,8 +156,11 @@ export interface Draft {
|
||||
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
|
||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
/** Woo species leaf; absent on legacy drafts. */
|
||||
species_id?: number | null;
|
||||
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
|
||||
inventoryPng: Blob | null; // stored so Continue can show Measure results
|
||||
/** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */
|
||||
|
||||
@@ -40,3 +40,22 @@ test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||
assert.equal(inToEighths(0.07), 1);
|
||||
assert.equal(inToEighths(1.999), 16);
|
||||
});
|
||||
|
||||
test('MeasurePanel exposes Species select (Woo taxonomy leaves)', () => {
|
||||
assert.match(panelSrc, /data-testid="measure-species"/);
|
||||
assert.match(panelSrc, /speciesLeavesFromTaxonomy|getTaxonomy/);
|
||||
assert.match(panelSrc, /aria-label="Species"/);
|
||||
});
|
||||
|
||||
test('MeasurePanel keeps interactive tape controls (merged Axis)', () => {
|
||||
assert.match(panelSrc, /mode="tape"/);
|
||||
assert.match(panelSrc, /onTapeChange/);
|
||||
assert.ok(!/Approve tape line/.test(panelSrc), 'Approve tape folded into Calculate');
|
||||
assert.match(panelSrc, /Calculate/);
|
||||
});
|
||||
|
||||
test('Calculate requires Species (carries to createSlab / Create title)', () => {
|
||||
assert.match(panelSrc, /speciesSel !== ''/);
|
||||
assert.match(panelSrc, /speciesOptsReady/);
|
||||
assert.match(panelSrc, /photos-ready-species/);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
|
||||
null,
|
||||
{ status: 'error', message: 'empty mask: no slab pixels detected' },
|
||||
{ status: 'ok', inventoryPng: ok },
|
||||
{ status: 'error', message: 'PNG size floor: shorter side is below 800 px' },
|
||||
{ status: 'error', message: 'PNG size floor: shorter side is below 300 px' },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
@@ -118,7 +118,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
|
||||
const err3 = lineup[4];
|
||||
assert.equal(err3.kind, 'error');
|
||||
if (err3.kind === 'error') {
|
||||
assert.match(err3.message, /floor|800/i);
|
||||
assert.match(err3.message, /floor|300/i);
|
||||
}
|
||||
|
||||
// Successful sibling still appears after cal+overlay, in capture order among siblings
|
||||
@@ -151,16 +151,37 @@ test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
|
||||
test('test(photos): skip clean cal when sourceBelowBlock; keep overlay + siblings (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('below'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
|
||||
sourceBelowFloor: true,
|
||||
sourceBelowBlock: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
|
||||
});
|
||||
|
||||
test('test(photos): warn band keeps clean cal + attaches warn; siblings with warn stay png (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('warn-cal'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [
|
||||
null,
|
||||
{ status: 'ok', inventoryPng: png('sib'), warn: 'PNG size warn: shorter side is below 800 px (300–799); photo included' },
|
||||
],
|
||||
sourceBelowWarn: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']);
|
||||
assert.equal(lineup[0].kind, 'png');
|
||||
if (lineup[0].kind === 'png') {
|
||||
assert.match(lineup[0].warn ?? '', /warn|800|300/i);
|
||||
}
|
||||
assert.equal(lineup[2].kind, 'png');
|
||||
if (lineup[2].kind === 'png') {
|
||||
assert.match(lineup[2].warn ?? '', /warn|800/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
|
||||
assert.deepEqual(buildPhotosReadyLineup({}), []);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -8,6 +8,8 @@ export type PhotosReadyLineupItem =
|
||||
blob: Blob;
|
||||
/** Capture-order index for non-calibration batch inventory PNGs. */
|
||||
batchIndex?: number;
|
||||
/** Soft size-floor warn (300–799); photo still shown. */
|
||||
warn?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
@@ -19,25 +21,45 @@ 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;
|
||||
/** When true, omit the clean calibration PNG (hard block < 300 px). */
|
||||
sourceBelowBlock?: boolean;
|
||||
/** Soft warn for calibration (300–799); clean cal still included. */
|
||||
sourceBelowWarn?: boolean;
|
||||
/** Calibration warn message when sourceBelowWarn. */
|
||||
calibrationWarn?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Photos-ready lineup:
|
||||
* 1. Clean calibration inventory PNG
|
||||
* 1. Clean calibration inventory PNG (skipped only on hard block)
|
||||
* 2. Dimension-overlay copy
|
||||
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
|
||||
* with error slots for empty mask / floor failures.
|
||||
* with error slots for empty mask / block-floor failures; warn stays on png.
|
||||
*/
|
||||
export function buildPhotosReadyLineup(
|
||||
input: PhotosReadyLineupInput,
|
||||
): PhotosReadyLineupItem[] {
|
||||
const items: PhotosReadyLineupItem[] = [];
|
||||
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
|
||||
const {
|
||||
inventoryPng,
|
||||
overlayPng,
|
||||
batchApply,
|
||||
sourceBelowBlock,
|
||||
sourceBelowWarn,
|
||||
calibrationWarn,
|
||||
} = input;
|
||||
|
||||
if (inventoryPng && !sourceBelowFloor) {
|
||||
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
|
||||
if (inventoryPng && !sourceBelowBlock) {
|
||||
const warn = sourceBelowWarn
|
||||
? (calibrationWarn
|
||||
?? 'PNG size warn: shorter side is below 800 px (300–799); photo included')
|
||||
: undefined;
|
||||
items.push({
|
||||
kind: 'png',
|
||||
role: 'calibration',
|
||||
blob: inventoryPng,
|
||||
...(warn ? { warn } : {}),
|
||||
});
|
||||
}
|
||||
if (overlayPng) {
|
||||
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
|
||||
@@ -53,6 +75,7 @@ export function buildPhotosReadyLineup(
|
||||
role: 'batch',
|
||||
blob: slot.inventoryPng,
|
||||
batchIndex: i,
|
||||
...(slot.warn ? { warn: slot.warn } : {}),
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
|
||||
@@ -211,7 +211,8 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
|
||||
// Distinctive PNG blob so tests cannot confuse with source JPEG thumbs
|
||||
return {
|
||||
blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -269,7 +270,8 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['ok-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -287,15 +289,15 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => {
|
||||
test('#62: below block floor (<300) 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
|
||||
const c = tinyJpeg('c'); // below block 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: 250, h: 200, slabPx: 250 * 200, slabBounds: { x: 0, y: 0, w: 250, h: 200 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
@@ -303,10 +305,11 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
|
||||
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
|
||||
return {
|
||||
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowFloor: below,
|
||||
blob: new Blob([short < 300 ? 'tiny' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowBlock: short < 300,
|
||||
sourceBelowWarn: short >= 300 && short < 800,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -317,9 +320,47 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
|
||||
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');
|
||||
assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error');
|
||||
if (apply![2]?.status === 'error') {
|
||||
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
|
||||
assert.match(apply![2].message, /floor|300|size/i, 'clear per-photo size-floor error');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test('#62: warn band (300–799) on sibling → ok with warn; photo included', async () => {
|
||||
installFakes();
|
||||
const a = tinyJpeg('a'); // ok
|
||||
const b = tinyJpeg('b'); // cal
|
||||
const c = tinyJpeg('c'); // warn band
|
||||
|
||||
FakeMaskWorker.behaviorFor = (blob) => {
|
||||
if (blob === c) {
|
||||
return { w: 500, h: 400, slabPx: 500 * 400, slabBounds: { x: 0, y: 0, w: 500, h: 400 } };
|
||||
}
|
||||
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
|
||||
};
|
||||
|
||||
const s = createCaptureSession({
|
||||
draft: store,
|
||||
encodeInventory: async (input) => {
|
||||
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
|
||||
return {
|
||||
blob: new Blob([short < 800 ? 'warn' : 'ok'], { type: 'image/png' }),
|
||||
sourceBelowBlock: short < 300,
|
||||
sourceBelowWarn: short >= 300 && short < 800,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' });
|
||||
await s.calculate();
|
||||
|
||||
const apply = batchApplyOf(get(s.capture));
|
||||
assert.ok(apply);
|
||||
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
|
||||
assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok');
|
||||
if (apply![2]?.status === 'ok') {
|
||||
assert.match(apply![2].warn ?? '', /warn|800|300/i, 'warn message attached');
|
||||
}
|
||||
s.dispose();
|
||||
});
|
||||
@@ -330,7 +371,8 @@ test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -373,7 +415,8 @@ test('#62: draft persists batchApply after Calculate (for #64 UI later)', async
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -401,7 +444,8 @@ test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mas
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -426,7 +470,8 @@ test('#62: Calculate does not re-enter Mask / does not open Mask controls for si
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['x'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -463,7 +508,8 @@ test('#62: resume restores batchApply alongside calibration results', async () =
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['resume-png'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
|
||||
@@ -343,16 +343,11 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
|
||||
|
||||
s.approveMask();
|
||||
cap = get(s.capture);
|
||||
assert.equal(cap.k, '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');
|
||||
assert.equal(cap.k, 'measure', 'approve Mask → combined Measure (tape+fields)');
|
||||
if (cap.k === 'measure') {
|
||||
assert.equal(cap.source.blob, b, 'Measure must keep the calibration source');
|
||||
assert.equal(cap.results, null);
|
||||
assert.equal(cap.speciesId, null);
|
||||
}
|
||||
|
||||
s.dispose();
|
||||
@@ -447,15 +442,16 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
|
||||
|
||||
const after = store.peek();
|
||||
assert.ok(after);
|
||||
assert.equal(after!.stage, 'axis');
|
||||
assert.equal(after!.stage, 'measure', 'mask approve lands on combined tape+measure');
|
||||
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');
|
||||
assert.equal(after!.species_id ?? null, null);
|
||||
|
||||
// Approve axis too — batch still untouched.
|
||||
// approveAxis is a no-op on measure — batch still untouched.
|
||||
s.approveAxis();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const meas = store.peek();
|
||||
@@ -520,10 +516,10 @@ test('#61: draft resume through measure restores calibration source (not photos[
|
||||
s2.dispose();
|
||||
});
|
||||
|
||||
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
|
||||
test('#61: App runs single Mask/Measure panels (no per-batch Mask loop)', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
assert.match(app, /MaskPanel/);
|
||||
assert.match(app, /AxisPanel/);
|
||||
assert.ok(!/AxisPanel/.test(app), 'AxisPanel folded into MeasurePanel');
|
||||
assert.match(app, /MeasurePanel/);
|
||||
// Dishonest proxy: must not iterate batch photos into Mask UI.
|
||||
assert.ok(
|
||||
|
||||
@@ -185,7 +185,8 @@ test('test(photos): Calculate stores overlayPng on results, clean inventory unto
|
||||
encodeN++;
|
||||
return {
|
||||
blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
};
|
||||
},
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
@@ -221,7 +222,8 @@ test('test(photos): single-photo batch still yields clean + overlayPng (#63)', a
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['only-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
@@ -243,7 +245,8 @@ test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', asy
|
||||
draft: store,
|
||||
encodeInventory: async () => ({
|
||||
blob: new Blob(['draft-clean'], { type: 'image/png' }),
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
}),
|
||||
} as Parameters<typeof createCaptureSession>[0]);
|
||||
|
||||
|
||||
+204
-31
@@ -10,7 +10,11 @@ import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
|
||||
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
|
||||
import { bakeTiltTransform, rotateCalibration } from '../png/rotateCal';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
import type { TapeLine } from './capture.types';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
@@ -41,19 +45,24 @@ export interface CaptureSession {
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
/** #99: replace axis rectangle nudge with full tape transform. */
|
||||
setTape(tape: TapeTransform): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
setSpecies(id: number | null): void;
|
||||
calculate(): Promise<void>;
|
||||
back(): void;
|
||||
getInventoryUrl(): string | null;
|
||||
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
||||
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface SessionOpts {
|
||||
draft?: DraftStore;
|
||||
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
|
||||
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }>;
|
||||
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
|
||||
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
|
||||
}
|
||||
@@ -204,7 +213,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
@@ -214,8 +223,9 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
|
||||
calibrationIndex: calIdx,
|
||||
source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
species_id: state.speciesId,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
overlayPng: state.results?.overlayPng ?? null,
|
||||
@@ -310,6 +320,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
|
||||
const tapeFromDraftOrBbox = (d: Draft, bbox: import('./capture.types').BBox, w: number, h: number): TapeLine => {
|
||||
if (d.tape && Number.isFinite(d.tape.spanPx)) {
|
||||
return { ...d.tape };
|
||||
}
|
||||
// Seed tilt so the mask long side sits near vertical under the fixed line.
|
||||
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
|
||||
const t = defaultTape(getCropFill(), seedTilt);
|
||||
const ep = w > 0 && h > 0 ? tapeEndpoints(t, w, h) : { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
return { ...t, spanPx: ep.spanPx };
|
||||
};
|
||||
|
||||
const axisWithTape = (bbox: import('./capture.types').BBox, tape: TapeTransform, w: number, h: number) => {
|
||||
const ep = w > 0 && h > 0
|
||||
? tapeEndpoints(tape, w, h)
|
||||
: { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
|
||||
const angleDeg = ep.spanPx > 0
|
||||
? ((Math.atan2(ep.uy, ep.ux) * 180) / Math.PI + 180) % 180
|
||||
: bbox.angleDeg;
|
||||
const nextBbox = orientedRect(bbox, angleDeg);
|
||||
const line: TapeLine = {
|
||||
tiltDeg: clampTiltDeg(tape.tiltDeg),
|
||||
zoom: tape.zoom,
|
||||
panX: tape.panX,
|
||||
panY: tape.panY,
|
||||
tickA: tape.tickA,
|
||||
tickB: tape.tickB,
|
||||
spanPx: ep.spanPx,
|
||||
};
|
||||
return { bbox: nextBbox, tape: line };
|
||||
};
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
batchPhotos = batchPhotosOf(d);
|
||||
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
|
||||
@@ -336,26 +377,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
||||
// Legacy axis-only drafts land on the combined tape+measure screen.
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask, axis,
|
||||
lengthEighths: d.lengthEighths || 0,
|
||||
thicknessEighths: d.thicknessEighths || 0,
|
||||
speciesId: d.species_id ?? null,
|
||||
results: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
{
|
||||
const bbox = draftAxisBbox(d);
|
||||
const w = source.widthPx;
|
||||
const h = source.heightPx;
|
||||
const axis = d.tape
|
||||
? axisWithTape(bbox, d.tape, w, h)
|
||||
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
axis,
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
speciesId: d.species_id ?? null,
|
||||
results: d.results
|
||||
? {
|
||||
...d.results,
|
||||
inventoryPng: d.inventoryPng ?? new Blob(),
|
||||
overlayPng: d.overlayPng ?? undefined,
|
||||
// Migrate legacy sourceBelowFloor → warn; missing flags default false.
|
||||
sourceBelowBlock: (d.results as { sourceBelowBlock?: boolean }).sourceBelowBlock ?? false,
|
||||
sourceBelowWarn:
|
||||
(d.results as { sourceBelowWarn?: boolean }).sourceBelowWarn
|
||||
?? (d.results as { sourceBelowFloor?: boolean }).sourceBelowFloor
|
||||
?? false,
|
||||
}
|
||||
: null,
|
||||
batchApply: d.batchApply,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
makeWorker(source.blob);
|
||||
@@ -469,41 +538,84 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
runPass();
|
||||
},
|
||||
|
||||
// #61: Axis uses the same calibration source; draft.batch siblings stay untouched.
|
||||
// #61: combined tape+measure 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 } } });
|
||||
const bbox = {
|
||||
corners: overlay.bbox.corners.map((c) => [...c] as [number, number]),
|
||||
longSidePx: overlay.bbox.longSidePx,
|
||||
angleDeg: overlay.bbox.angleDeg,
|
||||
};
|
||||
const w = state.source.widthPx;
|
||||
const h = state.source.heightPx;
|
||||
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
|
||||
const tape0 = defaultTape(getCropFill(), seedTilt);
|
||||
const axis = axisWithTape(bbox, tape0, w, h);
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: overlay, axis,
|
||||
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
|
||||
});
|
||||
void persist();
|
||||
},
|
||||
|
||||
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
|
||||
setAxisAngle(deg: number) {
|
||||
if (state.k !== 'axis') return;
|
||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
||||
void persist();
|
||||
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
|
||||
const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) };
|
||||
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
}
|
||||
},
|
||||
|
||||
setTape(tape: TapeTransform) {
|
||||
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
|
||||
const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) };
|
||||
const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx);
|
||||
set({ ...state, axis });
|
||||
void persist();
|
||||
}
|
||||
},
|
||||
|
||||
// Folded into Calculate on the combined screen; kept for tests / legacy axis drafts.
|
||||
approveAxis() {
|
||||
if (state.k === 'measure') {
|
||||
void persist();
|
||||
return;
|
||||
}
|
||||
if (state.k !== 'axis') return;
|
||||
const axis = axisWithTape(
|
||||
state.axis.bbox,
|
||||
state.axis.tape,
|
||||
state.source.widthPx,
|
||||
state.source.heightPx,
|
||||
);
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.axis.bbox },
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
axis,
|
||||
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
|
||||
});
|
||||
void persist();
|
||||
},
|
||||
|
||||
setLength(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
|
||||
set({ ...state, lengthEighths: Math.max(0, Math.round(e)), results: null });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setThickness(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
|
||||
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)), results: null });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setSpecies(id: number | null) {
|
||||
if (state.k !== 'measure') return;
|
||||
const speciesId = id != null && Number.isFinite(id) ? Math.round(id) : null;
|
||||
set({ ...state, speciesId });
|
||||
void persist();
|
||||
},
|
||||
|
||||
@@ -514,39 +626,82 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await decodeOrientedBitmap(source.blob);
|
||||
const tapeAxis = axis.tape.spanPx > 0
|
||||
? (() => {
|
||||
const ep = tapeEndpoints(axis.tape, source.widthPx, source.heightPx);
|
||||
return { start: ep.start, ux: ep.ux, uy: ep.uy, spanPx: ep.spanPx };
|
||||
})()
|
||||
: null;
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
tapeAxis,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
let belowBlock = false;
|
||||
let belowWarn = false;
|
||||
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
|
||||
let calBitmap = bitmap;
|
||||
let calMask = approvedMask;
|
||||
let calSource = source;
|
||||
let rotatedExtra: ImageBitmap | null = null;
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
|
||||
const rotated = await rotateCalibration(bitmap, approvedMask, source, axis.tape.tiltDeg);
|
||||
calBitmap = rotated.bitmap;
|
||||
calMask = rotated.mask;
|
||||
calSource = { ...source, widthPx: rotated.source.widthPx, heightPx: rotated.source.heightPx };
|
||||
if (rotated.bitmap !== bitmap) rotatedExtra = rotated.bitmap;
|
||||
} catch {
|
||||
/* keep unrotated cal if bake fails */
|
||||
}
|
||||
try {
|
||||
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
belowBlock = r.sourceBelowBlock;
|
||||
belowWarn = r.sourceBelowWarn;
|
||||
} catch {
|
||||
// CAPTURE.md: inventory is image/png even when encode is unavailable.
|
||||
pngBlob = new Blob([], { type: 'image/png' });
|
||||
}
|
||||
bitmap.close();
|
||||
if (rotatedExtra) rotatedExtra.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
|
||||
// #63: second copy of the calibration inventory PNG with dimension overlays.
|
||||
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
|
||||
// Overlay uses the same baked orientation as the clean cal PNG (#99 scope).
|
||||
// Length / width directions follow the tape (not wood MAR): map tick A→B
|
||||
// through the same bake as rotateCal, then orient the overlay bbox to that.
|
||||
let overlayBlob: Blob;
|
||||
try {
|
||||
const preEp = axis.tape.spanPx > 0
|
||||
? tapeEndpoints(axis.tape, source.widthPx, source.heightPx)
|
||||
: null;
|
||||
const bakeXform = bakeTiltTransform(source.widthPx, source.heightPx, axis.tape.tiltDeg);
|
||||
let overlayBbox = calMask.bbox;
|
||||
let overlayAxis: { start: [number, number]; end: [number, number] } | undefined;
|
||||
if (preEp && preEp.spanPx > 0) {
|
||||
const start = bakeXform.mapPoint(preEp.start[0], preEp.start[1]);
|
||||
const end = bakeXform.mapPoint(preEp.end[0], preEp.end[1]);
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 180) % 180;
|
||||
overlayBbox = orientedRect(calMask.bbox, angleDeg);
|
||||
overlayAxis = { start, end };
|
||||
}
|
||||
overlayBlob = await encodeOverlay({
|
||||
inventoryPng: pngBlob,
|
||||
bitset: approvedMask.bitset,
|
||||
widthPx: source.widthPx,
|
||||
heightPx: source.heightPx,
|
||||
bbox: axis.bbox,
|
||||
bitset: calMask.bitset,
|
||||
widthPx: calSource.widthPx,
|
||||
heightPx: calSource.heightPx,
|
||||
bbox: overlayBbox,
|
||||
scale: base.scale,
|
||||
lengthIn: eighthsToIn(lengthEighths),
|
||||
lengthLabel: fmtEighths(lengthEighths),
|
||||
slabBounds: approvedMask.slabBounds,
|
||||
source,
|
||||
slabBounds: calMask.slabBounds,
|
||||
source: calSource,
|
||||
axis: overlayAxis,
|
||||
});
|
||||
} catch {
|
||||
// Deterministic tests may inject a non-decodable inventory blob; still
|
||||
@@ -557,7 +712,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
...base,
|
||||
inventoryPng: pngBlob,
|
||||
overlayPng: overlayBlob,
|
||||
sourceBelowFloor: belowFloor,
|
||||
sourceBelowBlock: belowBlock,
|
||||
sourceBelowWarn: belowWarn,
|
||||
};
|
||||
const params = approvedMask.params;
|
||||
const calIndex = calibrationIndex != null && calibrationIndex >= 0
|
||||
@@ -589,16 +745,21 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
heightPx: derived.heightPx,
|
||||
};
|
||||
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
|
||||
let encoded: { blob: Blob; sourceBelowFloor: boolean };
|
||||
let encoded: { blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: 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');
|
||||
if (encoded.sourceBelowBlock) {
|
||||
throw new Error('PNG size floor: shorter side is below 300 px');
|
||||
}
|
||||
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
|
||||
const warn = encoded.sourceBelowWarn
|
||||
? 'PNG size warn: shorter side is below 800 px (300–799); photo included'
|
||||
: undefined;
|
||||
batchApply[index] = warn
|
||||
? { status: 'ok', inventoryPng: encoded.blob, warn }
|
||||
: { status: 'ok', inventoryPng: encoded.blob };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unable to process photo';
|
||||
batchApply[index] = { status: 'error', message };
|
||||
@@ -620,11 +781,19 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
set({ k: 'pickCalibration', photos: [...batchPhotos] });
|
||||
void persist();
|
||||
} else if (state.k === 'axis') {
|
||||
// back to mask keeps the last approved mask as the live overlay
|
||||
// Legacy: 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 } });
|
||||
if (state.results) {
|
||||
// Photos ready → re-edit tape + L/thickness/species on the same screen.
|
||||
set({ ...state, results: null, batchApply: undefined });
|
||||
if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; }
|
||||
void persist();
|
||||
} else {
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -632,6 +801,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
return pngUrl;
|
||||
},
|
||||
|
||||
currentMeasure() {
|
||||
return state.k === 'measure' ? state : null;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
teardownWorker();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
STAGE_H, STAGE_W, TILT_MAX, TILT_MIN, clampTickPair, clampTiltDeg, containFit,
|
||||
defaultTape, defaultTicks, lineX, stageToImage, tapeEndpoints,
|
||||
} from './tapeGeometry.ts';
|
||||
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts';
|
||||
|
||||
test('defaultTicks sit at crop insets for 90% fill', () => {
|
||||
const t = defaultTicks(0.9);
|
||||
const inset = STAGE_H * cropMarginFraction(0.9);
|
||||
assert.ok(Math.abs(t.tickA - inset) < 1e-9);
|
||||
assert.ok(Math.abs(t.tickB - (STAGE_H - inset)) < 1e-9);
|
||||
});
|
||||
|
||||
test('lineX is the middle of the 3:4 stage', () => {
|
||||
assert.equal(lineX(), STAGE_W / 2);
|
||||
});
|
||||
|
||||
test('identity tape: stage centre maps near image centre', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const tape = defaultTape(DEFAULT_CROP_FILL, 0);
|
||||
const [ix, iy] = stageToImage(STAGE_W / 2, STAGE_H / 2, tape, imgW, imgH);
|
||||
assert.ok(Math.abs(ix - imgW / 2) < 1e-6, `ix=${ix}`);
|
||||
assert.ok(Math.abs(iy - imgH / 2) < 1e-6, `iy=${iy}`);
|
||||
});
|
||||
|
||||
test('tapeEndpoints span equals tick distance in image px at identity (matching aspect)', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const tape = defaultTape(0.9, 0);
|
||||
const ep = tapeEndpoints(tape, imgW, imgH);
|
||||
const { scale0 } = containFit(imgW, imgH);
|
||||
const expected = Math.abs(tape.tickB - tape.tickA) / scale0;
|
||||
assert.ok(Math.abs(ep.spanPx - expected) < 1e-6, `span ${ep.spanPx} vs ${expected}`);
|
||||
// Direction should be +y (down the stage → down the image)
|
||||
assert.ok(Math.abs(ep.ux) < 1e-6, `ux=${ep.ux}`);
|
||||
assert.ok(ep.uy > 0.99, `uy=${ep.uy}`);
|
||||
});
|
||||
|
||||
test('zoom scales inverse: 2× zoom halves image-space tick span', () => {
|
||||
const imgW = 480, imgH = 640;
|
||||
const base = defaultTape(0.9, 0);
|
||||
const zoomed = { ...base, zoom: 2 };
|
||||
const a = tapeEndpoints(base, imgW, imgH).spanPx;
|
||||
const b = tapeEndpoints(zoomed, imgW, imgH).spanPx;
|
||||
assert.ok(Math.abs(b * 2 - a) < 1e-6, `base=${a} zoomed=${b}`);
|
||||
});
|
||||
|
||||
test('clampTickPair enforces gap and order', () => {
|
||||
const p = clampTickPair(400, 100, 40);
|
||||
assert.ok(p.tickB - p.tickA >= 40);
|
||||
assert.ok(p.tickA <= p.tickB);
|
||||
});
|
||||
|
||||
test('clampTiltDeg clamps to ±90', () => {
|
||||
assert.equal(TILT_MIN, -90);
|
||||
assert.equal(TILT_MAX, 90);
|
||||
assert.equal(clampTiltDeg(0), 0);
|
||||
assert.equal(clampTiltDeg(45.5), 45.5);
|
||||
assert.equal(clampTiltDeg(-45.5), -45.5);
|
||||
assert.equal(clampTiltDeg(90), 90);
|
||||
assert.equal(clampTiltDeg(-90), -90);
|
||||
assert.equal(clampTiltDeg(91), 90);
|
||||
assert.equal(clampTiltDeg(-100), -90);
|
||||
assert.equal(clampTiltDeg(Number.NaN), 0);
|
||||
});
|
||||
|
||||
test('defaultTape clamps seed tilt', () => {
|
||||
assert.equal(defaultTape(0.9, 120).tiltDeg, 90);
|
||||
assert.equal(defaultTape(0.9, -200).tiltDeg, -90);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Pure tape-line geometry (#99).
|
||||
* Fixed vertical line in the middle of a 3:4 stage; photo pans/zooms/tilts;
|
||||
* ticks slide on the line; L↔px from tick distance in image space.
|
||||
*/
|
||||
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop';
|
||||
|
||||
/** Logical stage matching the wireframe / inventory aspect. */
|
||||
export const STAGE_W = 480;
|
||||
export const STAGE_H = 640;
|
||||
|
||||
/** Interactive tilt limits (degrees). Slider midpoint = 0; both ways ±90. */
|
||||
export const TILT_MIN = -90;
|
||||
export const TILT_MAX = 90;
|
||||
|
||||
export function clampTiltDeg(deg: number): number {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
return Math.min(TILT_MAX, Math.max(TILT_MIN, deg));
|
||||
}
|
||||
|
||||
export interface TapeTransform {
|
||||
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
|
||||
tiltDeg: number;
|
||||
zoom: number;
|
||||
/** Pan in stage units. */
|
||||
panX: number;
|
||||
panY: number;
|
||||
/** Tick Y positions in stage units. */
|
||||
tickA: number;
|
||||
tickB: number;
|
||||
}
|
||||
|
||||
export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number; tickB: number } {
|
||||
const inset = STAGE_H * cropMarginFraction(fill);
|
||||
return { tickA: inset, tickB: STAGE_H - inset };
|
||||
}
|
||||
|
||||
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
|
||||
const t = defaultTicks(fill);
|
||||
return {
|
||||
tiltDeg: clampTiltDeg(seedTiltDeg),
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
tickA: t.tickA,
|
||||
tickB: t.tickB,
|
||||
};
|
||||
}
|
||||
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
|
||||
/** Contain-fit of the photo into the stage at zoom=1, pan=0. */
|
||||
export function containFit(imgW: number, imgH: number): { scale0: number; offX: number; offY: number } {
|
||||
const scale0 = Math.min(STAGE_W / Math.max(imgW, 1), STAGE_H / Math.max(imgH, 1));
|
||||
return {
|
||||
scale0,
|
||||
offX: (STAGE_W - imgW * scale0) / 2,
|
||||
offY: (STAGE_H - imgH * scale0) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a stage point through the inverse photo transform into source image pixels.
|
||||
* Transform order (forward): contain-fit → zoom+tilt about stage centre → pan.
|
||||
*/
|
||||
export function stageToImage(
|
||||
sx: number,
|
||||
sy: number,
|
||||
tape: TapeTransform,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): [number, number] {
|
||||
const cx = STAGE_W / 2;
|
||||
const cy = STAGE_H / 2;
|
||||
const { scale0, offX, offY } = containFit(imgW, imgH);
|
||||
// undo pan
|
||||
let x = sx - tape.panX - cx;
|
||||
let y = sy - tape.panY - cy;
|
||||
// undo rotate(tilt) scale(zoom) about centre
|
||||
const z = tape.zoom || 1;
|
||||
const a = -rad(tape.tiltDeg);
|
||||
const cos = Math.cos(a);
|
||||
const sin = Math.sin(a);
|
||||
const xr = (x * cos - y * sin) / z;
|
||||
const yr = (x * sin + y * cos) / z;
|
||||
x = xr + cx;
|
||||
y = yr + cy;
|
||||
// undo contain fit
|
||||
const ix = (x - offX) / scale0;
|
||||
const iy = (y - offY) / scale0;
|
||||
return [ix, iy];
|
||||
}
|
||||
|
||||
export function lineX(): number {
|
||||
return STAGE_W / 2;
|
||||
}
|
||||
|
||||
/** Image-space endpoints of the two ticks + span in px. */
|
||||
export function tapeEndpoints(
|
||||
tape: TapeTransform,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): { start: [number, number]; end: [number, number]; spanPx: number; ux: number; uy: number } {
|
||||
const start = stageToImage(lineX(), tape.tickA, tape, imgW, imgH);
|
||||
const end = stageToImage(lineX(), tape.tickB, tape, imgW, imgH);
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const spanPx = Math.hypot(dx, dy);
|
||||
const len = spanPx || 1;
|
||||
return { start, end, spanPx, ux: dx / len, uy: dy / len };
|
||||
}
|
||||
|
||||
export function clampTickPair(a: number, b: number, gap = 40): { tickA: number; tickB: number } {
|
||||
let tickA = a;
|
||||
let tickB = b;
|
||||
if (tickB < tickA) {
|
||||
const t = tickA;
|
||||
tickA = tickB;
|
||||
tickB = t;
|
||||
}
|
||||
const minY = 0;
|
||||
const maxY = STAGE_H;
|
||||
tickA = Math.max(minY, Math.min(tickA, maxY - gap));
|
||||
tickB = Math.min(maxY, Math.max(tickB, tickA + gap));
|
||||
return { tickA, tickB };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { decodeName } from './decodeName';
|
||||
|
||||
test('decodeName: ampersand and common entities', () => {
|
||||
assert.equal(decodeName('Cookies & Burl Slices'), 'Cookies & Burl Slices');
|
||||
assert.equal(decodeName('A < B > C'), 'A < B > C');
|
||||
assert.equal(decodeName('say "hi" 'there''), 'say "hi" \'there\'');
|
||||
assert.equal(decodeName('plain'), 'plain');
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Decode common HTML entities Woo puts in taxonomy display names. */
|
||||
export function decodeName(name: string): string {
|
||||
return name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Shared state for the one-column Create flow (UX.md §1): capture → review →
|
||||
// publish. App.svelte owns it; MeasurePanel hands off the calibrated capture,
|
||||
// Review hands to Publish, Publish clears the device draft on success.
|
||||
//
|
||||
// The flow always concerns a SINGLE slab — there is no list/picker in the
|
||||
// owner path (CAPTURE-25/30).
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import type { PhotosReadyLineupItem } from '../capture/photosReadyLineup';
|
||||
|
||||
export interface CalibratedCapture {
|
||||
lengthIn: number;
|
||||
thicknessIn: number;
|
||||
/** Woo species leaf from Measure; null if unset. Review does not edit. */
|
||||
speciesId: number | null;
|
||||
sqft: number;
|
||||
bdft: number;
|
||||
widthMinIn: number;
|
||||
widthMaxIn: number;
|
||||
widthAvgIn: number;
|
||||
sourceBelowBlock: boolean;
|
||||
sourceBelowWarn: boolean;
|
||||
lineup: PhotosReadyLineupItem[];
|
||||
}
|
||||
|
||||
export type Flow =
|
||||
| { phase: 'capture' }
|
||||
| { phase: 'saving' }
|
||||
| { phase: 'review'; slabId: string; capture: CalibratedCapture }
|
||||
| { phase: 'publish'; slabId: string; capture: CalibratedCapture }
|
||||
| { phase: 'done'; slabId: string; wooProductId: number; draft: boolean };
|
||||
|
||||
const initial: Flow = { phase: 'capture' };
|
||||
|
||||
export const flow = writable<Flow>(initial);
|
||||
|
||||
export function startSaving(): void {
|
||||
flow.set({ phase: 'saving' });
|
||||
}
|
||||
export function enterReview(slabId: string, capture: CalibratedCapture): void {
|
||||
flow.set({ phase: 'review', slabId, capture });
|
||||
}
|
||||
export function enterPublish(slabId: string, capture: CalibratedCapture): void {
|
||||
flow.set({ phase: 'publish', slabId, capture });
|
||||
}
|
||||
export function markDone(slabId: string, wooProductId: number, draft: boolean): void {
|
||||
flow.set({ phase: 'done', slabId, wooProductId, draft });
|
||||
}
|
||||
export function backToReview(slabId: string, capture: CalibratedCapture): void {
|
||||
flow.set({ phase: 'review', slabId, capture });
|
||||
}
|
||||
export function backToCapture(): void {
|
||||
flow.set({ phase: 'capture' });
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
let contentEnabled = false;
|
||||
let reachable = false;
|
||||
let description: string | null = null;
|
||||
let call1Suggestions: Record<string, unknown> | null = null;
|
||||
let shortDescription: string | null = null;
|
||||
let error = '';
|
||||
let busy = false;
|
||||
|
||||
@@ -30,10 +30,14 @@
|
||||
busy = true;
|
||||
error = '';
|
||||
description = null;
|
||||
shortDescription = null;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
if (res.ok) description = body.description;
|
||||
if (res.ok) {
|
||||
description = body.description;
|
||||
shortDescription = body.short_description ?? null;
|
||||
}
|
||||
else error = body.error?.message || `call2 failed (${res.status})`;
|
||||
} catch {
|
||||
error = 'network error';
|
||||
@@ -41,25 +45,6 @@
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function classify() {
|
||||
if (!slabId) {
|
||||
error = 'Enter a slab id.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
call1Suggestions = null;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
if (res.ok) call1Suggestions = body.suggestions;
|
||||
else error = body.error?.message || `call1 failed (${res.status})`;
|
||||
} catch {
|
||||
error = 'network error';
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
checkReadiness();
|
||||
</script>
|
||||
|
||||
@@ -68,6 +53,7 @@
|
||||
<p class="state">
|
||||
Content generation: {contentEnabled ? 'on' : 'off'} · endpoint: {reachable ? 'reachable' : 'unreachable'}
|
||||
</p>
|
||||
<p class="note">Call 1 (vision classification) is disabled on this branch. Call 2 description generation stays.</p>
|
||||
{#if !contentEnabled || !reachable}
|
||||
<p class="note">The Generate text control is disabled: it appears only when content generation is
|
||||
enabled and the inference endpoint is reachable.</p>
|
||||
@@ -78,13 +64,10 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<button on:click={generate} disabled={busy}>{busy ? 'Working…' : 'Generate description (Call 2)'}</button>
|
||||
<button on:click={classify} disabled={busy}>Classify metadata (Call 1)</button>
|
||||
</div>
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
{#if description}<p class="desc">{description}</p>{/if}
|
||||
{#if call1Suggestions}
|
||||
<pre class="sug">{JSON.stringify(call1Suggestions, null, 2)}</pre>
|
||||
{/if}
|
||||
{#if shortDescription}<p class="desc"><strong>Short:</strong> {shortDescription}</p>{/if}
|
||||
{#if description}<p class="desc"><strong>Long:</strong> {description}</p>{/if}
|
||||
{/if}
|
||||
<button class="mini" on:click={checkReadiness}>Recheck readiness</button>
|
||||
</div>
|
||||
@@ -98,6 +81,5 @@
|
||||
.note { color: #e8a; }
|
||||
.err { color: #e88; }
|
||||
.desc { margin-top: 12px; }
|
||||
.sug { background: #1c1c1c; padding: 8px; }
|
||||
.mini { margin-top: 12px; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { uuid } from '../../browser';
|
||||
|
||||
let slabId = '';
|
||||
let photos: {
|
||||
@@ -38,7 +39,7 @@
|
||||
busy = true;
|
||||
message = '';
|
||||
const form = new FormData();
|
||||
form.append('photo_id', photoId || crypto.randomUUID());
|
||||
form.append('photo_id', photoId || uuid());
|
||||
form.append('is_calibration', isCalibration ? '1' : '0');
|
||||
form.append('original', originalFile, originalFile.name);
|
||||
if (processedFile) form.append('processed', processedFile, processedFile.name);
|
||||
|
||||
@@ -1,69 +1,192 @@
|
||||
<script lang="ts">
|
||||
let slabId = '';
|
||||
let result: {
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
woo_product_id?: number;
|
||||
draft?: boolean;
|
||||
error?: { code: string; message: string; detail?: Record<string, unknown> };
|
||||
} | null = null;
|
||||
let busy = false;
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import {
|
||||
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
|
||||
type Slab, type Taxonomy, type PhotoRow,
|
||||
} from '../../api/slabs';
|
||||
import PublishPreview from './PublishPreview.svelte';
|
||||
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
|
||||
|
||||
async function publish() {
|
||||
if (!slabId) {
|
||||
result = { error: { code: 'input', message: 'Enter a slab id.' } };
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
result = null;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
if (res.ok) result = body;
|
||||
else result = { ok: false, error: body.error };
|
||||
} catch {
|
||||
result = { ok: false, error: { code: 'network', message: 'network error' } };
|
||||
}
|
||||
busy = false;
|
||||
// One-column flow: Review hands to Publish by id (UX.md §1).
|
||||
// Idle phase = Publish preview (glanceable confirm) before Woo draft POST.
|
||||
export let slabId: string;
|
||||
export let onPublished: (wooProductId: number, draft: boolean) => void;
|
||||
export let onBack: () => void;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let slab: Slab | null = null;
|
||||
let tax: Taxonomy | null = null;
|
||||
let photos: PhotoRow[] = [];
|
||||
let preview: PublishPreviewModel | null = null;
|
||||
let loading = true;
|
||||
let loadError = '';
|
||||
|
||||
interface PublishState {
|
||||
phase: 'idle' | 'working' | 'success' | 'failure';
|
||||
ok?: { wooProductId: number; draft: boolean };
|
||||
fail?: {
|
||||
code: string | null;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
partialMedia: boolean;
|
||||
duplicateSku: boolean;
|
||||
};
|
||||
}
|
||||
let st: PublishState = { phase: 'idle' };
|
||||
|
||||
// Map backend codes to the recovery branches (PUBLISH-30/31, QUALITY-120).
|
||||
function classify(code: string | null, message: string, detail: Record<string, unknown> | null): PublishState['fail'] {
|
||||
const d = detail ?? {};
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
recoverable: d.recoverable === true,
|
||||
partialMedia: d.partial_media === true,
|
||||
duplicateSku: code === 'duplicate_sku',
|
||||
};
|
||||
}
|
||||
|
||||
function doPublish() {
|
||||
st = { phase: 'working' };
|
||||
publishSlab(slabId)
|
||||
.then((r) => {
|
||||
st = { phase: 'success', ok: { wooProductId: r.woo_product_id, draft: r.draft } };
|
||||
// Mark the flow done (App switches to the Done panel) after a tick so the
|
||||
// success state renders first.
|
||||
onPublished(r.woo_product_id, r.draft);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e instanceof ApiError) {
|
||||
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) };
|
||||
} else {
|
||||
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editSku() { dispatch('back'); }
|
||||
function openExisting() { dispatch('open-existing'); }
|
||||
function backToReview() { onBack(); }
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [s, t, ph] = await Promise.all([
|
||||
getSlab(slabId),
|
||||
getTaxonomy().catch(() => null),
|
||||
listPhotos(slabId).catch(() => [] as PhotoRow[]),
|
||||
]);
|
||||
slab = s;
|
||||
tax = t;
|
||||
photos = ph;
|
||||
preview = buildPublishPreviewModel(
|
||||
s,
|
||||
t,
|
||||
ph,
|
||||
(id, kind) => photoUrl(slabId, id, kind),
|
||||
);
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="publish">
|
||||
<h2>Publish to WooCommerce</h2>
|
||||
<div>
|
||||
<label>Slab ID</label>
|
||||
<input bind:value={slabId} placeholder="uuid" />
|
||||
</div>
|
||||
<button on:click={publish} disabled={busy}>{busy ? 'Publishing…' : 'Publish'}</button>
|
||||
{#if loading}
|
||||
<section class="pub" aria-busy="true" data-testid="publish-loading">
|
||||
<h1>Publish preview</h1>
|
||||
<p class="busy">Loading…</p>
|
||||
</section>
|
||||
{:else if loadError}
|
||||
<section class="pub">
|
||||
<h1>Publish preview</h1>
|
||||
<p class="err" role="alert">{loadError}</p>
|
||||
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="pub" data-testid="publish-screen">
|
||||
<h1>Publish preview</h1>
|
||||
<p class="lede">Confirm what’s about to publish to WooCommerce as a draft.</p>
|
||||
|
||||
{#if result}
|
||||
{#if result.ok}
|
||||
<p class="ok">
|
||||
Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="err">
|
||||
{result.error?.message}
|
||||
{#if result.error?.detail?.recoverable === false}
|
||||
<span class="tag">unrecoverable</span>
|
||||
{:else if result.error?.detail?.recoverable === true}
|
||||
<span class="tag">recoverable — retry</span>
|
||||
{/if}
|
||||
{#if result.error?.detail?.partial_media}
|
||||
<span class="tag warn">orphan media — clean up manually</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if preview && (st.phase === 'idle' || st.phase === 'working')}
|
||||
<PublishPreview model={preview} />
|
||||
{:else if slab && st.phase !== 'idle' && st.phase !== 'working'}
|
||||
<p class="meta">SKU {slab.sku || '—'} · {slab.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if st.phase === 'idle'}
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish}>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
{:else if st.phase === 'working'}
|
||||
<button type="button" class="primary full" data-testid="publish-btn" disabled>
|
||||
Publishing…
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if st.phase === 'success'}
|
||||
<div class="status ok" data-testid="publish-ok">Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}</div>
|
||||
<p class="meta">Photos purged · device draft cleared.</p>
|
||||
<button type="button" class="primary full" data-testid="new-slab" on:click={() => {}}>Start a new slab</button>
|
||||
{:else if st.phase === 'failure' && st.fail}
|
||||
{#if st.fail.duplicateSku}
|
||||
<div class="step-x" data-testid="dup-sku">
|
||||
<div class="x n">✕</div>
|
||||
<h2>SKU already exists</h2>
|
||||
<p class="meta">“{slab?.sku}” is already in the store.</p>
|
||||
</div>
|
||||
<button type="button" class="primary full" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
|
||||
<button type="button" class="ghost full" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
|
||||
{:else if st.fail.code === 'unpublishable'}
|
||||
<div class="step-x" data-testid="not-ready">
|
||||
<div class="x n">✕</div>
|
||||
<h2>Not ready to publish</h2>
|
||||
<p class="meta">Some fields are missing. Go back and finish Review.</p>
|
||||
</div>
|
||||
<button type="button" class="primary full" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||
{:else}
|
||||
<div class="step-x" data-testid="store-failure">
|
||||
<div class="x n" class:rec={st.fail.recoverable}>{st.fail.partialMedia ? '⚠' : '↻'}</div>
|
||||
<h2>{st.fail.message}</h2>
|
||||
{#if st.fail.recoverable}<span class="tag">recoverable</span>{/if}
|
||||
{#if st.fail.partialMedia}<span class="tag warn">orphan media — remove manually</span>{/if}
|
||||
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
|
||||
</div>
|
||||
<button type="button" class="primary full" data-testid="retry-publish" on:click={doPublish}>Retry</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.publish { max-width: 520px; color: #eee; }
|
||||
label { display: block; margin: 8px 0 4px; }
|
||||
input { width: 100%; padding: 6px; }
|
||||
button { margin-top: 12px; }
|
||||
.ok { margin-top: 12px; color: #7fd37f; font-weight: 600; }
|
||||
.err { margin-top: 12px; color: #e88; }
|
||||
.tag { margin-left: 8px; font-size: 12px; padding: 1px 6px; border-radius: 4px; background: #444; }
|
||||
.tag.warn { background: #6a4; color: #000; }
|
||||
/* Match Review margins (UI nits): tight horizontal padding, mobile-first column. */
|
||||
.pub { max-width: 560px; margin: 0 auto; color: #eee; padding: 4px 4px 20px; box-sizing: border-box; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 6px; }
|
||||
h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||
.lede { color: #9a9a9a; font-size: .82rem; margin: 0 0 14px; }
|
||||
.meta { color: #888; font-size: .82rem; margin: 4px 0 10px; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
|
||||
.primary.full, .ghost.full { width: 100%; margin-top: 12px; }
|
||||
.ghost {
|
||||
box-sizing: border-box; padding: 14px 18px; border-radius: 3px;
|
||||
border: 1px solid #444; background: transparent; color: #ccc;
|
||||
font-size: .9rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.status { font-weight: 700; padding: 11px 13px; border-radius: 3px; font-size: .92rem; }
|
||||
.status.ok { background: #122a12; color: #7fd37f; }
|
||||
.step-x { background: #2a1512; border-radius: 3px; padding: 14px; margin: 14px 0; }
|
||||
.x { display: flex; align-items: center; gap: 8px; }
|
||||
.x .n { width: 22px; height: 22px; border-radius: 50%; background: #444; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .8rem; }
|
||||
.x .n.rec { background: #00e5ff; color: #002; }
|
||||
.tag { margin-left: 8px; font-size: .64rem; padding: 1px 6px; border-radius: 2px; background: #444; color: #ccc; }
|
||||
.tag.warn { background: #6a4; color: #000; } .tag.bad { background: #633; color: #ffb4a2; }
|
||||
.err { color: #e88; } .busy { color: #aaa; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { PublishPreviewModel } from './publishPreviewModel';
|
||||
|
||||
export let model: PublishPreviewModel;
|
||||
</script>
|
||||
|
||||
<div class="preview" data-testid="publish-preview">
|
||||
{#if model.photos.length}
|
||||
<div class="thumbs" data-testid="preview-thumbs" aria-label="Photo lineup">
|
||||
{#each model.photos as p, i (p.id)}
|
||||
<div class="thumb" class:primary={p.primary} data-testid="preview-thumb" data-primary={p.primary ? 'true' : ''}>
|
||||
<img src={p.url} alt="Photo {i + 1}" />
|
||||
{#if p.primary}<span class="pri">PRIMARY</span>{/if}
|
||||
<span class="seq" aria-hidden="true">{i + 1}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="head" data-testid="preview-head">
|
||||
<div class="sku" data-testid="preview-sku">SKU {model.sku}</div>
|
||||
<h2 class="title" data-testid="preview-title">{model.title}</h2>
|
||||
<div class="price" data-testid="preview-price">{model.price}</div>
|
||||
</div>
|
||||
|
||||
<dl class="facts" data-testid="preview-facts">
|
||||
{#each model.facts as f (f.label)}
|
||||
<div class="fact" data-testid="preview-fact" data-label={f.label}>
|
||||
<dt>{f.label}</dt>
|
||||
<dd>{f.value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
|
||||
<div class="block" data-testid="preview-short">
|
||||
<div class="lbl">Short description</div>
|
||||
<p class="body short">{model.shortDescription}</p>
|
||||
</div>
|
||||
|
||||
<div class="block" data-testid="preview-long">
|
||||
<div class="lbl">Description</div>
|
||||
<pre class="body long">{model.description}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview { color: #eee; }
|
||||
.thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.thumbs { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background-color: #eee;
|
||||
background-image:
|
||||
linear-gradient(45deg,#ccc 25%,transparent 25%),
|
||||
linear-gradient(-45deg,#ccc 25%,transparent 25%),
|
||||
linear-gradient(45deg,transparent 75%,#ccc 75%),
|
||||
linear-gradient(-45deg,transparent 75%,#ccc 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0,0 8px,8px -8px,-8px 0;
|
||||
}
|
||||
.thumb.primary { border-color: #00e5ff; }
|
||||
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.pri {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: #00e5ff; color: #002;
|
||||
font-size: .52rem; font-weight: 800;
|
||||
padding: 1px 4px; border-radius: 2px; letter-spacing: .03em;
|
||||
}
|
||||
.seq {
|
||||
position: absolute; bottom: 4px; left: 4px;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: rgba(0,0,0,.7); color: #fff;
|
||||
font-size: .62rem; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.head { margin: 0 0 12px; }
|
||||
.sku { color: #888; font-size: .78rem; margin: 0 0 4px; }
|
||||
.title { font-size: 1.15rem; font-weight: 700; margin: 0 0 6px; line-height: 1.3; }
|
||||
.price { font-size: 1.05rem; font-weight: 700; color: #7fd37f; }
|
||||
|
||||
.facts { margin: 0 0 14px; }
|
||||
.fact {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
border-bottom: 1px solid #2a2a2a; padding: 6px 0; font-size: .85rem;
|
||||
}
|
||||
.fact dt { color: #9a9a9a; margin: 0; flex: none; }
|
||||
.fact dd {
|
||||
margin: 0; text-align: right; font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.block { margin: 0 0 14px; }
|
||||
.lbl { display: block; font-size: .78rem; color: #bbb; margin: 0 0 4px; }
|
||||
.body {
|
||||
margin: 0; padding: 10px 12px;
|
||||
background: #141414; border: 1px solid #333; border-radius: 3px;
|
||||
color: #ddd; font-size: .86rem; line-height: 1.45;
|
||||
}
|
||||
.short { white-space: pre-wrap; word-break: break-word; }
|
||||
.long {
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
font-family: inherit; max-height: 320px; overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const publishSrc = readFileSync(join(dir, 'Publish.svelte'), 'utf8');
|
||||
const previewSrc = readFileSync(join(dir, 'PublishPreview.svelte'), 'utf8');
|
||||
const modelSrc = readFileSync(join(dir, 'publishPreviewModel.ts'), 'utf8');
|
||||
|
||||
test('Publish uses dedicated PublishPreview before Woo POST', () => {
|
||||
assert.match(publishSrc, /import PublishPreview from '\.\/PublishPreview\.svelte'/);
|
||||
assert.match(publishSrc, /buildPublishPreviewModel/);
|
||||
assert.match(publishSrc, /data-testid="publish-preview"|PublishPreview/);
|
||||
// Idle confirm still posts only via doPublish / publish-btn
|
||||
assert.match(publishSrc, /data-testid="publish-btn"/);
|
||||
assert.match(publishSrc, /data-testid="back-review"/);
|
||||
assert.match(publishSrc, /publishSlab\(slabId\)/);
|
||||
});
|
||||
|
||||
test('PublishPreview layout: thumbs → head → facts → short → long', () => {
|
||||
assert.match(previewSrc, /data-testid="publish-preview"/);
|
||||
assert.match(previewSrc, /data-testid="preview-thumbs"/);
|
||||
assert.match(previewSrc, /data-testid="preview-head"/);
|
||||
assert.match(previewSrc, /data-testid="preview-sku"/);
|
||||
assert.match(previewSrc, /data-testid="preview-title"/);
|
||||
assert.match(previewSrc, /data-testid="preview-price"/);
|
||||
assert.match(previewSrc, /data-testid="preview-facts"/);
|
||||
assert.match(previewSrc, /data-testid="preview-short"/);
|
||||
assert.match(previewSrc, /data-testid="preview-long"/);
|
||||
// Plain-text long description (pre-wrap), not HTML render
|
||||
assert.match(previewSrc, /white-space:\s*pre-wrap/);
|
||||
assert.match(previewSrc, /<pre class="body long"/);
|
||||
});
|
||||
|
||||
test('Preview model resolves taxonomy names; does not invent fields', () => {
|
||||
assert.match(modelSrc, /species_id/);
|
||||
assert.match(modelSrc, /wood_category_ids/);
|
||||
assert.match(modelSrc, /edge_type_term_id/);
|
||||
assert.match(modelSrc, /figure_term_ids/);
|
||||
assert.match(modelSrc, /grade_term_id/);
|
||||
assert.match(modelSrc, /moisture_term_id/);
|
||||
assert.match(modelSrc, /feat_tag_ids/);
|
||||
assert.match(modelSrc, /width_min_in/);
|
||||
assert.match(modelSrc, /bdft/);
|
||||
assert.match(modelSrc, /decodeName/);
|
||||
// No invented brand/voice fields
|
||||
assert.equal(modelSrc.includes('brand_voice'), false);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
buildPublishPreviewModel,
|
||||
formatWidths,
|
||||
} from './publishPreviewModel';
|
||||
import type { PhotoRow, Slab, Taxonomy } from '../../api/slabs';
|
||||
|
||||
function baseSlab(over: Partial<Slab> = {}): Slab {
|
||||
return {
|
||||
id: 's1',
|
||||
sku: 'SLAB-99',
|
||||
status: 'ready',
|
||||
price: 420.5,
|
||||
price_source: 'recommendation',
|
||||
length_in: 96,
|
||||
thickness_in: 2,
|
||||
sqft: 8,
|
||||
bdft: 16,
|
||||
width_min_in: 12,
|
||||
width_max_in: 18,
|
||||
width_avg_in: 15,
|
||||
species_id: 10,
|
||||
wood_category_ids: [20, 21],
|
||||
edge_type_term_id: 30,
|
||||
figure_term_ids: [40],
|
||||
grade_term_id: 50,
|
||||
thickness_term_id: 60,
|
||||
moisture_term_id: 70,
|
||||
fig_tag_ids: [],
|
||||
feat_tag_ids: [80],
|
||||
title: 'Live Edge Cedar Slab',
|
||||
short_description: '96″ × 12–18″ × 2″ cedar',
|
||||
description: 'A solid cedar slab.\n\n- Kiln dried\n- Live edge',
|
||||
content_source: 'llm',
|
||||
species_confidence: null,
|
||||
figure_confidence: null,
|
||||
grade_confidence: null,
|
||||
inference_status: null,
|
||||
calibration_photo_id: null,
|
||||
woo_product_id: null,
|
||||
client_rev: 1,
|
||||
server_rev: 2,
|
||||
created_at: 't',
|
||||
updated_at: 't',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const tax: Taxonomy = {
|
||||
categories: [
|
||||
{ id: 1, name: 'Species', slug: 'species', parent_id: null, woo_id: null },
|
||||
{ id: 10, name: 'Western Red Cedar', slug: 'wrc', parent_id: 1, woo_id: null },
|
||||
{ id: 2, name: 'wcat', slug: 'wcat', parent_id: null, woo_id: null },
|
||||
{ id: 20, name: 'Live edge wood', slug: 'liveedge', parent_id: 2, woo_id: null },
|
||||
{ id: 21, name: 'Slabs & Lumber', slug: 'snl', parent_id: 2, woo_id: null },
|
||||
],
|
||||
attributes: [
|
||||
{
|
||||
id: 1, name: 'Edge type', slug: 'edge',
|
||||
terms: [{ id: 30, name: 'Live Edge', slug: 'le' }],
|
||||
},
|
||||
{
|
||||
id: 2, name: 'Figure', slug: 'figure',
|
||||
terms: [{ id: 40, name: 'Quilted', slug: 'quilt' }],
|
||||
},
|
||||
{
|
||||
id: 3, name: 'Grade', slug: 'grade',
|
||||
terms: [{ id: 50, name: 'Select', slug: 'sel' }],
|
||||
},
|
||||
{
|
||||
id: 4, name: 'Moisture', slug: 'moisture',
|
||||
terms: [{ id: 70, name: 'Kiln dried', slug: 'kd' }],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{ id: 80, name: 'Bookmatched', slug: 'feat-bookmatched' },
|
||||
],
|
||||
taxonomy_anchor: null,
|
||||
taxonomy_last_sync_at: null,
|
||||
};
|
||||
|
||||
const photos: PhotoRow[] = [
|
||||
{
|
||||
id: 'p2', seq: 2, is_calibration: 0, role: 'batch',
|
||||
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
|
||||
},
|
||||
{
|
||||
id: 'p1', seq: 1, is_calibration: 1, role: 'calibration',
|
||||
width_px: 100, height_px: 100, has_original: 1, has_processed: 0,
|
||||
},
|
||||
];
|
||||
|
||||
test('formatWidths joins min–max and avg', () => {
|
||||
assert.equal(formatWidths(baseSlab()), '12–18″ (avg 15″)');
|
||||
});
|
||||
|
||||
test('buildPublishPreviewModel: ordered thumbs, decoded names, all facts', () => {
|
||||
const m = buildPublishPreviewModel(baseSlab(), tax, photos, (id, kind) => `/p/${id}/${kind}`);
|
||||
assert.deepEqual(m.photos.map((p) => p.id), ['p1', 'p2']);
|
||||
assert.equal(m.photos[0].primary, true);
|
||||
assert.equal(m.photos[0].url, '/p/p1/original');
|
||||
assert.equal(m.photos[1].url, '/p/p2/processed');
|
||||
assert.equal(m.sku, 'SLAB-99');
|
||||
assert.equal(m.title, 'Live Edge Cedar Slab');
|
||||
assert.equal(m.price, '$420.50');
|
||||
assert.equal(m.shortDescription, '96″ × 12–18″ × 2″ cedar');
|
||||
assert.match(m.description, /Kiln dried/);
|
||||
|
||||
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
|
||||
assert.equal(by.Species, 'Western Red Cedar');
|
||||
assert.equal(by['Wood categories'], 'Live edge wood, Slabs & Lumber');
|
||||
assert.equal(by.Edge, 'Live Edge');
|
||||
assert.equal(by.Figure, 'Quilted');
|
||||
assert.equal(by.Grade, 'Select');
|
||||
assert.equal(by.Moisture, 'Kiln dried');
|
||||
assert.equal(by.Features, 'Bookmatched');
|
||||
assert.equal(by.Length, '96″');
|
||||
assert.equal(by.Widths, '12–18″ (avg 15″)');
|
||||
assert.equal(by.Thickness, '2″');
|
||||
assert.equal(by.Bdft, '16.00');
|
||||
});
|
||||
|
||||
test('buildPublishPreviewModel: empty optional fields show em dash', () => {
|
||||
const m = buildPublishPreviewModel(
|
||||
baseSlab({
|
||||
figure_term_ids: [],
|
||||
feat_tag_ids: [],
|
||||
edge_type_term_id: null,
|
||||
title: null,
|
||||
short_description: null,
|
||||
description: null,
|
||||
price: null,
|
||||
sku: '',
|
||||
}),
|
||||
tax,
|
||||
[],
|
||||
() => '',
|
||||
);
|
||||
assert.equal(m.photos.length, 0);
|
||||
assert.equal(m.title, '—');
|
||||
assert.equal(m.sku, '—');
|
||||
assert.equal(m.price, '—');
|
||||
assert.equal(m.shortDescription, '—');
|
||||
assert.equal(m.description, '—');
|
||||
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
|
||||
assert.equal(by.Edge, '—');
|
||||
assert.equal(by.Figure, '—');
|
||||
assert.equal(by.Features, '—');
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Pure builders for the Publish preview confirmation screen.
|
||||
* Resolves taxonomy ids → display names; never invents fields beyond the slab.
|
||||
*/
|
||||
import { decodeName } from '../decodeName';
|
||||
import type { PhotoRow, Slab, Taxonomy, TaxAttribute, TaxCategory, TaxTag } from '../../api/slabs';
|
||||
|
||||
export interface PreviewPhoto {
|
||||
id: string;
|
||||
url: string;
|
||||
role: 'calibration' | 'overlay' | 'batch';
|
||||
/** Leftmost (seq order) = Woo cover. */
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
export interface PreviewFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PublishPreviewModel {
|
||||
photos: PreviewPhoto[];
|
||||
sku: string;
|
||||
title: string;
|
||||
price: string;
|
||||
facts: PreviewFact[];
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const fmt1 = (v: number) => {
|
||||
const s = v.toFixed(1);
|
||||
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||||
};
|
||||
const fmt2 = (v: number) => v.toFixed(2);
|
||||
|
||||
function catName(cats: TaxCategory[], id: number | null | undefined): string {
|
||||
if (id == null) return '—';
|
||||
const c = cats.find((x) => x.id === id);
|
||||
return c ? decodeName(c.name) : `#${id}`;
|
||||
}
|
||||
|
||||
function termName(attrs: TaxAttribute[], id: number | null | undefined): string {
|
||||
if (id == null) return '—';
|
||||
for (const a of attrs) {
|
||||
const t = a.terms.find((x) => x.id === id);
|
||||
if (t) return decodeName(t.name);
|
||||
}
|
||||
return `#${id}`;
|
||||
}
|
||||
|
||||
function attrTermsByName(attrs: TaxAttribute[], name: string): { id: number; name: string }[] {
|
||||
const attr = attrs.find((a) => a.name.toLowerCase() === name.toLowerCase());
|
||||
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: decodeName(t.name) }));
|
||||
}
|
||||
|
||||
function namesForIds(
|
||||
ids: number[],
|
||||
lookup: (id: number) => string | null,
|
||||
): string {
|
||||
if (!ids.length) return '—';
|
||||
return ids.map((id) => lookup(id) ?? `#${id}`).join(', ');
|
||||
}
|
||||
|
||||
function tagName(tags: TaxTag[], id: number): string | null {
|
||||
const t = tags.find((x) => x.id === id);
|
||||
return t ? decodeName(t.name) : null;
|
||||
}
|
||||
|
||||
/** Widths as "min–max (avg)" inches, matching Review dims language. */
|
||||
export function formatWidths(slab: Pick<Slab, 'width_min_in' | 'width_max_in' | 'width_avg_in'>): string {
|
||||
return `${fmt1(slab.width_min_in)}–${fmt1(slab.width_max_in)}″ (avg ${fmt1(slab.width_avg_in)}″)`;
|
||||
}
|
||||
|
||||
export function buildPublishPreviewModel(
|
||||
slab: Slab,
|
||||
tax: Taxonomy | null,
|
||||
photos: PhotoRow[],
|
||||
urlFor: (photoId: string, kind: 'original' | 'processed') => string,
|
||||
): PublishPreviewModel {
|
||||
const cats = tax?.categories ?? [];
|
||||
const attrs = tax?.attributes ?? [];
|
||||
const tags = tax?.tags ?? [];
|
||||
|
||||
const figureTerms = attrTermsByName(attrs, 'figure');
|
||||
const figureLookup = (id: number) => {
|
||||
const t = figureTerms.find((x) => x.id === id);
|
||||
return t ? t.name : null;
|
||||
};
|
||||
|
||||
const ordered = [...photos].sort((a, b) => a.seq - b.seq);
|
||||
const previewPhotos: PreviewPhoto[] = ordered.map((p, i) => ({
|
||||
id: p.id,
|
||||
url: urlFor(p.id, p.has_processed ? 'processed' : 'original'),
|
||||
role: p.role ?? 'batch',
|
||||
primary: i === 0,
|
||||
}));
|
||||
|
||||
const woodNames = namesForIds(slab.wood_category_ids ?? [], (id) => {
|
||||
const c = cats.find((x) => x.id === id);
|
||||
return c ? decodeName(c.name) : null;
|
||||
});
|
||||
|
||||
const facts: PreviewFact[] = [
|
||||
{ label: 'Species', value: catName(cats, slab.species_id) },
|
||||
{ label: 'Wood categories', value: woodNames },
|
||||
{ label: 'Edge', value: termName(attrs, slab.edge_type_term_id) },
|
||||
{
|
||||
label: 'Figure',
|
||||
value: namesForIds(slab.figure_term_ids ?? [], figureLookup),
|
||||
},
|
||||
{ label: 'Grade', value: termName(attrs, slab.grade_term_id) },
|
||||
{ label: 'Moisture', value: termName(attrs, slab.moisture_term_id) },
|
||||
{
|
||||
label: 'Features',
|
||||
value: namesForIds(slab.feat_tag_ids ?? [], (id) => tagName(tags, id)),
|
||||
},
|
||||
{ label: 'Length', value: `${fmt1(slab.length_in)}″` },
|
||||
{ label: 'Widths', value: formatWidths(slab) },
|
||||
{ label: 'Thickness', value: `${fmt1(slab.thickness_in)}″` },
|
||||
{ label: 'Bdft', value: fmt2(slab.bdft) },
|
||||
];
|
||||
|
||||
return {
|
||||
photos: previewPhotos,
|
||||
sku: slab.sku || '—',
|
||||
title: slab.title || '—',
|
||||
price: slab.price != null ? `$${slab.price.toFixed(2)}` : '—',
|
||||
facts,
|
||||
shortDescription: (slab.short_description ?? '').trim() || '—',
|
||||
description: (slab.description ?? '').trim() || '—',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
|
||||
// Squared/slight-radius, type-to-filter, select-all, live count badge, per-row
|
||||
// AI bubble on AI-recommended options. Works for 6 items or 60.
|
||||
//
|
||||
// Two modes:
|
||||
// - flat: pass `options` (single-level list).
|
||||
// - grouped: pass `groups` (parent/child). Parents are non-selectable header rows
|
||||
// (METADATA-10: "parents group only and are never assigned"); only the
|
||||
// leaf rows are selectable. Filtering hides a parent group when none of
|
||||
// its leaves match.
|
||||
// #90 closed state shows selected NAMES (not only a count). Done dispatches
|
||||
// `commit` so the parent can persist + re-review.
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import { selectedSummary } from './woodPrimary';
|
||||
|
||||
export interface Opt { id: number; name: string; ai: boolean }
|
||||
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
|
||||
|
||||
export let label: string;
|
||||
export let options: Opt[] = [];
|
||||
// Optional grouped shape. When provided, `options` is ignored and the panel renders
|
||||
// parent headers + selectable leaf rows.
|
||||
export let groups: Group[] = [];
|
||||
export let selected: number[];
|
||||
export let placeholder = 'Choose…';
|
||||
export let testid = '';
|
||||
export let onToggle: (id: number) => void;
|
||||
// #90: commit (persist + re-review) after a toggle settles. The parent wires this
|
||||
// so multi-select changes flush to the slab immediately — no need to touch another
|
||||
// field first. Debounced so a quick multi-tap doesn't fire a commit per click.
|
||||
export let onCommit: (() => void) | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let open = false;
|
||||
let filter = '';
|
||||
let focused: number | null = null;
|
||||
let commitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const grouped = groups.length > 0;
|
||||
|
||||
// Effective selectable set (leaves when grouped, options when flat).
|
||||
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
|
||||
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
|
||||
$: q = filter.trim().toLowerCase();
|
||||
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
|
||||
// Grouped: keep a parent only if at least one of its leaves survives the filter.
|
||||
$: filteredGroups = grouped
|
||||
? groups
|
||||
.map((g) => ({ parent: g.parent, leaves: q ? g.leaves.filter((o) => o.name.toLowerCase().includes(q)) : g.leaves }))
|
||||
.filter((g) => g.leaves.length > 0)
|
||||
: [];
|
||||
$: count = selected.length;
|
||||
$: summary = selectedSummary(selected, nameById, placeholder);
|
||||
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
|
||||
|
||||
function scheduleCommit() {
|
||||
if (typeof onCommit !== 'function') return;
|
||||
if (commitTimer) clearTimeout(commitTimer);
|
||||
commitTimer = setTimeout(() => { commitTimer = undefined; onCommit(); }, 250);
|
||||
}
|
||||
function toggle(id: number) {
|
||||
onToggle(id);
|
||||
focused = id;
|
||||
scheduleCommit(); // #90: persist + re-review without waiting for another field
|
||||
}
|
||||
function selectAll() {
|
||||
for (const o of filteredFlat) if (!selected.includes(o.id)) onToggle(o.id);
|
||||
scheduleCommit();
|
||||
}
|
||||
function clearAll() {
|
||||
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
|
||||
scheduleCommit();
|
||||
}
|
||||
function close() {
|
||||
open = false;
|
||||
filter = '';
|
||||
if (commitTimer) { clearTimeout(commitTimer); commitTimer = undefined; }
|
||||
if (typeof onCommit === 'function') onCommit(); // flush immediately on Done
|
||||
dispatch('commit');
|
||||
}
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
|
||||
function destroy() { if (commitTimer) clearTimeout(commitTimer); }
|
||||
onDestroy(destroy);
|
||||
// Rows are focusable options (role=option): Enter/Space toggles, matching click.
|
||||
function onRowKey(e: KeyboardEvent, id: number) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
|
||||
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
|
||||
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>
|
||||
{open
|
||||
? 'Choose ' + label.toLowerCase()
|
||||
: summary !== ''
|
||||
? summary
|
||||
: placeholder}
|
||||
</span>
|
||||
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
|
||||
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="ms-panel" role="listbox" aria-multiselectable="true">
|
||||
<div class="ms-tools">
|
||||
<input class="ms-search" type="text" placeholder="Filter…" bind:value={filter}
|
||||
data-testid={testid ? `${testid}-filter` : 'ms-filter'} aria-label={`Filter ${label}`} />
|
||||
<button type="button" class="ms-all" data-testid={testid ? `${testid}-all` : 'ms-all'}
|
||||
on:click={allFilteredSelected ? clearAll : selectAll}>
|
||||
{allFilteredSelected ? 'None' : 'All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if grouped}
|
||||
<ul class="ms-list">
|
||||
{#each filteredGroups as g (g.parent.id)}
|
||||
<li class="ms-group" aria-hidden="true">{g.parent.name}</li>
|
||||
{#each g.leaves as o (o.id)}
|
||||
<li
|
||||
class="ms-row leaf"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<li class="ms-empty">No matches</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<ul class="ms-list">
|
||||
{#each filteredFlat as o (o.id)}
|
||||
<li
|
||||
class="ms-row"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="ms-empty">No matches</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="ms-foot">
|
||||
<span>{allOpts.length} synced{filter.trim() ? ` · ${filteredFlat.length} shown` : ''}</span>
|
||||
<button type="button" on:click={close} data-testid={testid ? `${testid}-done` : 'ms-done'}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
|
||||
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
|
||||
.ms-btn .ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eee; }
|
||||
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
|
||||
.ms-arrow { color: #777; }
|
||||
.ms-panel { border-top: 1px solid #333; background: #161616; }
|
||||
.ms-tools { display: flex; gap: 8px; padding: 8px; align-items: center; }
|
||||
.ms-search { flex: 1; padding: 7px 9px; background: #0f0f0f; border: 1px solid #3a3a3a; border-radius: 3px; color: #eee; font-size: .85rem; }
|
||||
.ms-all { font-size: .74rem; color: #00e5ff; background: none; border: 0; cursor: pointer; white-space: nowrap; padding: 4px; }
|
||||
.ms-list { list-style: none; margin: 0; padding: 0; max-height: 200px; overflow-y: auto; }
|
||||
.ms-row { display: flex; align-items: center; gap: 9px; padding: 9px 12px; font-size: .9rem; border-top: 1px solid #222; cursor: pointer; }
|
||||
.ms-row:hover { background: #1f1f1f; }
|
||||
.ms-row.on { background: #12262b; }
|
||||
.ms-row.on .nm { color: #fff; }
|
||||
.ms-row .nm { flex: 1; display: flex; align-items: center; gap: 6px; }
|
||||
/* grouped: indented leaves under a non-selectable parent header */
|
||||
.ms-group { padding: 8px 12px 3px; font-size: .72rem; font-weight: 700; letter-spacing: .4px;
|
||||
text-transform: uppercase; color: #7f8a8a; background: #101010; border-top: 1px solid #222; }
|
||||
.ms-row.leaf { padding-left: 26px; }
|
||||
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
|
||||
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
|
||||
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
|
||||
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
canCreateDesc,
|
||||
missingCreateDesc,
|
||||
missingCreateDescLabels,
|
||||
} from './createDescGate';
|
||||
|
||||
const full = {
|
||||
contentLlmEnabled: true,
|
||||
title: 'Figured Walnut Live Edge Slab',
|
||||
speciesId: 11,
|
||||
woodCategoryIds: [21],
|
||||
primaryCatId: 21,
|
||||
edgeTermId: '321',
|
||||
figureTermIds: [328],
|
||||
gradeTermId: 342,
|
||||
moistureTermId: '326',
|
||||
};
|
||||
|
||||
test('Create desc gate: full set passes; does not require short or description', () => {
|
||||
assert.equal(canCreateDesc(full), true);
|
||||
assert.deepEqual(missingCreateDesc(full), []);
|
||||
});
|
||||
|
||||
test('Create desc gate: empty price still passes when other fields full', () => {
|
||||
// Price is publish-only; Create desc must not gate on it.
|
||||
assert.equal(canCreateDesc(full), true);
|
||||
assert.deepEqual(missingCreateDesc(full), []);
|
||||
});
|
||||
|
||||
test('Create desc gate: lists each missing field; no short/description/price key', () => {
|
||||
const miss = missingCreateDesc({
|
||||
...full,
|
||||
contentLlmEnabled: false,
|
||||
title: '',
|
||||
speciesId: null,
|
||||
woodCategoryIds: [],
|
||||
primaryCatId: '',
|
||||
edgeTermId: '',
|
||||
figureTermIds: [],
|
||||
gradeTermId: '',
|
||||
moistureTermId: '',
|
||||
});
|
||||
assert.deepEqual(miss, [
|
||||
'content_llm',
|
||||
'title',
|
||||
'species',
|
||||
'wood_categories',
|
||||
'primary',
|
||||
'edge',
|
||||
'figure',
|
||||
'grade',
|
||||
'moisture',
|
||||
]);
|
||||
assert.equal(miss.includes('short_description'), false);
|
||||
assert.equal(miss.includes('description'), false);
|
||||
assert.equal(miss.includes('price'), false);
|
||||
const labels = missingCreateDescLabels({
|
||||
...full,
|
||||
figureTermIds: [],
|
||||
});
|
||||
assert.ok(labels.includes('≥1 figure'));
|
||||
assert.equal(labels.includes('price'), false);
|
||||
});
|
||||
|
||||
test('Create desc gate: recomputes on field change (reactive-ready)', () => {
|
||||
assert.equal(canCreateDesc({ ...full, figureTermIds: [] }), false);
|
||||
assert.equal(canCreateDesc({ ...full, figureTermIds: [1] }), true);
|
||||
assert.equal(canCreateDesc({ ...full, moistureTermId: '' }), false);
|
||||
assert.equal(canCreateDesc({ ...full, moistureTermId: 326 }), true);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Create-desc gate (Call 2): disable Create desc unless these are present.
|
||||
* Does NOT require short_description, description, or price — Call 2 writes
|
||||
* short + long; price stays publish-only.
|
||||
*/
|
||||
|
||||
export type CreateDescInput = {
|
||||
contentLlmEnabled: boolean;
|
||||
title: string;
|
||||
speciesId: number | null | undefined;
|
||||
woodCategoryIds: number[];
|
||||
primaryCatId: number | '' | null | undefined;
|
||||
edgeTermId: number | string | '' | null | undefined;
|
||||
figureTermIds: number[];
|
||||
gradeTermId: number | string | '' | null | undefined;
|
||||
moistureTermId: number | string | '' | null | undefined;
|
||||
};
|
||||
|
||||
/** Human labels shown under Create desc when the gate fails. */
|
||||
export const CREATE_DESC_LABELS: Record<string, string> = {
|
||||
content_llm: 'content LLM on',
|
||||
title: 'title',
|
||||
species: 'species',
|
||||
wood_categories: '≥1 wood category',
|
||||
primary: 'primary wood category',
|
||||
edge: 'edge type',
|
||||
figure: '≥1 figure',
|
||||
grade: 'grade',
|
||||
moisture: 'moisture',
|
||||
};
|
||||
|
||||
function presentId(v: number | string | '' | null | undefined): boolean {
|
||||
if (v === null || v === undefined || v === '') return false;
|
||||
if (typeof v === 'number') return Number.isFinite(v);
|
||||
return String(v).trim() !== '';
|
||||
}
|
||||
|
||||
/** Return missing field keys (stable order). Empty ⇒ Create desc enabled. */
|
||||
export function missingCreateDesc(input: CreateDescInput): string[] {
|
||||
const missing: string[] = [];
|
||||
if (!input.contentLlmEnabled) missing.push('content_llm');
|
||||
if (!String(input.title ?? '').trim()) missing.push('title');
|
||||
if (input.speciesId == null || !Number.isFinite(Number(input.speciesId))) missing.push('species');
|
||||
if (!input.woodCategoryIds?.length) missing.push('wood_categories');
|
||||
if (!presentId(input.primaryCatId)) missing.push('primary');
|
||||
if (!presentId(input.edgeTermId)) missing.push('edge');
|
||||
if (!input.figureTermIds?.length) missing.push('figure');
|
||||
if (!presentId(input.gradeTermId)) missing.push('grade');
|
||||
if (!presentId(input.moistureTermId)) missing.push('moisture');
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function canCreateDesc(input: CreateDescInput): boolean {
|
||||
return missingCreateDesc(input).length === 0;
|
||||
}
|
||||
|
||||
export function missingCreateDescLabels(input: CreateDescInput): string[] {
|
||||
return missingCreateDesc(input).map((k) => CREATE_DESC_LABELS[k] ?? k);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
deriveFigTagIds,
|
||||
normName,
|
||||
scopedFeatTags,
|
||||
scopedFigTags,
|
||||
} from './figTags';
|
||||
|
||||
test('scoped tags require hyphenated slug prefix (not bare fig/feat)', () => {
|
||||
const tags = [
|
||||
{ id: 1, name: 'Curl', slug: 'fig-curl' },
|
||||
{ id: 2, name: 'Knot', slug: 'feat-knot' },
|
||||
{ id: 3, name: 'Figured', slug: 'figured' }, // no hyphen — out
|
||||
{ id: 4, name: 'Featured', slug: 'featured' },
|
||||
{ id: 5, name: 'fig-fake-name', slug: 'other' },
|
||||
];
|
||||
assert.deepEqual(scopedFigTags(tags).map((t) => t.id), [1]);
|
||||
assert.deepEqual(scopedFeatTags(tags).map((t) => t.id), [2]);
|
||||
});
|
||||
|
||||
test('deriveFigTagIds: casefold/strip name equality; Curl≠Curly', () => {
|
||||
const terms = [
|
||||
{ id: 10, name: 'Curl' },
|
||||
{ id: 11, name: ' Birdseye ' },
|
||||
];
|
||||
const tags = [
|
||||
{ id: 100, name: 'curl', slug: 'fig-curl' },
|
||||
{ id: 101, name: 'Curly', slug: 'fig-curly' },
|
||||
{ id: 102, name: 'Birdseye', slug: 'fig-birdseye' },
|
||||
{ id: 103, name: 'Knot', slug: 'feat-knot' },
|
||||
];
|
||||
assert.deepEqual(deriveFigTagIds([10], terms, tags), [100]);
|
||||
assert.deepEqual(deriveFigTagIds([10, 11], terms, tags).sort(), [100, 102]);
|
||||
// Curly tag stays unmatched when only Curl is selected
|
||||
assert.ok(!deriveFigTagIds([10], terms, tags).includes(101));
|
||||
assert.deepEqual(deriveFigTagIds([], terms, tags), []);
|
||||
});
|
||||
|
||||
test('normName strips and lowercases', () => {
|
||||
assert.equal(normName(' Curl '), 'curl');
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Derive fig-* product tag ids from selected Figure attribute terms.
|
||||
* Match is case-insensitive name equality (casefold/strip) against tags whose
|
||||
* slug starts with `fig-`. No hardcoded name bridges (Curl ≠ Curly).
|
||||
*/
|
||||
export function normName(name: string): string {
|
||||
// JS has no String.casefold; toLowerCase covers ASCII wood-term names.
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function scopedFigTags<T extends { slug: string }>(tags: T[]): T[] {
|
||||
return tags.filter((t) => t.slug.startsWith('fig-'));
|
||||
}
|
||||
|
||||
export function scopedFeatTags<T extends { slug: string }>(tags: T[]): T[] {
|
||||
return tags.filter((t) => t.slug.startsWith('feat-'));
|
||||
}
|
||||
|
||||
export function deriveFigTagIds(
|
||||
figureTermIds: number[],
|
||||
figureTerms: { id: number; name: string }[],
|
||||
tags: { id: number; name: string; slug: string }[],
|
||||
): number[] {
|
||||
const selected = new Set(
|
||||
figureTerms
|
||||
.filter((t) => figureTermIds.includes(t.id))
|
||||
.map((t) => normName(t.name)),
|
||||
);
|
||||
if (selected.size === 0) return [];
|
||||
return scopedFigTags(tags)
|
||||
.filter((t) => selected.has(normName(t.name)))
|
||||
.map((t) => t.id);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
keptInOrder,
|
||||
moveItem,
|
||||
missingMinimums,
|
||||
lineupValid,
|
||||
roleLabel,
|
||||
resolvePhotoUrl,
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
|
||||
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
|
||||
id,
|
||||
role,
|
||||
isCalibration: role === 'calibration',
|
||||
kept,
|
||||
});
|
||||
|
||||
test('lineup: keptInOrder returns kept photos in their current order', () => {
|
||||
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
|
||||
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
|
||||
});
|
||||
|
||||
test('lineup: leftmost kept is the primary', () => {
|
||||
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
|
||||
// After a drag the owner re-ordered; primary follows position, not role.
|
||||
assert.equal(keptInOrder(items)[0].id, 'b1');
|
||||
});
|
||||
|
||||
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
|
||||
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
|
||||
assert.equal(keptInOrder(items)[0].id, 'ov');
|
||||
});
|
||||
|
||||
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
|
||||
const a = ['x', 'y', 'z'];
|
||||
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
|
||||
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
|
||||
// Out-of-range is a no-op, never throws.
|
||||
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
|
||||
// Returns a new array, does not mutate the input.
|
||||
const before = a.slice();
|
||||
moveItem(a, 0, 2);
|
||||
assert.deepEqual(a, before);
|
||||
});
|
||||
|
||||
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
|
||||
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
|
||||
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
|
||||
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
|
||||
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
|
||||
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
|
||||
});
|
||||
|
||||
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
|
||||
assert.equal(lineupValid([p('b1', 'batch')]), false);
|
||||
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
|
||||
});
|
||||
|
||||
test('lineup: missingMinimums only counts KEPT photos', () => {
|
||||
// Both roles present in the array, but the cal is not kept.
|
||||
assert.deepEqual(
|
||||
missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]),
|
||||
['calibration'],
|
||||
);
|
||||
});
|
||||
|
||||
test('lineup: roleLabel is owner-facing', () => {
|
||||
assert.match(roleLabel('calibration'), /clean/);
|
||||
assert.match(roleLabel('overlay'), /overlay/);
|
||||
});
|
||||
|
||||
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
|
||||
// The first/Primary (calibration) photo carries a fresh capture blob. It must
|
||||
// become a valid object URL — never a raw Blob, never String(blob)="object Blob".
|
||||
const blob = new Blob(['png-bytes'], { type: 'image/png' });
|
||||
const fake = (b: Blob) => `blob:fake-${b.size}`;
|
||||
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
|
||||
// Empty/absent blob falls back to the stored endpoint.
|
||||
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// #90 Review photo strip (REVIEW-5 / PUBLISH-45). Pure logic split out so the
|
||||
// keep/remove + reorder + minimum enforcement is unit-testable without a browser.
|
||||
//
|
||||
// Model (approved in the wireframe): the owner taps a photo to keep/remove it,
|
||||
// drags to reorder, and the LEFTMOST kept photo is the primary (Woo cover). The
|
||||
// submission-ready minimum is at least one clean (calibration) photo AND one
|
||||
// dimension-overlay copy (REVIEW-5 "clean first pic plus its dimension-overlay
|
||||
// copy"). Batch photos are optional.
|
||||
|
||||
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
|
||||
|
||||
export interface LineupItem {
|
||||
id: string;
|
||||
role: PhotoRole;
|
||||
isCalibration: boolean;
|
||||
/** True = the owner keeps this photo in the submission. */
|
||||
kept: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kept photos in their current (left→right) order. The first kept photo is
|
||||
* the primary (Woo cover). Reordering is a plain array move; this helper just
|
||||
* exposes the kept subset so the caller can re-seq on persist.
|
||||
*/
|
||||
export function keptInOrder(items: LineupItem[]): LineupItem[] {
|
||||
return items.filter((it) => it.kept);
|
||||
}
|
||||
|
||||
/** Move an item from one index to another (drag-reorder). Returns a new array. */
|
||||
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
|
||||
return arr;
|
||||
}
|
||||
const next = arr.slice();
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
|
||||
* clean calibration photo and one dimension-overlay copy. Returns the missing
|
||||
* role(s) so the UI can name them in a nudge.
|
||||
*/
|
||||
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
|
||||
const kept = items.filter((it) => it.kept);
|
||||
const missing: PhotoRole[] = [];
|
||||
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
|
||||
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** True when the kept lineup is publishable from a photo standpoint. */
|
||||
export function lineupValid(kept: LineupItem[]): boolean {
|
||||
return missingMinimums(kept).length === 0;
|
||||
}
|
||||
|
||||
/** A short owner-facing label for a role (used in the minimum nudge). */
|
||||
export function roleLabel(role: PhotoRole): string {
|
||||
if (role === 'calibration') return 'a clean photo';
|
||||
if (role === 'overlay') return 'its dimension-overlay copy';
|
||||
return 'a batch photo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the <img> src for a strip photo. The in-memory capture blob (fresh
|
||||
* photo) must become a `blob:` object URL — assigning a raw Blob (or
|
||||
* `String(blob)` = "object Blob") to `src` renders a broken image. That was
|
||||
* bug #1: the first/Primary (calibration) photo is the one carrying a blob.
|
||||
* When there is no usable blob, fall back to the stored photo endpoint.
|
||||
*
|
||||
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
|
||||
* is unit-testable without a real DOM/Blob.
|
||||
*/
|
||||
export function resolvePhotoUrl(
|
||||
blob: Blob | null,
|
||||
endpoint: string,
|
||||
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
|
||||
): string {
|
||||
if (blob && blob.size > 0) return createObjectUrl(blob);
|
||||
return endpoint;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyMoistureDefault, resolveKilnDriedId } from './moistureDefault';
|
||||
|
||||
const terms = [
|
||||
{ id: 1, name: 'Air dried', slug: 'ad' },
|
||||
{ id: 326, name: 'Kiln dried', slug: 'kd' },
|
||||
];
|
||||
|
||||
test('moisture default: slug kd wins; name match fallback; scoped empty stays empty', () => {
|
||||
assert.equal(resolveKilnDriedId(terms), 326);
|
||||
assert.equal(resolveKilnDriedId([{ id: 9, name: 'Kiln dried', slug: 'kiln-dried' }]), 9);
|
||||
assert.equal(resolveKilnDriedId([{ id: 1, name: 'Air dried', slug: 'ad' }]), null);
|
||||
assert.equal(applyMoistureDefault('', terms), 326);
|
||||
assert.equal(applyMoistureDefault(null, terms), 326);
|
||||
assert.equal(applyMoistureDefault(1, terms), 1);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Moisture default: Kiln dried (slug `kd` / name match). METADATA-15.
|
||||
* Resolve by synced taxonomy — never a hardcoded Woo term id.
|
||||
*/
|
||||
|
||||
export type MoistureTerm = { id: number; name: string; slug: string };
|
||||
|
||||
export function resolveKilnDriedId(terms: MoistureTerm[]): number | null {
|
||||
const bySlug = terms.find((t) => t.slug.toLowerCase() === 'kd');
|
||||
if (bySlug) return bySlug.id;
|
||||
const byName = terms.find((t) => {
|
||||
const n = t.name.trim().toLowerCase();
|
||||
return n === 'kiln dried' || n === 'kiln-dried';
|
||||
});
|
||||
return byName ? byName.id : null;
|
||||
}
|
||||
|
||||
/** If moisture is empty, return kiln-dried id; else keep current. */
|
||||
export function applyMoistureDefault(
|
||||
current: number | '' | null | undefined,
|
||||
terms: MoistureTerm[],
|
||||
): number | '' {
|
||||
if (current !== null && current !== undefined && current !== '') {
|
||||
return typeof current === 'number' ? current : Number(current) || '';
|
||||
}
|
||||
const kd = resolveKilnDriedId(terms);
|
||||
return kd == null ? '' : kd;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const reviewSrc = readFileSync(join(dir, 'Review.svelte'), 'utf8');
|
||||
const figSrc = readFileSync(join(dir, 'figTags.ts'), 'utf8');
|
||||
|
||||
test('Review UI: no AI assist labels or AI badge chrome', () => {
|
||||
assert.equal(reviewSrc.includes('AI assist'), false);
|
||||
assert.equal(reviewSrc.includes('class="ai"'), false);
|
||||
assert.equal(reviewSrc.includes('ps-tag'), false);
|
||||
});
|
||||
|
||||
test('Review UI: no fig-* MultiSelect picker', () => {
|
||||
assert.equal(reviewSrc.includes('data-testid="fig-tag"'), false);
|
||||
assert.equal(reviewSrc.includes('figTagOpts'), false);
|
||||
assert.equal(reviewSrc.includes('label="fig-* tags"'), false);
|
||||
assert.equal(reviewSrc.includes('placeholder="Choose fig-* tags"'), false);
|
||||
assert.match(reviewSrc, /deriveFigTagIds/);
|
||||
assert.match(reviewSrc, /Features/);
|
||||
assert.match(reviewSrc, /testid="feat-tag"/);
|
||||
});
|
||||
|
||||
test('Tag family scope uses hyphenated slug prefix', () => {
|
||||
assert.match(figSrc, /startsWith\('fig-'\)/);
|
||||
assert.match(figSrc, /startsWith\('feat-'\)/);
|
||||
assert.equal(/startsWith\('fig'\)/.test(figSrc), false);
|
||||
assert.equal(/startsWith\('feat'\)/.test(figSrc), false);
|
||||
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
|
||||
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
|
||||
});
|
||||
|
||||
test('Review UI: View prompt + Create desc gate wiring', () => {
|
||||
assert.match(reviewSrc, /data-testid="view-prompt"/);
|
||||
assert.match(reviewSrc, /getCall2Prompt/);
|
||||
assert.match(reviewSrc, /createDescMissing/);
|
||||
assert.match(reviewSrc, /missingCreateDescLabels/);
|
||||
assert.match(reviewSrc, /orderWoodCatGroups/);
|
||||
assert.match(reviewSrc, /applyMoistureDefault/);
|
||||
assert.match(reviewSrc, /onCommit=\{commitReview\}/);
|
||||
// Description is not part of the Create-desc gate label list
|
||||
assert.equal(reviewSrc.includes("Create desc needs: description"), false);
|
||||
});
|
||||
|
||||
test('Review UI: live field commit + unified secondary buttons + moisture default', () => {
|
||||
assert.match(reviewSrc, /scheduleFieldCommit/);
|
||||
assert.match(reviewSrc, /scheduleDescCommit/);
|
||||
assert.match(reviewSrc, /class="btn-sec"/);
|
||||
assert.match(reviewSrc, /applyMoistureDefault/);
|
||||
assert.match(reviewSrc, /void syncSelects\(\)/);
|
||||
// No leftover white unstyled view-prompt class
|
||||
assert.equal(reviewSrc.includes('view-prompt-btn'), false);
|
||||
assert.equal(reviewSrc.includes('create-btn'), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
||||
|
||||
test('effectiveSpeciesId prefers slab over capture', () => {
|
||||
assert.equal(effectiveSpeciesId(281, 11), 281);
|
||||
assert.equal(effectiveSpeciesId(null, 11), 11);
|
||||
assert.equal(effectiveSpeciesId(undefined, 11), 11);
|
||||
assert.equal(effectiveSpeciesId(null, null), null);
|
||||
assert.equal(effectiveSpeciesId(undefined, undefined), null);
|
||||
});
|
||||
|
||||
test('effectiveSpeciesId ignores non-finite values', () => {
|
||||
assert.equal(effectiveSpeciesId(Number.NaN, 11), 11);
|
||||
assert.equal(effectiveSpeciesId(null, Number.NaN), null);
|
||||
});
|
||||
|
||||
test('canCreateTitleFromSpecies requires species + ≥1 wood category', () => {
|
||||
assert.equal(canCreateTitleFromSpecies(281, 1), true);
|
||||
assert.equal(canCreateTitleFromSpecies(281, 0), false);
|
||||
assert.equal(canCreateTitleFromSpecies(null, 2), false);
|
||||
assert.equal(canCreateTitleFromSpecies(null, 0), false);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Resolve Woo species leaf for Review Create-title gate + PUTs.
|
||||
*
|
||||
* Species is chosen on Measure and stored on the slab at createSlab. Review has
|
||||
* no picker; Create title/short-desc read species from the slab (Capture
|
||||
* handoff is a repair fallback when create omitted it).
|
||||
*/
|
||||
|
||||
export function effectiveSpeciesId(
|
||||
slabSpeciesId: number | null | undefined,
|
||||
captureSpeciesId: number | null | undefined,
|
||||
): number | null {
|
||||
if (slabSpeciesId != null && Number.isFinite(Number(slabSpeciesId))) {
|
||||
return Math.round(Number(slabSpeciesId));
|
||||
}
|
||||
if (captureSpeciesId != null && Number.isFinite(Number(captureSpeciesId))) {
|
||||
return Math.round(Number(captureSpeciesId));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canCreateTitleFromSpecies(
|
||||
speciesId: number | null,
|
||||
woodCategoryCount: number,
|
||||
): boolean {
|
||||
return speciesId != null && woodCategoryCount > 0;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { deriveThicknessTermId, type ThicknessBand } from './thicknessBand';
|
||||
|
||||
// Synced-Woo-shaped bands (id + name). The e2e fixture uses a single "2-4 in" band.
|
||||
const BANDS: ThicknessBand[] = [
|
||||
{ id: 39, name: '2-4 in' },
|
||||
];
|
||||
|
||||
test('TV-9 / QUALITY-95: 1 in rounds up into the enclosing synced band', () => {
|
||||
const bands: ThicknessBand[] = [
|
||||
{ id: 10, name: '1-2 in' },
|
||||
{ id: 39, name: '2-4 in' },
|
||||
];
|
||||
assert.equal(deriveThicknessTermId(1, bands), 10);
|
||||
assert.equal(deriveThicknessTermId(1.5, bands), 10);
|
||||
// 2.0 is exactly the upper bound of the 1-2 band → belongs to it.
|
||||
assert.equal(deriveThicknessTermId(2.0, bands), 10);
|
||||
// 2.01 falls into 2-4.
|
||||
assert.equal(deriveThicknessTermId(2.25, bands), 39);
|
||||
assert.equal(deriveThicknessTermId(4, bands), 39);
|
||||
});
|
||||
|
||||
test('e2e fixture: thickness_in 2 → the single "2-4 in" band (id 39)', () => {
|
||||
assert.equal(deriveThicknessTermId(2, BANDS), 39);
|
||||
});
|
||||
|
||||
test('above every band → empty (no invented band; upstream thickness fix resolves it)', () => {
|
||||
assert.equal(deriveThicknessTermId(5, BANDS), '');
|
||||
});
|
||||
|
||||
test('no thickness / non-finite → empty', () => {
|
||||
assert.equal(deriveThicknessTermId(null, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(undefined, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(0, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(NaN, BANDS), '');
|
||||
assert.equal(deriveThicknessTermId(2, []), '');
|
||||
});
|
||||
|
||||
test('band names are parsed from the synced names, never hardcoded (order-independent)', () => {
|
||||
// Reverse the array: the derivation must still pick the right band by bound.
|
||||
const bands: ThicknessBand[] = [
|
||||
{ id: 39, name: '2-4 in' },
|
||||
{ id: 10, name: '1-2 in' },
|
||||
];
|
||||
assert.equal(deriveThicknessTermId(1.5, bands), 10);
|
||||
assert.equal(deriveThicknessTermId(3, bands), 39);
|
||||
});
|
||||
|
||||
test('malformed band names are skipped without throwing', () => {
|
||||
const bands: ThicknessBand[] = [
|
||||
{ id: 1, name: 'N/A' },
|
||||
{ id: 2, name: '4-2 in' }, // inverted, unusable
|
||||
{ id: 39, name: '2-4 in' },
|
||||
];
|
||||
assert.equal(deriveThicknessTermId(3, bands), 39);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Deterministic thickness-band derivation (TV-9 / QUALITY-95, METADATA-15/25).
|
||||
*
|
||||
* The Woo "Thickness" attribute is a set of named bands (e.g. "2-4 in"). The
|
||||
* owner enters a numeric thickness in 1/8-inch steps; the band is derived by
|
||||
* rounding that thickness UP into the band whose numeric upper bound first
|
||||
* encloses it. The Review UI never exposes a band picker — the band always
|
||||
* follows from thickness_in (issue #88).
|
||||
*
|
||||
* Pure function so it is unit-testable without a browser/backend.
|
||||
*/
|
||||
|
||||
export interface ThicknessBand {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Parse the leading numeric bounds ("2-4 in" → {min:2,max:4}). Returns null if the name
|
||||
* carries no usable numeric upper bound (defensive — a band we can't place is skipped). */
|
||||
function parseBounds(name: string): { min: number; max: number } | null {
|
||||
// Match the first two numbers in the name, in order (handles "2-4 in", "1/8", "12in+").
|
||||
const nums = name.match(/\d+(?:[./]\d+)?/g);
|
||||
if (!nums) return null;
|
||||
const asNum = (s: string) => Number(s);
|
||||
const min = asNum(nums[0]);
|
||||
const max = nums.length >= 2 ? asNum(nums[1]) : min;
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
|
||||
if (max < min) return null; // malformed "4-2"; not a usable band
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round `thicknessIn` (inches, 1/8" steps) up into the synced Woo thickness band.
|
||||
* Returns the term id, or '' when no band encloses the value (no thickness known,
|
||||
* empty synced set, or a value above every band — all leave the field empty so the
|
||||
* owner's upstream fix, not a wrong band, resolves it).
|
||||
*/
|
||||
export function deriveThicknessTermId(
|
||||
thicknessIn: number | null | undefined,
|
||||
bands: ThicknessBand[],
|
||||
): number | '' {
|
||||
if (typeof thicknessIn !== 'number' || !Number.isFinite(thicknessIn) || thicknessIn <= 0) {
|
||||
return '';
|
||||
}
|
||||
// Sort bands by upper bound so "first enclosing max" is well-defined regardless of
|
||||
// the order Woo returned them in.
|
||||
const ranked = bands
|
||||
.map((b) => ({ b, bounds: parseBounds(b.name) }))
|
||||
.filter((x): x is { b: ThicknessBand; bounds: { min: number; max: number } } => x.bounds !== null)
|
||||
.sort((a, z) => a.bounds.max - z.bounds.max);
|
||||
for (const { b, bounds } of ranked) {
|
||||
// A value exactly on the band's max belongs to that band; above it, fall through.
|
||||
if (thicknessIn <= bounds.max) {
|
||||
return b.id;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { orderWoodCatGroups, PIN_LEAF_SLUG, PIN_PARENT_SLUG } from './woodCatOrder';
|
||||
|
||||
test('wood cat UI: snl parent first; liveedge leaf first under its parent', () => {
|
||||
const groups = orderWoodCatGroups([
|
||||
{
|
||||
parent: { id: 2, name: 'Turning', slug: 'turn' },
|
||||
leaves: [
|
||||
{ id: 20, name: 'Bowl blanks', slug: 'bowl' },
|
||||
{ id: 21, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||
],
|
||||
},
|
||||
{
|
||||
parent: { id: 1, name: 'Slabs & Lumber', slug: PIN_PARENT_SLUG },
|
||||
leaves: [
|
||||
{ id: 11, name: 'Dimensional', slug: 'dim' },
|
||||
{ id: 10, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(groups[0].parent.slug, PIN_PARENT_SLUG);
|
||||
assert.equal(groups[0].leaves[0].slug, PIN_LEAF_SLUG);
|
||||
assert.equal(groups[0].leaves[1].slug, 'dim');
|
||||
assert.equal(groups[1].parent.slug, 'turn');
|
||||
// liveedge also pinned first under Turning when present
|
||||
assert.equal(groups[1].leaves[0].slug, PIN_LEAF_SLUG);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* UI-only wood-category display order on Review.
|
||||
* Pin parent Slabs & Lumber (`snl`) first; pin leaf Live edge wood (`liveedge`)
|
||||
* first under its parent. Never changes assignment ids — sort only.
|
||||
*/
|
||||
|
||||
export const PIN_PARENT_SLUG = 'snl';
|
||||
export const PIN_LEAF_SLUG = 'liveedge';
|
||||
|
||||
export type OrderedLeaf = { id: number; name: string; slug?: string; ai?: boolean };
|
||||
export type OrderedGroup = {
|
||||
parent: { id: number; name: string; slug?: string };
|
||||
leaves: OrderedLeaf[];
|
||||
};
|
||||
|
||||
function pinFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
|
||||
const pinned: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const it of items) {
|
||||
if (isPinned(it)) pinned.push(it);
|
||||
else rest.push(it);
|
||||
}
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
/** Sort groups + leaves for the wood-category MultiSelect. */
|
||||
export function orderWoodCatGroups<T extends OrderedGroup>(groups: T[]): T[] {
|
||||
const withLeaves = groups.map((g) => ({
|
||||
...g,
|
||||
leaves: pinFirst(g.leaves, (l) => (l.slug ?? '') === PIN_LEAF_SLUG),
|
||||
})) as T[];
|
||||
return pinFirst(withLeaves, (g) => (g.parent.slug ?? '') === PIN_PARENT_SLUG);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||
|
||||
test('nextPrimary: empty selection clears primary', () => {
|
||||
assert.equal(nextPrimary([], 21), '');
|
||||
assert.equal(nextPrimary([], ''), '');
|
||||
});
|
||||
|
||||
test('nextPrimary: default is first selected (REVIEW-15)', () => {
|
||||
assert.equal(nextPrimary([21, 22], ''), 21);
|
||||
assert.equal(nextPrimary([21, 22], null), 21);
|
||||
});
|
||||
|
||||
test('nextPrimary: keeps current when still selected', () => {
|
||||
assert.equal(nextPrimary([21, 22], 22), 22);
|
||||
assert.equal(nextPrimary([22], 22), 22);
|
||||
});
|
||||
|
||||
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
|
||||
assert.equal(nextPrimary([22, 23], 21), 22);
|
||||
assert.equal(nextPrimary([23], 21), 23);
|
||||
});
|
||||
|
||||
test('selectedSummary: placeholder when empty', () => {
|
||||
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
|
||||
});
|
||||
|
||||
test('selectedSummary: shows names, then +N overflow', () => {
|
||||
const m = new Map([
|
||||
[1, 'Live Edge'],
|
||||
[2, 'Book Match'],
|
||||
[3, 'Cookie'],
|
||||
]);
|
||||
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
|
||||
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
|
||||
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
|
||||
});
|
||||
|
||||
test('selectedSummary: falls back to count when names unknown', () => {
|
||||
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* REVIEW-15: owner declares one primary wood category among the selected set.
|
||||
* Default primary = first selected. Deselecting the current primary promotes
|
||||
* the first remaining selected id. Empty selection clears primary.
|
||||
*/
|
||||
export function nextPrimary(
|
||||
selected: number[],
|
||||
current: number | '' | null | undefined,
|
||||
): number | '' {
|
||||
if (!selected.length) return '';
|
||||
const cur = typeof current === 'number' ? current : '';
|
||||
if (cur !== '' && selected.includes(cur)) return cur;
|
||||
return selected[0];
|
||||
}
|
||||
|
||||
/** Closed MultiSelect summary: names when known, else a count. */
|
||||
export function selectedSummary(
|
||||
selected: number[],
|
||||
nameById: Map<number, string>,
|
||||
placeholder: string,
|
||||
maxNames = 2,
|
||||
): string {
|
||||
if (!selected.length) return placeholder;
|
||||
const names = selected
|
||||
.map((id) => nameById.get(id))
|
||||
.filter((n): n is string => !!n && n.length > 0);
|
||||
if (!names.length) {
|
||||
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
|
||||
}
|
||||
if (names.length <= maxNames) return names.join(', ');
|
||||
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { decodeName } from '../decodeName';
|
||||
import { onMount } from 'svelte';
|
||||
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
||||
|
||||
@@ -52,16 +53,6 @@
|
||||
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
|
||||
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
|
||||
|
||||
function decodeName(name: string): string {
|
||||
// Woo sometimes returns HTML entities in category names.
|
||||
return name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function loadRates() {
|
||||
ratesLoading = true;
|
||||
ratesMessage = '';
|
||||
@@ -326,7 +317,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (default false)</label>
|
||||
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (Call 1 — DISABLED on this branch; toggle kept for settings round-trip)</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -115,6 +115,8 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
||||
|
||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||
// extent in px. 0 when the mask is empty.
|
||||
// #99: demoted to an optional sanity check — declared L comes from tape tick
|
||||
// distance in image space, not this projected guess.
|
||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { keepLargestComponent } from './largestComponent.ts';
|
||||
|
||||
test('keepLargestComponent: two blobs → only larger remains', () => {
|
||||
// 10×6 grid: large 4×3 block (12 px) at left, small 2×2 (4 px) at right
|
||||
const W = 10, H = 6;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y <= 3; y++)
|
||||
for (let x = 1; x <= 4; x++) bitset[y * W + x] = 1;
|
||||
for (let y = 2; y <= 3; y++)
|
||||
for (let x = 7; x <= 8; x++) bitset[y * W + x] = 1;
|
||||
|
||||
const before = bitset.reduce((a, c) => a + c, 0);
|
||||
assert.equal(before, 16);
|
||||
|
||||
const slabPx = keepLargestComponent(bitset, W, H);
|
||||
assert.equal(slabPx, 12, 'slabPx is pixel count of largest blob');
|
||||
|
||||
// Large blob intact
|
||||
for (let y = 1; y <= 3; y++)
|
||||
for (let x = 1; x <= 4; x++)
|
||||
assert.equal(bitset[y * W + x], 1, `large (${x},${y}) kept`);
|
||||
|
||||
// Small blob zeroed
|
||||
for (let y = 2; y <= 3; y++)
|
||||
for (let x = 7; x <= 8; x++)
|
||||
assert.equal(bitset[y * W + x], 0, `small (${x},${y}) removed`);
|
||||
|
||||
assert.equal(bitset.reduce((a, c) => a + c, 0), 12);
|
||||
});
|
||||
|
||||
test('keepLargestComponent: empty bitset → 0', () => {
|
||||
const bitset = new Uint8Array(4 * 4);
|
||||
assert.equal(keepLargestComponent(bitset, 4, 4), 0);
|
||||
});
|
||||
|
||||
test('keepLargestComponent: single blob unchanged', () => {
|
||||
const W = 5, H = 5;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y <= 3; y++)
|
||||
for (let x = 1; x <= 3; x++) bitset[y * W + x] = 1;
|
||||
const slabPx = keepLargestComponent(bitset, W, H);
|
||||
assert.equal(slabPx, 9);
|
||||
assert.equal(bitset.reduce((a, c) => a + c, 0), 9);
|
||||
});
|
||||
|
||||
test('keepLargestComponent: 4-connected — diagonal touch is two components', () => {
|
||||
// Two pixels only touching diagonally; larger is the two-pixel L on the left
|
||||
// of a separate single pixel that is diagonal to nothing of equal size...
|
||||
// Layout: three pixels as an L (size 3) and one diagonal fleck (size 1)
|
||||
// . # .
|
||||
// # # .
|
||||
// . . #
|
||||
const W = 3, H = 3;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
bitset[0 * W + 1] = 1;
|
||||
bitset[1 * W + 0] = 1;
|
||||
bitset[1 * W + 1] = 1;
|
||||
bitset[2 * W + 2] = 1; // diagonal-only to the L
|
||||
|
||||
const slabPx = keepLargestComponent(bitset, W, H);
|
||||
assert.equal(slabPx, 3);
|
||||
assert.equal(bitset[2 * W + 2], 0, 'diagonal fleck removed under 4-conn');
|
||||
assert.equal(bitset[0 * W + 1], 1);
|
||||
assert.equal(bitset[1 * W + 0], 1);
|
||||
assert.equal(bitset[1 * W + 1], 1);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Keep only the single largest connected component in a slab bitset.
|
||||
* Sheet gaps can leave stray chunks after sheet removal; those must not
|
||||
* affect bbox / slabBounds / slabPx.
|
||||
*
|
||||
* Connectivity: 4-connected (N/E/S/W). Mutates bitset in place; returns
|
||||
* remaining slab pixel count.
|
||||
*/
|
||||
export function keepLargestComponent(
|
||||
bitset: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
): number {
|
||||
const n = w * h;
|
||||
if (n === 0) return 0;
|
||||
|
||||
// labels[i] = component id (1-based), 0 = background / unvisited
|
||||
const labels = new Int32Array(n);
|
||||
const sizes: number[] = [0]; // sizes[id]
|
||||
let nextId = 0;
|
||||
|
||||
const qx = new Int32Array(n);
|
||||
const qy = new Int32Array(n);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = row + x;
|
||||
if (!bitset[i] || labels[i]) continue;
|
||||
nextId++;
|
||||
let size = 0;
|
||||
let qh = 0, qt = 0;
|
||||
qx[qt] = x;
|
||||
qy[qt] = y;
|
||||
qt++;
|
||||
labels[i] = nextId;
|
||||
while (qh < qt) {
|
||||
const cx = qx[qh];
|
||||
const cy = qy[qh];
|
||||
qh++;
|
||||
size++;
|
||||
// 4-connected neighbors
|
||||
const nbrs: [number, number][] = [
|
||||
[cx - 1, cy],
|
||||
[cx + 1, cy],
|
||||
[cx, cy - 1],
|
||||
[cx, cy + 1],
|
||||
];
|
||||
for (const [nx, ny] of nbrs) {
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
const ni = ny * w + nx;
|
||||
if (!bitset[ni] || labels[ni]) continue;
|
||||
labels[ni] = nextId;
|
||||
qx[qt] = nx;
|
||||
qy[qt] = ny;
|
||||
qt++;
|
||||
}
|
||||
}
|
||||
sizes[nextId] = size;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextId === 0) return 0;
|
||||
|
||||
let bestId = 1;
|
||||
let bestSize = sizes[1];
|
||||
for (let id = 2; id <= nextId; id++) {
|
||||
if (sizes[id] > bestSize) {
|
||||
bestSize = sizes[id];
|
||||
bestId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextId === 1) return bestSize;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (bitset[i] && labels[i] !== bestId) bitset[i] = 0;
|
||||
}
|
||||
return bestSize;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
|
||||
import { measure } from './measure.ts';
|
||||
|
||||
test('#99: tape span drives scale, not maskSpanAlong', () => {
|
||||
const W = 480, H = 100;
|
||||
const bitset = new Uint8Array(W * H).fill(1);
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
// Declared L = 48"; tape span = 240 px → 5 px/in (not the full 480 mask span).
|
||||
const m = measure(bitset, W, H, box, 384, 8, W * H, {
|
||||
start: [100, 50],
|
||||
ux: 1,
|
||||
uy: 0,
|
||||
spanPx: 240,
|
||||
});
|
||||
assert.ok(Math.abs(m.scale - 5) < 1e-9, `scale ${m.scale}`);
|
||||
});
|
||||
@@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb
|
||||
return { start: bbox.corners[3], end: bbox.corners[0] };
|
||||
}
|
||||
|
||||
export interface TapeAxis {
|
||||
/** Image-space tape start (tick A). */
|
||||
start: [number, number];
|
||||
/** Image-space unit direction tick A → tick B. */
|
||||
ux: number;
|
||||
uy: number;
|
||||
/** Image-space distance between ticks (owner-declared tape length in px). */
|
||||
spanPx: number;
|
||||
}
|
||||
|
||||
// Wood end→end along the approved direction: project the mask onto the axis
|
||||
// unit vector and return the segment on the bbox center line covering that span.
|
||||
// Used only as a fallback / sanity path when no tape ticks are present (#99).
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array, w: number, h: number, bbox: BBox,
|
||||
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
|
||||
@@ -71,16 +82,38 @@ function sampleWidths(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param tape When provided (#99), scale = tape.spanPx / L and width stations
|
||||
* walk from the tick-A endpoint. maskSpanAlong is not used for scale.
|
||||
*/
|
||||
export function measure(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
tape?: TapeAxis | null,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowBlock: false; sourceBelowWarn: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
let start: [number, number];
|
||||
let ux: number;
|
||||
let uy: number;
|
||||
let scale: number;
|
||||
if (tape && tape.spanPx > 0 && lengthIn > 0) {
|
||||
start = tape.start;
|
||||
ux = tape.ux;
|
||||
uy = tape.uy;
|
||||
scale = tape.spanPx / lengthIn;
|
||||
// Optional sanity: maskSpanAlong remains available for diagnostics only.
|
||||
void maskSpanAlong;
|
||||
} else {
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
// Legacy fallback when no tape ticks — keep prior behaviour for old drafts.
|
||||
scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
start = seg.start;
|
||||
ux = seg.ux;
|
||||
uy = seg.uy;
|
||||
}
|
||||
const every6In = sampleWidths(bitset, w, h, start, ux, uy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
const min = Math.min(...every6In);
|
||||
@@ -99,6 +132,7 @@ export function measure(
|
||||
widths,
|
||||
sqft,
|
||||
bdft,
|
||||
sourceBelowFloor: false,
|
||||
sourceBelowBlock: false,
|
||||
sourceBelowWarn: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { clampFill, cropMarginFraction, DEFAULT_CROP_FILL } from './crop.ts';
|
||||
import { _resetCropFillForTests, getCropFill, setCropFill } from '../settings/cropFill.ts';
|
||||
import { composeInventoryFrame } from './inventory.ts';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
test('cropMarginFraction(0.9) === 0.05', () => {
|
||||
assert.ok(Math.abs(cropMarginFraction(0.9) - 0.05) < 1e-12);
|
||||
assert.equal(DEFAULT_CROP_FILL, 0.9);
|
||||
});
|
||||
|
||||
test('clampFill bounds', () => {
|
||||
assert.equal(clampFill(2), 1);
|
||||
assert.equal(clampFill(0), 0.01);
|
||||
assert.equal(clampFill(Number.NaN), DEFAULT_CROP_FILL);
|
||||
});
|
||||
|
||||
test('getCropFill / setCropFill round-trip', () => {
|
||||
_resetCropFillForTests();
|
||||
assert.equal(getCropFill(), 0.9);
|
||||
assert.equal(setCropFill(0.8), 0.8);
|
||||
assert.equal(getCropFill(), 0.8);
|
||||
_resetCropFillForTests();
|
||||
});
|
||||
|
||||
test('composeInventoryFrame honors cropFill override (#99)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const mask: Pick<Mask, 'bitset' | 'slabBounds'> = {
|
||||
bitset,
|
||||
slabBounds: { x: 100, y: 100, w: 2000, h: 1800 },
|
||||
};
|
||||
const rgba = new Uint8Array(source.widthPx * source.heightPx * 4).fill(255);
|
||||
const out90 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.9);
|
||||
const out80 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.8);
|
||||
|
||||
const extent = (frame: typeof out90) => {
|
||||
let minx = frame.width, maxx = -1, miny = frame.height, maxy = -1;
|
||||
for (let y = 0; y < frame.height; y++) {
|
||||
for (let x = 0; x < frame.width; x++) {
|
||||
if (frame.rgba[(y * frame.width + x) * 4 + 3] !== 0) {
|
||||
if (x < minx) minx = x;
|
||||
if (x > maxx) maxx = x;
|
||||
if (y < miny) miny = y;
|
||||
if (y > maxy) maxy = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { w: maxx - minx + 1, h: maxy - miny + 1 };
|
||||
};
|
||||
const e90 = extent(out90);
|
||||
const e80 = extent(out80);
|
||||
assert.ok(Math.abs(e90.w - 720) <= 2, `90% w ${e90.w}`);
|
||||
assert.ok(e80.w < e90.w - 10, `80% fill must be tighter than 90%: ${e80.w} vs ${e90.w}`);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared crop constants for inventory PNG + tape-line overlay (#99).
|
||||
* Aspect is fixed 3:4; crop_fill only moves the long-axis end insets.
|
||||
* Non-retro: changing fill affects future encodes only, not stored PNGs.
|
||||
*
|
||||
* Size floors (shorter side of slab bbox, source px):
|
||||
* - FLOOR_WARN_PX (800): target frame shorter side when source allows;
|
||||
* 300–799 → warn but still encode / include.
|
||||
* - FLOOR_BLOCK_PX (300): hard fail / exclude below this.
|
||||
*/
|
||||
|
||||
export const DEFAULT_CROP_FILL = 0.9;
|
||||
export const ASPECT_W = 3;
|
||||
export const ASPECT_H = 4;
|
||||
/** Target inventory frame shorter side when source can supply it. */
|
||||
export const FLOOR_WARN_PX = 800;
|
||||
/** Hard-block threshold: shorter side below this fails / excludes the photo. */
|
||||
export const FLOOR_BLOCK_PX = 300;
|
||||
/** @deprecated Prefer FLOOR_WARN_PX; kept as alias for the target frame size. */
|
||||
export const FLOOR_PX = FLOOR_WARN_PX;
|
||||
|
||||
/** Fraction of the long axis inset from each end: (1 - fill) / 2. */
|
||||
export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number {
|
||||
const f = clampFill(fill);
|
||||
return (1 - f) / 2;
|
||||
}
|
||||
|
||||
export function clampFill(fill: number): number {
|
||||
if (!Number.isFinite(fill)) return DEFAULT_CROP_FILL;
|
||||
return Math.min(1, Math.max(0.01, fill));
|
||||
}
|
||||
@@ -30,7 +30,8 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
|
||||
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
|
||||
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.equal(out.sourceBelowBlock, true, '4px slab is below block floor');
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.ok(out.width < 800, 'undersized source stays small');
|
||||
|
||||
let opaque = 0;
|
||||
@@ -66,10 +67,11 @@ test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => {
|
||||
);
|
||||
assert.equal(out.width, 800);
|
||||
assert.equal(out.height, 1067);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
});
|
||||
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => {
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, no warn/block', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 1200, heightPx: 1000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
@@ -77,10 +79,24 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
|
||||
maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.equal(out.width, 800);
|
||||
});
|
||||
|
||||
test('warn band: shorter side 500 (300–799) → sourceBelowWarn, encode allowed, no upscale past source', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 600, heightPx: 500 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 600, h: 500 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowWarn, true);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed warn floor`);
|
||||
});
|
||||
|
||||
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
@@ -107,7 +123,7 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
|
||||
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
|
||||
});
|
||||
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||
test('belowWarn (was belowFloor): 900×700 → warn not block, outW capped at warn floor', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
@@ -115,6 +131,20 @@ test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upsc
|
||||
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`);
|
||||
assert.equal(out.sourceBelowWarn, true);
|
||||
assert.equal(out.sourceBelowBlock, false);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the warn floor`);
|
||||
});
|
||||
|
||||
test('belowBlock: shorter side 200 → sourceBelowBlock, no upscale', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 250, heightPx: 200 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 250, h: 200 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowBlock, true);
|
||||
assert.equal(out.sourceBelowWarn, false);
|
||||
assert.ok(out.width <= 800);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const FLOOR = 800; // shorter side, px
|
||||
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
|
||||
const ASPECT_W = 3;
|
||||
const ASPECT_H = 4;
|
||||
import { ASPECT_H, ASPECT_W, FLOOR_WARN_PX, FLOOR_BLOCK_PX, DEFAULT_CROP_FILL } from './crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
@@ -12,20 +9,27 @@ export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
/** Optional override; default reads live settings.crop_fill (#99). */
|
||||
cropFill?: number;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
sourceBelowFloor: boolean;
|
||||
/** Shorter side of slab bbox is below FLOOR_BLOCK_PX (hard fail). */
|
||||
sourceBelowBlock: boolean;
|
||||
/** Shorter side is in [FLOOR_BLOCK_PX, FLOOR_WARN_PX) — encode/include with warn. */
|
||||
sourceBelowWarn: boolean;
|
||||
}
|
||||
|
||||
export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
cropFill: number = DEFAULT_CROP_FILL,
|
||||
): Frame {
|
||||
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
const y0 = Math.max(0, Math.round(b.y));
|
||||
@@ -33,8 +37,12 @@ export function composeInventoryFrame(
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const shortSide = Math.min(sw, sh);
|
||||
const sourceBelowBlock = shortSide < FLOOR_BLOCK_PX;
|
||||
const sourceBelowWarn = !sourceBelowBlock && shortSide < FLOOR_WARN_PX;
|
||||
// Undersized vs warn floor: frame from available source (no upscale to 800).
|
||||
const undersized = shortSide < FLOOR_WARN_PX;
|
||||
const outW = undersized ? Math.max(1, Math.min(sw, FLOOR_WARN_PX)) : FLOOR_WARN_PX;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
@@ -59,13 +67,14 @@ export function composeInventoryFrame(
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
||||
return { width: outW, height: outH, rgba, sourceBelowBlock, sourceBelowWarn };
|
||||
}
|
||||
|
||||
export async function encodeInventoryPng(
|
||||
input: InventoryInput,
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
): Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }> {
|
||||
const { bitmap, mask, source } = input;
|
||||
const fill = input.cropFill ?? getCropFill();
|
||||
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||
const srcCtx = srcCanvas.getContext('2d')!;
|
||||
srcCtx.drawImage(bitmap, 0, 0);
|
||||
@@ -74,10 +83,15 @@ export async function encodeInventoryPng(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
fill,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
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 };
|
||||
return {
|
||||
blob,
|
||||
sourceBelowBlock: frame.sourceBelowBlock,
|
||||
sourceBelowWarn: frame.sourceBelowWarn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyMaskAlpha } from './maskedPreview';
|
||||
|
||||
test('applyMaskAlpha keeps slab pixels and clears the background', () => {
|
||||
const rgba = new Uint8ClampedArray([
|
||||
1, 2, 3, 255,
|
||||
4, 5, 6, 255,
|
||||
7, 8, 9, 255,
|
||||
]);
|
||||
const out = applyMaskAlpha(rgba, new Uint8Array([1, 0]));
|
||||
assert.deepEqual([...out], [1, 2, 3, 255, 4, 5, 6, 0, 7, 8, 9, 0]);
|
||||
assert.deepEqual([...rgba], [1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255]);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
|
||||
/**
|
||||
* Apply a source-grid mask to decoded RGBA pixels without changing their
|
||||
* dimensions. The transparent pixels remain in the full source frame so the
|
||||
* tape transform and all measurement geometry keep using source coordinates.
|
||||
*/
|
||||
export function applyMaskAlpha(
|
||||
rgba: Uint8ClampedArray,
|
||||
bitset: Uint8Array,
|
||||
): Uint8ClampedArray {
|
||||
const out = new Uint8ClampedArray(rgba);
|
||||
const pixels = out.length / 4;
|
||||
for (let p = 0; p < pixels; p++) {
|
||||
if (p >= bitset.length || !bitset[p]) out[p * 4 + 3] = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an EXIF-oriented source photo with the approved mask as alpha.
|
||||
* This is display-only; the original source blob and mask stay in session
|
||||
* state for geometry, measurement, and inventory encoding.
|
||||
*/
|
||||
export async function createMaskedPreviewBlob(
|
||||
source: Blob,
|
||||
bitset: Uint8Array,
|
||||
): Promise<Blob> {
|
||||
const bitmap = await decodeOrientedBitmap(source);
|
||||
try {
|
||||
const width = bitmap.width;
|
||||
const height = bitmap.height;
|
||||
const hasOffscreenCanvas = typeof OffscreenCanvas !== 'undefined';
|
||||
const canvas: OffscreenCanvas | HTMLCanvasElement = hasOffscreenCanvas
|
||||
? new OffscreenCanvas(width, height)
|
||||
: Object.assign(document.createElement('canvas'), { width, height });
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
||||
if (!ctx) throw new Error('Could not create preview canvas');
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const image = ctx.getImageData(0, 0, width, height);
|
||||
image.data.set(applyMaskAlpha(image.data, bitset));
|
||||
ctx.putImageData(image, 0, 0);
|
||||
if (hasOffscreenCanvas) {
|
||||
return await (canvas as OffscreenCanvas).convertToBlob({ type: 'image/png' });
|
||||
}
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
(canvas as HTMLCanvasElement).toBlob((blob: Blob | null) => (blob ? resolve(blob) : reject(new Error('Could not encode preview'))), 'image/png');
|
||||
});
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
@@ -1117,3 +1117,110 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥
|
||||
const tipSpanSrc = tipHi - tipLo;
|
||||
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: when MAR angle ≠ tape angle, length || tape and width ⊥ tape (~2°)', () => {
|
||||
// Wood MAR is horizontal (angleDeg 0); tape / approved axis is vertical (90°).
|
||||
// Without optional axis (or orientedRect to tape), widths would be vertical.
|
||||
// With tape-driven axis, length is vertical and width left→right ≈ horizontal.
|
||||
const W = 240, H = 400;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
// Slightly elongated horizontally so MAR long side ≈ 0°, but still a tall slab.
|
||||
for (let y = 40; y <= 360; y++) {
|
||||
for (let x = 40; x <= 200; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const mar = minAreaRect(bitset, W, H)!;
|
||||
// Force wood-long-axis bbox to 0° — intentionally NOT the tape (vertical).
|
||||
const woodBbox = orientedRect(mar, 0);
|
||||
assert.equal(woodBbox.angleDeg, 0);
|
||||
|
||||
// Tape vertical: tick A near bottom, B near top (image y↓).
|
||||
const tapeStart: [number, number] = [120, 350];
|
||||
const tapeEnd: [number, number] = [120, 50];
|
||||
const tapeAngleDeg = ((Math.atan2(tapeEnd[1] - tapeStart[1], tapeEnd[0] - tapeStart[0]) * 180) / Math.PI + 180) % 180;
|
||||
// Overlay bbox keeps MAR extents but direction from tape (session.calculate path).
|
||||
const tapeBbox = orientedRect(woodBbox, tapeAngleDeg);
|
||||
const scale = 10;
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: tapeBbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
axis: { start: tapeStart, end: tapeEnd },
|
||||
});
|
||||
|
||||
// Length a→b must be parallel to tape (vertical → |dx| small vs |dy|).
|
||||
const ldx = plan.length.b[0] - plan.length.a[0];
|
||||
const ldy = plan.length.b[1] - plan.length.a[1];
|
||||
const llen = Math.hypot(ldx, ldy) || 1;
|
||||
const lux = ldx / llen, luy = ldy / llen;
|
||||
const tdx = tapeEnd[0] - tapeStart[0], tdy = tapeEnd[1] - tapeStart[1];
|
||||
const tlen = Math.hypot(tdx, tdy) || 1;
|
||||
const tux = tdx / tlen, tuy = tdy / tlen;
|
||||
const lengthDot = Math.abs(lux * tux + luy * tuy);
|
||||
assert.ok(lengthDot > 0.999, `length must || tape (dot=${lengthDot})`);
|
||||
|
||||
assert.ok(plan.widths.length > 0, 'expected width stations');
|
||||
// Tape-perpendicular = horizontal for a vertical tape.
|
||||
for (const w of plan.widths) {
|
||||
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
|
||||
let deg = (ang * 180) / Math.PI;
|
||||
if (deg > 90) deg -= 180;
|
||||
if (deg < -90) deg += 180;
|
||||
assert.ok(
|
||||
Math.abs(deg) <= 2,
|
||||
`width station ${w.stationIn} left→right must be within 2° of tape-perpendicular/horizontal (deg=${deg.toFixed(2)})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Contrast: same wood bbox WITHOUT tape axis would follow MAR (horizontal length).
|
||||
const woodPlan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: woodBbox,
|
||||
scale,
|
||||
lengthIn: 32,
|
||||
lengthLabel: '32"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
const wdx = woodPlan.length.b[0] - woodPlan.length.a[0];
|
||||
const wdy = woodPlan.length.b[1] - woodPlan.length.a[1];
|
||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
||||
// Wood MAR length should NOT be parallel to vertical tape.
|
||||
const woodDot = Math.abs((wdx / wlen) * tux + (wdy / wlen) * tuy);
|
||||
assert.ok(woodDot < 0.5, `wood-MAR overlay must disagree with tape (dot=${woodDot})`);
|
||||
});
|
||||
|
||||
test('buildOverlayPlan: optional axis alone drives direction even if bbox angle wrong', () => {
|
||||
const W = 200, H = 400;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 50; y <= 350; y++) {
|
||||
for (let x = 70; x <= 130; x++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const mar = minAreaRect(bitset, W, H)!;
|
||||
// Wrong bbox angle on purpose (horizontal) while axis is vertical.
|
||||
const wrongBbox = orientedRect(mar, 0);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: wrongBbox,
|
||||
scale: 10,
|
||||
lengthIn: 30,
|
||||
lengthLabel: '30"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
axis: { start: [100, 340], end: [100, 60] },
|
||||
});
|
||||
assert.ok(plan.widths.length > 0);
|
||||
for (const w of plan.widths) {
|
||||
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
|
||||
let deg = (ang * 180) / Math.PI;
|
||||
if (deg > 90) deg -= 180;
|
||||
if (deg < -90) deg += 180;
|
||||
assert.ok(Math.abs(deg) <= 2, `axis override: width deg=${deg.toFixed(2)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BBox } from '../capture/capture.types';
|
||||
import { axisEndpoints } from '../measure/measure';
|
||||
import { DEFAULT_CROP_FILL } from './crop';
|
||||
import { getCropFill } from '../settings/cropFill';
|
||||
|
||||
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
|
||||
export function overlayWidthStations(lengthIn: number): number[] {
|
||||
@@ -79,6 +81,12 @@ export interface OverlayPlanInput {
|
||||
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
|
||||
inventoryW?: number;
|
||||
inventoryH?: number;
|
||||
/**
|
||||
* Optional explicit length axis (e.g. baked tape tick A→B). When set, (ux,uy)
|
||||
* come from this segment instead of bbox corners — keeps width ⊥ tape even
|
||||
* when MAR wood long side differs from the tape.
|
||||
*/
|
||||
axis?: { start: [number, number]; end: [number, number] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +160,7 @@ function maskAxisSegment(
|
||||
w: number,
|
||||
h: number,
|
||||
bbox: BBox,
|
||||
axis?: { start: [number, number]; end: [number, number] },
|
||||
): {
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
@@ -162,7 +171,7 @@ function maskAxisSegment(
|
||||
uy: number;
|
||||
span: number;
|
||||
} {
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const { start: a, end: b } = axis ?? axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
@@ -268,9 +277,9 @@ function fmtInches(inches: number): string {
|
||||
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const {
|
||||
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
|
||||
inventoryW, inventoryH,
|
||||
inventoryW, inventoryH, axis,
|
||||
} = input;
|
||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
|
||||
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox, axis);
|
||||
// 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);
|
||||
@@ -485,8 +494,9 @@ export function inventorySourceMap(
|
||||
source: { widthPx: number; heightPx: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
cropFill: number = DEFAULT_CROP_FILL,
|
||||
): InventoryMap {
|
||||
const FILL = 0.9;
|
||||
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
|
||||
const x0 = Math.max(0, Math.round(slabBounds.x));
|
||||
const y0 = Math.max(0, Math.round(slabBounds.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
|
||||
@@ -517,6 +527,10 @@ export interface EncodeOverlayInput {
|
||||
lengthLabel: string;
|
||||
slabBounds: { x: number; y: number; w: number; h: number };
|
||||
source: { widthPx: number; heightPx: number };
|
||||
/** Optional override; default reads live settings.crop_fill (#99). */
|
||||
cropFill?: number;
|
||||
/** Optional explicit length axis in source px (baked tape A→B). */
|
||||
axis?: { start: [number, number]; end: [number, number] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -531,7 +545,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
|
||||
const fill = input.cropFill ?? getCropFill();
|
||||
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
|
||||
const plan = buildOverlayPlan({
|
||||
bitset: input.bitset,
|
||||
widthPx: input.widthPx,
|
||||
@@ -543,6 +558,7 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
|
||||
mapToInventory,
|
||||
inventoryW: outW,
|
||||
inventoryH: outH,
|
||||
axis: input.axis,
|
||||
});
|
||||
drawOverlayPlan(ctx, plan);
|
||||
return await canvas.convertToBlob({ type: 'image/png' });
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { bakeTiltTransform } from './rotateCal.ts';
|
||||
|
||||
test('bakeTiltTransform: identity ≈0° leaves points and size unchanged', () => {
|
||||
const x = bakeTiltTransform(800, 600, 0);
|
||||
assert.equal(x.identity, true);
|
||||
assert.equal(x.outW, 800);
|
||||
assert.equal(x.outH, 600);
|
||||
assert.deepEqual(x.mapPoint(100, 200), [100, 200]);
|
||||
assert.deepEqual(x.mapPoint(0, 0), [0, 0]);
|
||||
});
|
||||
|
||||
test('bakeTiltTransform: 360° folds to identity', () => {
|
||||
const x = bakeTiltTransform(400, 300, 360);
|
||||
assert.equal(x.identity, true);
|
||||
assert.deepEqual(x.mapPoint(10, 20), [10, 20]);
|
||||
});
|
||||
|
||||
test('bakeTiltTransform: 90° CW maps centre→centre; corner into AABB canvas', () => {
|
||||
const srcW = 200, srcH = 100;
|
||||
const x = bakeTiltTransform(srcW, srcH, 90);
|
||||
assert.equal(x.identity, false);
|
||||
// Rotated AABB of 200×100 about centre ≈ 100×200 (ceil may add 1px from float).
|
||||
assert.ok(x.outW >= 100 && x.outW <= 101, `outW=${x.outW}`);
|
||||
assert.ok(x.outH >= 200 && x.outH <= 201, `outH=${x.outH}`);
|
||||
const [cx, cy] = x.mapPoint(srcW / 2, srcH / 2);
|
||||
assert.ok(Math.abs(cx - x.outW / 2) < 1e-9);
|
||||
assert.ok(Math.abs(cy - x.outH / 2) < 1e-9);
|
||||
// Top-left (0,0) relative (−100,−50) → CW90 → (~50,~−100) → +out/2
|
||||
const [tlx, tly] = x.mapPoint(0, 0);
|
||||
assert.ok(Math.abs(tlx - (50 + x.outW / 2)) < 1e-4, `tlx=${tlx}`);
|
||||
assert.ok(Math.abs(tly - (-100 + x.outH / 2)) < 1e-4, `tly=${tly}`);
|
||||
});
|
||||
|
||||
test('bakeTiltTransform: maps a tape-like vertical segment; direction rotates with tilt', () => {
|
||||
const srcW = 400, srcH = 600;
|
||||
const start: [number, number] = [200, 100];
|
||||
const end: [number, number] = [200, 500];
|
||||
const preUx = 0, preUy = 1; // vertical down
|
||||
for (const tilt of [15, 30, -20, 45]) {
|
||||
const x = bakeTiltTransform(srcW, srcH, tilt);
|
||||
const a = x.mapPoint(start[0], start[1]);
|
||||
const b = x.mapPoint(end[0], end[1]);
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
// Expected: rotate (preUx, preUy) clockwise by tilt
|
||||
const rad = (((tilt % 360) + 360) % 360) * Math.PI / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
const expUx = preUx * cos - preUy * sin;
|
||||
const expUy = preUx * sin + preUy * cos;
|
||||
const dot = ux * expUx + uy * expUy;
|
||||
assert.ok(dot > 0.999, `tilt=${tilt} direction mismatch dot=${dot}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope).
|
||||
* Used only for the calibration inventory PNG + dimension-overlay clone.
|
||||
* Batch siblings are never passed through here.
|
||||
*/
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
|
||||
export interface RotatedCal {
|
||||
bitmap: ImageBitmap;
|
||||
mask: Mask;
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
}
|
||||
|
||||
/** Pure math for the same clockwise-about-centre bake used by rotateCalibration. */
|
||||
export interface BakeTiltTransform {
|
||||
/** True when tilt is ~0° (identity; no resample). */
|
||||
identity: boolean;
|
||||
tiltDeg: number;
|
||||
srcW: number;
|
||||
srcH: number;
|
||||
outW: number;
|
||||
outH: number;
|
||||
/** Map a point from pre-bake source coords into baked canvas coords. */
|
||||
mapPoint: (x: number, y: number) => [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clockwise rotation about image centre; output canvas sized to AABB of rotated
|
||||
* corners — identical sizing to rotateCalibration (canvas translate+rotate path).
|
||||
*/
|
||||
export function bakeTiltTransform(
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
tiltDeg: number,
|
||||
): BakeTiltTransform {
|
||||
const a = ((tiltDeg % 360) + 360) % 360;
|
||||
if (a < 0.05 || a > 359.95) {
|
||||
return {
|
||||
identity: true,
|
||||
tiltDeg: a,
|
||||
srcW,
|
||||
srcH,
|
||||
outW: srcW,
|
||||
outH: srcH,
|
||||
mapPoint: (x, y) => [x, y],
|
||||
};
|
||||
}
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[srcW, 0],
|
||||
[srcW, srcH],
|
||||
[0, srcH],
|
||||
].map(([x, y]) => {
|
||||
const cx = x - srcW / 2;
|
||||
const cy = y - srcH / 2;
|
||||
return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number];
|
||||
});
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const [x, y] of corners) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
const outW = Math.max(1, Math.ceil(maxX - minX));
|
||||
const outH = Math.max(1, Math.ceil(maxY - minY));
|
||||
return {
|
||||
identity: false,
|
||||
tiltDeg: a,
|
||||
srcW,
|
||||
srcH,
|
||||
outW,
|
||||
outH,
|
||||
mapPoint: (x, y) => {
|
||||
const cx = x - srcW / 2;
|
||||
const cy = y - srcH / 2;
|
||||
return [cx * cos - cy * sin + outW / 2, cx * sin + cy * cos + outH / 2];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate).
|
||||
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
|
||||
* Identity (≈0°) returns clones of inputs without resampling.
|
||||
*/
|
||||
export async function rotateCalibration(
|
||||
bitmap: ImageBitmap,
|
||||
mask: Mask,
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>,
|
||||
tiltDeg: number,
|
||||
): Promise<RotatedCal> {
|
||||
const xform = bakeTiltTransform(source.widthPx, source.heightPx, tiltDeg);
|
||||
if (xform.identity) {
|
||||
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
|
||||
return { bitmap, mask, source };
|
||||
}
|
||||
const { srcW, srcH, outW, outH, tiltDeg: a } = xform;
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
ctx.rotate(rad);
|
||||
ctx.drawImage(bitmap, -srcW / 2, -srcH / 2);
|
||||
|
||||
// Nearest-neighbour resample of the bitset into the rotated frame.
|
||||
const bitset = new Uint8Array(outW * outH);
|
||||
const invCos = Math.cos(-rad);
|
||||
const invSin = Math.sin(-rad);
|
||||
for (let y = 0; y < outH; y++) {
|
||||
for (let x = 0; x < outW; x++) {
|
||||
const dx = x - outW / 2;
|
||||
const dy = y - outH / 2;
|
||||
const sx = Math.round(dx * invCos - dy * invSin + srcW / 2);
|
||||
const sy = Math.round(dx * invSin + dy * invCos + srcH / 2);
|
||||
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
|
||||
bitset[y * outW + x] = mask.bitset[sy * srcW + sx];
|
||||
}
|
||||
}
|
||||
let slabPx = 0;
|
||||
for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++;
|
||||
const bounds = slabBounds(bitset, outW, outH);
|
||||
const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox;
|
||||
const outBmp = canvas.transferToImageBitmap();
|
||||
return {
|
||||
bitmap: outBmp,
|
||||
mask: {
|
||||
params: mask.params,
|
||||
slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds: bounds,
|
||||
},
|
||||
source: { widthPx: outW, heightPx: outH },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Thin frontend-only crop_fill setting (#99).
|
||||
* Default 0.9 — replaces duplicated FILL = 0.9 in png/inventory + png/overlay.
|
||||
* Not a full M3 Settings surface; localStorage so it survives reloads.
|
||||
*/
|
||||
import { DEFAULT_CROP_FILL, clampFill } from '../png/crop';
|
||||
|
||||
const KEY = 'slabuploader.crop_fill';
|
||||
|
||||
let cached: number | null = null;
|
||||
|
||||
export function getCropFill(): number {
|
||||
if (cached != null) return cached;
|
||||
if (typeof localStorage === 'undefined') {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw == null) {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
cached = clampFill(Number.parseFloat(raw));
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULT_CROP_FILL;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCropFill(fill: number): number {
|
||||
const next = clampFill(fill);
|
||||
cached = next;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(next));
|
||||
} catch {
|
||||
/* private mode / quota — in-memory still updated */
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Test helper: clear cached value (and optional storage). */
|
||||
export function _resetCropFillForTests(): void {
|
||||
cached = null;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
|
||||
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
|
||||
import { keepLargestComponent } from '../mask/largestComponent';
|
||||
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let data: Uint8ClampedArray | null = null;
|
||||
@@ -33,6 +34,11 @@ function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
if (slabPx === 0) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
// Sheet gaps leave stray chunks; lock onto the single largest slab object.
|
||||
slabPx = keepLargestComponent(bitset, w, h);
|
||||
if (slabPx === 0) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
const bbox = minAreaRect(bitset, w, h);
|
||||
if (!bbox) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user