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:
2026-09-25 06:57:29 -07:00
parent 4ca2ef57a7
commit 49c22c9385
106 changed files with 5502 additions and 8863 deletions
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import type { Photo } from '../lib/types';
import { initialTapeLine } from '../geometry/rect';
import { decodeOrientedBitmap } from './decodeOrientedBitmap';
export let photo: Photo;
const dispatch = createEventDispatcher<{ approve: { tapeLine: { start: [number, number]; end: [number, number] } } }>();
let canvas: HTMLCanvasElement;
let bitmap: ImageBitmap | null = null;
let angle = 0;
let zoom = 1;
let dragging: 'start' | 'end' | null = null;
// Initial guides come from the shared geometry module once the bitmap size
// is known; a prior tapeLine (resume) wins.
let start: [number, number] = [...(photo.tapeLine?.start ?? initialTapeLine(1, 1).start)];
let end: [number, number] = [...(photo.tapeLine?.end ?? initialTapeLine(1, 1).end)];
function fit() {
if (!bitmap) return 1;
return Math.min(canvas.clientWidth / bitmap.width, canvas.clientHeight / bitmap.height) * zoom;
}
function screenPoint(point: [number, number]): [number, number] {
const scale = fit();
const cx = canvas.clientWidth / 2;
const cy = canvas.clientHeight / 2;
return [cx + (point[0] - bitmap!.width / 2) * scale, cy + (point[1] - bitmap!.height / 2) * scale];
}
function photoPoint(event: PointerEvent): [number, number] {
const rect = canvas.getBoundingClientRect();
const scale = fit();
return [
Math.max(0, Math.min(bitmap!.width, (event.clientX - rect.left - canvas.clientWidth / 2) / scale + bitmap!.width / 2)),
Math.max(0, Math.min(bitmap!.height, (event.clientY - rect.top - canvas.clientHeight / 2) / scale + bitmap!.height / 2))
];
}
function draw() {
if (!bitmap) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
const context = canvas.getContext('2d');
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.fillStyle = '#141414';
context.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
context.save();
context.translate(canvas.clientWidth / 2, canvas.clientHeight / 2);
context.rotate((angle * Math.PI) / 180);
const scale = fit();
context.scale(scale, scale);
context.drawImage(bitmap, -bitmap.width / 2, -bitmap.height / 2);
context.restore();
const a = screenPoint(start);
const b = screenPoint(end);
context.strokeStyle = '#00e5ff';
context.lineWidth = 3;
context.beginPath(); context.moveTo(...a); context.lineTo(...b); context.stroke();
for (const point of [a, b]) {
context.fillStyle = '#ffd600';
context.beginPath(); context.arc(...point, 10, 0, Math.PI * 2); context.fill();
context.strokeStyle = '#111'; context.stroke();
}
}
function onPointerDown(event: PointerEvent) {
const point = photoPoint(event);
const distance = (candidate: [number, number]) => Math.hypot(candidate[0] - point[0], candidate[1] - point[1]);
const threshold = 40 / fit();
if (distance(start) < threshold) dragging = 'start';
else if (distance(end) < threshold) dragging = 'end';
canvas.setPointerCapture(event.pointerId);
}
function onPointerMove(event: PointerEvent) {
if (!dragging) return;
const point = photoPoint(event);
if (dragging === 'start') start = point;
else end = point;
draw();
}
function onPointerUp(event: PointerEvent) {
dragging = null;
canvas.releasePointerCapture(event.pointerId);
}
function approve() {
if (Math.hypot(end[0] - start[0], end[1] - start[1]) < 4) return;
dispatch('approve', { tapeLine: { start: [...start], end: [...end] } });
}
onMount(() => {
let alive = true;
const resize = new ResizeObserver(draw);
resize.observe(canvas);
void decodeOrientedBitmap(photo.blob).then((decoded) => {
if (!alive) { decoded.close(); return; }
bitmap = decoded;
if (!photo.tapeLine) {
const guide = initialTapeLine(decoded.width, decoded.height);
start = guide.start;
end = guide.end;
}
draw();
});
return () => { alive = false; resize.disconnect(); bitmap?.close(); };
});
</script>
<section class="panel">
<h2>Tape-line calibration</h2>
<p>Place the two yellow ticks on the actual start and end marks of the measuring tape. This line becomes the length axis and calibration scale.</p>
<canvas bind:this={canvas} aria-label="Calibration photo with draggable tape endpoints"
on:pointerdown={onPointerDown} on:pointermove={onPointerMove} on:pointerup={onPointerUp}></canvas>
<div class="controls">
<label>Rotate {angle.toFixed(1)}°
<input type="range" min="-180" max="180" step="0.1" bind:value={angle} on:input={draw} />
</label>
<label>Zoom {Math.round(zoom * 100)}%
<input type="range" min="0.5" max="4" step="0.05" bind:value={zoom} on:input={draw} />
</label>
</div>
<button class="primary" on:click={approve}>Use tape line</button>
</section>
<style>
.panel { padding: 1rem 0; }
p { color: #aaa; line-height: 1.45; }
canvas { display: block; width: 100%; height: min(58vh, 560px); min-height: 280px; background: #141414; border: 1px solid #333; touch-action: none; }
.controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin: 1rem 0; }
label { display: grid; gap: 0.35rem; }
input { accent-color: #00e5ff; }
.primary { min-height: 48px; width: 100%; background: #00e5ff; color: #002; border: 0; font-weight: 700; }
</style>