0bc86ecc6d
Rapid sensitivity drags triggered a flood of async mask runs; an in-flight run could settle after the owner pressed Approve and clobber the state, so the Approve click read a snapshot with overlay:null and early-returned. Debounce 500ms after the user stops adjusting so the mask settles before any interaction and the click reads a stable state.
247 lines
11 KiB
Svelte
247 lines
11 KiB
Svelte
<script lang="ts">
|
||
import { createEventDispatcher } from 'svelte';
|
||
import type { CaptureSession } from './session';
|
||
import type { Sheet } from './capture.types';
|
||
|
||
export let session: CaptureSession;
|
||
const dispatch = createEventDispatcher<{ 'photos-ready': void }>();
|
||
|
||
const stateStore = session.state;
|
||
$: state = $stateStore;
|
||
$: cap = state.capture;
|
||
$: kind = cap.k;
|
||
|
||
$: photos = kind === 'batch' || kind === 'pickCalibration' ? cap.photos : [];
|
||
$: control = kind === 'mask' ? cap.control : null;
|
||
$: overlay = kind === 'mask' ? cap.overlay : null;
|
||
$: failed = kind === 'mask' ? cap.failed : false;
|
||
$: measure = kind === 'measure' ? cap : null;
|
||
$: results = measure?.results ?? null;
|
||
|
||
// Mask preview: the calibration photo with removed (non-slab) pixels shown
|
||
// as a light-gray checkerboard (CAPTURE-45/50). Redrawn when the overlay
|
||
// settles. The photo element is bound so its natural size is available.
|
||
let maskCanvas: HTMLCanvasElement;
|
||
let maskImg: HTMLImageElement;
|
||
let maskObjUrl = '';
|
||
|
||
$: if (kind === 'mask' && cap.source) {
|
||
if (maskObjUrl) URL.revokeObjectURL(maskObjUrl);
|
||
maskObjUrl = URL.createObjectURL(cap.source.blob);
|
||
}
|
||
|
||
$: if (kind === 'mask' && maskImg?.complete && maskImg.naturalWidth > 0 && overlay) {
|
||
drawMaskPreview();
|
||
}
|
||
|
||
function drawMaskPreview() {
|
||
const canvas = maskCanvas;
|
||
if (!canvas || !maskImg || !overlay) return;
|
||
const w = maskImg.naturalWidth;
|
||
const h = maskImg.naturalHeight;
|
||
// Cap the canvas at a sensible size for performance; keep aspect.
|
||
const maxDim = 900;
|
||
const scale = Math.min(1, maxDim / Math.max(w, h));
|
||
canvas.width = Math.round(w * scale);
|
||
canvas.height = Math.round(h * scale);
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height);
|
||
// Downsample the full-res bitset to the canvas grid and checker removed px.
|
||
const bitset = overlay.bitset;
|
||
const cw = canvas.width;
|
||
const ch = canvas.height;
|
||
const cell = 6; // checker cell px
|
||
const imgData = ctx.getImageData(0, 0, cw, ch);
|
||
const px = imgData.data;
|
||
for (let y = 0; y < ch; y++) {
|
||
for (let x = 0; x < cw; x++) {
|
||
const sx = Math.min(w - 1, Math.round((x / cw) * w));
|
||
const sy = Math.min(h - 1, Math.round((y / ch) * h));
|
||
if (bitset[sy * w + sx]) continue; // slab pixel: keep photo
|
||
const checker = ((Math.floor(x / cell) + Math.floor(y / cell)) % 2 === 0);
|
||
const i = (y * cw + x) * 4;
|
||
const g = checker ? 210 : 235;
|
||
px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255;
|
||
}
|
||
}
|
||
ctx.putImageData(imgData, 0, 0);
|
||
}
|
||
|
||
let fileInput: HTMLInputElement;
|
||
|
||
// Auto-run the mask so the overlay (and preview) populate without the owner
|
||
// pressing Approve first. Debounced 500ms after the user stops adjusting the
|
||
// sheet/sensitivity, so rapid slider drags don't trigger a flood of async
|
||
// mask runs (and an in-flight run can't clobber the state around an Approve
|
||
// click). Keyed on control values, not the overlay, so settling never loops.
|
||
let maskRunTimer = 0;
|
||
$: controlKey = kind === 'mask' && cap.control ? `${cap.control.sheet}:${cap.control.sensitivity}` : null;
|
||
$: if (controlKey && cap.source && cap.source.widthPx > 0) {
|
||
clearTimeout(maskRunTimer);
|
||
maskRunTimer = setTimeout(() => {
|
||
void session.approveMask();
|
||
}, 500);
|
||
}
|
||
|
||
function pickFiles(e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
if (!input.files) return;
|
||
for (const file of Array.from(input.files)) {
|
||
session.addToBatch(file);
|
||
}
|
||
input.value = '';
|
||
}
|
||
|
||
function onSheetChange(e: Event) {
|
||
session.setSheet((e.target as HTMLSelectElement).value as Sheet);
|
||
}
|
||
function onSensitivityInput(e: Event) {
|
||
session.setSensitivity(Number((e.target as HTMLInputElement).value));
|
||
}
|
||
function onLengthChange(e: Event) {
|
||
session.setLength(Number((e.target as HTMLInputElement).value));
|
||
}
|
||
function onThicknessChange(e: Event) {
|
||
session.setThickness(Number((e.target as HTMLInputElement).value));
|
||
}
|
||
|
||
function onThumbKey(e: KeyboardEvent, i: number) {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
session.pickCalibration(i);
|
||
}
|
||
}
|
||
|
||
function fmt(eighths: number): string {
|
||
const whole = Math.floor(eighths / 8);
|
||
const rem = eighths - whole * 8;
|
||
if (rem === 0) return `${whole}″`;
|
||
const frac: Record<number, string> = { 1: '⅛', 2: '¼', 3: '⅜', 4: '½', 5: '⅝', 6: '¾', 7: '⅞' };
|
||
return whole > 0 ? `${whole} ${frac[rem] ?? `${rem}/8`}″` : `${frac[rem] ?? `${rem}/8`}″`;
|
||
}
|
||
</script>
|
||
|
||
<section>
|
||
{#if kind === 'batch'}
|
||
<h2>1. Batch photos</h2>
|
||
<p>Add one or more photos of the slab on a green or black sheet.</p>
|
||
<div class="picker-row">
|
||
<button type="button" class="picker" on:click={() => fileInput?.click()}>Choose photos</button>
|
||
<span>{photos.length} photo{photos.length === 1 ? '' : 's'}</span>
|
||
<input bind:this={fileInput} class="file-input" type="file" multiple accept="image/*" on:change={pickFiles} />
|
||
</div>
|
||
{#if photos.length}
|
||
<div class="thumbs">
|
||
{#each photos as p, i (i)}
|
||
<div class="thumb">
|
||
<img src={URL.createObjectURL(p.blob)} alt="photo {i}" />
|
||
<button on:click={() => session.removeFromBatch(i)}>×</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<button class="primary" on:click={() => session.continueBatch()}>Continue →</button>
|
||
{/if}
|
||
|
||
{:else if kind === 'pickCalibration'}
|
||
<h2>2. Pick calibration photo</h2>
|
||
<p>Choose the reference shot that will drive mask, axis, and measure.</p>
|
||
<div class="thumbs">
|
||
{#each photos as p, i (i)}
|
||
<div class="thumb" role="button" tabindex="0" on:click={() => session.pickCalibration(i)} on:keydown={(e) => onThumbKey(e, i)} aria-label="pick photo {i} as calibration">
|
||
<img src={URL.createObjectURL(p.blob)} alt="photo {i}" />
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<button on:click={() => session.back()}>← Back</button>
|
||
|
||
{:else if kind === 'mask'}
|
||
<h2>3. Mask</h2>
|
||
<p>Adjust sheet and sensitivity, then approve the mask. Removed background shows as a gray checkerboard.</p>
|
||
{#if cap.source && maskObjUrl}
|
||
<div class="mask-preview">
|
||
<canvas bind:this={maskCanvas} aria-label="mask preview"></canvas>
|
||
<img bind:this={maskImg} src={maskObjUrl} alt="calibration photo (hidden, drives mask preview)" class="mask-src" />
|
||
</div>
|
||
{/if}
|
||
{#if control}
|
||
<div class="controls">
|
||
<label>Sheet
|
||
<select value={control.sheet} on:change={onSheetChange}>
|
||
<option value="auto">Auto</option>
|
||
<option value="green">Green</option>
|
||
<option value="black">Black</option>
|
||
</select>
|
||
</label>
|
||
<label>Sensitivity {control.sensitivity}
|
||
<input type="range" min="0" max="100" step="1" value={control.sensitivity} on:input={onSensitivityInput} />
|
||
</label>
|
||
</div>
|
||
{/if}
|
||
{#if overlay && overlay.slabPx === 0}
|
||
<p class="err">No slab pixels detected — the mask removed everything. Lower the sensitivity, or set the sheet to match what the photo sits on, then retake if the shot is dark.</p>
|
||
{:else if failed}
|
||
<p class="err">No slab pixels detected. Adjust controls or retake.</p>
|
||
{:else if overlay}
|
||
<p>Mask settled: {overlay.slabPx} slab pixels.</p>
|
||
{/if}
|
||
<div class="actions">
|
||
<button class="primary" disabled={!overlay || overlay.slabPx === 0} on:click={() => session.approveMaskAxis()}>Approve mask →</button>
|
||
<button on:click={() => session.back()}>← Back</button>
|
||
</div>
|
||
|
||
{:else if kind === 'axis'}
|
||
<h2>4. Length axis</h2>
|
||
<p>Place the tape endpoints at the two ends of the slab's length.</p>
|
||
<div class="actions">
|
||
<button class="primary" on:click={() => session.approveAxis()}>Approve axis →</button>
|
||
<button on:click={() => session.back()}>← Back</button>
|
||
</div>
|
||
|
||
{:else if kind === 'measure'}
|
||
<h2>5. Measure</h2>
|
||
<p>Enter length and thickness (1/8″ steps). Widths, sqft, and bdft are derived from the calibration mask.</p>
|
||
{#if measure}
|
||
<div class="controls">
|
||
<label>Length (eighths) <input type="number" min="0" step="1" value={measure.lengthEighths} on:change={onLengthChange} /></label>
|
||
<label>Thickness (eighths) <input type="number" min="0" step="1" value={measure.thicknessEighths} on:change={onThicknessChange} /></label>
|
||
</div>
|
||
{/if}
|
||
{#if results}
|
||
<div class="results">
|
||
<div>Length <strong>{fmt(results.lengthEighths)}</strong></div>
|
||
<div>Thickness <strong>{fmt(results.thicknessEighths)}</strong></div>
|
||
<div>Sqft <strong>{results.sqft}</strong></div>
|
||
<div>Bdft <strong>{results.bdft}</strong></div>
|
||
</div>
|
||
<button class="primary" on:click={() => dispatch('photos-ready')}>Photos ready →</button>
|
||
{:else}
|
||
<button class="primary" disabled={!measure || measure.lengthEighths === 0 || measure.thicknessEighths === 0} on:click={() => void session.calculate()}>Calculate</button>
|
||
{/if}
|
||
<button on:click={() => session.back()}>← Back</button>
|
||
|
||
{:else}
|
||
<p>Unknown capture state.</p>
|
||
{/if}
|
||
</section>
|
||
|
||
<style>
|
||
.mask-preview { margin: 1rem 0; }
|
||
.mask-preview canvas { width: 100%; height: auto; max-height: 70vh; object-fit: contain; display: block; border: 1px solid #444; background: #000; }
|
||
.mask-src { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||
.picker-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 1rem 0; color: #aaa; }
|
||
.picker { min-height: 44px; padding: 0.65rem 1rem; background: #00e5ff; color: #002; border: 0; font-weight: 700; border-radius: 4px; }
|
||
.file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||
.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.5rem; margin: 1rem 0; }
|
||
.thumb { position: relative; border: 1px solid #444; padding: 4px; }
|
||
.thumb img { width: 100%; height: 80px; object-fit: cover; display: block; }
|
||
.thumb button { position: absolute; top: 2px; right: 2px; background: #a33; color: #fff; border: none; border-radius: 3px; width: 20px; height: 20px; }
|
||
.controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
||
.controls label { display: grid; gap: 0.35rem; }
|
||
.controls input, .controls select { padding: 0.3rem; background: #222; color: #eee; border: 1px solid #444; }
|
||
.actions { display: flex; gap: 0.75rem; margin: 1rem 0; }
|
||
.primary { background: #0a7; color: #fff; border: none; padding: 0.6rem 1.2rem; }
|
||
.err { color: #f88; }
|
||
.results { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; background: #1a1a1a; padding: 1rem; border-radius: 4px; margin: 1rem 0; }
|
||
</style>
|