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:
1 parent
bec28fd0c1
commit
abef0f982b
3 files changed
+139
-4
No files matched your search
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user