feat(capture): ship Milestone 1 browser capture app
Why Phone and PC need a single capture flow that ends at inventory PNG and board-foot numbers with no backend, inference, or Woo create. Scope Add frontend/ Vite+Svelte capture UI with Resume, Add photo, Mask, Length axis, and Measure. Mask runs in a worker with Auto/Green/Black and a 2.0s sensitivity debounce. Measure computes scale, widths, sqft, bdft, and a 3:4 alpha inventory PNG. Persist one device draft in IndexedDB. Serve the static build from frontend/Dockerfile via nginx (deploy/nginx-m1.conf). No FastAPI. Verification sudo docker build -f frontend/Dockerfile -t slabuploader-m1 . sudo docker run -d --name slabuploader-m1 -p 0.0.0.0:18080:80 slabuploader-m1 curl http://127.0.0.1:18080/ returns 200 Playwright: synthetic green-sheet photo through Calculate shows sqft/bdft
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'measure' }>;
|
||||
export let setLength: (e: number) => void;
|
||||
export let setThickness: (e: number) => void;
|
||||
export let calculate: () => Promise<void>;
|
||||
export let back: () => void;
|
||||
export let pngUrl: string | null;
|
||||
|
||||
let calcBusy = false;
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
try { await calculate(); } finally { calcBusy = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Measure</h1>
|
||||
<PhotoStage blob={state.source.blob} bbox={state.axis.bbox} heading={state.axis.heading} />
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<div class="stepper">
|
||||
<button type="button" class="thumb" on:click={() => setLength(state.lengthEighths - 1)} aria-label="Length minus">−</button>
|
||||
<span class="v">{fmtEighths(state.lengthEighths)}</span>
|
||||
<button type="button" class="thumb" on:click={() => setLength(state.lengthEighths + 1)} aria-label="Length plus">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Thickness</span>
|
||||
<div class="stepper">
|
||||
<button type="button" class="thumb" on:click={() => setThickness(state.thicknessEighths - 1)} aria-label="Thickness minus">−</button>
|
||||
<span class="v">{fmtEighths(state.thicknessEighths)}</span>
|
||||
<button type="button" class="thumb" on:click={() => setThickness(state.thicknessEighths + 1)} aria-label="Thickness plus">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={calcBusy || state.lengthEighths === 0 || state.thicknessEighths === 0}
|
||||
on:click={doCalc}
|
||||
>
|
||||
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if state.results}
|
||||
<div class="results">
|
||||
<div class="nums">
|
||||
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
||||
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
|
||||
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
|
||||
</div>
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 1600px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{/if}
|
||||
{#if pngUrl && !state.results.sourceBelowFloor}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||
.field { flex: 1; }
|
||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
||||
.stepper { display: flex; align-items: center; gap: 12px; }
|
||||
.thumb {
|
||||
width: 52px; height: 52px; border-radius: 50%; font-size: 1.7rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
.v { flex: 1; text-align: center; font-size: 1.15rem; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.results { margin-top: 20px; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
|
||||
.png-wrap img { max-width: 100%; border-radius: 4px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user