1b32f71c91
The axis-preview draw reactive only depended on the image being complete, so it fired before approvedMask was populated (canvas stayed at the default 300x150 and nothing drew). Add cap.approvedMask to the reactive deps so the sheet-removed photo + tape line render once the frozen mask is present.
317 lines
14 KiB
Svelte
317 lines
14 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);
|
||
}
|
||
|
||
// Axis preview: the sheet-removed photo with the length-axis tape line
|
||
// (red) drawn from axis.tapeLine.start to .end in source-pixel coords.
|
||
let axisCanvas: HTMLCanvasElement;
|
||
let axisImg: HTMLImageElement;
|
||
let axisObjUrl = '';
|
||
|
||
$: if (kind === 'axis' && cap.source) {
|
||
if (axisObjUrl) URL.revokeObjectURL(axisObjUrl);
|
||
axisObjUrl = URL.createObjectURL(cap.source.blob);
|
||
}
|
||
|
||
$: if (kind === 'axis' && cap.approvedMask && axisImg?.complete && axisImg.naturalWidth > 0) {
|
||
drawAxisPreview();
|
||
}
|
||
|
||
function drawAxisPreview() {
|
||
const canvas = axisCanvas;
|
||
if (!canvas || !axisImg || !cap.approvedMask) return;
|
||
const w = axisImg.naturalWidth;
|
||
const h = axisImg.naturalHeight;
|
||
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(axisImg, 0, 0, canvas.width, canvas.height);
|
||
// Checkerboard the removed (non-slab) pixels.
|
||
const bitset = cap.approvedMask.bitset;
|
||
const cw = canvas.width;
|
||
const ch = canvas.height;
|
||
const cell = 6;
|
||
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;
|
||
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);
|
||
// Draw the tape line (length axis) in red.
|
||
const [x1, y1] = cap.axis.tapeLine.start;
|
||
const [x2, y2] = cap.axis.tapeLine.end;
|
||
ctx.strokeStyle = '#ff3b30';
|
||
ctx.lineWidth = Math.max(2, canvas.width / 300);
|
||
ctx.beginPath();
|
||
ctx.moveTo((x1 / w) * cw, (y1 / h) * ch);
|
||
ctx.lineTo((x2 / w) * cw, (y2 / h) * ch);
|
||
ctx.stroke();
|
||
// Endpoint dots
|
||
ctx.fillStyle = '#ff3b30';
|
||
for (const [ex, ey] of [[x1, y1], [x2, y2]] as [number, number][]) {
|
||
ctx.beginPath();
|
||
ctx.arc((ex / w) * cw, (ey / h) * ch, Math.max(3, canvas.width / 150), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
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>The sheet is removed. The red line marks the slab's length axis — confirm it runs end-to-end, then approve.</p>
|
||
{#if cap.source}
|
||
<div class="mask-preview">
|
||
<canvas bind:this={axisCanvas} aria-label="axis preview"></canvas>
|
||
<img bind:this={axisImg} src={axisObjUrl} alt="calibration photo (hidden, drives axis preview)" class="mask-src" />
|
||
</div>
|
||
{/if}
|
||
<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>
|