feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
<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 }>();
|
||||
|
||||
$: state = $session.state;
|
||||
$: 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;
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
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.</p>
|
||||
{#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 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>
|
||||
.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>
|
||||
Reference in New Issue
Block a user