Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eaedcb53dd | |||
| 11c8124674 | |||
| 44d428b6f4 | |||
| f20d84c543 | |||
| fbcd05169f | |||
| b3a9fdf330 | |||
| 114e0b5116 | |||
| 81e9184c7f | |||
| b328871549 | |||
| b781ffeb66 | |||
| 0e94b1fd2a | |||
| c6a48c8d91 | |||
| 0679e1cef7 | |||
| 503449452c | |||
| bb78e35ac3 | |||
| d86fc93ed6 |
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
backend
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.svelte-kit
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/README.md
|
||||
.audit
|
||||
docs/archive
|
||||
+16
-1
@@ -1,4 +1,19 @@
|
||||
# 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
-12
@@ -1,7 +1,7 @@
|
||||
# Milestone 1 — Capture
|
||||
|
||||
Runs in the browser on phone and PC as ordinary client code.
|
||||
No inference URL. No model. No backend. Mask, rectangle, measurements, and PNG encode are deterministic functions of the photo and user inputs.
|
||||
No inference URL. No model. No backend. Mask, rectangle, measurements, crop, and PNG encode are deterministic functions of the photo and user inputs.
|
||||
|
||||
Milestone 1 uses one reference photo. Further angles are later milestones.
|
||||
Milestone 1 ends when the user sees the inventory PNG and measurements. Milestone 2 is Review. Milestone 3 is Create. This file does not create a WooCommerce product.
|
||||
@@ -33,9 +33,9 @@ Otherwise the app opens Add photo.
|
||||
3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step.
|
||||
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
6. App sets `scale = longest_axis_px / L`.
|
||||
7. App computes widths, sqft, and bdft from the mask, and writes the inventory PNG.
|
||||
8. User sees the PNG and the numbers. Milestone 1 stops here.
|
||||
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
|
||||
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 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 transparent PNG and the numbers. Milestone 1 stops here.
|
||||
|
||||
Calculate does not create a WooCommerce product.
|
||||
|
||||
@@ -53,13 +53,13 @@ One photo component is reused. Each state advances on approve. Back returns to t
|
||||
2. Add photo — camera or picker. One photo. Setup rules visible.
|
||||
3. Mask — background removal on that photo.
|
||||
4. Length axis — bounding rectangle and long-side axis on the same photo.
|
||||
5. Measure — length, thickness, Calculate, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
|
||||
5. Measure — length, thickness, Calculate, crop, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
|
||||
|
||||
## Display
|
||||
|
||||
Preview fits the device width. Height is capped so Mask and Measure controls stay on screen. Phone and PC use the same states.
|
||||
|
||||
The preview is display only. Mask, rectangle, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
|
||||
The preview is display only. Mask, rectangle, crop, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
|
||||
|
||||
## Runtime
|
||||
|
||||
@@ -83,22 +83,34 @@ Mask work runs in the worker. The last good overlay stays visible while a pass r
|
||||
|
||||
Approve is blocked when the mask has no slab pixels. User retakes.
|
||||
|
||||
User approves the mask when only the slab remains. That mask is the footprint for Length axis, measurements, and the inventory PNG.
|
||||
User approves the mask when only the slab remains. That mask is the footprint for Length axis, crop, measurements, and the inventory PNG.
|
||||
|
||||
## Length axis
|
||||
|
||||
On the same photo, draw the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
On the same photo, keep the approved mask in view and draw the minimum-area bounding rectangle of the mask with the length axis on the long side of that rectangle. User rotates the rectangle (slider with minus and plus) until that direction follows the slab length, then approves the axis.
|
||||
|
||||
## Measure
|
||||
|
||||
User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
|
||||
- `scale = longest_axis_px / L`
|
||||
- Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it
|
||||
- `scale = longest_axis_px / L` where `longest_axis_px` is the mask/wood span along the approved length direction (slab pixels end-to-end on that axis), not the rectangle edge length. The rectangle sets direction only; user-entered `L` is wood end→end along that direction.
|
||||
- Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0.
|
||||
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
||||
- `bdft = sqft × thickness_in` (2 decimals)
|
||||
- Crop: 3:4 portrait, slab centered, 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 ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
||||
5. Encode as PNG with alpha.
|
||||
|
||||
## Device draft
|
||||
|
||||
@@ -122,7 +134,7 @@ The first inventory photo is the measurement reference. Later inventory photos u
|
||||
| Alpha | Kept through encode and upload |
|
||||
| Aspect | 3:4 portrait |
|
||||
| Layout | Slab centered, 80% fill at the widest and tallest points |
|
||||
| Size | After crop, shorter side ≥ 1600px when the source can supply it |
|
||||
| Size | After crop, shorter side ≥ 800px when the source can supply it |
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
Editor preview of alpha is a light gray checkerboard. The storefront uses the theme background.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Build the static app, then serve only the built artifacts.
|
||||
# Build context is the repo root: `docker build -f frontend/Dockerfile .`
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY deploy/nginx-m1.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>SlabUploader — Capture</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; }
|
||||
#app { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1996
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
// Supported clients: iOS Safari, Android Chrome, PC Chrome/Edge/Firefox.
|
||||
// Gated on required API presence; anything missing → hard stop.
|
||||
export function supported(): string | null {
|
||||
if (typeof window === 'undefined') return 'window unavailable';
|
||||
if (typeof createImageBitmap !== 'function') return 'createImageBitmap';
|
||||
if (typeof Worker === 'undefined') return 'Worker';
|
||||
if (typeof indexedDB === 'undefined') return 'IndexedDB';
|
||||
const hasOffscreen =
|
||||
typeof OffscreenCanvas !== 'undefined' &&
|
||||
typeof OffscreenCanvas.prototype.convertToBlob === 'function';
|
||||
const hasCanvasBlob =
|
||||
typeof HTMLCanvasElement !== 'undefined' &&
|
||||
typeof HTMLCanvasElement.prototype.toBlob === 'function';
|
||||
if (!hasOffscreen && !hasCanvasBlob) return 'canvas PNG encode';
|
||||
const ua = navigator.userAgent;
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isAndroid = /Android/i.test(ua);
|
||||
const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|Edg|Firefox|FxiOS/i.test(ua);
|
||||
const isChrome = /Chrome|CriOS/i.test(ua) && !/Edg/i.test(ua);
|
||||
const isEdge = /Edg/i.test(ua);
|
||||
const isFirefox = /Firefox|FxiOS/i.test(ua);
|
||||
if (isIOS && isSafari) return null;
|
||||
if (isAndroid && isChrome) return null;
|
||||
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
||||
return 'unsupported browser';
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="179" step="1"
|
||||
value={angleDeg}
|
||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
||||
<span class="val">{angleDeg}°</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { Capture, Sheet } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'mask' }>;
|
||||
export let busy: boolean;
|
||||
export let setSensitivity: (n: number) => void;
|
||||
export let setSheet: (s: Sheet) => void;
|
||||
export let approveMask: () => void;
|
||||
export let retakes: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
const sheets: Sheet[] = ['auto', 'green', 'black'];
|
||||
$: empty = state.overlay !== null && state.overlay.slabPx === 0;
|
||||
$: maskW = state.overlay && state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 && state.overlay ? state.overlay.bitset.length / maskW : 0;
|
||||
$: canApprove = state.overlay !== null && state.overlay.slabPx > 0;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<div class="stage-wrap">
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
maskBitset={state.overlay?.bitset ?? null}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
{#if busy}<div class="busy">Working…</div>{/if}
|
||||
{#if state.failed}<div class="warn">Mask pass failed. Adjust and retry, or retake the photo.</div>{/if}
|
||||
{#if empty}<div class="warn">No slab pixels in the mask. Adjust sensitivity or retake.</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="seg" role="group" aria-label="Sheet">
|
||||
{#each sheets as s (s)}
|
||||
<button type="button" class:active={state.control.sheet === s} on:click={() => setSheet(s)}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sens">
|
||||
<button type="button" class="thumb" aria-label="Decrease sensitivity" on:click={() => setSensitivity(state.control.sensitivity - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="100" step="1"
|
||||
value={state.control.sensitivity}
|
||||
on:input={(e) => setSensitivity(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Increase sensitivity" on:click={() => setSensitivity(state.control.sensitivity + 1)}>+</button>
|
||||
<span class="val">{state.control.sensitivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="ghost" on:click={retakes}>Retake</button>
|
||||
<button type="button" class="primary" disabled={!canApprove} on:click={approveMask}>
|
||||
Approve mask
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.stage-wrap { position: relative; }
|
||||
.busy {
|
||||
position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7);
|
||||
color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 0.85rem;
|
||||
}
|
||||
.warn { color: #ffb4a2; margin-top: 8px; font-size: 0.9rem; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.seg { display: flex; gap: 8px; }
|
||||
.seg button {
|
||||
flex: 1; padding: 12px; font-size: 1rem; border-radius: 8px;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ccc;
|
||||
}
|
||||
.seg button.active { background: #00e5ff; color: #002; font-weight: 700; border-color: #00e5ff; }
|
||||
.sens { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 2.2ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 12px; margin-top: 20px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
@@ -0,0 +1,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 800px 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
export let onContinue: () => void;
|
||||
export let onFresh: () => void;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Resume?</h1>
|
||||
<p>You have an in-progress slab on this device.</p>
|
||||
<div class="row">
|
||||
<button class="primary" type="button" on:click={onContinue}>Continue</button>
|
||||
<button class="ghost" type="button" on:click={onFresh}>Start fresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; text-align: center; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
p { color: #aaa; }
|
||||
.row { display: flex; gap: 12px; justify-content: center; margin-top: 24px; }
|
||||
.primary {
|
||||
flex: 1; max-width: 200px; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost {
|
||||
flex: 1; max-width: 200px; padding: 14px; border-radius: 10px; border: 1px solid #444;
|
||||
background: transparent; color: #ccc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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 };
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fitDisplay } from './fitDisplay.ts';
|
||||
|
||||
test('parentW=0 → null (no size until laid out)', () => {
|
||||
assert.equal(fitDisplay(0, 1000, 750, 420), null);
|
||||
assert.equal(fitDisplay(-5, 1000, 750, 420), null);
|
||||
});
|
||||
|
||||
test('280 wide, 4:3 landscape → contain, under the height cap', () => {
|
||||
const fit = fitDisplay(280, 1600, 1200, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitW, 280);
|
||||
assert.ok(Math.abs(fit.fitH - 210) < 1e-9);
|
||||
assert.ok(fit.fitH <= 420);
|
||||
});
|
||||
|
||||
test('portrait under maxH never stretches: height capped, width shrinks', () => {
|
||||
// 1000 wide source, 3000 tall. Full-width fit would be 900 tall > 420.
|
||||
const fit = fitDisplay(400, 1000, 3000, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitH, 420);
|
||||
assert.ok(Math.abs(fit.fitW - 140) < 1e-9);
|
||||
// aspect preserved: fitW/fitH === imgW/imgH
|
||||
assert.ok(Math.abs(fit.fitW / fit.fitH - 1000 / 3000) < 1e-9);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// Contain-fit of an image into a parent width with a height cap. Pure and
|
||||
// display-only; all measurement math stays on source pixels.
|
||||
// Null when the parent is not laid out yet (clientWidth 0) — the caller must
|
||||
// not finalize CSS size until it is, to avoid a wrong-aspect flash.
|
||||
export function fitDisplay(
|
||||
parentW: number, imgW: number, imgH: number, maxH: number,
|
||||
): { fitW: number; fitH: number } | null {
|
||||
if (parentW <= 0 || imgW <= 0 || imgH <= 0) return null;
|
||||
const fitW = parentW;
|
||||
let fitH = (imgH / imgW) * fitW;
|
||||
if (fitH > maxH) {
|
||||
// Cap height and shrink width to preserve aspect; never stretch.
|
||||
fitH = maxH;
|
||||
return { fitW: (fitH * imgW) / imgH, fitH };
|
||||
}
|
||||
return { fitW, fitH };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
import { inToEighths } from './capture.types';
|
||||
|
||||
const panelSrc = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
|
||||
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)),
|
||||
'Length L must be a type=number input',
|
||||
);
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)),
|
||||
'Thickness must be a type=number input',
|
||||
);
|
||||
assert.equal(
|
||||
panelSrc.includes('class="stepper"'),
|
||||
false,
|
||||
'stepper-only UI is the UAT fail; primary control must be typed input',
|
||||
);
|
||||
assert.equal(
|
||||
/<span class="v">\{fmtEighths\(state\.lengthEighths\)\}<\/span>/.test(panelSrc),
|
||||
false,
|
||||
'Length must not be a read-only fmtEighths span',
|
||||
);
|
||||
});
|
||||
|
||||
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||
assert.equal(inToEighths(12), 96);
|
||||
assert.equal(inToEighths(12.1), 97);
|
||||
assert.equal(inToEighths(0.06), 0);
|
||||
assert.equal(inToEighths(0.07), 1);
|
||||
assert.equal(inToEighths(1.999), 16);
|
||||
});
|
||||
@@ -0,0 +1,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}"`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { get, set, del } from 'idb-keyval';
|
||||
import type { Draft } from '../capture/capture.types';
|
||||
|
||||
const KEY = 'slabuploader-draft';
|
||||
|
||||
export async function loadDraft(): Promise<Draft | null> {
|
||||
try {
|
||||
const d = await get<Draft>(KEY);
|
||||
return d && d.version === 1 ? d : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDraft(d: Draft): Promise<void> {
|
||||
await set(KEY, d);
|
||||
}
|
||||
|
||||
export async function clearDraft(): Promise<void> {
|
||||
try {
|
||||
await del(KEY);
|
||||
} catch {
|
||||
// idb unavailable: draft persistence is best-effort, app state is not
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
|
||||
import { measure } from '../measure/measure.ts';
|
||||
|
||||
// 100×20 horizontal slab
|
||||
const W = 100, H = 20;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
|
||||
|
||||
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
|
||||
const sx = maskSpanAlong(bitset, W, H, 1, 0);
|
||||
const sy = maskSpanAlong(bitset, W, H, 0, 1);
|
||||
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
|
||||
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
|
||||
});
|
||||
|
||||
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||
const prev = minAreaRect(bitset, W, H);
|
||||
assert.ok(prev, 'minAreaRect returned a box');
|
||||
const rotated = orientedRect(prev, 45);
|
||||
assert.ok(Math.abs(rotated.angleDeg - 45) < 1e-9);
|
||||
assert.equal(rotated.longSidePx, prev.longSidePx);
|
||||
// The long side (angleDeg 0 → +x) must be preserved by the rotation.
|
||||
const longOf = (c) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]);
|
||||
assert.ok(Math.abs(longOf(rotated.corners) - longOf(prev.corners)) < 1e-9);
|
||||
assert.ok(Math.abs(longOf(prev.corners) - prev.longSidePx) < 1e-9);
|
||||
// angle normalization to [0, 180)
|
||||
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||
});
|
||||
|
||||
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
||||
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
||||
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
||||
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
||||
const TW = 20, TH = 100;
|
||||
const thin = new Uint8Array(TW * TH).fill(1);
|
||||
const thinBox = minAreaRect(thin, TW, TH);
|
||||
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
||||
const rotated = orientedRect(thinBox, 0);
|
||||
const lengthEighths = 24; // 3 in
|
||||
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
|
||||
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
|
||||
// far from the old longSidePx/3 ≈ 33.
|
||||
const ex = rotated.corners[0][0] - rotated.corners[3][0];
|
||||
const ey = rotated.corners[0][1] - rotated.corners[3][1];
|
||||
const elen = Math.hypot(ex, ey);
|
||||
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
|
||||
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
|
||||
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { ABox, BBox } from '../capture/capture.types';
|
||||
|
||||
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (bitset[row + x]) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return { x: 0, y: 0, w: 0, h: 0 };
|
||||
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
// Convex hull (Andrew's monotone chain). Points are [x, y].
|
||||
export function convexHull(pts: [number, number][]): [number, number][] {
|
||||
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (p.length < 3) return p;
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: [number, number][] = [];
|
||||
for (const pt of p) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
|
||||
lower.push(pt);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = p.length - 1; i >= 0; i--) {
|
||||
const pt = p[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
|
||||
upper.push(pt);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
// Rotating calipers: true minimum-area bounding rectangle of the mask.
|
||||
export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | null {
|
||||
const pts: [number, number][] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) if (bitset[row + x]) pts.push([x, y]);
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const hull = convexHull(pts);
|
||||
|
||||
let best = { area: Infinity, cx: 0, cy: 0, ux: 1, uy: 0, hw: 0, hh: 0 };
|
||||
const n = hull.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
const ex = hull[j][0] - hull[i][0];
|
||||
const ey = hull[j][1] - hull[i][1];
|
||||
const len = Math.hypot(ex, ey);
|
||||
if (len === 0) continue;
|
||||
const ux = ex / len, uy = ey / len;
|
||||
const vx = -uy, vy = ux;
|
||||
let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const dx = hull[k][0] - hull[i][0];
|
||||
const dy = hull[k][1] - hull[i][1];
|
||||
const u = dx * ux + dy * uy;
|
||||
const v = dx * vx + dy * vy;
|
||||
if (u < minU) minU = u;
|
||||
if (u > maxU) maxU = u;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
const rw = maxU - minU;
|
||||
const rh = maxV - minV;
|
||||
const area = rw * rh;
|
||||
if (area < best.area) {
|
||||
best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
// long-side direction: the edge with the larger extent
|
||||
const lu = best.hw >= best.hh ? best.ux : -best.uy;
|
||||
const lv = best.hw >= best.hh ? best.uy : best.ux;
|
||||
const su = best.hw >= best.hh ? -best.uy : best.ux;
|
||||
const sv = best.hw >= best.hh ? -best.ux : -best.uy;
|
||||
const halfL = best.hw >= best.hh ? best.hw : best.hh;
|
||||
const halfS = best.hw >= best.hh ? best.hh : best.hw;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]);
|
||||
}
|
||||
const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
|
||||
return { corners, longSidePx: halfL * 2, angleDeg };
|
||||
}
|
||||
|
||||
// Rotate the long side to a new angle, keeping center and half-extents.
|
||||
// Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the
|
||||
// long side (the axis arrow direction).
|
||||
export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
||||
const a = ((angleDeg % 180) + 180) % 180;
|
||||
const halfL = prev.longSidePx / 2;
|
||||
const halfS = Math.hypot(prev.corners[0][0] - prev.corners[1][0], prev.corners[0][1] - prev.corners[1][1]) / 2;
|
||||
// corners[i] is a corner point: average of all four = center
|
||||
const cx = (prev.corners[0][0] + prev.corners[1][0] + prev.corners[2][0] + prev.corners[3][0]) / 4;
|
||||
const cy = (prev.corners[0][1] + prev.corners[1][1] + prev.corners[2][1] + prev.corners[3][1]) / 4;
|
||||
const r = (a * Math.PI) / 180;
|
||||
const lu = Math.cos(r), lv = Math.sin(r);
|
||||
const su = -lv, sv = lu;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [aSign, bSign] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([cx + aSign * halfL * lu + bSign * halfS * su, cy + aSign * halfL * lv + bSign * halfS * sv]);
|
||||
}
|
||||
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||
}
|
||||
|
||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||
// extent in px. 0 when the mask is empty.
|
||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < min) min = p;
|
||||
if (p > max) max = p;
|
||||
}
|
||||
}
|
||||
return max < min ? 0 : max - min;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
const root = document.querySelector('#app');
|
||||
if (root) new App({ target: root });
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
|
||||
import { measure } from './measure.ts';
|
||||
|
||||
// Uniform slab, 10 px/in: full W×H canvas, axis +x via minAreaRect angle 0.
|
||||
function uniform(W: number, H: number) {
|
||||
const bitset = new Uint8Array(W * H).fill(1);
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
return { bitset, box, slabPx: W * H };
|
||||
}
|
||||
|
||||
// Tapered slab, 10 px/in: tips under 6 in and past L−6 in are `tipPx` wide
|
||||
// (centered), the bulk is full canvas height.
|
||||
function tapered(W: number, H: number, lengthEighths: number, tipPx: number) {
|
||||
const inIn = W / 10;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
const half = tipPx / 2;
|
||||
for (let x = 0; x < W; x++) {
|
||||
const p = x / 10;
|
||||
const w = p < 6 || p > inIn - 6 ? tipPx : H;
|
||||
for (let y = (H - w) / 2; y < (H + w) / 2; y++) bitset[y * W + x] = 1;
|
||||
}
|
||||
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
|
||||
return { bitset, box, slabPx: bitset.reduce((a, c) => a + c, 0), lengthEighths };
|
||||
}
|
||||
|
||||
test('tapered 48in slab: min/max reflect the bulk, tips excluded', () => {
|
||||
const { bitset, box, slabPx, lengthEighths } = tapered(480, 100, 384, 20); // tips 2in, bulk 10in
|
||||
const m = measure(bitset, 480, 100, box, lengthEighths, 8, slabPx);
|
||||
assert.equal(m.widths.every6In.length, 7, `stations ${m.widths.every6In.length} (6..42in)`);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 76 && e <= 84, `station ${e / 8}in must be bulk (~10in)`);
|
||||
}
|
||||
assert.ok(m.widths.min >= 76 && m.widths.min <= 84, `min ${m.widths.min / 8}in`);
|
||||
assert.ok(m.widths.max >= 76 && m.widths.max <= 84, `max ${m.widths.max / 8}in`);
|
||||
});
|
||||
|
||||
test('uniform slab: interior stations keep the full width', () => {
|
||||
const { bitset, box, slabPx } = uniform(480, 100);
|
||||
const m = measure(bitset, 480, 100, box, 384, 8, slabPx);
|
||||
assert.equal(m.widths.every6In.length, 7);
|
||||
for (const e of m.widths.every6In) {
|
||||
assert.ok(e >= 72 && e <= 104, `station ${e / 8}in`);
|
||||
}
|
||||
});
|
||||
|
||||
test('short slab L=12in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(120, 100);
|
||||
const m = measure(bitset, 120, 100, box, 96, 8, slabPx);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
|
||||
test('short slab L=10in: no interior stations, widths are empty/zero', () => {
|
||||
const { bitset, box, slabPx } = uniform(100, 100);
|
||||
const m = measure(bitset, 100, 100, box, 80, 8, slabPx);
|
||||
assert.deepEqual(m.widths, { min: 0, max: 0, avg: 0, every6In: [] });
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// Display / direction: corners[3] → corners[0] is the long side.
|
||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||
return { start: bbox.corners[3], end: bbox.corners[0] };
|
||||
}
|
||||
|
||||
// Wood end→end along the approved direction: project the mask onto the axis
|
||||
// unit vector and return the segment on the bbox center line covering that span.
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array, w: number, h: number, bbox: BBox,
|
||||
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen, uy = ey / elen;
|
||||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
||||
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) return { start: a, end: b, span: 0, ux, uy };
|
||||
return {
|
||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||
span: maxP - minP,
|
||||
ux,
|
||||
uy,
|
||||
};
|
||||
}
|
||||
|
||||
// Interior stations only (6, 12, … ≤ L−6). Tips dominate min/max on tapered
|
||||
// boards, so the first and last 6 in are excluded. Each sample is a line
|
||||
// probe perpendicular to the axis; width = max run of slab px across it.
|
||||
function sampleWidths(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
start: [number, number], ux: number, uy: number,
|
||||
scale: number, lengthIn: number,
|
||||
): Eighths[] {
|
||||
const px = -uy, py = ux;
|
||||
const stepPx = 6 * scale;
|
||||
const lastPx = lengthIn > 12 ? Math.floor((lengthIn - 6) / 6) * 6 * scale : 0;
|
||||
const out: Eighths[] = [];
|
||||
const half = Math.max(2, Math.round((w + h) / 4));
|
||||
for (let t = stepPx; t <= lastPx; t += stepPx) {
|
||||
const ox = start[0] + ux * t;
|
||||
const oy = start[1] + uy * t;
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let p = -half; p <= half; p++) {
|
||||
const x = Math.round(ox + px * p);
|
||||
const y = Math.round(oy + py * p);
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||||
if (bitset[y * w + x]) {
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) continue;
|
||||
out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function measure(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
const min = Math.min(...every6In);
|
||||
const max = Math.max(...every6In);
|
||||
const avg = Math.round(every6In.reduce((a, b) => a + b, 0) / every6In.length);
|
||||
widths = { min, max, avg, every6In };
|
||||
} else {
|
||||
widths = { min: 0, max: 0, avg: 0, every6In: [] };
|
||||
}
|
||||
const sqft = Math.round((slabPx / (scale * scale) / 144) * 100) / 100;
|
||||
const bdft = Math.round(sqft * (thicknessEighths / 8) * 100) / 100;
|
||||
return {
|
||||
lengthEighths,
|
||||
thicknessEighths,
|
||||
scale,
|
||||
widths,
|
||||
sqft,
|
||||
bdft,
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeInventoryFrame } from './inventory.ts';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const makePhoto = (w: number, h: number, paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255]) => {
|
||||
const rgba = new Uint8Array(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b, a] = paint(x, y);
|
||||
const i = (y * w + x) * 4;
|
||||
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = a;
|
||||
}
|
||||
}
|
||||
return { width: w, height: h, rgba };
|
||||
};
|
||||
|
||||
const maskFor = (bitset: Uint8Array, slabBounds: Mask['slabBounds']): Pick<Mask, 'bitset' | 'slabBounds'> =>
|
||||
({ bitset, slabBounds });
|
||||
|
||||
test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha 255', () => {
|
||||
const W = 8, H = 8;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y < 5; y++) for (let x = 1; x < 5; x++) bitset[y * W + x] = 1;
|
||||
bitset[2 * W + 2] = 0;
|
||||
bitset[3 * W + 3] = 0;
|
||||
const SLAB = [200, 40, 20, 255] as const;
|
||||
const SHEET = [0, 255, 0, 255] as const;
|
||||
const src = makePhoto(W, H, (x, y) => (bitset[y * W + x] === 1 ? [...SLAB] : [...SHEET]));
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
|
||||
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
|
||||
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width < 800, 'undersized source stays small');
|
||||
|
||||
let opaque = 0;
|
||||
let transparent = 0;
|
||||
for (let i = 0; i < out.rgba.length; i += 4) {
|
||||
const a = out.rgba[i + 3];
|
||||
if (a === 0) {
|
||||
transparent++;
|
||||
assert.equal(out.rgba[i], 0);
|
||||
assert.equal(out.rgba[i + 1], 0);
|
||||
assert.equal(out.rgba[i + 2], 0);
|
||||
continue;
|
||||
}
|
||||
opaque++;
|
||||
assert.equal(a, 255);
|
||||
assert.deepEqual(
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]],
|
||||
[SLAB[0], SLAB[1], SLAB[2]],
|
||||
'opaque pixel must be slab RGB, never sheet green',
|
||||
);
|
||||
}
|
||||
assert.ok(opaque > 0, 'slab pixels are opaque');
|
||||
assert.ok(transparent > 0, 'frame has transparent pixels');
|
||||
});
|
||||
|
||||
test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2000, heightPx: 1800 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 2000, h: 1800 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.width, 800);
|
||||
assert.equal(out.height, 1067);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
});
|
||||
|
||||
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 1200, heightPx: 1000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
assert.equal(out.width, 800);
|
||||
});
|
||||
|
||||
test('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 - 640) <= 2, `width extent ${wExtent} ≈ 640 (0.8×800)`);
|
||||
assert.ok(Math.abs(hExtent - 576) <= 2, `height extent ${hExtent} ≈ 576 (1800×0.32)`);
|
||||
});
|
||||
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const FLOOR = 800; // shorter side, px
|
||||
const FILL = 0.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 };
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import sveltePreprocess from 'svelte-preprocess';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ preprocess: sveltePreprocess() })],
|
||||
worker: { format: 'es' },
|
||||
});
|
||||
Reference in New Issue
Block a user