12 Commits

Author SHA1 Message Date
grokbot_ty fbcd05169f fix(capture): apply approved mask alpha in inventory PNG
Why
CAPTURE.md Crop keeps slab pixels and makes removed pixels transparent.
encodeInventoryPng only drawImage-cropped the AABB from the raw photo.

Scope
composeInventoryFrame samples the source through approvedMask.bitset.
Non-slab pixels stay alpha 0. Drop the 8% MARGIN; fill 80% of mask
extents in a 3:4 frame. Floor rules unchanged.

Verification
cd frontend && npm test
12 pass, including alpha contract (no sheet green in opaque pixels).
2026-09-14 19:11:52 +00:00
grokbot_ty b3a9fdf330 test(capture): require mask alpha in inventory PNG compose
Why
Calculate showed accurate numbers but the inventory PNG kept the sheet
background. Measure already uses approvedMask.bitset; encode did not.

Scope
Add composeInventoryFrame and inventory.test.ts alpha/crop proofs.
Wire encodeInventoryPng({ bitmap, mask, source }) at calculate().
This commit still paints the slabBounds AABB without the bitset gate so
the alpha contract fails until the follow-up fix.

Verification
node --import tsx --test src/png/inventory.test.ts
alpha contract fails (sheet green leaks); 3:4 / 80% / floor pass.
2026-09-14 19:11:52 +00:00
grokbot_ty 114e0b5116 fix(capture): type Length L and Thickness as inch inputs
Replace MeasurePanel steppers with number inputs. Commit on blur, change,
and Calculate snaps inches to nearest 1/8 via inToEighths. Calculate
enables when both drafts parse to > 0. Axis, mask, and scale math unchanged.
2026-09-14 18:26:31 +00:00
grokbot_ty 81e9184c7f test(capture): require typed Measure length and thickness inputs
Ty UAT rejects ± steppers as the only Length L / Thickness control.
Assert MeasurePanel exposes type=number inputs and that inches snap via inToEighths.
2026-09-14 18:26:31 +00:00
grokbot_ty b328871549 fix(capture): rotate length-axis rectangle and scale from mask span
Length axis keeps the approved mask, replaces Flip 180 with a ± angle
slider, and sets scale from wood span along the approved direction rather
than bbox.longSidePx. Widths still sample every 6 in perpendicular to that
axis.
2026-09-14 16:37:00 +00:00
grokbot_ty b781ffeb66 fix(capture): preserve PhotoStage preview aspect on first paint
Contain-fit waits for a real parent width, uses ResizeObserver, and shrinks
width when maxH would otherwise stretch the CSS box. Pure fitDisplay is
covered by node tests.
2026-09-14 16:37:00 +00:00
grokbot_ty 0e94b1fd2a fix(capture): offer camera and gallery on Add photo
Why
On phone, the single file control opened the camera because it set
capture=environment. CAPTURE.md requires camera or picker.

Scope
AddPhoto.svelte now has Take photo (capture=environment) and Choose
from gallery (no capture). Both call the same onPicked path.

Verification
Playwright against overnight after redeploy asserts two inputs and
gallery has no capture attribute.
2026-09-14 15:23:08 +00:00
grokbot_ty c6a48c8d91 deploy: overnight M1 UAT on ubudual :10881 2026-09-14 15:12:30 +00:00
grokbot_ty 0679e1cef7 deploy: keep overnight M1 UAT on box :18080
ubudual :10880 is Ty local Node/SvelteKit — do not overwrite.
2026-09-14 15:10:03 +00:00
grokbot_ty 503449452c deploy: pin M1 UAT to ubudual5060:10880 2026-09-14 15:03:47 +00:00
Ty bb78e35ac3 docs: name crop as Calculate step — 3:4, 80% fill, transparent PNG 2026-09-14 14:16:41 +00:00
grokbot_ty d86fc93ed6 feat(capture): ship Milestone 1 browser capture app
Why
Phone and PC need a single capture flow that ends at inventory PNG and
board-foot numbers with no backend, inference, or Woo create.

Scope
Add frontend/ Vite+Svelte capture UI with Resume, Add photo, Mask,
Length axis, and Measure. Mask runs in a worker with Auto/Green/Black
and a 2.0s sensitivity debounce. Measure computes scale, widths, sqft,
bdft, and a 3:4 alpha inventory PNG. Persist one device draft in
IndexedDB. Serve the static build from frontend/Dockerfile via nginx
(deploy/nginx-m1.conf). No FastAPI.

