8.0 KiB
Scrap Tracer — Design
Purpose
Makerspace Delft keeps a bucket of leftover laser-cut material. To reuse it, members need a 1:1 vector outline of a scrap piece (including holes where parts were already cut out) that they can import into their laser software to check whether their design fits.
Scrap Tracer turns a phone photo of a scrap piece into a correctly scaled DXF/SVG outline.
Success: a member photographs a piece, sets the scale with a couple of clicks, downloads a file, and imports it into the laser software where it matches the real piece within a few mm.
Scope
In scope (v1):
- Load a photo, set scale from one known length, detect the outline automatically, with a manual polygon fallback, apply a safety margin, export DXF and SVG.
Out of scope (v1):
- Perspective correction (photos are assumed roughly top-down; see Future).
- Inventory/catalogue of scrap pieces; nothing is persisted.
- Point-by-point editing of detected contours.
- Desktop packaging (Tauri) and browser end-to-end tests.
Users and environment
- Makerspace members, low friction is essential.
- Photos are taken on a phone; the tool is used on a computer (typically the laser PC).
- The recommended laser software is RDWorks; others (LightBurn, Inkscape) may be used. RDWorks imports DXF reliably but not SVG, so DXF is the primary export, SVG secondary.
- Accuracy target: a few mm. The safety margin absorbs the rest.
Tech stack
- Svelte 5 + TypeScript on Vite (plain Svelte, not SvelteKit). Single page, no backend.
- OpenCV.js (
@techstark/opencv-js) for image processing, running fully client-side. - Vitest for tests.
- Deployed as static files on Cloudflare Pages. The build output can later be wrapped with Tauri without changes to the app.
User flow
One screen: a zoomable/pannable image canvas, with a sidebar listing the steps.
- Load photo. Drag-drop or file picker. The image is downscaled to about 2000 px on the long side for processing.
- Set scale. The user clicks two points on a feature of known length and enters the length in mm. The reference line and label stay visible, and px/mm is displayed.
- Detect outline. Detection runs automatically once the photo loads and reruns when a
control changes. The result is drawn over the photo.
- Threshold slider. The initial value comes from Otsu's method.
- Invert toggle, for when the piece is darker than the background.
- Click to pick the piece. Only the connected region under the click (the seed point) is kept, including its holes. If there is no seed, the largest region that does not touch the image border is used.
- Draw manually: click polygon points for the outer edge, then optionally add hole polygons. This replaces the automatic result.
- Safety margin. Slider from −10 to +5 mm, default −2 mm, with a live preview. Negative values shrink usable area: the outer edge moves inward and holes grow. It requires the scale to be set.
- Export. "Download DXF" (primary) and "Download SVG". The file contains outlines only,
in mm, with the bounding box's top-left at (0,0). The filename includes the size, e.g.
scrap-412x287mm.dxf.
The overlay always shows exactly what will be exported.
Architecture
Thin Svelte UI on top of small pure TypeScript modules:
| Module | Responsibility | Depends on |
|---|---|---|
src/lib/scale.ts |
two points + length in mm → px/mm | nothing |
src/lib/detect.ts |
image + params → contours in px | OpenCV (injected) |
src/lib/margin.ts |
polygons in px + margin_px → contours in px (for manual mode) | OpenCV (injected), detect.ts helpers |
src/lib/geometry.ts |
px contours → mm polygons: scale, simplify, normalise to origin, bounding box | nothing |
src/lib/export-dxf.ts |
mm polygons → DXF text | nothing |
src/lib/export-svg.ts |
mm polygons → SVG text | nothing |
src/lib/opencv-loader.ts |
load OpenCV.js once, async | OpenCV.js |
src/components/ImageCanvas.svelte |
display, zoom/pan, overlays, click handling | — |
src/components/Sidebar.svelte |
step controls | — |
src/App.svelte |
owns app state, wires modules together | all of the above |
Shared types
type Point = { x: number; y: number };
type Shape = { outer: Point[]; holes: Point[][] }; // px or mm depending on stage
type RasterImage = { width: number; height: number; data: Uint8ClampedArray }; // RGBA, ImageData-compatible
type DetectParams = {
threshold: number; // 0–255
invert: boolean;
seed: Point | null; // px; null = largest non-border region
marginPx: number; // negative shrinks the piece
};
Detection pipeline (detect(cv, image: RasterImage, params): Shape | null)
- Convert to grayscale, apply a light blur, threshold (inverted if requested) → binary mask.
- Morphological open/close with a small kernel to remove speckle.
- Connected components. Keep the component under
seed, or else the largest component that doesn't touch the border. - Apply the margin: erode (negative) or dilate (positive) the mask by |marginPx| using an elliptical kernel. Holes then grow or shrink as well.
findContourswithRETR_CCOMP. Take the outer contour of the largest remaining component and its child holes.- Return
nullif nothing remains.
detect.ts also exports otsuThreshold(cv, image): number for the initial slider value.
Simplification (approxPolyDP-style, Douglas–Peucker in geometry.ts) uses a tolerance of
about 0.5 mm and runs after conversion to mm, so it's independent of photo resolution.
Manual mode
The user's clicked polygons are rasterised into a mask (fillPoly, with holes drawn as
background). That mask then goes through steps 4–5 of the same pipeline, so there is one
margin implementation.
Exports
- DXF: a minimal ASCII DXF (R12-compatible) with one closed
LWPOLYLINE/POLYLINEper contour,$INSUNITS = 4(mm). The Y axis is flipped so the piece isn't mirrored. It must import in RDWorks. - SVG:
width/heightinmmwith a matchingviewBox, so 1 user unit = 1 mm. One<path>withfill-rule="evenodd", stroke only.
State
A single Svelte $state object in App.svelte: image, scale points, length in mm, detection
params, manual polygons, mode (auto | manual), result shape. Derived values (px/mm, mm
shape, export enabled) use $derived. Nothing is persisted.
Error handling
- OpenCV fails to load: show an error with a reload button.
- No region found, or the region is the whole image: show "Nothing detected, try adjusting the threshold or invert, or draw manually."
- A margin that removes the piece entirely: show the same warning, and keep export disabled.
- Export is disabled until the scale is set and a non-empty shape exists.
- A zero-length scale line or non-positive mm value: reject the input with an inline message.
Testing
Vitest, run in Node.
detect.ts. Since it takes a plainRasterImageand an injectedcv, tests load@techstark/opencv-jsheadless.- Synthetic images are generated in the test. They check that the outer bounding box and area are within about 2 px, that the hole count is correct, that a stray blob is ignored when the seed is on the piece, that invert works, and that a margin of ±N px moves the outer edge and hole edges by about N px.
- Real photo fixtures in
tests/fixtures/(to be supplied: 3–5 photos of plywood, MDF and acrylic), each with expected size and hole count, checked with loose tolerances.
scale.ts,geometry.ts. Unit tests for scale maths, simplification, origin normalisation and the bounding box.export-dxf.ts/export-svg.ts. Check structure (sections, closed polylines, units), that coordinates are in mm, and the Y flip in DXF.- UI. Manual check, plus one manual import test of an exported DXF in RDWorks.
Future (not v1)
- Perspective correction from a reference rectangle (e.g. an A4 sheet) via
warpPerspective. - Contour point editing.
- Tauri desktop packaging.