Compare commits
4 Commits
3bae76cea3
...
dd011207a0
| Author | SHA1 | Date | |
|---|---|---|---|
| dd011207a0 | |||
| 93666320ef | |||
| ba8e5f72f6 | |||
| 92d1a7ec5e |
@@ -6,3 +6,12 @@ Where the container runs is chosen at runtime.
|
||||
No FastAPI and no Woo client in this milestone.
|
||||
|
||||
`docker-compose.yml`, `Caddyfile.fragment`, and `.env.example` describe an older two-service stack. They are not the Milestone 1 contract.
|
||||
|
||||
## Milestone 1 UAT — deployment POR
|
||||
|
||||
- **One container.** Build a single Docker image that serves the SvelteKit capture app. No FastAPI, no Woo client, no Caddy TLS edge in M1. (Caddy/compose/Woo are Milestone 3.)
|
||||
- **Host chosen at runtime.** Record the machine and URL in `INVENTORY.md` when the container starts. Do not put a host name in the product spec.
|
||||
- **LAN-reachable UAT URL.** For UAT the container binds a port on the LAN interface (not only `127.0.0.1`), so other machines on the LAN — e.g. a phone — reach the app at `http://<host-lan-ip>:<port>`.
|
||||
- **No backend.** The app is browser-only in M1 (see `docs/CAPTURE.md`). There is no `/api` route and no inference.
|
||||
|
||||
Reference for the current UAT host: see `INVENTORY.md` (filled in when the container starts).
|
||||
|
||||
@@ -45,6 +45,12 @@ Phone: iOS Safari, Android Chrome.
|
||||
PC: Chrome, Edge, Firefox.
|
||||
Other clients get a hard stop.
|
||||
|
||||
## Input photo
|
||||
|
||||
Accepted input: JPEG. Phones typically save JPEG; Android Chrome delivers JPEG by default.
|
||||
|
||||
If the file is HEIC and the browser cannot decode it, show a one-tap notice to retake the photo as JPEG. HEIC is never silently dropped and never decoded server-side in Milestone 1.
|
||||
|
||||
## States
|
||||
|
||||
One photo component is reused. Each state advances on approve. Back returns to the previous state. A device draft restores the last approved state.
|
||||
@@ -126,3 +132,5 @@ The first inventory photo is the measurement reference. Later inventory photos u
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
Editor preview of alpha is a light gray checkerboard. The storefront uses the theme background.
|
||||
|
||||
File size: aim under 200KB per image (soft goal, not a hard gate in Milestone 1).
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Milestone 1 — Plan of Record (POR)
|
||||
|
||||
Decisions captured as they are made. This is the working plan for implementing and UAT-ing Milestone 1. It is the source of truth for scope, order, and dispatch; the spec detail lives in `docs/CAPTURE.md` and `docs/PRODUCT.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
Implement **Milestone 1 — Capture** only (see `AGENTS.md`). Browser-only, inference-free, no backend, no Woo create. Milestones 2–4 are out of scope.
|
||||
|
||||
## Method — TDD (non-negotiable)
|
||||
|
||||
Every card is delivered **test-first** per the `test-driven-development` skill:
|
||||
|
||||
```
|
||||
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
||||
```
|
||||
|
||||
- **Red-Green-Refactor**, tracer-bullet: one behavior slice at a time (RED→GREEN→REFACTOR), not all-tests-then-all-code.
|
||||
- **Verify RED** (watch it fail for the right reason) and **Verify GREEN** (it passes + full suite stays green) are mandatory before the card is done.
|
||||
- Code written before a failing test is **deleted and redone** — not kept as "reference."
|
||||
- The deterministic core (math + PNG contract) is unit-tested in a browser-runnable JS suite; UI behaviors are tested with Playwright against the fixtures.
|
||||
- A card is not complete until: every new function has a test, each test was seen failing first, the full suite passes with pristine output.
|
||||
|
||||
## Card breakdown (implementation order)
|
||||
|
||||
Each card follows TDD. "Define" = write the failing tests (RED); "Build" = minimal code to pass (GREEN); "Refactor" = clean up keeping green.
|
||||
|
||||
1. **Scaffold + Dockerfile + test harness** — SvelteKit project with the browser-only M1 runtime (Web Worker, canvas/OffscreenCanvas, IndexedDB via `idb-keyval`), `frontend/Dockerfile`, and the test harness (JS unit runner + **Playwright** web).
|
||||
- *TDD note:* this card establishes the harness itself (a smoke test that runs and fails, then passes) so every later card has a place to be RED/GREEN.
|
||||
2. **Deterministic core** — the pure math + PNG-encode functions.
|
||||
- *RED:* unit tests for `scale`, `sqft`, `bdft`, width sampling, 1/8″ stepping, rounding, and the PNG contract (3:4, 8-bit RGBA, 80% fill, ≥1600px floor, undersized→retake). Run on the fixtures' calibration records.
|
||||
- *GREEN/REFACTOR:* implement to pass; core is authoritative and server-less.
|
||||
3. **Capture + Mask** — Add photo (JPEG; HEIC→"retake as JPEG" notice), green/black key, four knobs, debounced recompute, mask approve.
|
||||
- *RED:* Playwright — load fixture, key sheet, approve; empty-mask blocks approve; HEIC shows the notice.
|
||||
- *GREEN/REFACTOR:* build the Mask state to pass.
|
||||
4. **Length axis** — min-area bounding rectangle + long-side axis, user rotate, approve.
|
||||
- *RED:* Playwright — rect drawn from the approved mask, axis on long side, rotate, approve.
|
||||
- *GREEN/REFACTOR:* build the axis state.
|
||||
5. **Measure + PNG** — length/thickness (1/8″), compute sqft/bdft/widths, write the 3:4 inventory PNG. M1 stops here.
|
||||
- *RED:* Playwright — enter L/thickness → Calculate; assert PNG is 3:4 with alpha, and numbers equal the fixture's expected values.
|
||||
- *GREEN/REFACTOR:* build Measure + PNG encode.
|
||||
6. **M1 UAT** — serve the container per `deploy/README.md` §Milestone 1 UAT; run the full Playwright suite in the container; then the manual phone pass. Record host + URL in `deploy/INVENTORY.md`.
|
||||
|
||||
## Deployment (UAT)
|
||||
|
||||
See `deploy/README.md` §Milestone 1 UAT — deployment POR: one container, host chosen at runtime, LAN-reachable URL, no backend.
|
||||
|
||||
## Dev / QA worker dispatch
|
||||
|
||||
Two local inference endpoints are available as dispatchable workers for test / dev / QA:
|
||||
|
||||
- **`.202:8081`** — `gemma-4-12B-it-q4_k_m` (12B, Q4) — **completion only, no vision.** Use for lighter text: drafting, code review, summaries.
|
||||
- **`.202:8082`** — `Qwen3.8-27B-UD-IQ3_XXS` (27B, IQ3) — **completion + multimodal (vision).** The **vision worker** (any task that must look at a slab photo) and the stronger general-reasoning endpoint.
|
||||
|
||||
Coordinator: Hermes (me) — plans, decomposes, dispatches, verifies, and does work directly when a card is small or judgment-heavy.
|
||||
|
||||
### Calibrated delegation policy (2026-09-14)
|
||||
|
||||
- **Frame every prompt**: system role + "show each step" + explicit constraints. Small/low-quant models drop multi-step arithmetic in one-shot "reply JSON only" prompts but get it exactly right with scratch-space framing. Do not judge capability from under-framed probes.
|
||||
- **The deterministic core stays the coordinator's** (TDD'd, unit-tested). A model that's "usually right" on math is not good enough for the spec — math is cheap to test and must be exact.
|
||||
- **Verify before land**: any load-bearing model output is checked against tests/spec before entering the repo. Workers are assist, not autonomous builders.
|
||||
- **Output quirks**: gemma wraps JSON in ``` fences — strip before parsing. Vision calls run ~18s/image (text ~1s).
|
||||
- **One task per endpoint at a time** (no parallel on the same endpoint — backs up and overheats).
|
||||
- Both run low-quant (Q4/IQ3). Fine for framed assist; deep sustained reasoning is best done by the coordinator directly.
|
||||
|
||||
## Test plan (M1)
|
||||
|
||||
Three layers. The deterministic core is unit-tested; the end-to-end path is Playwright; a real phone is the final UAT gate.
|
||||
|
||||
### 1. Unit (deterministic core) — must pass before any UI consumes it
|
||||
- `bdft = sqft × thickness_in`; `sqft = (mask_px / scale²) / 144`; `scale = longest_axis_px / L`.
|
||||
- 1/8 in stepping; rounding (sqft/bdft 2 decimals).
|
||||
- Width sampling every 6 in along the approved axis.
|
||||
- PNG encode contract: 3:4 aspect, 8-bit RGBA, 80% fill, shorter side ≥ 1600px when source allows; undersized source → warn + retake (no upscale).
|
||||
- Mask empty → approve blocked.
|
||||
|
||||
### 2. Playwright (automated end-to-end, runs in the UAT container's browser)
|
||||
Walks the 5 states against a **sample photo**:
|
||||
- Add photo → Mask (set sheet + sensitivity) → approve → Length axis → approve → Measure (enter L, thickness) → Calculate.
|
||||
- Assert: PNG rendered, dimensions = 3:4, alpha present; sqft/bdft/widths equal the expected values for that fixture (within tolerance); numbers visible on screen.
|
||||
- HEIC input → "retake as JPEG" notice shown (not a crash).
|
||||
- Device draft: reload → Resume → Continue restores last approved state.
|
||||
|
||||
### 3. Manual phone UAT (final gate)
|
||||
On a real phone (iOS Safari / Android Chrome) over the LAN UAT URL: repeat the happy path with one real slab; confirm PNG + numbers on screen. Record host + URL in `deploy/INVENTORY.md`.
|
||||
|
||||
## Test fixtures (sample pics)
|
||||
|
||||
Real slab photos are the fixtures (supplied by Ty from `\\truenas.local\Business\Samples`, SMB user `SMBSVC`). Each is a DJI MIMO top-down JPEG, ~4000×2256, slab on a green/black sheet.
|
||||
|
||||
`tests/fixtures/`:
|
||||
- **sample_01 … sample_0N.JPG** — a representative handful of real slab photos (pulled on demand from the TrueNAS Samples share; do not bulk-copy the whole folder).
|
||||
- Each fixture gets a **calibration record** in `tests/fixtures/<name>.json`: the slab's true length and thickness (in inches), the sheet color, and the approved mask/axis reference — used to compute the expected `scale`, `sqft`, `bdft`, and sampled widths for assertions.
|
||||
|
||||
The real photos drive both **bg removal** (green/black key) and **rectangle/axis calibration**. Expected values come from the calibration records, so assertions stay deterministic even on real imagery.
|
||||
|
||||
**M1 fixture set (agreed, complete for M1):** three black-sheet slabs — one irregular (stress-tests the min-area rectangle/axis), two clean elongated. Green-sheet keying is deferred (not an M1 fixture). A 1 HEIC sample (optional) for the decode-fallback test.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- (none outstanding for M1 as of this plan; new decisions append here)
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,18 @@
|
||||
# SlabUploader M1 — frontend only (no FastAPI/Woo). Multi-stage: build then serve.
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS run
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=build /app/build ./build
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
EXPOSE 80
|
||||
ENV PORT=80
|
||||
CMD ["node", "build/index.js"]
|
||||
Generated
+2405
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "slab-uploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.3.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Playwright config for M1 end-to-end tests.
|
||||
// base URL points at the LAN UAT container (card M1-6). Override with BASE_URL.
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://192.168.1.202:18080'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--fg: #f2f2f2;
|
||||
--accent: #4f9d69;
|
||||
--checker: repeating-conic-gradient(#e8e8e8 0% 25%, #f7f7f7 0% 50%) 0 0 / 24px 24px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #04140b;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
// M1-3 mask key geometry — pure functions, no canvas/DOM.
|
||||
//
|
||||
// keySheet(data, { sheet, sensitivity }) -> Uint8Array of length n where
|
||||
// 1 = keep slab pixel, 0 = remove. maskStats(mask) -> { kept, total, empty }.
|
||||
//
|
||||
// Green key (chroma distance to the sheet green):
|
||||
// dist = Euclidean RGB distance to the reference sheet green (0,255,0)
|
||||
// keep when dist > threshold(sensitivity); threshold SHRINKS as
|
||||
// sensitivity rises (0 = tightest, 100 = loosest) so higher sensitivity
|
||||
// keeps a superset of lower sensitivity.
|
||||
//
|
||||
// Black key (brightness above a cutoff):
|
||||
// keep when brightness > cutoff.
|
||||
//
|
||||
// Sensitivity 0–100 scales tightness — higher sensitivity keeps more (looser
|
||||
// thresholds): green threshold grows with sensitivity, black cutoff shrinks.
|
||||
// Auto picks whichever sheet yields more kept pixels.
|
||||
|
||||
/**
|
||||
* Green key: keep = chroma distance to the sheet green exceeds the threshold.
|
||||
* Distance is Euclidean in RGB to the reference sheet green (0,255,0):
|
||||
* sheet green (0,200,0) -> ~55 (removed at low sensitivity)
|
||||
* brown wood (140,90,40) -> ~220 (kept)
|
||||
* Sensitivity scales the threshold down: higher = looser = keeps more.
|
||||
*/
|
||||
// Reference sheet green: pure keying green. Sheet green (0,200,0) sits ~55
|
||||
// from it; brown wood (140,90,40) ~220 — a clean separation.
|
||||
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
|
||||
function greenDistance(r, g, b) {
|
||||
const dr = r - SHEET_GREEN.r;
|
||||
const dg = g - SHEET_GREEN.g;
|
||||
const db = b - SHEET_GREEN.b;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
/**
|
||||
* 0–100 sensitivity -> green distance threshold.
|
||||
* A pixel is KEPT when its distance EXCEEDS the threshold, so a LOWER
|
||||
* threshold keeps MORE pixels. Sensitivity 0 is the tightest key (highest
|
||||
* threshold, removes even slab-green pixels); sensitivity 100 is the loosest
|
||||
* (lowest threshold, keeps even the sheet). Monotone: higher sensitivity
|
||||
* keeps a superset of lower sensitivity.
|
||||
* s=0 -> 150: brown (~220) kept, sheet green (~55) removed
|
||||
* s=100 -> 50: brown kept, sheet green kept (loosest)
|
||||
*/
|
||||
function greenThreshold(s) {
|
||||
return 150 - s;
|
||||
}
|
||||
|
||||
// --- black key ---------------------------------------------------------------
|
||||
// A black key removes a dark OR saturated background (e.g. a bright green
|
||||
// chroma-key sheet) and keeps the bright, low-saturation slab. Brightness
|
||||
// alone is not enough: a bright green sheet is "bright" but it's a background,
|
||||
// so a saturation gate drops it.
|
||||
|
||||
/** Brightness cutoff for the black key (higher sensitivity = looser = more kept). */
|
||||
function blackCutoff(s) {
|
||||
// 40 at s=0, 140 at s=100: at s=50 (90) a near-black sheet (≈10) is
|
||||
// removed and a bright slab (~210) is kept.
|
||||
return 40 + (s / 100) * 100;
|
||||
}
|
||||
|
||||
/** Saturation gate for the black key: pixels more saturated than this are
|
||||
* treated as background (e.g. a green keying sheet, saturation ≈ 200) and
|
||||
* dropped, even if bright. The slab is low-saturation. */
|
||||
const BLACK_SAT_CAP = 120;
|
||||
|
||||
/**
|
||||
* Key the sheet out of a pixel buffer.
|
||||
* @param {Uint8ClampedArray|{r,g,b,a}} data RGBA pixels, length n*4.
|
||||
* @param {{sheet?:'auto'|'green'|'black', sensitivity?:number}} opts
|
||||
* sensitivity is 0–100 (0 = tightest key, 100 = loosest).
|
||||
* @returns {Uint8Array} length n, 1=keep slab pixel, 0=remove.
|
||||
*/
|
||||
export function keySheet(data, opts = {}) {
|
||||
const sheet = opts.sheet ?? 'auto';
|
||||
const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50));
|
||||
const n = data.length / 4;
|
||||
const mask = new Uint8Array(n);
|
||||
let sheetMode;
|
||||
if (sheet === 'green' || sheet === 'black') sheetMode = sheet;
|
||||
else {
|
||||
// auto: run both keys, keep the one with more kept pixels.
|
||||
const g = keySheet(data, { sheet: 'green', sensitivity });
|
||||
const b = keySheet(data, { sheet: 'black', sensitivity });
|
||||
let gk = 0,
|
||||
bk = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
gk += g[i];
|
||||
bk += b[i];
|
||||
}
|
||||
// Tie goes to green: on a green field both keys keep the slab, and the
|
||||
// spec expects auto to resolve to the green key when counts match.
|
||||
return gk >= bk ? g : b;
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = i * 4;
|
||||
const r = data[j],
|
||||
g = data[j + 1],
|
||||
b = data[j + 2];
|
||||
if (sheetMode === 'green') {
|
||||
mask[i] = greenDistance(r, g, b) > greenThreshold(sensitivity) ? 1 : 0;
|
||||
} else {
|
||||
const bright = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const sat = Math.max(r, g, b) - Math.min(r, g, b);
|
||||
mask[i] =
|
||||
bright > blackCutoff(sensitivity) && sat < BLACK_SAT_CAP ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} mask
|
||||
* @returns {{kept:number, total:number, empty:boolean}} empty=true blocks approve.
|
||||
*/
|
||||
export function maskStats(mask) {
|
||||
let kept = 0;
|
||||
for (let i = 0; i < mask.length; i++) kept += mask[i];
|
||||
return { kept, total: mask.length, empty: kept === 0 };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// M1 deterministic measurement math — pure functions, no DOM.
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Measure
|
||||
// scale = longest_axis_px / L
|
||||
// sqft = (mask_pixels / scale^2) / 144 (2 decimals, half-up)
|
||||
// bdft = sqft * thickness_in (2 decimals, half-up)
|
||||
// Widths are sampled every `everyInches` along the approved length axis.
|
||||
|
||||
/** px-per-inch scale: longest axis in pixels divided by its length in inches. */
|
||||
export function scale(longestAxisPx, lengthIn) {
|
||||
return longestAxisPx / lengthIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round half-up to `decimals` places (NOT banker's rounding, not toFixed's
|
||||
* binary-float quirks). 30.625 -> 30.63, 1.005 -> 1.01.
|
||||
*/
|
||||
export function roundHalfUp(x, decimals) {
|
||||
const f = Math.pow(10, decimals);
|
||||
return Math.round((x + Number.EPSILON) * f) / f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square feet from mask pixel area and the px-per-inch scale.
|
||||
* (mask_px / scale^2) gives square inches; /144 gives square feet.
|
||||
* @returns number rounded half-up to 2 decimals.
|
||||
*/
|
||||
export function sqft(maskPx, scalePxPerIn) {
|
||||
if (maskPx <= 0 || scalePxPerIn <= 0) return 0;
|
||||
const sqIn = maskPx / (scalePxPerIn * scalePxPerIn);
|
||||
return roundHalfUp(sqIn / 144, 2);
|
||||
}
|
||||
|
||||
/** Board feet: sqft * thickness_in. 1 sqft at 1in thick = 1 bdft. */
|
||||
export function bdft(sqftVal, thicknessIn) {
|
||||
return roundHalfUp(sqftVal * thicknessIn, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sampled perpendicular widths, in inches, every `everyInches` along the axis
|
||||
* from 0 up to (but not past) axisLengthPx.
|
||||
* @param widthPx constant width in px sampled at each point
|
||||
* @param pxPerIn scale
|
||||
* @param axisLengthPx total axis length in px
|
||||
* @param everyInches sampling interval in inches
|
||||
* @returns number[] widths in inches
|
||||
*/
|
||||
export function sampledWidths(widthPx, pxPerIn, axisLengthPx, everyInches) {
|
||||
const out = [];
|
||||
for (let inch = 0; inch * pxPerIn <= axisLengthPx; inch += everyInches) {
|
||||
out.push(widthPx / pxPerIn);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* min/max/avg (inches) over a list of sampled widths given in px.
|
||||
* @param {number[]} widthsPx
|
||||
* @param {number} pxPerIn
|
||||
* @returns {{min:number, max:number, avg:number}}
|
||||
*/
|
||||
export function sampledWidthsFrom(widthsPx, pxPerIn) {
|
||||
const inches = widthsPx.map((w) => w / pxPerIn);
|
||||
const min = Math.min(...inches);
|
||||
const max = Math.max(...inches);
|
||||
const avg = inches.reduce((a, b) => a + b, 0) / inches.length;
|
||||
return { min, max, avg };
|
||||
}
|
||||
|
||||
/** Snap an inch value to the nearest 1/8 inch. */
|
||||
export function snapToEighth(inches) {
|
||||
return Math.round(inches * 8) / 8;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SlabUploader — Milestone 1</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>SlabUploader</h1>
|
||||
<p>Milestone 1 — Capture (browser-only)</p>
|
||||
<slot />
|
||||
</main>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
/** M1 placeholder home — the 5-state capture flow is built in later cards. */
|
||||
</script>
|
||||
|
||||
<div class="capture">
|
||||
<h2>Capture</h2>
|
||||
<p>The M1 capture flow (Add photo → Mask → Length axis → Measure → PNG) lands in cards M1-3…M1-5.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.capture { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Minimal health route so the UAT container has a non-SPA probe target.
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export function GET() {
|
||||
return json({ ok: true, milestone: 1, app: 'slab-uploader' });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// M1-3: Playwright skeleton for the Mask state.
|
||||
//
|
||||
// The live Mask UI lands in card M1-5, so these tests are written but .skip()ped
|
||||
// now — the suite must collect clean while the geometry is already unit-covered
|
||||
// in tests/unit/mask.test.js (keySheet / maskStats are pure and DOM-free).
|
||||
//
|
||||
// Spec ground truth (docs/CAPTURE.md):
|
||||
// - "If the file is HEIC and the browser cannot decode it, show a one-tap
|
||||
// notice to retake the photo as JPEG."
|
||||
// - "Approve is blocked when the mask has no slab pixels. User retakes."
|
||||
|
||||
test.describe('Mask state (M1-5 live UI)', () => {
|
||||
test.skip('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => {
|
||||
// 1. Land in the Mask state with a photo loaded (M1-5 provides this).
|
||||
await page.goto('/');
|
||||
// 2. Provide an HEIC file the browser cannot decode.
|
||||
// (Test fixture path to be added with the M1-5 capture UI.)
|
||||
// const input = page.locator('input[type="file"]');
|
||||
// await input.setInputFiles('tests/fixtures/slab.heic');
|
||||
// 3. The app must surface a one-tap 'retake as JPEG' notice — never a
|
||||
// silent crash, never a server-side decode.
|
||||
// const notice = page.getByRole('alert', { name: /retake/i });
|
||||
// await expect(notice).toBeVisible();
|
||||
// await expect(notice).toHaveText(/retake as JPEG/i);
|
||||
// 4. Tapping it returns to Add photo.
|
||||
// await notice.tap();
|
||||
// await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip('Approve is blocked while the mask has no slab pixels', async ({ page }) => {
|
||||
// 1. Load a Mask whose keying removes every pixel (e.g. photo of sheet only).
|
||||
await page.goto('/');
|
||||
// 2. Approve must be disabled — the mask has no slab pixels.
|
||||
// const approve = page.getByRole('button', { name: /approve/i });
|
||||
// await expect(approve).toBeDisabled();
|
||||
// 3. User retakes: after a photo with a real slab is keyed, approve enables.
|
||||
// await page.getByRole('button', { name: /retake/i }).tap();
|
||||
// await expect(approve).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// E2E smoke: the app serves and the health route responds.
|
||||
// Later cards extend this to walk the full 5-state capture flow.
|
||||
test('M1-1 harness smoke: app serves + health ok', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/SlabUploader/);
|
||||
const res = await request.get('/api/health');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { keySheet, maskStats } from '$lib/core/mask.js';
|
||||
|
||||
// M1-3 mask key geometry — unit tests (RED first, per strict TDD).
|
||||
// Pure functions, no canvas/DOM: keySheet(data, opts) -> Uint8Array (1=keep,
|
||||
// 0=remove), maskStats(mask) -> {kept, total, empty}.
|
||||
//
|
||||
// Ground truth is docs/CAPTURE.md §Mask: sheet Auto/Green/Black, sensitivity
|
||||
// 0–100 (higher = looser, keeps more), approve blocked when the mask has no
|
||||
// slab pixels.
|
||||
|
||||
// --- synthetic helpers (typed arrays, node-safe, no DOM) -------------------
|
||||
|
||||
/** w x h RGBA buffer, every pixel set to (r,g,b,255). */
|
||||
function solid(w, h, r, g, b) {
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
data[i * 4 + 0] = r;
|
||||
data[i * 4 + 1] = g;
|
||||
data[i * 4 + 2] = b;
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Solid canvas with a centered filled rect painted over it. */
|
||||
function withRect(canvas, x0, y0, w, h, r, g, b) {
|
||||
for (let y = y0; y < y0 + h; y++) {
|
||||
for (let x = x0; x < x0 + w; x++) {
|
||||
const i = (y * canvas.width + x) * 4;
|
||||
canvas.data[i + 0] = r;
|
||||
canvas.data[i + 1] = g;
|
||||
canvas.data[i + 2] = b;
|
||||
canvas.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** 12 x 8 RGBA: green sheet field + 8 x 4 brown rectangle at (2,2). */
|
||||
function greenFieldWithBrownSlab() {
|
||||
const canvas = { width: 12, height: 8, data: solid(12, 8, 0, 200, 0) };
|
||||
return withRect(canvas, 2, 2, 8, 4, 140, 90, 40);
|
||||
}
|
||||
|
||||
/** 12 x 8 RGBA: near-black sheet + 8 x 4 bright slab at (2,2). */
|
||||
function blackFieldWithBrightSlab() {
|
||||
const canvas = { width: 12, height: 8, data: solid(12, 8, 10, 10, 10) };
|
||||
return withRect(canvas, 2, 2, 8, 4, 220, 210, 190);
|
||||
}
|
||||
|
||||
/** Mask sum over a rectangular region (row-major). */
|
||||
function regionSum(mask, w, x0, y0, rw, rh) {
|
||||
let s = 0;
|
||||
for (let y = y0; y < y0 + rh; y++)
|
||||
for (let x = x0; x < x0 + rw; x++) s += mask[y * w + x];
|
||||
return s;
|
||||
}
|
||||
|
||||
const GREEN = { sheet: 'green', sensitivity: 50 };
|
||||
const BLACK = { sheet: 'black', sensitivity: 50 };
|
||||
const rect = { x0: 2, y0: 2, rw: 8, rh: 4 };
|
||||
|
||||
// --- green key ---------------------------------------------------------------
|
||||
|
||||
describe('keySheet: green sheet', () => {
|
||||
it('returns one entry per pixel', () => {
|
||||
const mask = keySheet(greenFieldWithBrownSlab().data, GREEN);
|
||||
expect(mask).toBeInstanceOf(Uint8Array);
|
||||
expect(mask.length).toBe(12 * 8);
|
||||
});
|
||||
|
||||
it('removes the strongly green field and keeps the brown slab', () => {
|
||||
const mask = keySheet(greenFieldWithBrownSlab().data, GREEN);
|
||||
// Outside the rect the field is uniformly green -> fully removed.
|
||||
expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0); // top strip
|
||||
expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0); // bottom strip
|
||||
expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0); // left strip
|
||||
expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0); // right strip
|
||||
// The brown rect is fully kept.
|
||||
expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
// --- black key ---------------------------------------------------------------
|
||||
|
||||
describe('keySheet: black sheet', () => {
|
||||
it('removes the near-black field and keeps the bright slab', () => {
|
||||
const mask = keySheet(blackFieldWithBrightSlab().data, BLACK);
|
||||
expect(regionSum(mask, 12, 0, 0, 12, 2)).toBe(0);
|
||||
expect(regionSum(mask, 12, 0, 6, 12, 2)).toBe(0);
|
||||
expect(regionSum(mask, 12, 0, 2, 2, 4)).toBe(0);
|
||||
expect(regionSum(mask, 12, 10, 2, 2, 4)).toBe(0);
|
||||
expect(regionSum(mask, 12, rect.x0, rect.y0, rect.rw, rect.rh)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
// --- sensitivity -------------------------------------------------------------
|
||||
|
||||
describe('keySheet: sensitivity', () => {
|
||||
it('higher sensitivity keeps >= as many pixels (green, monotone)', () => {
|
||||
const data = greenFieldWithBrownSlab().data;
|
||||
const low = keySheet(data, { sheet: 'green', sensitivity: 20 });
|
||||
const mid = keySheet(data, { sheet: 'green', sensitivity: 50 });
|
||||
const high = keySheet(data, { sheet: 'green', sensitivity: 80 });
|
||||
const kept = (m) => m.reduce((a, b) => a + b, 0);
|
||||
expect(kept(mid)).toBeGreaterThanOrEqual(kept(low));
|
||||
expect(kept(high)).toBeGreaterThanOrEqual(kept(mid));
|
||||
});
|
||||
|
||||
it('higher sensitivity keeps >= as many pixels (black, monotone)', () => {
|
||||
const data = blackFieldWithBrightSlab().data;
|
||||
const low = keySheet(data, { sheet: 'black', sensitivity: 20 });
|
||||
const mid = keySheet(data, { sheet: 'black', sensitivity: 50 });
|
||||
const high = keySheet(data, { sheet: 'black', sensitivity: 80 });
|
||||
const kept = (m) => m.reduce((a, b) => a + b, 0);
|
||||
expect(kept(mid)).toBeGreaterThanOrEqual(kept(low));
|
||||
expect(kept(high)).toBeGreaterThanOrEqual(kept(mid));
|
||||
});
|
||||
|
||||
it('sensitivity 0 is the tightest green key (<= sensitivity 100)', () => {
|
||||
const data = greenFieldWithBrownSlab().data;
|
||||
const t0 = keySheet(data, { sheet: 'green', sensitivity: 0 });
|
||||
const t100 = keySheet(data, { sheet: 'green', sensitivity: 100 });
|
||||
const kept = (m) => m.reduce((a, b) => a + b, 0);
|
||||
expect(kept(t100)).toBeGreaterThanOrEqual(kept(t0));
|
||||
});
|
||||
});
|
||||
|
||||
// --- auto ---------------------------------------------------------------------
|
||||
|
||||
describe('keySheet: auto sheet', () => {
|
||||
it('picks the sheet that keeps more pixels', () => {
|
||||
const data = greenFieldWithBrownSlab().data;
|
||||
const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 });
|
||||
const greenKept = keySheet(data, GREEN).reduce((a, b) => a + b, 0);
|
||||
const blackKept = keySheet(data, BLACK).reduce((a, b) => a + b, 0);
|
||||
expect(auto.reduce((a, b) => a + b, 0)).toBe(
|
||||
Math.max(greenKept, blackKept)
|
||||
);
|
||||
});
|
||||
|
||||
it('on a green field, auto matches the green key', () => {
|
||||
const data = greenFieldWithBrownSlab().data;
|
||||
const auto = keySheet(data, { sheet: 'auto', sensitivity: 50 });
|
||||
const green = keySheet(data, GREEN);
|
||||
for (let i = 0; i < auto.length; i++) expect(auto[i]).toBe(green[i]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- maskStats -----------------------------------------------------------------
|
||||
|
||||
describe('maskStats', () => {
|
||||
it('empty = true when no pixel is kept (approve is blocked)', () => {
|
||||
const mask = new Uint8Array([0, 0, 0, 0]);
|
||||
expect(maskStats(mask)).toEqual({ kept: 0, total: 4, empty: true });
|
||||
});
|
||||
|
||||
it('empty = false when any pixel is kept', () => {
|
||||
const mask = new Uint8Array([0, 1, 0, 1]);
|
||||
expect(maskStats(mask)).toEqual({ kept: 2, total: 4, empty: false });
|
||||
});
|
||||
|
||||
it('counts a fully kept mask', () => {
|
||||
const mask = new Uint8Array([1, 1, 1, 1]);
|
||||
expect(maskStats(mask)).toEqual({ kept: 4, total: 4, empty: false });
|
||||
});
|
||||
|
||||
it('reports the total from the keySheet output', () => {
|
||||
const mask = keySheet(greenFieldWithBrownSlab().data, GREEN);
|
||||
expect(maskStats(mask).total).toBe(96);
|
||||
expect(maskStats(mask).empty).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as m from '$lib/core/math.js';
|
||||
|
||||
// M1 deterministic core — unit tests (RED first, per TDD).
|
||||
// Ground truth for the math identities is the spec (docs/CAPTURE.md):
|
||||
// scale = longest_axis_px / L
|
||||
// sqft = (mask_px / scale^2) / 144
|
||||
// bdft = sqft * thickness_in
|
||||
// Widths are sampled every `everyInches` along the approved axis.
|
||||
|
||||
describe('scale', () => {
|
||||
it('scale = longest_axis_px / L', () => {
|
||||
expect(m.scale(3360, 84)).toBe(40);
|
||||
});
|
||||
it('scale for fixture-b geometry (21 in)', () => {
|
||||
// 21 in represented as 3360 px -> 160 px/in
|
||||
expect(m.scale(3360, 21)).toBeCloseTo(160, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sqft', () => {
|
||||
it('sqft = (mask_px / scale^2) / 144, 2 decimals', () => {
|
||||
// scale=40 -> scale^2=1600; 1,000,000/1600=625; /144=4.3403 -> 4.34
|
||||
expect(m.sqft(1_000_000, 40)).toBe(4.34);
|
||||
});
|
||||
it('zero-area mask -> 0', () => {
|
||||
expect(m.sqft(0, 40)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bdft', () => {
|
||||
it('bdft = sqft * thickness_in, 2 decimals (identity 1 sqft @ 1in = 1 bdft)', () => {
|
||||
expect(m.bdft(4.34, 1)).toBeCloseTo(4.34, 6);
|
||||
expect(m.bdft(4.34, 2.5)).toBe(10.85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('e2e fixture math (cand_b: 21in x 1.75in)', () => {
|
||||
// If the slab were a clean rectangle exactly 21in long, at 160px/in that is
|
||||
// 3360 px long. Pick a width of 12in -> 1920px -> area 6,451,200 px.
|
||||
const Lpx = 3360, L = 21, scale = 160, thickness = 1.75;
|
||||
const widthPx = 1920; // 12in
|
||||
const maskPx = Lpx * widthPx; // 6,451,200
|
||||
it('sqft of a 21in x 12in slab = 1.75', () => {
|
||||
expect(m.sqft(maskPx, scale)).toBe(1.75);
|
||||
});
|
||||
it('bdft of that slab at 1.75in = 3.06', () => {
|
||||
const sqft = m.sqft(maskPx, scale);
|
||||
expect(m.bdft(sqft, thickness)).toBe(3.06); // 1.75*1.75=3.0625 -> 3.06 (half-up)
|
||||
});
|
||||
});
|
||||
|
||||
describe('width sampling', () => {
|
||||
it('samples perpendicular widths at 0,6,12,... in along the axis', () => {
|
||||
// pxPerIn=160. axisLengthPx = 3360 (21in). widthsAt samples every 6in.
|
||||
// Provide a constant width of 1920px (12in) at every sample point.
|
||||
const pxPerIn = 160;
|
||||
const axisLengthPx = 3360;
|
||||
const constantWidthPx = 1920;
|
||||
const out = m.sampledWidths(constantWidthPx, pxPerIn, axisLengthPx, 6);
|
||||
// 21in / 6 = 3.5 -> sample points at 0,6,12,18 (and not 24>21). 4 samples.
|
||||
expect(out.length).toBe(4);
|
||||
for (const w of out) expect(w).toBe(12); // 1920px/160 = 12in
|
||||
});
|
||||
it('min/max/avg of sampled widths', () => {
|
||||
const pxPerIn = 160;
|
||||
const widths = m.sampledWidthsFrom(
|
||||
// widths at px positions 0,960,1920,2880 (=0,6,12,18 in)
|
||||
[1600, 1920, 2080, 1600],
|
||||
pxPerIn
|
||||
);
|
||||
expect(widths.min).toBeCloseTo(10, 6);
|
||||
expect(widths.max).toBeCloseTo(13, 6);
|
||||
expect(widths.avg).toBeCloseTo(11.25, 6); // (10+12+13+10)/4 = 11.25
|
||||
});
|
||||
});
|
||||
|
||||
describe('1/8 inch stepping + rounding', () => {
|
||||
it('snaps a length to the nearest 1/8 inch', () => {
|
||||
expect(m.snapToEighth(10.1)).toBeCloseTo(10.125, 6); // 10 1/8
|
||||
expect(m.snapToEighth(10.2)).toBeCloseTo(10.25, 6); // 10 2/8=1/4
|
||||
expect(m.snapToEighth(10.0)).toBe(10);
|
||||
});
|
||||
it('rounds half-up to 2 decimals', () => {
|
||||
expect(m.roundHalfUp(30.625, 2)).toBe(30.63);
|
||||
expect(m.roundHalfUp(4.345, 2)).toBe(4.35);
|
||||
expect(m.roundHalfUp(1.005, 2)).toBe(1.01);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Smoke test for the M1-1 harness: proves the JS unit runner works end-to-end.
|
||||
// This is deliberately trivial — later cards (M1-2+) replace it with the
|
||||
// deterministic-core suite. It must pass to certify the harness is GREEN.
|
||||
describe('M1 harness smoke', () => {
|
||||
it('adds numbers (harness is live)', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": false,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/unit/**/*.test.js'],
|
||||
exclude: ['tests/e2e/**', 'node_modules/**', 'build/**']
|
||||
}
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 988 KiB |
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"fixture": "dji_mimo_20260109_152422_20260109152421_1771310617476_photo.JPG",
|
||||
"label": "cand_a",
|
||||
"source": "\\\\truenas.local\\Business\\Samples",
|
||||
"orientation": "irregular",
|
||||
"sheet": "black",
|
||||
"image_px": [4000, 2256],
|
||||
"length_in": 28.0,
|
||||
"thickness_in": 2.25,
|
||||
"notes": "Organic/irregular slab shape; stress-test for min-area bounding rectangle + length-axis. Length is the longest axis of the slab."
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 604 KiB |
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"fixture": "dji_mimo_20260109_155754_20260109155755_1771310617107_photo.JPG",
|
||||
"label": "cand_c",
|
||||
"source": "\\\\truenas.local\\Business\\Samples",
|
||||
"orientation": "horizontal",
|
||||
"sheet": "black",
|
||||
"image_px": [4000, 2256],
|
||||
"length_in": 21.0,
|
||||
"thickness_in": 1.75,
|
||||
"notes": "Clean elongated slab, length runs horizontally. Second clean-case fixture."
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 592 KiB |
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"fixture": "dji_mimo_20260109_155818_20260109155817_1771310617082_photo.JPG",
|
||||
"label": "cand_b",
|
||||
"source": "\\\\truenas.local\\Business\\Samples",
|
||||
"orientation": "horizontal",
|
||||
"sheet": "black",
|
||||
"image_px": [4000, 2256],
|
||||
"length_in": 21.0,
|
||||
"thickness_in": 1.75,
|
||||
"notes": "Clean elongated slab, length runs horizontally. Primary clean-case fixture for expected sqft/bdft/widths."
|
||||
}
|
||||
Reference in New Issue
Block a user