Verification
sudo docker build -f frontend/Dockerfile -t slabuploader-m1 .
sudo docker run -d --name slabuploader-m1 -p 0.0.0.0:18080:80 slabuploader-m1
curl http://127.0.0.1:18080/ returns 200
Playwright: synthetic green-sheet photo through Calculate shows sqft/bdft
2026-09-14 07:53:39 +00:00
31 changed files with 3952 additions and 11 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
backend
frontend/node_modules
frontend/dist
frontend/.svelte-kit
**/.DS_Store
*.md
!deploy/README.md
.audit
docs/archive
+16 -1
View File
@@ -1,4 +1,19 @@
# Host # Host
Record the machine and URL when a container is started. ## Overnight Milestone 1 Capture (poteto Build)
- Host: `ubudual5060` / Tailscale `100.64.0.9`
- Container: `slabuploader-m1-overnight` (image `slabuploader-m1:overnight`)
- Port: **10881** → 80
- URL: `http://100.64.0.9:10881`
- Also available on the Grok Bot box at `http://100.64.0.3:18080` (not LAN-reachable for Ty).
## Ty local Node/SvelteKit (do not overwrite)
- Host: `ubudual5060` / `100.64.0.9`
- Container: `slabuploader-m1`
- Port: **10880** → 80
- URL: `http://100.64.0.9:10880`
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than 10880.
Do not put a host name in the product spec. Do not put a host name in the product spec.
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: fall back to index.html for any path
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
}
+22 -10
View File
@@ -1,7 +1,7 @@
# Milestone 1 — Capture # Milestone 1 — Capture
Runs in the browser on phone and PC as ordinary client code. Runs in the browser on phone and PC as ordinary client code.
No inference URL. No model. No backend. Mask, rectangle, measurements, and PNG encode are deterministic functions of the photo and user inputs. No inference URL. No model. No backend. Mask, rectangle, measurements, crop, and PNG encode are deterministic functions of the photo and user inputs.
Milestone 1 uses one reference photo. Further angles are later milestones. Milestone 1 uses one reference photo. Further angles are later milestones.
Milestone 1 ends when the user sees the inventory PNG and measurements. Milestone 2 is Review. Milestone 3 is Create. This file does not create a WooCommerce product. Milestone 1 ends when the user sees the inventory PNG and measurements. Milestone 2 is Review. Milestone 3 is Create. This file does not create a WooCommerce product.
@@ -33,9 +33,9 @@ Otherwise the app opens Add photo.
3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step. 3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step.
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis. 4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate. 5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
6. App sets `scale = longest_axis_px / L`. 6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
7. App computes widths, sqft, and bdft from the mask, and writes the inventory PNG. 7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 80% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
8. User sees the PNG and the numbers. Milestone 1 stops here. 8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
Calculate does not create a WooCommerce product. Calculate does not create a WooCommerce product.
@@ -53,13 +53,13 @@ One photo component is reused. Each state advances on approve. Back returns to t
2. Add photo — camera or picker. One photo. Setup rules visible. 2. Add photo — camera or picker. One photo. Setup rules visible.
3. Mask — background removal on that photo. 3. Mask — background removal on that photo.
4. Length axis — bounding rectangle and long-side axis on the same photo. 4. Length axis — bounding rectangle and long-side axis on the same photo.
5. Measure — length, thickness, Calculate, inventory PNG, widths, sqft, bdft. Milestone 1 stops here. 5. Measure — length, thickness, Calculate, crop, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
## Display ## Display
Preview fits the device width. Height is capped so Mask and Measure controls stay on screen. Phone and PC use the same states. Preview fits the device width. Height is capped so Mask and Measure controls stay on screen. Phone and PC use the same states.
The preview is display only. Mask, rectangle, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview. The preview is display only. Mask, rectangle, crop, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
## Runtime ## Runtime
@@ -83,22 +83,34 @@ Mask work runs in the worker. The last good overlay stays visible while a pass r
Approve is blocked when the mask has no slab pixels. User retakes. Approve is blocked when the mask has no slab pixels. User retakes.
User approves the mask when only the slab remains. That mask is the footprint for Length axis, measurements, and the inventory PNG. User approves the mask when only the slab remains. That mask is the footprint for Length axis, crop, measurements, and the inventory PNG.
## Length axis ## Length axis
On the same photo, draw the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis. On the same photo, keep the approved mask in view and draw the minimum-area bounding rectangle of the mask with the length axis on the long side of that rectangle. User rotates the rectangle (slider with minus and plus) until that direction follows the slab length, then approves the axis.
## Measure ## Measure
User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
- `scale = longest_axis_px / L` - `scale = longest_axis_px / L` where `longest_axis_px` is the mask/wood span along the approved length direction (slab pixels end-to-end on that axis), not the rectangle edge length. The rectangle sets direction only; user-entered `L` is wood end→end along that direction.
- Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it - Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it
- `sqft = (mask_pixels / scale²) / 144` (2 decimals) - `sqft = (mask_pixels / scale²) / 144` (2 decimals)
- `bdft = sqft × thickness_in` (2 decimals) - `bdft = sqft × thickness_in` (2 decimals)
- Crop: 3:4 portrait, slab centered, 80% fill at the widest and tallest points, transparent background
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
User sees the inventory PNG and those numbers. Stop. Calculate does not create a WooCommerce product. User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
## Crop
On Calculate, the app builds the inventory image from the approved mask:
1. Keep slab pixels from the source photo. Removed pixels are transparent.
2. Place the slab in a 3:4 portrait frame, centered.
3. Scale so the widest and tallest points of the slab fill 80% of the frame. Extra margin where the slab is narrower.
4. After that crop, shorter side ≥ 1600px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
5. Encode as PNG with alpha.
## Device draft ## Device draft
+13
View File
@@ -0,0 +1,13 @@
# Build the static app, then serve only the built artifacts.
# Build context is the repo root: `docker build -f frontend/Dockerfile .`
FROM node:20-slim AS build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx-m1.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>SlabUploader — Capture</title>
<style>
html, body { margin: 0; padding: 0; background: #111; }
#app { display: block; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1996
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "slabuploader-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --import tsx --test src/**/*.test.ts"
},
"dependencies": {
"idb-keyval": "^6.3.0",
"svelte": "^4.2.20"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"svelte-preprocess": "^5.1.4",
"tsx": "^4.23.13",
"typescript": "^5.7.3",
"vite": "^5.4.19"
}
}
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { supported } from './browser';
import { createCaptureSession, type CaptureSession } from './capture/session';
import type { Capture } from './capture/capture.types';
import Resume from './capture/Resume.svelte';
import AddPhoto from './capture/AddPhoto.svelte';
import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession();
let cap: Capture = { k: 'addPhoto' };
let busy = false;
let pngUrl: string | null = null;
const unsubCap = session.capture.subscribe((c) => {
cap = c;
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
});
const unsubBusy = session.busy.subscribe((b) => {
busy = b;
});
const pick = (f: File) => void session.addPhoto(f);
onMount(() => {
void session.resumeProbe();
});
onDestroy(() => {
unsubCap();
unsubBusy();
session.dispose();
});
</script>
{#if gate}
<main class="hardstop">
<h1>Unsupported browser</h1>
<p>
SlabUploader needs a modern browser with canvas, workers, and IndexedDB support:
iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox.
</p>
<p class="detail">Missing: {gate}</p>
</main>
{:else}
<main class="app">
{#if cap.k === 'resume'}
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
{:else if cap.k === 'addPhoto'}
<AddPhoto onPicked={pick} />
{:else if cap.k === 'mask'}
<MaskPanel
state={cap}
{busy}
setSensitivity={session.setSensitivity}
setSheet={session.setSheet}
approveMask={session.approveMask}
retakes={session.retakes}
back={session.back}
/>
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis}
back={session.back}
/>
{:else}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
calculate={session.calculate}
back={session.back}
{pngUrl}
/>
{/if}
</main>
{/if}
<style>
:global(body) {
margin: 0;
background: #121212;
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
}
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
.hardstop h1 { font-size: 1.6rem; }
.detail { color: #888; font-size: 0.85rem; }
</style>
+26
View File
@@ -0,0 +1,26 @@
// Supported clients: iOS Safari, Android Chrome, PC Chrome/Edge/Firefox.
// Gated on required API presence; anything missing → hard stop.
export function supported(): string | null {
if (typeof window === 'undefined') return 'window unavailable';
if (typeof createImageBitmap !== 'function') return 'createImageBitmap';
if (typeof Worker === 'undefined') return 'Worker';
if (typeof indexedDB === 'undefined') return 'IndexedDB';
const hasOffscreen =
typeof OffscreenCanvas !== 'undefined' &&
typeof OffscreenCanvas.prototype.convertToBlob === 'function';
const hasCanvasBlob =
typeof HTMLCanvasElement !== 'undefined' &&
typeof HTMLCanvasElement.prototype.toBlob === 'function';
if (!hasOffscreen && !hasCanvasBlob) return 'canvas PNG encode';
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isAndroid = /Android/i.test(ua);
const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|Edg|Firefox|FxiOS/i.test(ua);
const isChrome = /Chrome|CriOS/i.test(ua) && !/Edg/i.test(ua);
const isEdge = /Edg/i.test(ua);
const isFirefox = /Firefox|FxiOS/i.test(ua);
if (isIOS && isSafari) return null;
if (isAndroid && isChrome) return null;
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
return 'unsupported browser';
}
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
export let onPicked: (f: File) => void;
const pick = (e: Event) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) onPicked(f);
};
</script>
<section class="panel">
<h1>Add photo</h1>
<ul class="setup">
<li>Sheet is green or black, fills the frame around the slab.</li>
<li>Slab fully on the sheet, nothing on top of it.</li>
<li>Top-down, lens parallel to the face.</li>
<li>Slab length along the long side of the picture.</li>
</ul>
<div class="pick">
<label class="pick-btn">Take photo
<input type="file" accept="image/*" capture="environment" on:change={pick} />
</label>
<label class="pick-btn">Choose from gallery
<input type="file" accept="image/*" on:change={pick} />
</label>
</div>
</section>
<style>
.panel { padding: 24px 16px; color: #eee; }
h1 { font-size: 1.4rem; }
.setup { color: #bbb; line-height: 1.6; font-size: 0.95rem; }
.pick { margin-top: 20px; display: flex; flex-direction: column; gap: 12px; }
.pick-btn {
background: #00e5ff; color: #002; border: 0; border-radius: 8px;
padding: 12px 20px; font-size: 1rem; font-weight: 600;
text-align: center; cursor: pointer;
}
.pick-btn input[type='file'] { display: none; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Capture } from './capture.types';
import PhotoStage from './PhotoStage.svelte';
export let state: Extract<Capture, { k: 'axis' }>;
export let setAxisAngle: (deg: number) => void;
export let approveAxis: () => void;
export let back: () => void;
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
</script>
<section class="panel">
<h1>Length axis</h1>
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
/>
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
<input
type="range" min="0" max="179" step="1"
value={angleDeg}
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
<span class="val">{angleDeg}°</span>
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import type { Capture, Sheet } from './capture.types';
import PhotoStage from './PhotoStage.svelte';
export let state: Extract<Capture, { k: 'mask' }>;
export let busy: boolean;
export let setSensitivity: (n: number) => void;
export let setSheet: (s: Sheet) => void;
export let approveMask: () => void;
export let retakes: () => void;
export let back: () => void;
const sheets: Sheet[] = ['auto', 'green', 'black'];
$: empty = state.overlay !== null && state.overlay.slabPx === 0;
$: maskW = state.overlay && state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 && state.overlay ? state.overlay.bitset.length / maskW : 0;
$: canApprove = state.overlay !== null && state.overlay.slabPx > 0;
</script>
<section class="panel">
<h1>Mask</h1>
<div class="stage-wrap">
<PhotoStage
blob={state.source.blob}
maskBitset={state.overlay?.bitset ?? null}
{maskW}
{maskH}
/>
{#if busy}<div class="busy">Working…</div>{/if}
{#if state.failed}<div class="warn">Mask pass failed. Adjust and retry, or retake the photo.</div>{/if}
{#if empty}<div class="warn">No slab pixels in the mask. Adjust sensitivity or retake.</div>{/if}
</div>
<div class="controls">
<div class="seg" role="group" aria-label="Sheet">
{#each sheets as s (s)}
<button type="button" class:active={state.control.sheet === s} on:click={() => setSheet(s)}>
{s[0].toUpperCase() + s.slice(1)}
</button>
{/each}
</div>
<div class="sens">
<button type="button" class="thumb" aria-label="Decrease sensitivity" on:click={() => setSensitivity(state.control.sensitivity - 1)}>−</button>
<input
type="range" min="0" max="100" step="1"
value={state.control.sensitivity}
on:input={(e) => setSensitivity(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Increase sensitivity" on:click={() => setSensitivity(state.control.sensitivity + 1)}>+</button>
<span class="val">{state.control.sensitivity}</span>
</div>
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="ghost" on:click={retakes}>Retake</button>
<button type="button" class="primary" disabled={!canApprove} on:click={approveMask}>
Approve mask
</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.stage-wrap { position: relative; }
.busy {
position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7);
color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 0.85rem;
}
.warn { color: #ffb4a2; margin-top: 8px; font-size: 0.9rem; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.seg { display: flex; gap: 8px; }
.seg button {
flex: 1; padding: 12px; font-size: 1rem; border-radius: 8px;
border: 1px solid #444; background: #1d1d1d; color: #ccc;
}
.seg button.active { background: #00e5ff; color: #002; font-weight: 700; border-color: #00e5ff; }
.sens { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 2.2ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 12px; margin-top: 20px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
</style>
+146
View File
@@ -0,0 +1,146 @@
<script lang="ts">
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import PhotoStage from './PhotoStage.svelte';
export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void;
export let setThickness: (e: number) => void;
export let calculate: () => Promise<void>;
export let back: () => void;
export let pngUrl: string | null;
let calcBusy = false;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
};
const commitLength = () => {
const inches = positiveInches(String(lengthIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setLength(eighths);
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
const commitThickness = () => {
const inches = positiveInches(String(thicknessIn));
const eighths = inches === null ? 0 : inToEighths(inches);
setThickness(eighths);
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
const doCalc = async () => {
calcBusy = true;
try {
commitLength();
commitThickness();
await calculate();
} finally { calcBusy = false; }
};
</script>
<section class="panel">
<h1>Measure</h1>
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
/>
<div class="fields">
<div class="field">
<span class="label">Length L</span>
<input
type="number"
aria-label="Length L"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={lengthIn}
on:blur={commitLength}
on:change={commitLength}
/>
</div>
<div class="field">
<span class="label">Thickness</span>
<input
type="number"
aria-label="Thickness"
inputmode="decimal"
step="0.125"
min="0"
placeholder="0"
bind:value={thicknessIn}
on:blur={commitThickness}
on:change={commitThickness}
/>
</div>
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
type="button"
class="primary"
disabled={calcBusy || !canCalc}
on:click={doCalc}
>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
{#if state.results}
<div class="results">
<div class="nums">
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
</div>
{#if state.results.sourceBelowFloor}
<div class="warn">Source is below the 1600px floor for the inventory PNG. Retake a larger photo.</div>
{/if}
{#if pngUrl && !state.results.sourceBelowFloor}
<div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" />
</div>
{/if}
</div>
{/if}
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.field input {
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
font-variant-numeric: tabular-nums;
}
.row { display: flex; gap: 10px; margin-top: 20px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.results { margin-top: 20px; }
.nums { display: flex; flex-direction: column; gap: 8px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
.nums span:first-child { color: #aaa; }
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
.png-wrap img { max-width: 100%; border-radius: 4px; }
</style>
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import type { BBox } from './capture.types';
import { fitDisplay } from './fitDisplay';
// Display-only: renders the photo fitted to device width (height capped) plus
// one overlay layer. All math lives on source pixels, not this canvas.
export let blob: Blob;
export let maxH = 420;
export let bbox: BBox | null = null;
export let maskBitset: Uint8Array | null = null;
export let maskW = 0;
export let maskH = 0;
let url: string | null = null;
let canvas: HTMLCanvasElement | undefined;
let stage: HTMLDivElement | undefined;
let imgW = 0, imgH = 0; // source px, learned once per blob
let parentW = 0;
const setBlobUrl = (b: Blob) => {
if (url) URL.revokeObjectURL(url);
url = URL.createObjectURL(b);
};
$: if (blob) {
setBlobUrl(blob);
imgW = 0;
}
const applyFit = () => {
if (!canvas || !imgW) return;
if (parentW <= 0 && stage) parentW = stage.clientWidth;
const fit = fitDisplay(parentW, imgW, imgH, maxH);
if (!fit) return;
canvas.style.width = `${fit.fitW}px`;
canvas.style.height = `${fit.fitH}px`;
};
const redraw = () => {
if (!url || !canvas) return;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const img = new Image();
img.onload = () => {
if (!canvas) return;
imgW = img.width;
imgH = img.height;
canvas.width = imgW;
canvas.height = imgH;
applyFit();
ctx.drawImage(img, 0, 0);
if (maskBitset && maskW > 0 && maskH > 0) {
const off = document.createElement('canvas');
off.width = maskW;
off.height = maskH;
const octx = off.getContext('2d')!;
const d = octx.createImageData(maskW, maskH);
const tile = 8;
for (let p = 0; p < maskW * maskH; p++) {
if (!maskBitset[p]) {
const x = p % maskW;
const y = (p / maskW) | 0;
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
d.data[p * 4] = light ? 220 : 180;
d.data[p * 4 + 1] = light ? 220 : 180;
d.data[p * 4 + 2] = light ? 220 : 180;
d.data[p * 4 + 3] = 220;
}
}
octx.putImageData(d, 0, 0);
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
}
if (bbox && bbox.corners.length === 4) {
const c = bbox.corners;
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = Math.max(2, canvas.width / 400);
ctx.beginPath();
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
ctx.closePath();
ctx.stroke();
// The axis reads corners[3] → corners[0], the long side.
const a = c[3];
const b = c[0];
ctx.strokeStyle = '#ff3d71';
ctx.lineWidth = Math.max(3, canvas.width / 300);
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
ctx.fillStyle = '#ff3d71';
ctx.beginPath();
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
const r = Math.max(10, canvas.width / 60);
ctx.moveTo(b[0], b[1]);
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fill();
}
};
img.src = url;
};
$: if (url || bbox || maskBitset) void tick().then(redraw);
onMount(() => {
if (stage) parentW = stage.clientWidth;
const ro = new ResizeObserver((entries) => {
const e = entries[0];
if (!e) return;
const w = e.contentRect.width;
if (w === parentW) return;
parentW = w;
applyFit();
});
if (stage) ro.observe(stage);
return () => ro.disconnect();
});
onDestroy(() => {
if (url) URL.revokeObjectURL(url);
});
</script>
<div class="stage" bind:this={stage}>
{#if url}
<canvas bind:this={canvas}></canvas>
{:else}
<div class="loading">Decoding photo…</div>
{/if}
</div>
<style>
.stage { width: 100%; display: flex; justify-content: center; }
canvas { display: block; border-radius: 8px; background: #222; }
.loading { padding: 40px; color: #999; }
</style>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
export let onContinue: () => void;
export let onFresh: () => void;
</script>
<section class="panel">
<h1>Resume?</h1>
<p>You have an in-progress slab on this device.</p>
<div class="row">
<button class="primary" type="button" on:click={onContinue}>Continue</button>
<button class="ghost" type="button" on:click={onFresh}>Start fresh</button>
</div>
</section>
<style>
.panel { padding: 24px 16px; text-align: center; color: #eee; }
h1 { font-size: 1.4rem; }
p { color: #aaa; }
.row { display: flex; gap: 12px; justify-content: center; margin-top: 24px; }
.primary {
flex: 1; max-width: 200px; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost {
flex: 1; max-width: 200px; padding: 14px; border-radius: 10px; border: 1px solid #444;
background: transparent; color: #ccc;
}
</style>
+132
View File
@@ -0,0 +1,132 @@
// Domain model for the M1 capture flow.
// The Capture tagged union is the state machine: each branch carries exactly the
// data its state can hold, so an illegal state is a type error.
// Integer eighths of an inch; divide by 8 only at display.
export type Eighths = number;
export const inToEighths = (inches: number): Eighths => Math.round(inches * 8);
export const eighthsToIn = (e: Eighths): number => e / 8;
// Source: all geometry, measurement, and PNG work uses source pixels, never the
// CSS size of the preview.
export interface Source {
widthPx: number;
heightPx: number;
blob: Blob;
}
export type Sheet = 'auto' | 'green' | 'black';
export interface MaskParams {
sheet: Sheet;
sensitivity: number; // 0–100, step 1
}
// Axis-aligned box, source px.
export interface ABox {
x: number;
y: number;
w: number;
h: number;
}
// Minimum-area bounding rectangle of the mask, source px.
export interface BBox {
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
longSidePx: number; // geometric extent only; scale uses mask span along the axis
angleDeg: number; // long-side angle, degrees, [0, 180)
}
// The approved mask is the footprint for axis, measurement, and the inventory PNG.
export interface Mask {
params: MaskParams;
slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox;
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
}
// Direction only: an oriented rectangle over the slab. The arrow reads
// corners[3] → corners[0] (the long side).
export interface Axis {
bbox: BBox;
}
export interface Widths {
min: Eighths;
max: Eighths;
avg: Eighths;
every6In: Eighths[];
}
export interface Measures {
lengthEighths: Eighths;
thicknessEighths: Eighths;
scale: number; // px per inch
widths: Widths;
sqft: number; // 2 dp
bdft: number; // 2 dp
inventoryPng: Blob;
sourceBelowFloor: boolean;
}
// The machine. 'resume' exists only while a draft exists and has not been chosen.
export type Capture =
| { k: 'resume'; draft: Draft }
| { k: 'addPhoto' }
| {
k: 'mask';
source: Source;
control: MaskParams; // immediate control value (what the UI shows)
overlay: Mask | null; // last settled mask; null until first pass
failed: boolean;
}
| {
k: 'axis';
source: Source;
approvedMask: Mask; // frozen footprint
axis: Axis; // live axis.bbox being nudged
}
| {
k: 'measure';
source: Source;
approvedMask: Mask;
axis: Axis; // frozen
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Measures | null;
};
// Device draft (IndexedDB): one in-progress slab. Small by design — the bitset
// and PNG are re-derived from stored data, never stored second-hand.
export interface Draft {
version: 1;
stage: 'mask' | 'axis' | 'measure'; // last approved state
source: Source;
maskParams: MaskParams; // → re-derive mask on resume
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
lengthEighths: Eighths;
thicknessEighths: Eighths;
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed
inventoryPng: Blob | null; // stored so Continue can show Measure results
}
// Worker protocol. Job ids increase; the worker drops anything older than the
// current id, so a stale result can never blank a newer one.
export type WorkerIn =
| { id: number; kind: 'init'; blob: Blob }
| { id: number; kind: 'mask'; sheet: Sheet; sensitivity: number }
| { id: number; kind: 'invalidate' };
export type WorkerOut =
| { id: number; kind: 'decoded'; w: number; h: number }
| {
id: number;
kind: 'ready';
slabPx: number;
bitset: Uint8Array;
bbox: BBox;
slabBounds: ABox;
}
| { id: number; kind: 'failed'; reason: string };
+26
View File
@@ -0,0 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fitDisplay } from './fitDisplay.ts';
test('parentW=0 → null (no size until laid out)', () => {
assert.equal(fitDisplay(0, 1000, 750, 420), null);
assert.equal(fitDisplay(-5, 1000, 750, 420), null);
});
test('280 wide, 4:3 landscape → contain, under the height cap', () => {
const fit = fitDisplay(280, 1600, 1200, 420);
assert.ok(fit);
assert.equal(fit.fitW, 280);
assert.ok(Math.abs(fit.fitH - 210) < 1e-9);
assert.ok(fit.fitH <= 420);
});
test('portrait under maxH never stretches: height capped, width shrinks', () => {
// 1000 wide source, 3000 tall. Full-width fit would be 900 tall > 420.
const fit = fitDisplay(400, 1000, 3000, 420);
assert.ok(fit);
assert.equal(fit.fitH, 420);
assert.ok(Math.abs(fit.fitW - 140) < 1e-9);
// aspect preserved: fitW/fitH === imgW/imgH
assert.ok(Math.abs(fit.fitW / fit.fitH - 1000 / 3000) < 1e-9);
});
+17
View File
@@ -0,0 +1,17 @@
// Contain-fit of an image into a parent width with a height cap. Pure and
// display-only; all measurement math stays on source pixels.
// Null when the parent is not laid out yet (clientWidth 0) — the caller must
// not finalize CSS size until it is, to avoid a wrong-aspect flash.
export function fitDisplay(
parentW: number, imgW: number, imgH: number, maxH: number,
): { fitW: number; fitH: number } | null {
if (parentW <= 0 || imgW <= 0 || imgH <= 0) return null;
const fitW = parentW;
let fitH = (imgH / imgW) * fitW;
if (fitH > maxH) {
// Cap height and shrink width to preserve aspect; never stretch.
fitH = maxH;
return { fitW: (fitH * imgW) / imgH, fitH };
}
return { fitW, fitH };
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import { inToEighths } from './capture.types';
const panelSrc = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
'utf8',
);
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
assert.ok(
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)),
'Length L must be a type=number input',
);
assert.ok(
inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)),
'Thickness must be a type=number input',
);
assert.equal(
panelSrc.includes('class="stepper"'),
false,
'stepper-only UI is the UAT fail; primary control must be typed input',
);
assert.equal(
/<span class="v">\{fmtEighths\(state\.lengthEighths\)\}<\/span>/.test(panelSrc),
false,
'Length must not be a read-only fmtEighths span',
);
});
test('typed inches snap to nearest 1/8 via inToEighths', () => {
assert.equal(inToEighths(12), 96);
assert.equal(inToEighths(12.1), 97);
assert.equal(inToEighths(0.06), 0);
assert.equal(inToEighths(0.07), 1);
assert.equal(inToEighths(1.999), 16);
});
+363
View File
@@ -0,0 +1,363 @@
import { writable } from 'svelte/store';
import type {
Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
} from './capture.types';
import { eighthsToIn } from './capture.types';
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng } from '../png/inventory';
const DEBOUNCE_MS = 2000;
const BUSY_MS = 300;
export interface CaptureSession {
capture: import('svelte/store').Readable<Capture>;
busy: import('svelte/store').Readable<boolean>;
resumeProbe(): Promise<void>;
continueDraft(): void;
startFresh(): void;
addPhoto(file: File): Promise<void>;
retakes(): void;
setSensitivity(n: number): void;
setSheet(s: Sheet): void;
approveMask(): void;
setAxisAngle(deg: number): void;
approveAxis(): void;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
calculate(): Promise<void>;
back(): void;
getInventoryUrl(): string | null;
dispose(): void;
}
// The one writer. Owns the worker port, the single debounce timer, and the draft.
// Every transition to an approved branch persists; no explicit "save".
export function createCaptureSession(): CaptureSession {
const capture = writable<Capture>({ k: 'addPhoto' });
const busy = writable(false);
let state: Capture = { k: 'addPhoto' };
let worker: Worker | null = null;
let jobId = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
let passStarted = 0;
let busyTimer: ReturnType<typeof setTimeout> | null = null;
let pngUrl: string | null = null;
let resumed = false;
const set = (next: Capture) => {
state = next;
capture.set(next);
};
const teardownWorker = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (worker) {
worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn);
worker.terminate();
worker = null;
}
if (pngUrl) {
URL.revokeObjectURL(pngUrl);
pngUrl = null;
}
};
const makeWorker = (blob: Blob) => {
if (worker) worker.terminate();
worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (e: MessageEvent<WorkerOut>) => onWorkerOut(e.data);
worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn);
};
const onWorkerOut = (msg: WorkerOut) => {
if (msg.id < jobId - 1) return; // stale
if (msg.kind === 'decoded') {
if (state.k === 'mask') {
set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } });
}
schedulePass();
return;
}
busy.set(false);
if (busyTimer) {
clearTimeout(busyTimer);
busyTimer = null;
}
if (state.k !== 'mask') return;
if (msg.kind === 'failed') {
set({ ...state, failed: true });
return;
}
const mask: Mask = {
params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity },
slabPx: msg.slabPx,
bitset: msg.bitset,
bbox: msg.bbox,
slabBounds: msg.slabBounds,
};
set({ ...state, overlay: mask, failed: false });
};
const runPass = () => {
if (state.k !== 'mask' || !worker) return;
passStarted = Date.now();
busyTimer = setTimeout(() => busy.set(true), BUSY_MS);
worker.postMessage({
id: ++jobId,
kind: 'mask',
sheet: state.control.sheet,
sensitivity: state.control.sensitivity,
} satisfies WorkerIn);
};
// The 2.0s debounce: re-armed on every control change; a sheet change
// recomputes immediately (spec allows), so it clears the pending pass.
const schedulePass = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(runPass, DEBOUNCE_MS);
};
const draftFromState = (): Draft | null => {
const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds });
if (state.k === 'axis') {
return {
version: 1, stage: 'axis', source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
if (state.k === 'measure') {
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
return {
version: 1, stage: 'measure', source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
};
}
return null;
};
const persist = async () => {
const d = draftFromState();
if (d) await saveDraft(d as Draft);
};
// Re-derive the mask bitset from the stored photo + params so Measure can run.
const rederiveMask = (source: Source, params: MaskParams, stub: Omit<Mask, 'bitset' | 'params'>): Promise<Mask> =>
new Promise((resolve, reject) => {
const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
let id = 0;
const fail = (reason: string) => {
w.terminate();
reject(new Error(reason));
};
w.onmessage = (e: MessageEvent<WorkerOut>) => {
const msg = e.data;
if (msg.kind === 'decoded') {
w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
return;
}
if (msg.kind === 'failed') {
fail(msg.reason);
return;
}
if (msg.kind === 'ready') {
w.terminate();
resolve({
params,
slabPx: msg.slabPx || stub.slabPx,
bitset: msg.bitset,
bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
});
}
};
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
});
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
// fall back to the mask's own bbox.
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
const restoreFromDraft = async (d: Draft) => {
const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
if (d.stage === 'mask') {
teardownWorker();
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false });
makeWorker(source.blob);
return;
}
teardownWorker();
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
return;
}
if (d.inventoryPng) {
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(d.inventoryPng);
}
set({
k: 'measure', source, approvedMask: mask,
axis: { bbox: draftAxisBbox(d) },
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
results: d.results
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
: null,
});
} catch {
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
makeWorker(source.blob);
}
};
return {
capture,
busy,
async resumeProbe() {
if (resumed) return;
resumed = true;
const d = await loadDraft();
if (d) set({ k: 'resume', draft: d });
},
continueDraft() {
if (state.k !== 'resume') return;
void restoreFromDraft(state.draft);
},
async startFresh() {
if (state.k !== 'resume') return;
teardownWorker();
await clearDraft();
set({ k: 'addPhoto' });
},
async addPhoto(file: File) {
teardownWorker();
set({ k: 'mask', source: { widthPx: 0, heightPx: 0, blob: file }, control: { sheet: 'auto', sensitivity: 50 }, overlay: null, failed: false });
makeWorker(file);
},
retakes() {
teardownWorker();
set({ k: 'addPhoto' });
},
setSensitivity(n: number) {
if (state.k !== 'mask') return;
const s = Math.max(0, Math.min(100, Math.round(n)));
set({ ...state, control: { ...state.control, sensitivity: s } });
schedulePass();
},
setSheet(s: Sheet) {
if (state.k !== 'mask') return;
set({ ...state, control: { ...state.control, sheet: s } });
runPass();
},
approveMask() {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
const overlay = state.overlay;
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
void persist();
},
setAxisAngle(deg: number) {
if (state.k !== 'axis') return;
const a = ((Math.round(deg) % 180) + 180) % 180;
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
void persist();
},
approveAxis() {
if (state.k !== 'axis') return;
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: { bbox: state.axis.bbox },
lengthEighths: 0, thicknessEighths: 0, results: null,
});
void persist();
},
setLength(e: Eighths) {
if (state.k !== 'measure') return;
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
void persist();
},
setThickness(e: Eighths) {
if (state.k !== 'measure') return;
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
void persist();
},
async calculate() {
if (state.k !== 'measure') return;
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return;
const bitmap = await createImageBitmap(source.blob);
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
);
let pngBlob: Blob;
let belowFloor = false;
try {
const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
} catch {
pngBlob = new Blob();
}
bitmap.close();
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(pngBlob);
const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
set({ ...state, results });
void persist();
},
back() {
if (state.k === 'mask') {
teardownWorker();
set({ k: 'addPhoto' });
} else if (state.k === 'axis') {
// back to mask keeps the last approved mask as the live overlay
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
}
},
getInventoryUrl() {
return pngUrl;
},
dispose() {
teardownWorker();
},
};
}
// Display helper: format eighths as inches with fractions.
export function fmtEighths(e: Eighths): string {
const whole = Math.floor(eighthsToIn(e));
const rem = e - whole * 8;
if (rem === 0) return `${whole}"`;
const frac: Record<number, string> = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' };
const part = frac[rem] ?? `${rem}/8`;
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
}
+25
View File
@@ -0,0 +1,25 @@
import { get, set, del } from 'idb-keyval';
import type { Draft } from '../capture/capture.types';
const KEY = 'slabuploader-draft';
export async function loadDraft(): Promise<Draft | null> {
try {
const d = await get<Draft>(KEY);
return d && d.version === 1 ? d : null;
} catch {
return null;
}
}
export async function saveDraft(d: Draft): Promise<void> {
await set(KEY, d);
}
export async function clearDraft(): Promise<void> {
try {
await del(KEY);
} catch {
// idb unavailable: draft persistence is best-effort, app state is not
}
}
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
import { measure } from '../measure/measure.ts';
// 100×20 horizontal slab
const W = 100, H = 20;
const bitset = new Uint8Array(W * H);
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
const sx = maskSpanAlong(bitset, W, H, 1, 0);
const sy = maskSpanAlong(bitset, W, H, 0, 1);
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
});
test('orientedRect changes angleDeg and preserves longSidePx', () => {
const prev = minAreaRect(bitset, W, H);
assert.ok(prev, 'minAreaRect returned a box');
const rotated = orientedRect(prev, 45);
assert.ok(Math.abs(rotated.angleDeg - 45) < 1e-9);
assert.equal(rotated.longSidePx, prev.longSidePx);
// The long side (angleDeg 0 → +x) must be preserved by the rotation.
const longOf = (c) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]);
assert.ok(Math.abs(longOf(rotated.corners) - longOf(prev.corners)) < 1e-9);
assert.ok(Math.abs(longOf(prev.corners) - prev.longSidePx) < 1e-9);
// angle normalization to [0, 180)
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
});
test('measure scale uses mask span along the axis, not longSidePx', () => {
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
// crosses only the 20-px slab width — span 19, not longSidePx 99.
const TW = 20, TH = 100;
const thin = new Uint8Array(TW * TH).fill(1);
const thinBox = minAreaRect(thin, TW, TH);
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
const rotated = orientedRect(thinBox, 0);
const lengthEighths = 24; // 3 in
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
// far from the old longSidePx/3 ≈ 33.
const ex = rotated.corners[0][0] - rotated.corners[3][0];
const ey = rotated.corners[0][1] - rotated.corners[3][1];
const elen = Math.hypot(ex, ey);
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
});
+130
View File
@@ -0,0 +1,130 @@
import type { ABox, BBox } from '../capture/capture.types';
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
let minX = w, minY = h, maxX = -1, maxY = -1;
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
if (bitset[row + x]) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return { x: 0, y: 0, w: 0, h: 0 };
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
}
// Convex hull (Andrew's monotone chain). Points are [x, y].
export function convexHull(pts: [number, number][]): [number, number][] {
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (p.length < 3) return p;
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower: [number, number][] = [];
for (const pt of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
lower.push(pt);
}
const upper: [number, number][] = [];
for (let i = p.length - 1; i >= 0; i--) {
const pt = p[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
upper.push(pt);
}
lower.pop();
upper.pop();
return lower.concat(upper);
}
// Rotating calipers: true minimum-area bounding rectangle of the mask.
export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | null {
const pts: [number, number][] = [];
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) if (bitset[row + x]) pts.push([x, y]);
}
if (pts.length < 2) return null;
const hull = convexHull(pts);
let best = { area: Infinity, cx: 0, cy: 0, ux: 1, uy: 0, hw: 0, hh: 0 };
const n = hull.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const ex = hull[j][0] - hull[i][0];
const ey = hull[j][1] - hull[i][1];
const len = Math.hypot(ex, ey);
if (len === 0) continue;
const ux = ex / len, uy = ey / len;
const vx = -uy, vy = ux;
let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
for (let k = 0; k < n; k++) {
const dx = hull[k][0] - hull[i][0];
const dy = hull[k][1] - hull[i][1];
const u = dx * ux + dy * uy;
const v = dx * vx + dy * vy;
if (u < minU) minU = u;
if (u > maxU) maxU = u;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
const rw = maxU - minU;
const rh = maxV - minV;
const area = rw * rh;
if (area < best.area) {
best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 };
}
}
// long-side direction: the edge with the larger extent
const lu = best.hw >= best.hh ? best.ux : -best.uy;
const lv = best.hw >= best.hh ? best.uy : best.ux;
const su = best.hw >= best.hh ? -best.uy : best.ux;
const sv = best.hw >= best.hh ? -best.ux : -best.uy;
const halfL = best.hw >= best.hh ? best.hw : best.hh;
const halfS = best.hw >= best.hh ? best.hh : best.hw;
const corners: [number, number][] = [];
for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]);
}
const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
return { corners, longSidePx: halfL * 2, angleDeg };
}
// Rotate the long side to a new angle, keeping center and half-extents.
// Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the
// long side (the axis arrow direction).
export function orientedRect(prev: BBox, angleDeg: number): BBox {
const a = ((angleDeg % 180) + 180) % 180;
const halfL = prev.longSidePx / 2;
const halfS = Math.hypot(prev.corners[0][0] - prev.corners[1][0], prev.corners[0][1] - prev.corners[1][1]) / 2;
// corners[i] is a corner point: average of all four = center
const cx = (prev.corners[0][0] + prev.corners[1][0] + prev.corners[2][0] + prev.corners[3][0]) / 4;
const cy = (prev.corners[0][1] + prev.corners[1][1] + prev.corners[2][1] + prev.corners[3][1]) / 4;
const r = (a * Math.PI) / 180;
const lu = Math.cos(r), lv = Math.sin(r);
const su = -lv, sv = lu;
const corners: [number, number][] = [];
for (const [aSign, bSign] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
corners.push([cx + aSign * halfL * lu + bSign * halfS * su, cy + aSign * halfL * lv + bSign * halfS * sv]);
}
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
}
// Project every slab pixel onto the unit vector (ux, uy); return max − min
// extent in px. 0 when the mask is empty.
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
let min = Infinity, max = -Infinity;
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
if (!bitset[row + x]) continue;
const p = x * ux + y * uy;
if (p < min) min = p;
if (p > max) max = p;
}
}
return max < min ? 0 : max - min;
}
+4
View File
@@ -0,0 +1,4 @@
import App from './App.svelte';
const root = document.querySelector('#app');
if (root) new App({ target: root });
+104
View File
@@ -0,0 +1,104 @@
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
import { maskSpanAlong } from '../geometry/rect';
// Display / direction: corners[3] → corners[0] is the long side.
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
return { start: bbox.corners[3], end: bbox.corners[0] };
}
// Wood end→end along the approved direction: project the mask onto the axis
// unit vector and return the segment on the bbox center line covering that span.
function maskAxisSegment(
bitset: Uint8Array, w: number, h: number, bbox: BBox,
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
const { start: a, end: b } = axisEndpoints(bbox);
const ex = b[0] - a[0];
const ey = b[1] - a[1];
const elen = Math.hypot(ex, ey) || 1;
const ux = ex / elen, uy = ey / elen;
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
const cProj = cx * ux + cy * uy;
let minP = Infinity, maxP = -Infinity;
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
if (!bitset[row + x]) continue;
const p = x * ux + y * uy;
if (p < minP) minP = p;
if (p > maxP) maxP = p;
}
}
if (maxP < minP) return { start: a, end: b, span: 0, ux, uy };
return {
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
span: maxP - minP,
ux,
uy,
};
}
// Sampled every 6 in along the wood axis; each sample is a line probe
// perpendicular to the axis, width = max run of slab px across the probe.
function sampleWidths(
bitset: Uint8Array, w: number, h: number,
start: [number, number], ux: number, uy: number,
scale: number, lengthIn: number,
): Eighths[] {
const px = -uy, py = ux;
const stepPx = 6 * scale;
const count = Math.max(1, Math.floor(lengthIn / 6) + 1);
const out: Eighths[] = [];
const half = Math.max(2, Math.round((w + h) / 4));
for (let i = 0; i < count; i++) {
const t = i * stepPx;
const ox = start[0] + ux * t;
const oy = start[1] + uy * t;
let minP = Infinity, maxP = -Infinity;
for (let p = -half; p <= half; p++) {
const x = Math.round(ox + px * p);
const y = Math.round(oy + py * p);
if (x < 0 || y < 0 || x >= w || y >= h) continue;
if (bitset[y * w + x]) {
if (p < minP) minP = p;
if (p > maxP) maxP = p;
}
}
if (maxP < minP) continue;
out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
}
return out;
}
export function measure(
bitset: Uint8Array, w: number, h: number,
bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
const lengthIn = lengthEighths / 8;
const seg = maskAxisSegment(bitset, w, h, bbox);
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);
const max = Math.max(...every6In);
const avg = Math.round(every6In.reduce((a, b) => a + b, 0) / every6In.length);
widths = { min, max, avg, every6In };
} else {
widths = { min: 0, max: 0, avg: 0, every6In: [] };
}
const sqft = Math.round((slabPx / (scale * scale) / 144) * 100) / 100;
const bdft = Math.round(sqft * (thicknessEighths / 8) * 100) / 100;
return {
lengthEighths,
thicknessEighths,
scale,
widths,
sqft,
bdft,
sourceBelowFloor: false,
};
}
+108
View File
@@ -0,0 +1,108 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { composeInventoryFrame } from './inventory.ts';
import type { Mask, Source } from '../capture/capture.types';
const makePhoto = (w: number, h: number, paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255]) => {
const rgba = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const [r, g, b, a] = paint(x, y);
const i = (y * w + x) * 4;
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = a;
}
}
return { width: w, height: h, rgba };
};
const maskFor = (bitset: Uint8Array, slabBounds: Mask['slabBounds']): Pick<Mask, 'bitset' | 'slabBounds'> =>
({ bitset, slabBounds });
test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha 255', () => {
const W = 8, H = 8;
const bitset = new Uint8Array(W * H);
for (let y = 1; y < 5; y++) for (let x = 1; x < 5; x++) bitset[y * W + x] = 1;
bitset[2 * W + 2] = 0;
bitset[3 * W + 3] = 0;
const SLAB = [200, 40, 20, 255] as const;
const SHEET = [0, 255, 0, 255] as const;
const src = makePhoto(W, H, (x, y) => (bitset[y * W + x] === 1 ? [...SLAB] : [...SHEET]));
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
assert.equal(out.sourceBelowFloor, true);
assert.ok(out.width < 1600, 'undersized source stays small');
let opaque = 0;
let transparent = 0;
for (let i = 0; i < out.rgba.length; i += 4) {
const a = out.rgba[i + 3];
if (a === 0) {
transparent++;
assert.equal(out.rgba[i], 0);
assert.equal(out.rgba[i + 1], 0);
assert.equal(out.rgba[i + 2], 0);
continue;
}
opaque++;
assert.equal(a, 255);
assert.deepEqual(
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]],
[SLAB[0], SLAB[1], SLAB[2]],
'opaque pixel must be slab RGB, never sheet green',
);
}
assert.ok(opaque > 0, 'slab pixels are opaque');
assert.ok(transparent > 0, 'frame has transparent pixels');
});
test('3:4 frame at the floor: 2000×1800 slab → 1600×2133', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2000, heightPx: 1800 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
makePhoto(source.widthPx, source.heightPx),
maskFor(bitset, { x: 0, y: 0, w: 2000, h: 1800 }),
source,
);
assert.equal(out.width, 1600);
assert.equal(out.height, 2133);
assert.equal(out.sourceBelowFloor, false);
});
test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
makePhoto(source.widthPx, source.heightPx),
maskFor(bitset, { x: 100, y: 100, w: 2000, h: 1800 }),
source,
);
let minx = out.width, maxx = -1, miny = out.height, maxy = -1;
for (let y = 0; y < out.height; y++) {
for (let x = 0; x < out.width; x++) {
if (out.rgba[(y * out.width + x) * 4 + 3] !== 0) {
if (x < minx) minx = x;
if (x > maxx) maxx = x;
if (y < miny) miny = y;
if (y > maxy) maxy = y;
}
}
}
const xs = [minx, maxx], ys = [miny, maxy];
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
assert.ok(Math.abs(wExtent - 1280) <= 2, `width extent ${wExtent} ≈ 1280 (0.8×1600)`);
assert.ok(Math.abs(hExtent - 1152) <= 2, `height extent ${hExtent} ≈ 1152 (0.8×1800×0.64)`);
});
test('belowFloor: small slab → sourceBelowFloor, outW < 1600', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
makePhoto(source.widthPx, source.heightPx),
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
source,
);
assert.equal(out.sourceBelowFloor, true);
assert.ok(out.width < 1600, `outW ${out.width}`);
});
+83
View File
@@ -0,0 +1,83 @@
import type { Mask, Source } from '../capture/capture.types';
const FLOOR = 1600; // shorter side, px
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab
const ASPECT_W = 3;
const ASPECT_H = 4;
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
export interface InventoryInput {
bitmap: ImageBitmap;
mask: MaskPick;
source: SourcePick;
}
export interface Frame {
width: number;
height: number;
rgba: Uint8Array;
sourceBelowFloor: boolean;
}
export function composeInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
): Frame {
const b = mask.slabBounds;
const x0 = Math.max(0, Math.round(b.x));
const y0 = Math.max(0, Math.round(b.y));
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(b.w)));
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
const sw = x1 - x0;
const sh = y1 - y0;
const belowFloor = Math.min(sw, sh) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
const dw = sw * scale;
const dh = sh * scale;
const ox = (outW - dw) / 2;
const oy = (outH - dh) / 2;
const rgba = new Uint8Array(outW * outH * 4);
const xEnd = ox + dw;
const yEnd = oy + dh;
for (let y = 0; y < outH; y++) {
for (let x = 0; x < outW; x++) {
if (x + 0.5 < ox || x + 0.5 >= xEnd || y + 0.5 < oy || y + 0.5 >= yEnd) continue;
const sx = Math.floor(x0 + (x + 0.5 - ox) / scale);
const sy = Math.floor(y0 + (y + 0.5 - oy) / scale);
if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue;
if (mask.bitset[sy * source.widthPx + sx] !== 1) continue;
const si = (sy * source.widthPx + sx) * 4;
const di = (y * outW + x) * 4;
rgba[di] = src.rgba[si];
rgba[di + 1] = src.rgba[si + 1];
rgba[di + 2] = src.rgba[si + 2];
rgba[di + 3] = 255;
}
}
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
}
export async function encodeInventoryPng(
input: InventoryInput,
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
const { bitmap, mask, source } = input;
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
const srcCtx = srcCanvas.getContext('2d')!;
srcCtx.drawImage(bitmap, 0, 0);
const img = srcCtx.getImageData(0, 0, source.widthPx, source.heightPx);
const frame = composeInventoryFrame(
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
mask,
source,
);
const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!;
ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0);
const blob = await out.convertToBlob({ type: 'image/png' });
return { blob, sourceBelowFloor: frame.sourceBelowFloor };
}
+107
View File
@@ -0,0 +1,107 @@
import { minAreaRect, slabBounds } from '../geometry/rect';
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
let bitmap: ImageBitmap | null = null;
let data: Uint8ClampedArray | null = null;
let w = 0, h = 0;
let current = 0; // highest job id issued by main; older replies are dropped
// Keep slab pixels; remove the sheet. Higher sensitivity widens the sheet match.
const greenKeep = (r: number, g: number, b: number, s: number): boolean => {
const t = s / 100;
const isSheet = g - Math.max(r, b) > 10 + 35 * t;
return !isSheet;
};
const blackKeep = (r: number, g: number, b: number, s: number): boolean => {
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const isSheet = lum < 40 + 140 * (s / 100);
return !isSheet;
};
const autoPick = (samples: { r: number; g: number; b: number }[]): 'green' | 'black' => {
let gr = 0, gl = 0;
for (const p of samples) {
if (p.g - Math.max(p.r, p.b) > 20) gr++;
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
}
return gl >= gr ? 'black' : 'green';
};
function runMask(params: MaskParams, id: number): WorkerOut {
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
let sheet = params.sheet;
if (sheet === 'auto') {
const samples: { r: number; g: number; b: number }[] = [];
for (let y = 0; y < h; y += 16)
for (let x = 0; x < w; x += 16) {
const i = (y * w + x) * 4;
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
}
sheet = autoPick(samples);
}
const keep = sheet === 'green' ? greenKeep : blackKeep;
const s = Math.max(0, Math.min(100, params.sensitivity));
const bitset = new Uint8Array(w * h);
let slabPx = 0;
for (let i = 0, p = 0; i < data.length; i += 4, p++) {
if (keep(data[i], data[i + 1], data[i + 2], s)) {
bitset[p] = 1;
slabPx++;
}
}
if (slabPx === 0) {
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
}
const bbox = minAreaRect(bitset, w, h);
if (!bbox) {
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
}
return { id, kind: 'ready', slabPx, bitset, bbox, slabBounds: slabBounds(bitset, w, h) };
}
self.onmessage = (e: MessageEvent<WorkerIn>) => {
const msg = e.data;
if (msg.id < current) return; // superseded by a newer job
current = msg.id;
if (msg.kind === 'init') {
if (bitmap) {
bitmap.close();
bitmap = null;
}
data = null;
w = h = 0;
createImageBitmap(msg.blob)
.then((bmp) => {
w = bmp.width;
h = bmp.height;
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
data = ctx.getImageData(0, 0, w, h).data;
bitmap = bmp;
self.postMessage({ id: msg.id, kind: 'decoded', w, h } as WorkerOut);
})
.catch((err) => self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut));
return;
}
if (msg.kind === 'invalidate') {
if (bitmap) {
bitmap.close();
bitmap = null;
}
data = null;
w = h = 0;
return;
}
try {
const out = runMask({ sheet: msg.sheet, sensitivity: msg.sensitivity }, msg.id);
if (out.kind === 'ready') {
self.postMessage(out, [out.bitset.buffer]);
} else {
self.postMessage(out);
}
} catch (err) {
self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut);
}
};
export {};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": false,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
"exclude": ["src/**/*.test.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import sveltePreprocess from 'svelte-preprocess';
export default defineConfig({
plugins: [svelte({ preprocess: sveltePreprocess() })],
worker: { format: 'es' },
});