Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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;
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -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.
|
||||
@@ -34,8 +34,8 @@ Otherwise the app opens Add photo.
|
||||
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.
|
||||
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,7 +83,7 @@ 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
|
||||
|
||||
@@ -97,8 +97,20 @@ User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), th
|
||||
- Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it
|
||||
- `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 ≥ 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
|
||||
|
||||
|
||||
@@ -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
+1520
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "slabuploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"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}
|
||||
rotateAxis={session.rotateAxis}
|
||||
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,32 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let rotateAxis: () => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">The arrow points along the slab length. If it does not follow the slab, flip it.</p>
|
||||
<PhotoStage blob={state.source.blob} bbox={state.approvedMask.bbox} heading={state.heading} />
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={rotateAxis}>Flip 180°</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; }
|
||||
.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,101 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } 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;
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
try { await calculate(); } finally { calcBusy = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Measure</h1>
|
||||
<PhotoStage blob={state.source.blob} bbox={state.axis.bbox} heading={state.axis.heading} />
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<div class="stepper">
|
||||
<button type="button" class="thumb" on:click={() => setLength(state.lengthEighths - 1)} aria-label="Length minus">−</button>
|
||||
<span class="v">{fmtEighths(state.lengthEighths)}</span>
|
||||
<button type="button" class="thumb" on:click={() => setLength(state.lengthEighths + 1)} aria-label="Length plus">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Thickness</span>
|
||||
<div class="stepper">
|
||||
<button type="button" class="thumb" on:click={() => setThickness(state.thicknessEighths - 1)} aria-label="Thickness minus">−</button>
|
||||
<span class="v">{fmtEighths(state.thicknessEighths)}</span>
|
||||
<button type="button" class="thumb" on:click={() => setThickness(state.thicknessEighths + 1)} aria-label="Thickness plus">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={calcBusy || state.lengthEighths === 0 || state.thicknessEighths === 0}
|
||||
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; }
|
||||
.stepper { display: flex; align-items: center; gap: 12px; }
|
||||
.thumb {
|
||||
width: 52px; height: 52px; border-radius: 50%; font-size: 1.7rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
.v { flex: 1; text-align: center; font-size: 1.15rem; 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,113 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
|
||||
// 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 heading: 1 | -1 = 1;
|
||||
export let maskBitset: Uint8Array | null = null;
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
let url: string | null = null;
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
const setBlobUrl = (b: Blob) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
url = URL.createObjectURL(b);
|
||||
};
|
||||
|
||||
$: if (blob) setBlobUrl(blob);
|
||||
|
||||
const redraw = () => {
|
||||
if (!url || !canvas) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!canvas) return;
|
||||
const fitW = canvas.parentElement?.clientWidth || canvas.clientWidth || 320;
|
||||
const fitH = Math.min(maxH, (img.height / img.width) * fitW);
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.style.width = `${fitW}px`;
|
||||
canvas.style.height = `${fitH}px`;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
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();
|
||||
const a = heading === 1 ? c[1] : c[0];
|
||||
const b = heading === 1 ? c[0] : c[1];
|
||||
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 || heading || maskBitset) void tick().then(redraw);
|
||||
|
||||
const onResize = () => redraw();
|
||||
if (typeof window !== 'undefined') window.addEventListener('resize', onResize);
|
||||
onDestroy(() => {
|
||||
if (typeof window !== 'undefined') window.removeEventListener('resize', onResize);
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="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 { max-width: 100%; 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,131 @@
|
||||
// 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, counter-clockwise
|
||||
longSidePx: number; // longest_axis_px → scale numerator
|
||||
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
|
||||
}
|
||||
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
heading: 1 | -1; // which way along the long side is forward (180° flip)
|
||||
}
|
||||
|
||||
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
|
||||
heading: 1 | -1;
|
||||
}
|
||||
| {
|
||||
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
|
||||
heading: 1 | -1; // present when stage >= axis
|
||||
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,356 @@
|
||||
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 { 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;
|
||||
rotateAxis(): 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), heading: state.heading,
|
||||
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), heading: state.axis.heading,
|
||||
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);
|
||||
});
|
||||
|
||||
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, heading: d.heading });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: mask.bbox, heading: d.heading },
|
||||
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();
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.overlay, heading: 1 });
|
||||
void persist();
|
||||
},
|
||||
|
||||
rotateAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({ ...state, heading: state.heading === 1 ? -1 : 1 });
|
||||
void persist();
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.approvedMask.bbox, heading: state.heading },
|
||||
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, axis.heading, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const r = await encodeInventoryPng(bitmap, approvedMask.slabBounds);
|
||||
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, heading: state.axis.heading });
|
||||
}
|
||||
},
|
||||
|
||||
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,94 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
const root = document.querySelector('#app');
|
||||
if (root) new App({ target: root });
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||
|
||||
// Long-side start point per heading: heading 1 starts at corners[1] so that
|
||||
// the axis reads corners[1] -> corners[0] (see rect.ts corner order).
|
||||
export function axisEndpoints(bbox: BBox, heading: 1 | -1): { start: [number, number]; end: [number, number] } {
|
||||
return heading === 1 ? { start: bbox.corners[1], end: bbox.corners[0] } : { start: bbox.corners[0], end: bbox.corners[1] };
|
||||
}
|
||||
|
||||
// Sampled every 6 in along the 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], end: [number, number],
|
||||
scale: number, lengthIn: number,
|
||||
): Eighths[] {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ax = dx / len, ay = dy / len;
|
||||
const px = -ay, py = ax; // perpendicular
|
||||
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 / (count - 1 || 1)) * len;
|
||||
const ox = start[0] + ax * t;
|
||||
const oy = start[1] + ay * 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; // no slab px on this probe
|
||||
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, heading: 1 | -1,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
const scale = bbox.longSidePx / lengthIn;
|
||||
const { start, end } = axisEndpoints(bbox, heading);
|
||||
const every6In = sampleWidths(bitset, w, h, start, end, 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,38 @@
|
||||
import type { ABox } from '../capture/capture.types';
|
||||
|
||||
const FLOOR = 1600; // shorter side, px
|
||||
const FILL = 0.8; // 80% fill at widest and tallest points
|
||||
const MARGIN = 0.08; // crop padding around slab bounds
|
||||
const ASPECT_W = 3;
|
||||
const ASPECT_H = 4;
|
||||
|
||||
// 3:4 portrait RGBA PNG: slab centered at 80% fill (widest and tallest points),
|
||||
// shorter side >= 1600 when the source can supply it. No upscale: an undersized
|
||||
// source yields sourceBelowFloor = true (warn + require a new photo).
|
||||
export async function encodeInventoryPng(
|
||||
bitmap: ImageBitmap,
|
||||
slab: ABox,
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
const cw = Math.round(slab.w * (1 + 2 * MARGIN));
|
||||
const ch = Math.round(slab.h * (1 + 2 * MARGIN));
|
||||
const sx = Math.max(0, Math.round(slab.x - slab.w * MARGIN));
|
||||
const sy = Math.max(0, Math.round(slab.y - slab.h * MARGIN));
|
||||
const srcW = Math.min(bitmap.width - sx, Math.max(1, cw));
|
||||
const srcH = Math.min(bitmap.height - sy, Math.max(1, ch));
|
||||
|
||||
const belowFloor = Math.min(srcW, srcH) < FLOOR;
|
||||
// Portrait 3:4: width is the shorter side. Meet the floor when source allows.
|
||||
const outW = belowFloor ? Math.max(1, Math.min(srcW, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, outW, outH);
|
||||
const maxW = outW * FILL;
|
||||
const maxH = outH * FILL;
|
||||
const scale = Math.min(maxW / srcW, maxH / srcH);
|
||||
const dw = srcW * scale;
|
||||
const dh = srcH * scale;
|
||||
ctx.drawImage(bitmap, sx, sy, srcW, srcH, (outW - dw) / 2, (outH - dh) / 2, dw, dh);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
return { blob, sourceBelowFloor: belowFloor };
|
||||
}
|
||||
@@ -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,16 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -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