feat: add step sidebar with scale, detection, margin and export controls

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 14:57:22 +02:00
1 parent bec28fd0c1
commit abef0f982b
3 files changed
+139 -4

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Scrap Tracer
Turn a phone photo of a laser-cut scrap piece into a 1:1 DXF/SVG outline, so you can check in
RDWorks (or LightBurn/Inkscape) whether your design fits on it. Built for Makerspace Delft.
## Use
1. Photograph the piece from straight above, on a contrasting surface, with something of known length visible.
2. Load the photo, click **Measure**, click both ends of the known length, and enter it in mm.
3. Check the green outline. If needed, adjust the threshold, tick "darker than background",
click the piece, or draw the outline by hand.
4. Set the safety margin (default −2 mm shrinks the usable area a little).
5. Download DXF and import it in your laser software at 1:1, in mm.
## Develop
npm install
npm run dev # local dev server
npm test # unit tests (Vitest, OpenCV runs headless in Node)
npm run check # type check
npm run build # static build in dist/
## Deploy (Cloudflare Pages)
Build command `npm run build`, output directory `dist`. No server or environment variables needed.
+2 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import ImageCanvas from './components/ImageCanvas.svelte';
import Sidebar from './components/Sidebar.svelte';
import { AppState } from './lib/app-state.svelte';
import { loadOpenCV } from './lib/opencv-loader';
@@ -22,10 +23,7 @@
<div class="layout">
<aside>
<input type="file" accept="image/*" onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) app.loadFile(file);
}} />
<Sidebar {app} />
</aside>
<main ondragover={(e) => e.preventDefault()} {ondrop}>
{#if app.image}
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import type { AppState, Tool } from '../lib/app-state.svelte';
import { downloadText } from '../lib/download';
import { toDxf } from '../lib/export-dxf';
import { toSvg } from '../lib/export-svg';
import { scrapFilename, shapeSize } from '../lib/geometry';
let { app }: { app: AppState } = $props();
const size = $derived(app.exportShape ? shapeSize(app.exportShape) : null);
const nothingDetected = $derived(app.cv !== null && app.image !== null && app.shapePx === null);
function toggle(tool: Tool) {
app.tool = app.tool === tool ? 'none' : tool;
}
function onFile(e: Event & { currentTarget: HTMLInputElement }) {
const file = e.currentTarget.files?.[0];
if (file) app.loadFile(file);
}
function exportAs(ext: 'dxf' | 'svg') {
const shape = app.exportShape;
if (!shape) return;
const text = ext === 'dxf' ? toDxf(shape) : toSvg(shape);
downloadText(scrapFilename(shape, ext), text, ext === 'dxf' ? 'application/dxf' : 'image/svg+xml');
}
</script>
<h1>Scrap Tracer</h1>
{#if app.cvError}
<p class="error">{app.cvError} <button onclick={() => location.reload()}>Reload</button></p>
{:else if !app.cv}
<p class="muted">Loading image processing…</p>
{/if}
<section>
<h2>1. Photo</h2>
<label class="file">Load photo <input type="file" accept="image/*" onchange={onFile} /></label>
{#if app.loadError}<p class="error">{app.loadError}</p>{/if}
</section>
<section class:disabled={!app.image}>
<h2>2. Scale</h2>
<p class="muted">Click both ends of something whose length you know.</p>
<button class:active={app.tool === 'scale'} onclick={() => toggle('scale')} disabled={!app.image}>
{app.scalePoints.length === 2 ? 'Re-measure' : 'Measure'}
</button>
<label>Length <input type="number" min="1" step="any" bind:value={app.lengthMm} /> mm</label>
{#if app.scaleInvalid}
<p class="error">Enter a length above 0 and pick two different points.</p>
{:else if app.pxPerMm}
<p class="muted">{app.pxPerMm.toFixed(2)} px/mm</p>
{/if}
</section>
<section class:disabled={!app.image}>
<h2>3. Outline</h2>
{#if app.mode === 'auto'}
<label>Threshold <input type="range" min="0" max="255" bind:value={app.threshold} /> {app.threshold}</label>
<button onclick={() => app.autoThreshold()} disabled={!app.cv || !app.image}>Auto</button>
<label><input type="checkbox" bind:checked={app.invert} /> Piece is darker than background</label>
<button class:active={app.tool === 'seed'} onclick={() => toggle('seed')} disabled={!app.image}>Click to pick piece</button>
{/if}
<button class:active={app.tool === 'draw'} onclick={() => toggle('draw')} disabled={!app.image}>
{app.manualOuter.length === 0 ? 'Draw outline manually' : 'Draw a hole'}
</button>
{#if app.drawing.length > 0}
<button onclick={() => app.finishPolygon()}>Finish {app.manualOuter.length === 0 ? 'outline' : 'hole'}</button>
{/if}
{#if app.mode === 'manual' || app.drawing.length > 0}
<button onclick={() => app.clearManual()}>Back to automatic</button>
{/if}
{#if nothingDetected}
<p class="warning">Nothing detected, try adjusting the threshold or invert, or draw manually.</p>
{/if}
</section>
<section class:disabled={!app.pxPerMm}>
<h2>4. Safety margin</h2>
<label>
<input type="range" min="-10" max="5" step="0.5" bind:value={app.marginMm} disabled={!app.pxPerMm} />
{app.marginMm} mm
</label>
{#if !app.pxPerMm}<p class="muted">Set the scale first.</p>{/if}
</section>
<section>
<h2>5. Export</h2>
{#if size}<p>Piece: {Math.round(size.width)} × {Math.round(size.height)} mm</p>{/if}
<button class="primary" onclick={() => exportAs('dxf')} disabled={!app.exportShape}>Download DXF</button>
<button onclick={() => exportAs('svg')} disabled={!app.exportShape}>Download SVG</button>
{#if !app.exportShape}
<p class="muted">{app.pxPerMm ? 'No outline to export yet.' : 'Set the scale first.'}</p>
{/if}
</section>
<style>
h1 { font-size: 1.3rem; margin: 0 0 12px; }
h2 { font-size: 1rem; margin: 0 0 6px; }
section { padding: 12px 0; border-top: 1px solid #eee; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
section.disabled { opacity: 0.5; }
label { display: flex; gap: 6px; align-items: center; }
input[type='number'] { width: 80px; }
.file input { max-width: 200px; }
button.active { outline: 2px solid #2962ff; }
button.primary { font-weight: bold; }
.muted { color: #777; margin: 0; font-size: 0.9rem; }
.error { color: #c62828; margin: 0; }
.warning { color: #e65100; margin: 0; }
</style>