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,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>
|
||||
Reference in New Issue
Block a user