81 KiB
Scrap Tracer Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A static web app that turns a phone photo of a laser-cut scrap piece into a correctly scaled DXF/SVG outline (outer edge + holes) with an adjustable safety margin.
Architecture: Svelte 5 UI on top of small pure TypeScript modules. Image processing (threshold → component selection → erode/dilate margin → contours) runs client-side in OpenCV.js. The detector takes plain RasterImage data and an injected cv, so it is unit-tested headless in Node. A single AppState class (runes in a .svelte.ts module) owns all state and derives the detected shape and export geometry.
Tech Stack: Svelte 5, TypeScript 6, Vite 8, @sveltejs/vite-plugin-svelte 7, @techstark/opencv-js 4.12.0-release.1, Vitest 5, jpeg-js (test fixtures only).
Spec: docs/superpowers/specs/2026-10-15-scrap-tracer-design.md
Global Constraints
- Plain Svelte 5 + TypeScript on Vite. No SvelteKit, no backend; all processing runs in the browser.
- OpenCV comes from
@techstark/opencv-js, pinned to exactly4.12.0-release.1, loaded lazily with a dynamicimport(). - The OpenCV module object is a thenable. Never
return cvfrom an async function orresolve(cv), because it hangs forever (verified). Always wrap it:{ cv }. - Photos are processed at ≤ 2000 px on the long side.
- Scale comes from one known length: two clicked points plus a length in mm.
- Safety margin slider: −10 to +5 mm, step 0.5, default −2 mm. Negative shrinks the usable area.
- Simplification tolerance: 0.5 mm, applied after converting to mm.
- DXF is the primary export: ASCII R12 (
AC1009), one closedPOLYLINEper ring,$INSUNITS= 4 (mm), Y axis flipped, CRLF line endings. SVG is secondary:width/heightinmm,viewBoxin mm. - Exported geometry is normalised so the outer bounding box's top-left is (0,0).
- Filename:
scrap-<W>x<H>mm.<dxf|svg>, with W and H rounded to whole mm. - Detection-failure message text, verbatim:
Nothing detected, try adjusting the threshold or invert, or draw manually. - Deploy target: Cloudflare Pages, static output (
npm run build→dist/). - Environment note: if
npmfails withEROFSon~/.npm, prefix commands withnpm_config_cache=$TMPDIR/npmcache.
Review Focus
- Phone photos. Portrait shots with EXIF rotation, 12+ MP: the image must appear upright and be downscaled to ≤ 2000 px.
fitSizeis tested in Task 8. Orientation is checked by hand in Task 11. - Clicks while zoomed or panned. A click must map to the exact image pixel under the cursor, and clicks outside the photo are ignored. Covered by the
view.tsround-trip tests (Task 8) and the "click outside image ignored" test (Task 9). - Seed lands on background or in a hole after the threshold changes. Detection should fall back to the automatic piece choice rather than show nothing. Tested in Task 5.
- Tightly framed piece touching the photo edge. Without a seed it gives no result (and the message). With a seed on the piece it's detected. Tested in Task 5.
- Glare or speckle creating tiny false holes. Holes smaller than 3 mm in both directions are dropped from the export, so they don't become pointless cuts. Tested in Task 2.
File Structure
package.json, vite.config.ts, svelte.config.js, tsconfig.json, index.html, .gitignore, README.md
src/
main.ts mount App
App.svelte layout, OpenCV loading, drag-drop
components/
ImageCanvas.svelte photo display, zoom/pan, overlays, click → app.handleImageClick
Sidebar.svelte step controls, export buttons, messages
lib/
types.ts Point, Shape, RasterImage, DetectParams
scale.ts scaleFromLine
geometry.ts bounds, shapeSize, simplifyRing, prepareForExport, scrapFilename, formatMm
export-svg.ts toSvg
export-dxf.ts toDxf
opencv-loader.ts CV type, loadOpenCV
detect.ts otsuThreshold, detect, traceMask
margin.ts shapeFromPolygons (manual mode)
view.ts View, screenToImage, zoomAt, fitView
load-image.ts fitSize, loadPhoto
download.ts downloadText
app-state.svelte.ts AppState class
tests/
helpers/images.ts synthetic image + mask builders
scale.test.ts, geometry.test.ts, export-svg.test.ts, export-dxf.test.ts,
trace-mask.test.ts, detect.test.ts, margin.test.ts, fixtures.test.ts,
view.test.ts, load-image.test.ts, app-state.test.ts
fixtures/manifest.json, fixtures/README.md
Task 1: Project scaffold, shared types, scale
Files:
- Create:
package.json,vite.config.ts,svelte.config.js,tsconfig.json,index.html,.gitignore,src/main.ts,src/App.svelte(placeholder),src/lib/types.ts,src/lib/scale.ts - Test:
tests/scale.test.ts
Interfaces:
-
Produces:
type Point = { x: number; y: number }type Shape = { outer: Point[]; holes: Point[][] }type RasterImage = { width: number; height: number; data: Uint8ClampedArray<ArrayBuffer> }(RGBA, ImageData-compatible)type DetectParams = { threshold: number; invert: boolean; seed: Point | null; marginPx: number }scaleFromLine(a: Point, b: Point, lengthMm: number): number | null, which returns px per mm, or null if the input is invalid
-
Step 1: Create
package.json
{
"name": "scrap-tracer",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
}
}
- Step 2: Install dependencies
npm i @techstark/opencv-js@4.12.0-release.1
npm i -D svelte@^5.57 vite@^8.3 @sveltejs/vite-plugin-svelte@^7.3 typescript@^6.0 svelte-check@^4.7 @tsconfig/svelte@^5.0 vitest@^5.0 jpeg-js@^0.4.4 @types/node@^22
Check that package.json now has "@techstark/opencv-js": "4.12.0-release.1" (exact). If npm wrote ^4.12.0-release.1, change it to the exact version.
- Step 3: Create config files
vite.config.ts:
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
// Rune modules must compile for the browser runtime under Vitest too.
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
build: { chunkSizeWarningLimit: 12000 },
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
testTimeout: 30000,
hookTimeout: 60000,
},
});
svelte.config.js:
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { preprocess: vitePreprocess({ script: true }) };
tsconfig.json:
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts", "vite.config.ts"]
}
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scrap Tracer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
.gitignore:
node_modules
dist
src/main.ts:
import { mount } from 'svelte';
import App from './App.svelte';
mount(App, { target: document.getElementById('app')! });
src/App.svelte (placeholder, replaced in Task 10):
<h1>Scrap Tracer</h1>
- Step 4: Create
src/lib/types.ts
export type Point = { x: number; y: number };
/** A piece outline: one outer ring plus hole rings. Units depend on the pipeline stage (px or mm). */
export type Shape = { outer: Point[]; holes: Point[][] };
/** RGBA pixels, structurally compatible with ImageData. */
export type RasterImage = { width: number; height: number; data: Uint8ClampedArray<ArrayBuffer> };
export type DetectParams = {
threshold: number;
invert: boolean;
seed: Point | null;
/** Negative shrinks the piece (and grows holes). */
marginPx: number;
};
- Step 5: Write the failing test
tests/scale.test.ts
import { describe, expect, it } from 'vitest';
import { scaleFromLine } from '../src/lib/scale';
describe('scaleFromLine', () => {
it('returns pixels per mm', () => {
expect(scaleFromLine({ x: 0, y: 0 }, { x: 300, y: 400 }, 100)).toBeCloseTo(5);
});
it('rejects a non-positive or missing length', () => {
expect(scaleFromLine({ x: 0, y: 0 }, { x: 100, y: 0 }, 0)).toBeNull();
expect(scaleFromLine({ x: 0, y: 0 }, { x: 100, y: 0 }, -5)).toBeNull();
expect(scaleFromLine({ x: 0, y: 0 }, { x: 100, y: 0 }, NaN)).toBeNull();
});
it('rejects a line shorter than one pixel', () => {
expect(scaleFromLine({ x: 10, y: 10 }, { x: 10.5, y: 10 }, 100)).toBeNull();
});
});
- Step 6: Run the test to verify it fails
Run: npx vitest run tests/scale.test.ts
Expected: FAIL, Cannot find module '../src/lib/scale' or a similar resolution error.
- Step 7: Implement
src/lib/scale.ts
import type { Point } from './types';
export function scaleFromLine(a: Point, b: Point, lengthMm: number): number | null {
const lengthPx = Math.hypot(b.x - a.x, b.y - a.y);
if (!(lengthMm > 0) || lengthPx < 1) return null;
return lengthPx / lengthMm;
}
- Step 8: Run tests and checks
Run: npx vitest run tests/scale.test.ts && npm run check && npm run build
Expected: 3 tests pass, svelte-check reports 0 errors, the build produces dist/index.html.
- Step 9: Commit
git add package.json package-lock.json vite.config.ts svelte.config.js tsconfig.json index.html .gitignore src tests
git commit -m "feat: scaffold Svelte app with shared types and scale calculation"
Task 2: Geometry (mm conversion, simplification, normalisation)
Files:
- Create:
src/lib/geometry.ts - Test:
tests/geometry.test.ts
Interfaces:
-
Consumes:
Point,Shapefromsrc/lib/types.ts -
Produces:
type Bounds = { minX: number; minY: number; maxX: number; maxY: number }bounds(points: Point[]): BoundsshapeSize(shape: Shape): { width: number; height: number }, the size of the outer ring's bounding boxsimplifyRing(ring: Point[], tolerance: number): Point[], Douglas–Peucker on a closed ring (no repeated closing point)prepareForExport(shapePx: Shape, pxPerMm: number, toleranceMm?: number): Shape | null, which converts px to mm, simplifies, drops holes under 3 mm and rings with fewer than 3 points, and normalises to the origin. Returns null if the outer ring degenerates.scrapFilename(shapeMm: Shape, ext: 'dxf' | 'svg'): stringformatMm(n: number): string, at most 3 decimals, no trailing zeros, never-0
-
Step 1: Write the failing test
tests/geometry.test.ts
import { describe, expect, it } from 'vitest';
import {
bounds, formatMm, prepareForExport, scrapFilename, shapeSize, simplifyRing,
} from '../src/lib/geometry';
import type { Point } from '../src/lib/types';
/** Square ring from (x0,y0) with side `size`, one point every `step` units. */
function denseSquare(x0: number, y0: number, size: number, step = 1): Point[] {
const pts: Point[] = [];
for (let i = 0; i < size; i += step) pts.push({ x: x0 + i, y: y0 });
for (let i = 0; i < size; i += step) pts.push({ x: x0 + size, y: y0 + i });
for (let i = 0; i < size; i += step) pts.push({ x: x0 + size - i, y: y0 + size });
for (let i = 0; i < size; i += step) pts.push({ x: x0, y: y0 + size - i });
return pts;
}
describe('bounds / shapeSize', () => {
it('measures the outer ring', () => {
const shape = { outer: denseSquare(5, 10, 20), holes: [] };
expect(bounds(shape.outer)).toEqual({ minX: 5, minY: 10, maxX: 25, maxY: 30 });
expect(shapeSize(shape)).toEqual({ width: 20, height: 20 });
});
});
describe('simplifyRing', () => {
it('reduces collinear points of a square to its 4 corners', () => {
const result = simplifyRing(denseSquare(0, 0, 10), 0.1);
expect(result).toHaveLength(4);
expect(bounds(result)).toEqual({ minX: 0, minY: 0, maxX: 10, maxY: 10 });
});
it('removes wiggles below tolerance but keeps bumps above it', () => {
const ring = denseSquare(0, 0, 100);
ring[50] = { x: 100.3, y: 50 }; // on the right edge, 0.3 out
expect(simplifyRing(ring, 0.5)).toHaveLength(4);
ring[50] = { x: 102, y: 50 }; // 2 out
expect(simplifyRing(ring, 0.5).some((p) => p.x === 102)).toBe(true);
});
it('leaves tiny rings alone', () => {
const tri = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }];
expect(simplifyRing(tri, 5)).toEqual(tri);
});
});
describe('prepareForExport', () => {
it('converts px to mm and moves the outer bounding box to the origin', () => {
const shapePx = { outer: denseSquare(100, 50, 200), holes: [denseSquare(150, 100, 40)] };
const mm = prepareForExport(shapePx, 4)!; // 4 px per mm
expect(bounds(mm.outer)).toEqual({ minX: 0, minY: 0, maxX: 50, maxY: 50 });
expect(mm.holes).toHaveLength(1);
expect(bounds(mm.holes[0])).toEqual({ minX: 12.5, minY: 12.5, maxX: 22.5, maxY: 22.5 });
expect(mm.outer).toHaveLength(4);
});
it('drops holes smaller than 3 mm in both directions (glare/speckle)', () => {
const shapePx = {
outer: denseSquare(0, 0, 400),
holes: [denseSquare(100, 100, 8), denseSquare(200, 200, 40)], // 2 mm and 10 mm at 4 px/mm
};
const mm = prepareForExport(shapePx, 4)!;
expect(mm.holes).toHaveLength(1);
expect(shapeSize({ outer: mm.holes[0], holes: [] }).width).toBeCloseTo(10);
});
it('keeps a long thin hole that is under 3 mm in only one direction', () => {
const slot: Point[] = [{ x: 100, y: 100 }, { x: 300, y: 100 }, { x: 300, y: 104 }, { x: 100, y: 104 }];
const mm = prepareForExport({ outer: denseSquare(0, 0, 400), holes: [slot] }, 4)!;
expect(mm.holes).toHaveLength(1);
});
it('returns null when the outer ring degenerates', () => {
expect(prepareForExport({ outer: [{ x: 0, y: 0 }, { x: 1, y: 1 }], holes: [] }, 4)).toBeNull();
});
});
describe('scrapFilename / formatMm', () => {
it('names the file after the rounded size', () => {
const shape = { outer: [{ x: 0, y: 0 }, { x: 412.4, y: 0 }, { x: 412.4, y: 286.6 }, { x: 0, y: 286.6 }], holes: [] };
expect(scrapFilename(shape, 'dxf')).toBe('scrap-412x287mm.dxf');
expect(scrapFilename(shape, 'svg')).toBe('scrap-412x287mm.svg');
});
it('formats numbers compactly', () => {
expect(formatMm(1.23456)).toBe('1.235');
expect(formatMm(2)).toBe('2');
expect(formatMm(-0.0001)).toBe('0');
});
});
- Step 2: Run the test to verify it fails
Run: npx vitest run tests/geometry.test.ts
Expected: FAIL, module ../src/lib/geometry not found.
- Step 3: Implement
src/lib/geometry.ts
import type { Point, Shape } from './types';
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
const MIN_HOLE_MM = 3;
export function bounds(points: Point[]): Bounds {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return { minX, minY, maxX, maxY };
}
export function shapeSize(shape: Shape): { width: number; height: number } {
const b = bounds(shape.outer);
return { width: b.maxX - b.minX, height: b.maxY - b.minY };
}
export function simplifyRing(ring: Point[], tolerance: number): Point[] {
if (ring.length <= 3) return ring.slice();
// Split at the vertex farthest from the start so each half is an open polyline.
let far = 0;
let farDist = -1;
for (let i = 1; i < ring.length; i++) {
const d = Math.hypot(ring[i].x - ring[0].x, ring[i].y - ring[0].y);
if (d > farDist) {
farDist = d;
far = i;
}
}
const a = douglasPeucker(ring.slice(0, far + 1), tolerance);
const b = douglasPeucker([...ring.slice(far), ring[0]], tolerance);
return [...a.slice(0, -1), ...b.slice(0, -1)];
}
function douglasPeucker(points: Point[], tolerance: number): Point[] {
if (points.length < 3) return points;
const first = points[0];
const last = points[points.length - 1];
let index = 0;
let maxDist = 0;
for (let i = 1; i < points.length - 1; i++) {
const d = distanceToSegment(points[i], first, last);
if (d > maxDist) {
maxDist = d;
index = i;
}
}
if (maxDist <= tolerance) return [first, last];
const left = douglasPeucker(points.slice(0, index + 1), tolerance);
const right = douglasPeucker(points.slice(index), tolerance);
return [...left.slice(0, -1), ...right];
}
function distanceToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
if (len2 === 0) return Math.hypot(p.x - a.x, p.y - a.y);
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
export function prepareForExport(shapePx: Shape, pxPerMm: number, toleranceMm = 0.5): Shape | null {
const toMm = (ring: Point[]) =>
simplifyRing(ring.map((p) => ({ x: p.x / pxPerMm, y: p.y / pxPerMm })), toleranceMm);
const outer = toMm(shapePx.outer);
if (outer.length < 3) return null;
const holes = shapePx.holes.map(toMm).filter((hole) => {
if (hole.length < 3) return false;
const { width, height } = shapeSize({ outer: hole, holes: [] });
return width >= MIN_HOLE_MM || height >= MIN_HOLE_MM;
});
const { minX, minY } = bounds(outer);
const shift = (ring: Point[]) => ring.map((p) => ({ x: p.x - minX, y: p.y - minY }));
return { outer: shift(outer), holes: holes.map(shift) };
}
export function scrapFilename(shapeMm: Shape, ext: 'dxf' | 'svg'): string {
const { width, height } = shapeSize(shapeMm);
return `scrap-${Math.round(width)}x${Math.round(height)}mm.${ext}`;
}
export function formatMm(n: number): string {
const rounded = Number(n.toFixed(3));
return String(rounded === 0 ? 0 : rounded);
}
- Step 4: Run tests
Run: npx vitest run tests/geometry.test.ts
Expected: all tests PASS.
- Step 5: Commit
git add src/lib/geometry.ts tests/geometry.test.ts
git commit -m "feat: add geometry helpers for mm conversion, simplification and export prep"
Task 3: SVG and DXF exporters
Files:
- Create:
src/lib/export-svg.ts,src/lib/export-dxf.ts - Test:
tests/export-svg.test.ts,tests/export-dxf.test.ts
Interfaces:
-
Consumes:
Shape(in mm, normalised to the origin, whichprepareForExportguarantees), plusshapeSizeandformatMmfromgeometry.ts -
Produces:
toSvg(shapeMm: Shape): string,toDxf(shapeMm: Shape): string -
Step 1: Write the failing test
tests/export-svg.test.ts
import { describe, expect, it } from 'vitest';
import { toSvg } from '../src/lib/export-svg';
const shape = {
outer: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 50 }, { x: 0, y: 50 }],
holes: [[{ x: 10, y: 10 }, { x: 20, y: 10 }, { x: 20, y: 20.5 }]],
};
describe('toSvg', () => {
it('declares its size in mm with a matching viewBox', () => {
const svg = toSvg(shape);
expect(svg).toContain('width="100mm"');
expect(svg).toContain('height="50mm"');
expect(svg).toContain('viewBox="0 0 100 50"');
});
it('writes one closed subpath per ring in a single evenodd path', () => {
const svg = toSvg(shape);
const d = svg.match(/ d="([^"]+)"/)![1];
expect(d.match(/M/g)).toHaveLength(2);
expect(d.match(/Z/g)).toHaveLength(2);
expect(d).toContain('M0,0 L100,0 L100,50 L0,50 Z');
expect(d).toContain('20,20.5');
expect(svg).toContain('fill-rule="evenodd"');
expect(svg).toContain('fill="none"');
});
});
- Step 2: Write the failing test
tests/export-dxf.test.ts
import { describe, expect, it } from 'vitest';
import { toDxf } from '../src/lib/export-dxf';
const shape = {
outer: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 50 }, { x: 0, y: 50 }],
holes: [[{ x: 10, y: 10 }, { x: 20, y: 10 }, { x: 20, y: 20 }]],
};
/** Split DXF text into [groupCode, value] pairs. */
function pairs(dxf: string): [string, string][] {
const lines = dxf.split('\r\n');
if (lines[lines.length - 1] === '') lines.pop();
const out: [string, string][] = [];
for (let i = 0; i < lines.length; i += 2) out.push([lines[i].trim(), lines[i + 1]]);
return out;
}
describe('toDxf', () => {
it('is a well-formed R12 file in mm with CRLF line endings', () => {
const dxf = toDxf(shape);
expect(dxf).not.toMatch(/[^\r]\n/);
const p = pairs(dxf);
expect(p[0]).toEqual(['0', 'SECTION']);
expect(p).toContainEqual(['1', 'AC1009']);
const unitsIdx = p.findIndex(([c, v]) => c === '9' && v === '$INSUNITS');
expect(p[unitsIdx + 1]).toEqual(['70', '4']);
expect(p[p.length - 1]).toEqual(['0', 'EOF']);
});
it('writes one closed POLYLINE per ring with all vertices', () => {
const p = pairs(toDxf(shape));
const entities = p.filter(([c]) => c === '0').map(([, v]) => v);
expect(entities.filter((e) => e === 'POLYLINE')).toHaveLength(2);
expect(entities.filter((e) => e === 'VERTEX')).toHaveLength(7);
expect(entities.filter((e) => e === 'SEQEND')).toHaveLength(2);
const polyIdx = p.findIndex(([c, v]) => c === '0' && v === 'POLYLINE');
const flags = p.slice(polyIdx, polyIdx + 8).find(([c]) => c === '70');
expect(flags).toEqual(['70', '1']);
});
it('flips Y so the piece is not mirrored', () => {
const p = pairs(toDxf(shape));
const firstVertex = p.findIndex(([c, v]) => c === '0' && v === 'VERTEX');
const x = p.slice(firstVertex).find(([c]) => c === '10')![1];
const y = p.slice(firstVertex).find(([c]) => c === '20')![1];
expect([x, y]).toEqual(['0', '50']); // (0,0) top-left → (0,50) in Y-up space
});
});
- Step 3: Run the tests to verify they fail
Run: npx vitest run tests/export-svg.test.ts tests/export-dxf.test.ts
Expected: FAIL, modules not found.
- Step 4: Implement
src/lib/export-svg.ts
import { formatMm, shapeSize } from './geometry';
import type { Point, Shape } from './types';
export function toSvg(shapeMm: Shape): string {
const { width, height } = shapeSize(shapeMm);
const w = formatMm(width);
const h = formatMm(height);
const d = [shapeMm.outer, ...shapeMm.holes].map(ringPath).join(' ');
return [
'<?xml version="1.0" encoding="UTF-8"?>',
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" height="${h}mm" viewBox="0 0 ${w} ${h}">`,
` <path d="${d}" fill="none" fill-rule="evenodd" stroke="#ff0000" stroke-width="0.1"/>`,
'</svg>',
'',
].join('\n');
}
function ringPath(ring: Point[]): string {
return 'M' + ring.map((p) => `${formatMm(p.x)},${formatMm(p.y)}`).join(' L') + ' Z';
}
- Step 5: Implement
src/lib/export-dxf.ts
import { formatMm, shapeSize } from './geometry';
import type { Shape } from './types';
/** Minimal ASCII DXF R12: one closed POLYLINE per ring, units mm, Y axis up. */
export function toDxf(shapeMm: Shape): string {
const { height } = shapeSize(shapeMm);
const out: (string | number)[] = [
0, 'SECTION', 2, 'HEADER',
9, '$ACADVER', 1, 'AC1009',
9, '$INSUNITS', 70, 4,
0, 'ENDSEC',
0, 'SECTION', 2, 'ENTITIES',
];
for (const ring of [shapeMm.outer, ...shapeMm.holes]) {
out.push(0, 'POLYLINE', 8, '0', 66, 1, 70, 1, 10, '0.0', 20, '0.0', 30, '0.0');
for (const p of ring) {
out.push(0, 'VERTEX', 8, '0', 10, formatMm(p.x), 20, formatMm(height - p.y), 30, '0.0');
}
out.push(0, 'SEQEND', 8, '0');
}
out.push(0, 'ENDSEC', 0, 'EOF');
// RDWorks is a Windows tool; CRLF is the most widely accepted DXF line ending.
return out.join('\r\n') + '\r\n';
}
- Step 6: Run tests
Run: npx vitest run tests/export-svg.test.ts tests/export-dxf.test.ts
Expected: all PASS.
- Step 7: Commit
git add src/lib/export-svg.ts src/lib/export-dxf.ts tests/export-svg.test.ts tests/export-dxf.test.ts
git commit -m "feat: add DXF (R12) and SVG exporters"
Task 4: OpenCV loader, Otsu threshold, mask tracing with margin
Files:
- Create:
src/lib/opencv-loader.ts,src/lib/detect.ts(first part),tests/helpers/images.ts - Test:
tests/trace-mask.test.ts
Interfaces:
- Consumes:
RasterImage,Shape,Pointfromtypes.ts - Produces:
type CV = typeof import('@techstark/opencv-js')loadOpenCV(): Promise<{ cv: CV }>, which loads once and caches. The result is wrapped because the module is a thenable.otsuThreshold(cv: CV, image: RasterImage): number, run on the blurred grayscale image, 0–255traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null.maskis CV_8UC1 with 255 = piece and is not modified. Applies the erode/dilate margin, then returns the largest outer contour with its holes, in px. Returns null if nothing remains.- Test helpers:
grayImage(width, height, paint: (x, y) => number): RasterImage,maskFrom(cv, width, height, inside: (x, y) => boolean): Mat
Notes for the implementer:
-
Every
cv.Mat,cv.MatVectorand kernel you create must be.delete()d in afinally, because they live on the WASM heap. That includes Mats returned bycontours.get(i). -
If the bundled OpenCV typings reject a call that works at runtime, cast that single argument. Don't loosen
CVtoany. -
Step 1: Implement
src/lib/opencv-loader.ts
export type CV = typeof import('@techstark/opencv-js');
let loading: Promise<{ cv: CV }> | null = null;
// Wrapped in an object: the cv module is a thenable, so resolving with it directly never settles.
export function loadOpenCV(): Promise<{ cv: CV }> {
loading ??= (async () => {
const mod = (await import('@techstark/opencv-js')) as unknown as { default: CV };
const cv = mod.default;
if (!cv.Mat) {
await new Promise<void>((resolve) => {
(cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve();
});
}
return { cv };
})();
return loading;
}
- Step 2: Create
tests/helpers/images.ts
import type { Mat } from '@techstark/opencv-js';
import type { CV } from '../../src/lib/opencv-loader';
import type { RasterImage } from '../../src/lib/types';
/** Grayscale RGBA image where `paint(x, y)` returns the brightness 0–255. */
export function grayImage(width: number, height: number, paint: (x: number, y: number) => number): RasterImage {
const data = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const v = paint(x, y);
data[i] = data[i + 1] = data[i + 2] = v;
data[i + 3] = 255;
}
}
return { width, height, data };
}
/** CV_8UC1 mask with 255 where `inside(x, y)`. Caller must delete it. */
export function maskFrom(cv: CV, width: number, height: number, inside: (x: number, y: number) => boolean): Mat {
const mask = cv.Mat.zeros(height, width, cv.CV_8UC1) as Mat;
const data = mask.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (inside(x, y)) data[y * width + x] = 255;
}
}
return mask;
}
export const inRect = (x: number, y: number, x0: number, y0: number, x1: number, y1: number) =>
x >= x0 && x < x1 && y >= y0 && y < y1;
export const inCircle = (x: number, y: number, cx: number, cy: number, r: number) =>
(x - cx) ** 2 + (y - cy) ** 2 <= r * r;
- Step 3: Write the failing test
tests/trace-mask.test.ts
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { Mat } from '@techstark/opencv-js';
import { otsuThreshold, traceMask } from '../src/lib/detect';
import { bounds } from '../src/lib/geometry';
import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
import { grayImage, inCircle, inRect, maskFrom } from './helpers/images';
let cv: CV;
const mats: Mat[] = [];
beforeAll(async () => {
({ cv } = await loadOpenCV());
});
afterEach(() => {
mats.splice(0).forEach((m) => m.delete());
});
/** 200x160 plate: rect x 40..159, y 30..129 with a round hole r=15 at (100,80). */
function plate(): Mat {
const m = maskFrom(cv, 200, 160, (x, y) => inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15));
mats.push(m);
return m;
}
describe('loadOpenCV', () => {
it('resolves to the same cv instance every time', async () => {
const again = await loadOpenCV();
expect(again.cv).toBe(cv);
expect(typeof cv.Mat).toBe('function');
});
});
describe('traceMask', () => {
it('returns the outer contour and the hole', () => {
const shape = traceMask(cv, plate(), 0)!;
const b = bounds(shape.outer);
expect(Math.abs(b.minX - 40)).toBeLessThanOrEqual(2);
expect(Math.abs(b.maxX - 159)).toBeLessThanOrEqual(2);
expect(Math.abs(b.minY - 30)).toBeLessThanOrEqual(2);
expect(Math.abs(b.maxY - 129)).toBeLessThanOrEqual(2);
expect(shape.holes).toHaveLength(1);
const h = bounds(shape.holes[0]);
expect(h.minX).toBeGreaterThan(82);
expect(h.maxX).toBeLessThan(118);
});
it('shrinks the outer edge and grows holes for a negative margin', () => {
const shape = traceMask(cv, plate(), -5)!;
const b = bounds(shape.outer);
expect(Math.abs(b.minX - 45)).toBeLessThanOrEqual(2);
expect(Math.abs(b.maxX - 154)).toBeLessThanOrEqual(2);
const h = bounds(shape.holes[0]);
expect(Math.abs(h.minX - 79)).toBeLessThanOrEqual(2);
expect(Math.abs(h.maxX - 121)).toBeLessThanOrEqual(2);
});
it('grows the outer edge for a positive margin, even past the image border', () => {
const shape = traceMask(cv, plate(), 5)!;
expect(Math.abs(bounds(shape.outer).minX - 35)).toBeLessThanOrEqual(2);
const edge = maskFrom(cv, 100, 100, (x, y) => inRect(x, y, 0, 20, 60, 80));
mats.push(edge);
expect(bounds(traceMask(cv, edge, 5)!.outer).minX).toBeLessThan(0);
});
it('keeps the larger part when erosion splits the piece in two', () => {
// 60x80 block and 40x40 block joined by a 7 px bridge; an 11 px erosion cuts the bridge.
const m = maskFrom(cv, 200, 120, (x, y) =>
inRect(x, y, 20, 20, 80, 100) || inRect(x, y, 120, 40, 160, 80) || inRect(x, y, 80, 56, 120, 63));
mats.push(m);
const b = bounds(traceMask(cv, m, -5)!.outer);
expect(b.maxX).toBeLessThan(100);
});
it('returns null when the margin removes the piece entirely', () => {
const m = maskFrom(cv, 50, 50, (x, y) => inRect(x, y, 20, 20, 30, 30));
mats.push(m);
expect(traceMask(cv, m, -8)).toBeNull();
});
it('does not modify the input mask', () => {
const m = plate();
const before = cv.countNonZero(m);
traceMask(cv, m, -5);
expect(cv.countNonZero(m)).toBe(before);
});
});
describe('otsuThreshold', () => {
it('lands between background and piece brightness', () => {
const image = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
const t = otsuThreshold(cv, image);
expect(t).toBeGreaterThan(30);
expect(t).toBeLessThan(220);
});
});
- Step 4: Run the test to verify it fails
Run: npx vitest run tests/trace-mask.test.ts
Expected: FAIL, ../src/lib/detect not found. The loadOpenCV test may already pass.
- Step 5: Implement the first part of
src/lib/detect.ts
import type { Mat } from '@techstark/opencv-js';
import type { CV } from './opencv-loader';
import type { Point, RasterImage, Shape } from './types';
/** Blurred grayscale; shared by thresholding and Otsu so both see the same pixels. */
function toGray(cv: CV, image: RasterImage): Mat {
const src = cv.matFromImageData(image as unknown as ImageData);
const gray = new cv.Mat();
try {
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
cv.GaussianBlur(gray, gray, new cv.Size(5, 5), 0);
return gray;
} finally {
src.delete();
}
}
export function otsuThreshold(cv: CV, image: RasterImage): number {
const gray = toGray(cv, image);
const out = new cv.Mat();
try {
return Math.round(cv.threshold(gray, out, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU));
} finally {
gray.delete();
out.delete();
}
}
export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
const radius = Math.round(Math.abs(marginPx));
// Padding keeps dilation from being clipped and contours off the image edge.
const pad = radius + 2;
const padded = new cv.Mat();
const contours = new cv.MatVector();
const hierarchy = new cv.Mat();
let kernel: Mat | null = null;
try {
cv.copyMakeBorder(mask, padded, pad, pad, pad, pad, cv.BORDER_CONSTANT, new cv.Scalar(0));
if (radius > 0) {
kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(2 * radius + 1, 2 * radius + 1));
if (marginPx < 0) cv.erode(padded, padded, kernel);
else cv.dilate(padded, padded, kernel);
}
cv.findContours(padded, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE);
const count = contours.size();
const h = hierarchy.data32S; // per contour: [next, prev, firstChild, parent]
let best = -1;
let bestArea = 0;
for (let i = 0; i < count; i++) {
if (h[i * 4 + 3] !== -1) continue;
const c = contours.get(i);
const area = cv.contourArea(c);
c.delete();
if (area > bestArea) {
bestArea = area;
best = i;
}
}
if (best === -1) return null;
const ring = (i: number): Point[] => {
const c = contours.get(i);
const d = c.data32S;
const pts: Point[] = [];
for (let k = 0; k < d.length; k += 2) pts.push({ x: d[k] - pad, y: d[k + 1] - pad });
c.delete();
return pts;
};
const holes: Point[][] = [];
for (let i = 0; i < count; i++) {
if (h[i * 4 + 3] === best) holes.push(ring(i));
}
return { outer: ring(best), holes };
} finally {
padded.delete();
contours.delete();
hierarchy.delete();
kernel?.delete();
}
}
- Step 6: Run tests
Run: npx vitest run tests/trace-mask.test.ts
Expected: all PASS. The first run loads OpenCV, which takes a few seconds.
- Step 7: Commit
git add src/lib/opencv-loader.ts src/lib/detect.ts tests/helpers/images.ts tests/trace-mask.test.ts
git commit -m "feat: add OpenCV loader, Otsu threshold and margin-aware mask tracing"
Task 5: Automatic detection with piece selection
Files:
- Modify:
src/lib/detect.ts(adddetectandpickComponent) - Test:
tests/detect.test.ts
Interfaces:
- Consumes:
toGray,traceMask(same file),DetectParams,RasterImage,Shape - Produces:
detect(cv: CV, image: RasterImage, params: DetectParams): Shape | null
How it works:
- Threshold the blurred grayscale image (
THRESH_BINARY, orTHRESH_BINARY_INVwheninvert). - Morphological open, then close, with a 5×5 ellipse.
- Connected components (8-connectivity).
- Pick a component:
- If
seedis inside the image and on a component: use it, unless that component's bounding box covers the whole image, in which case return null. - Otherwise, fall back to the largest component whose bounding box doesn't touch the image border.
- If there's none, return null.
- If
traceMaskon the selected component withmarginPx.
- Step 1: Write the failing test
tests/detect.test.ts
import { beforeAll, describe, expect, it } from 'vitest';
import { detect } from '../src/lib/detect';
import { bounds } from '../src/lib/geometry';
import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
import type { DetectParams } from '../src/lib/types';
import { grayImage, inCircle, inRect } from './helpers/images';
let cv: CV;
beforeAll(async () => {
({ cv } = await loadOpenCV());
});
const params = (overrides: Partial<DetectParams> = {}): DetectParams => ({
threshold: 128, invert: false, seed: null, marginPx: 0, ...overrides,
});
/** Light plate x 40..159, y 30..129 with hole r=15 at (100,80), plus a small light blob, on dark ground. */
const plateScene = grayImage(200, 160, (x, y) => {
const plate = inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15);
const blob = inRect(x, y, 170, 140, 185, 152);
return plate || blob ? 220 : 30;
});
const near = (actual: number, expected: number) => expect(Math.abs(actual - expected)).toBeLessThanOrEqual(2);
describe('detect', () => {
it('finds the plate and its hole, ignoring the smaller blob', () => {
const shape = detect(cv, plateScene, params())!;
const b = bounds(shape.outer);
near(b.minX, 40); near(b.maxX, 159); near(b.minY, 30); near(b.maxY, 129);
expect(shape.holes).toHaveLength(1);
});
it('handles a dark piece on a light background with invert', () => {
const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
expect(detect(cv, inverted, params())).toBeNull();
const b = bounds(detect(cv, inverted, params({ invert: true }))!.outer);
near(b.minX, 40); near(b.maxX, 159);
});
it('selects the clicked piece even when another piece is larger', () => {
const shape = detect(cv, plateScene, params({ seed: { x: 177, y: 146 } }))!;
const b = bounds(shape.outer);
near(b.minX, 170); near(b.maxX, 184);
});
it('falls back to the automatic choice when the seed is on background or in a hole', () => {
for (const seed of [{ x: 5, y: 5 }, { x: 100, y: 80 }]) {
const b = bounds(detect(cv, plateScene, params({ seed }))!.outer);
near(b.minX, 40);
}
});
it('ignores a seed outside the image', () => {
const b = bounds(detect(cv, plateScene, params({ seed: { x: -10, y: 500 } }))!.outer);
near(b.minX, 40);
});
it('needs a seed for a piece touching the photo border', () => {
const framed = grayImage(200, 160, (x, y) => (inRect(x, y, 0, 30, 120, 130) ? 220 : 30));
expect(detect(cv, framed, params())).toBeNull();
const b = bounds(detect(cv, framed, params({ seed: { x: 50, y: 80 } }))!.outer);
near(b.minX, 0); near(b.maxX, 119);
});
it('returns null when the clicked region is the whole image', () => {
const allLight = grayImage(100, 100, () => 220);
expect(detect(cv, allLight, params({ seed: { x: 50, y: 50 } }))).toBeNull();
});
it('returns null when nothing passes the threshold', () => {
expect(detect(cv, grayImage(100, 100, () => 30), params())).toBeNull();
});
it('applies the margin', () => {
const b = bounds(detect(cv, plateScene, params({ marginPx: -5 }))!.outer);
near(b.minX, 45); near(b.maxX, 154);
});
it('removes single-pixel speckle from the piece', () => {
const speckled = grayImage(200, 160, (x, y) => {
if (x === 70 && y === 50) return 30; // one dark pixel inside the plate
return inRect(x, y, 40, 30, 160, 130) ? 220 : 30;
});
expect(detect(cv, speckled, params())!.holes).toHaveLength(0);
});
});
- Step 2: Run the test to verify it fails
Run: npx vitest run tests/detect.test.ts
Expected: FAIL, detect is not exported from ../src/lib/detect.
- Step 3: Add
detecttosrc/lib/detect.ts
Change the type import at the top to import type { DetectParams, Point, RasterImage, Shape } from './types'; and append:
export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape | null {
const gray = toGray(cv, image);
const mask = new cv.Mat();
const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(5, 5));
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
try {
const type = params.invert ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY;
cv.threshold(gray, mask, params.threshold, 255, type);
cv.morphologyEx(mask, mask, cv.MORPH_OPEN, kernel);
cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel);
const count = cv.connectedComponentsWithStats(mask, labels, stats, centroids, 8, cv.CV_32S);
const label = pickComponent(cv, labels, stats, count, image.width, image.height, params.seed);
if (label === null) return null;
const lab = labels.data32S;
const m = mask.data;
for (let i = 0; i < lab.length; i++) m[i] = lab[i] === label ? 255 : 0;
return traceMask(cv, mask, params.marginPx);
} finally {
gray.delete();
mask.delete();
kernel.delete();
labels.delete();
stats.delete();
centroids.delete();
}
}
function pickComponent(
cv: CV, labels: Mat, stats: Mat, count: number, width: number, height: number, seed: Point | null,
): number | null {
const stat = (label: number, field: number) => stats.intAt(label, field);
const box = (label: number) => {
const left = stat(label, cv.CC_STAT_LEFT);
const top = stat(label, cv.CC_STAT_TOP);
return { left, top, right: left + stat(label, cv.CC_STAT_WIDTH), bottom: top + stat(label, cv.CC_STAT_HEIGHT) };
};
if (seed) {
const x = Math.round(seed.x);
const y = Math.round(seed.y);
if (x >= 0 && y >= 0 && x < width && y < height) {
const label = labels.intAt(y, x);
if (label !== 0) {
const b = box(label);
const coversImage = b.left === 0 && b.top === 0 && b.right === width && b.bottom === height;
return coversImage ? null : label;
}
}
}
let best: number | null = null;
let bestArea = 0;
for (let label = 1; label < count; label++) {
const b = box(label);
if (b.left === 0 || b.top === 0 || b.right === width || b.bottom === height) continue;
const area = stat(label, cv.CC_STAT_AREA);
if (area > bestArea) {
bestArea = area;
best = label;
}
}
return best;
}
- Step 4: Run tests
Run: npx vitest run tests/detect.test.ts tests/trace-mask.test.ts
Expected: all PASS.
- Step 5: Commit
git add src/lib/detect.ts tests/detect.test.ts
git commit -m "feat: detect piece outline with threshold, seed selection and margin"
Task 6: Manual polygons through the same margin pipeline
Files:
- Create:
src/lib/margin.ts - Test:
tests/margin.test.ts
Interfaces:
-
Consumes:
traceMask(cv, mask, marginPx)fromdetect.ts,CV,Shape,Point -
Produces:
shapeFromPolygons(cv: CV, width: number, height: number, polygons: Shape, marginPx: number): Shape | null. Rasterises the outer ring as the piece and the holes as background, then traces it with the margin. Returns null if the outer ring has fewer than 3 points. -
Step 1: Write the failing test
tests/margin.test.ts
import { beforeAll, describe, expect, it } from 'vitest';
import { bounds } from '../src/lib/geometry';
import { shapeFromPolygons } from '../src/lib/margin';
import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
let cv: CV;
beforeAll(async () => {
({ cv } = await loadOpenCV());
});
const outer = [{ x: 40, y: 30 }, { x: 160, y: 30 }, { x: 160, y: 130 }, { x: 40, y: 130 }];
const hole = [{ x: 90, y: 70 }, { x: 110, y: 70 }, { x: 110, y: 90 }, { x: 90, y: 90 }];
const near = (actual: number, expected: number) => expect(Math.abs(actual - expected)).toBeLessThanOrEqual(2);
describe('shapeFromPolygons', () => {
it('traces the drawn outline and hole', () => {
const shape = shapeFromPolygons(cv, 200, 160, { outer, holes: [hole] }, 0)!;
const b = bounds(shape.outer);
near(b.minX, 40); near(b.maxX, 160); near(b.minY, 30); near(b.maxY, 130);
expect(shape.holes).toHaveLength(1);
});
it('applies the margin to outline and hole', () => {
const shape = shapeFromPolygons(cv, 200, 160, { outer, holes: [hole] }, -5)!;
near(bounds(shape.outer).minX, 45);
near(bounds(shape.holes[0]).minX, 84);
});
it('returns null for an outline with fewer than 3 points', () => {
expect(shapeFromPolygons(cv, 200, 160, { outer: outer.slice(0, 2), holes: [] }, 0)).toBeNull();
});
});
- Step 2: Run the test to verify it fails
Run: npx vitest run tests/margin.test.ts
Expected: FAIL, ../src/lib/margin not found.
- Step 3: Implement
src/lib/margin.ts
import type { Mat } from '@techstark/opencv-js';
import { traceMask } from './detect';
import type { CV } from './opencv-loader';
import type { Point, Shape } from './types';
export function shapeFromPolygons(cv: CV, width: number, height: number, polygons: Shape, marginPx: number): Shape | null {
if (polygons.outer.length < 3) return null;
const mask = cv.Mat.zeros(height, width, cv.CV_8UC1) as Mat;
try {
fill(cv, mask, [polygons.outer], 255);
fill(cv, mask, polygons.holes.filter((h) => h.length >= 3), 0);
return traceMask(cv, mask, marginPx);
} finally {
mask.delete();
}
}
function fill(cv: CV, mask: Mat, rings: Point[][], value: number) {
if (rings.length === 0) return;
const mats = rings.map((ring) =>
cv.matFromArray(ring.length, 1, cv.CV_32SC2, ring.flatMap((p) => [Math.round(p.x), Math.round(p.y)])),
);
const vector = new cv.MatVector();
try {
mats.forEach((m) => vector.push_back(m));
cv.fillPoly(mask, vector, new cv.Scalar(value));
} finally {
vector.delete();
mats.forEach((m) => m.delete());
}
}
- Step 4: Run tests
Run: npx vitest run tests/margin.test.ts
Expected: all PASS.
- Step 5: Commit
git add src/lib/margin.ts tests/margin.test.ts
git commit -m "feat: run manual polygons through the shared margin pipeline"
Task 7: Real-photo fixture harness
Files:
- Create:
tests/fixtures/manifest.json,tests/fixtures/README.md - Test:
tests/fixtures.test.ts
Interfaces:
-
Consumes:
detect,otsuThreshold,prepareForExport,shapeSize,loadOpenCV -
Produces: nothing at runtime. The suite is skipped until photos are added to the manifest.
-
Step 1: Create
tests/fixtures/manifest.json
[]
- Step 2: Create
tests/fixtures/README.md
# Real photo fixtures
Regression photos for `detect()`. Each entry in `manifest.json` is checked by `tests/fixtures.test.ts`.
To add one:
1. Take a top-down photo of a scrap piece with something of known length visible.
2. Rotate it upright and resize it so the long side is at most 2000 px. Save as JPEG.
EXIF orientation is ignored by the test decoder.
3. Measure the real piece's bounding box in mm.
4. Open the photo in the app, set the scale, and note px/mm, the threshold you used, and,
if needed, a seed point (shown in image px).
5. Add an entry:
{
"file": "plywood-1.jpg",
"threshold": null,
"invert": false,
"seed": null,
"pxPerMm": 3.21,
"expected": { "widthMm": 412, "heightMm": 287, "holes": 2 },
"toleranceMm": 8
}
`threshold: null` means Otsu's automatic value. `seed` is `{ "x": ..., "y": ... }` or `null`.
- Step 3: Write
tests/fixtures.test.ts
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import jpeg from 'jpeg-js';
import { beforeAll, describe, expect, it } from 'vitest';
import { detect, otsuThreshold } from '../src/lib/detect';
import { prepareForExport, shapeSize } from '../src/lib/geometry';
import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
import type { Point, RasterImage } from '../src/lib/types';
type FixtureCase = {
file: string;
threshold: number | null;
invert: boolean;
seed: Point | null;
pxPerMm: number;
expected: { widthMm: number; heightMm: number; holes: number };
toleranceMm: number;
};
const dir = fileURLToPath(new URL('./fixtures/', import.meta.url));
const cases: FixtureCase[] = JSON.parse(readFileSync(dir + 'manifest.json', 'utf8'));
function readJpeg(path: string): RasterImage {
const decoded = jpeg.decode(readFileSync(path), { useTArray: true, formatAsRGBA: true });
return { width: decoded.width, height: decoded.height, data: new Uint8ClampedArray(decoded.data) };
}
describe.skipIf(cases.length === 0)('detect on real photos', () => {
let cv: CV;
beforeAll(async () => {
({ cv } = await loadOpenCV());
});
it.each(cases)('$file', (c) => {
const image = readJpeg(dir + c.file);
const threshold = c.threshold ?? otsuThreshold(cv, image);
const shape = detect(cv, image, { threshold, invert: c.invert, seed: c.seed, marginPx: 0 });
expect(shape).not.toBeNull();
const mm = prepareForExport(shape!, c.pxPerMm)!;
const size = shapeSize(mm);
expect(Math.abs(size.width - c.expected.widthMm)).toBeLessThanOrEqual(c.toleranceMm);
expect(Math.abs(size.height - c.expected.heightMm)).toBeLessThanOrEqual(c.toleranceMm);
expect(mm.holes).toHaveLength(c.expected.holes);
});
});
- Step 4: Run tests
Run: npx vitest run tests/fixtures.test.ts && npm run check
Expected: the suite is reported as skipped (no failures), and svelte-check shows 0 errors. If jpeg-js types are missing, add declare module 'jpeg-js'; in tests/jpeg-js.d.ts.
- Step 5: Commit
git add tests/fixtures tests/fixtures.test.ts
git commit -m "test: add real-photo fixture harness for detection regressions"
Task 8: View transform, photo loading, download helper
Files:
- Create:
src/lib/view.ts,src/lib/load-image.ts,src/lib/download.ts - Test:
tests/view.test.ts,tests/load-image.test.ts
Interfaces:
-
Consumes:
Point,RasterImage -
Produces:
type View = { scale: number; offsetX: number; offsetY: number }. The screen position isimage * scale + offset.screenToImage(p: Point, view: View): PointzoomAt(view: View, screenPoint: Point, factor: number): View, which keeps the image point underscreenPointfixed, with the scale clamped to [0.05, 20]fitView(imageWidth: number, imageHeight: number, viewWidth: number, viewHeight: number, padding?: number): View, which centres the imageMAX_SIDE = 2000,fitSize(width: number, height: number, maxSide: number): { width: number; height: number }, which never upscalesloadPhoto(file: Blob): Promise<RasterImage>, which applies EXIF orientation and downscales toMAX_SIDEdownloadText(filename: string, text: string, mime: string): void
-
Step 1: Write the failing test
tests/view.test.ts
import { describe, expect, it } from 'vitest';
import { fitView, screenToImage, zoomAt, type View } from '../src/lib/view';
describe('view transform', () => {
it('maps screen to image coordinates', () => {
const view: View = { scale: 2, offsetX: 10, offsetY: 20 };
expect(screenToImage({ x: 30, y: 60 }, view)).toEqual({ x: 10, y: 20 });
});
it('keeps the point under the cursor fixed while zooming', () => {
const view: View = { scale: 0.5, offsetX: 40, offsetY: -15 };
const cursor = { x: 300, y: 200 };
const before = screenToImage(cursor, view);
const zoomed = zoomAt(view, cursor, 3);
expect(zoomed.scale).toBeCloseTo(1.5);
const after = screenToImage(cursor, zoomed);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
});
it('clamps zoom', () => {
expect(zoomAt({ scale: 10, offsetX: 0, offsetY: 0 }, { x: 0, y: 0 }, 100).scale).toBe(20);
expect(zoomAt({ scale: 0.1, offsetX: 0, offsetY: 0 }, { x: 0, y: 0 }, 0.001).scale).toBe(0.05);
});
it('fits and centres the image in the viewport', () => {
const view = fitView(2000, 1000, 1016, 1016, 8);
expect(view.scale).toBeCloseTo(0.5);
expect(view.offsetX).toBeCloseTo(8);
expect(view.offsetY).toBeCloseTo(258);
});
});
- Step 2: Write the failing test
tests/load-image.test.ts
import { describe, expect, it } from 'vitest';
import { fitSize, MAX_SIDE } from '../src/lib/load-image';
describe('fitSize', () => {
it('downscales a 12 MP landscape photo to the max side', () => {
expect(fitSize(4000, 3000, MAX_SIDE)).toEqual({ width: 2000, height: 1500 });
});
it('downscales portrait photos by their height', () => {
expect(fitSize(3024, 4032, 2000)).toEqual({ width: 1500, height: 2000 });
});
it('never upscales small images', () => {
expect(fitSize(800, 600, 2000)).toEqual({ width: 800, height: 600 });
});
});
- Step 3: Run the tests to verify they fail
Run: npx vitest run tests/view.test.ts tests/load-image.test.ts
Expected: FAIL, modules not found.
- Step 4: Implement
src/lib/view.ts
import type { Point } from './types';
export type View = { scale: number; offsetX: number; offsetY: number };
const MIN_SCALE = 0.05;
const MAX_SCALE = 20;
export function screenToImage(p: Point, view: View): Point {
return { x: (p.x - view.offsetX) / view.scale, y: (p.y - view.offsetY) / view.scale };
}
export function zoomAt(view: View, screenPoint: Point, factor: number): View {
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, view.scale * factor));
const anchor = screenToImage(screenPoint, view);
return { scale, offsetX: screenPoint.x - anchor.x * scale, offsetY: screenPoint.y - anchor.y * scale };
}
export function fitView(imageWidth: number, imageHeight: number, viewWidth: number, viewHeight: number, padding = 16): View {
const scale = Math.min((viewWidth - 2 * padding) / imageWidth, (viewHeight - 2 * padding) / imageHeight);
return {
scale,
offsetX: (viewWidth - imageWidth * scale) / 2,
offsetY: (viewHeight - imageHeight * scale) / 2,
};
}
- Step 5: Implement
src/lib/load-image.ts
import type { RasterImage } from './types';
export const MAX_SIDE = 2000;
export function fitSize(width: number, height: number, maxSide: number): { width: number; height: number } {
const s = Math.min(1, maxSide / Math.max(width, height));
return { width: Math.round(width * s), height: Math.round(height * s) };
}
export async function loadPhoto(file: Blob): Promise<RasterImage> {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
try {
const { width, height } = fitSize(bitmap.width, bitmap.height, MAX_SIDE);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, width, height);
return { width, height, data: ctx.getImageData(0, 0, width, height).data };
} finally {
bitmap.close();
}
}
- Step 6: Implement
src/lib/download.ts
export function downloadText(filename: string, text: string, mime: string): void {
const url = URL.createObjectURL(new Blob([text], { type: mime }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
- Step 7: Run tests and type check
Run: npx vitest run tests/view.test.ts tests/load-image.test.ts && npm run check
Expected: all PASS, 0 type errors.
- Step 8: Commit
git add src/lib/view.ts src/lib/load-image.ts src/lib/download.ts tests/view.test.ts tests/load-image.test.ts
git commit -m "feat: add view transform, photo loading and download helpers"
Task 9: AppState
Files:
- Create:
src/lib/app-state.svelte.ts - Test:
tests/app-state.test.ts
Interfaces:
- Consumes:
scaleFromLine,detect,otsuThreshold,shapeFromPolygons,prepareForExport,loadPhoto,CV, the types - Produces:
type Tool = 'none' | 'scale' | 'seed' | 'draw'and theAppStateclass:- Fields (reactive):
cv: CV | null,cvError: string | null,image: RasterImage | null,loadError: string | nulltool: ToolscalePoints: Point[],lengthMm: number | nullthreshold: number,invert: boolean,seed: Point | nullmarginMm: number(default −2)mode: 'auto' | 'manual'manualOuter: Point[],manualHoles: Point[][],drawing: Point[]
- Derived:
pxPerMm: number | nullscaleInvalid: boolean, true when two points are placed and a length is entered but the scale is invalidmarginPx: number, which is 0 until the scale is setshapePx: Shape | nullexportShape: Shape | null
- Methods:
setCv(cv),setImage(image),loadFile(file: Blob): Promise<void>,handleImageClick(p: Point),finishPolygon(),clearManual(),autoThreshold()
- Fields (reactive):
Click routing:
scale: the first click sets[p], the second appends and switches the tool tonone. A click when 2 points already exist restarts with[p].seed: setsseed, switches the tool tonone.draw: appends todrawing.- Any tool: clicks outside
0 ≤ x < width,0 ≤ y < heightare ignored.
finishPolygon(): if drawing has ≥ 3 points, it becomes manualOuter when that's empty, otherwise it's pushed to manualHoles. drawing is cleared and mode becomes 'manual'. With fewer than 3 points, drawing is just cleared.
- Step 1: Write the failing test
tests/app-state.test.ts
import { describe, expect, it } from 'vitest';
import { AppState } from '../src/lib/app-state.svelte';
const image = { width: 100, height: 80, data: new Uint8ClampedArray(100 * 80 * 4) };
function withImage() {
const app = new AppState();
app.setImage(image);
return app;
}
describe('AppState', () => {
it('starts with sensible defaults', () => {
const app = new AppState();
expect(app.marginMm).toBe(-2);
expect(app.mode).toBe('auto');
expect(app.tool).toBe('none');
expect(app.shapePx).toBeNull();
expect(app.exportShape).toBeNull();
});
it('sets the scale from two clicks, then leaves the scale tool', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
expect(app.tool).toBe('scale');
app.handleImageClick({ x: 70, y: 10 });
expect(app.tool).toBe('none');
app.lengthMm = 30;
expect(app.pxPerMm).toBeCloseTo(2);
expect(app.marginPx).toBeCloseTo(-4);
});
it('restarts the scale line on a third click', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
app.handleImageClick({ x: 70, y: 10 });
app.tool = 'scale';
app.handleImageClick({ x: 5, y: 5 });
expect(app.scalePoints).toEqual([{ x: 5, y: 5 }]);
expect(app.pxPerMm).toBeNull();
});
it('flags an invalid scale and keeps the margin at 0 until the scale is valid', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
app.handleImageClick({ x: 10, y: 10 });
app.lengthMm = 50;
expect(app.scaleInvalid).toBe(true);
expect(app.marginPx).toBe(0);
});
it('ignores clicks outside the image', () => {
const app = withImage();
app.tool = 'seed';
app.handleImageClick({ x: -1, y: 10 });
app.handleImageClick({ x: 50, y: 80 });
expect(app.seed).toBeNull();
expect(app.tool).toBe('seed');
});
it('sets the seed and leaves the seed tool', () => {
const app = withImage();
app.tool = 'seed';
app.handleImageClick({ x: 20, y: 30 });
expect(app.seed).toEqual({ x: 20, y: 30 });
expect(app.tool).toBe('none');
});
it('draws an outline then holes, switching to manual mode', () => {
const app = withImage();
app.tool = 'draw';
[{ x: 1, y: 1 }, { x: 90, y: 1 }, { x: 90, y: 70 }].forEach((p) => app.handleImageClick(p));
app.finishPolygon();
expect(app.manualOuter).toHaveLength(3);
expect(app.mode).toBe('manual');
[{ x: 20, y: 20 }, { x: 30, y: 20 }, { x: 30, y: 30 }].forEach((p) => app.handleImageClick(p));
app.finishPolygon();
expect(app.manualHoles).toHaveLength(1);
expect(app.drawing).toEqual([]);
});
it('discards a polygon with fewer than 3 points', () => {
const app = withImage();
app.tool = 'draw';
app.handleImageClick({ x: 1, y: 1 });
app.finishPolygon();
expect(app.manualOuter).toEqual([]);
expect(app.mode).toBe('auto');
});
it('clearManual returns to auto mode', () => {
const app = withImage();
app.tool = 'draw';
[{ x: 1, y: 1 }, { x: 90, y: 1 }, { x: 90, y: 70 }].forEach((p) => app.handleImageClick(p));
app.finishPolygon();
app.clearManual();
expect(app.mode).toBe('auto');
expect(app.manualOuter).toEqual([]);
expect(app.tool).toBe('none');
});
it('a new image resets scale, seed and manual work', () => {
const app = withImage();
app.tool = 'scale';
app.handleImageClick({ x: 10, y: 10 });
app.seed = { x: 5, y: 5 };
app.manualOuter = [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 1 }];
app.mode = 'manual';
app.setImage(image);
expect(app.scalePoints).toEqual([]);
expect(app.seed).toBeNull();
expect(app.manualOuter).toEqual([]);
expect(app.mode).toBe('auto');
expect(app.tool).toBe('none');
expect(app.threshold).toBe(128);
});
});
- Step 2: Run the test to verify it fails
Run: npx vitest run tests/app-state.test.ts
Expected: FAIL, ../src/lib/app-state.svelte not found.
- Step 3: Implement
src/lib/app-state.svelte.ts
import { detect, otsuThreshold } from './detect';
import { prepareForExport } from './geometry';
import { loadPhoto } from './load-image';
import { shapeFromPolygons } from './margin';
import type { CV } from './opencv-loader';
import { scaleFromLine } from './scale';
import type { Point, RasterImage, Shape } from './types';
export type Tool = 'none' | 'scale' | 'seed' | 'draw';
const DEFAULT_THRESHOLD = 128;
export class AppState {
// Raw: the cv module and pixel buffers must never be wrapped in reactive proxies.
cv = $state.raw<CV | null>(null);
cvError = $state<string | null>(null);
image = $state.raw<RasterImage | null>(null);
loadError = $state<string | null>(null);
tool = $state<Tool>('none');
scalePoints = $state<Point[]>([]);
lengthMm = $state<number | null>(null);
threshold = $state(DEFAULT_THRESHOLD);
invert = $state(false);
seed = $state<Point | null>(null);
marginMm = $state(-2);
mode = $state<'auto' | 'manual'>('auto');
manualOuter = $state<Point[]>([]);
manualHoles = $state<Point[][]>([]);
drawing = $state<Point[]>([]);
pxPerMm = $derived(
this.scalePoints.length === 2 && this.lengthMm !== null
? scaleFromLine(this.scalePoints[0], this.scalePoints[1], this.lengthMm)
: null,
);
scaleInvalid = $derived(this.scalePoints.length === 2 && this.lengthMm !== null && this.pxPerMm === null);
marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0);
shapePx = $derived.by((): Shape | null => {
const { cv, image } = this;
if (!cv || !image) return null;
if (this.mode === 'manual') {
return shapeFromPolygons(cv, image.width, image.height, { outer: this.manualOuter, holes: this.manualHoles }, this.marginPx);
}
return detect(cv, image, { threshold: this.threshold, invert: this.invert, seed: this.seed, marginPx: this.marginPx });
});
exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null);
setCv(cv: CV) {
this.cv = cv;
if (this.image) this.threshold = otsuThreshold(cv, this.image);
}
setImage(image: RasterImage) {
this.image = image;
this.tool = 'none';
this.scalePoints = [];
this.seed = null;
this.clearManual();
this.threshold = this.cv ? otsuThreshold(this.cv, image) : DEFAULT_THRESHOLD;
}
async loadFile(file: Blob) {
try {
this.loadError = null;
this.setImage(await loadPhoto(file));
} catch {
this.loadError = 'Could not read this image file.';
}
}
autoThreshold() {
if (this.cv && this.image) this.threshold = otsuThreshold(this.cv, this.image);
}
handleImageClick(p: Point) {
const image = this.image;
if (!image || p.x < 0 || p.y < 0 || p.x >= image.width || p.y >= image.height) return;
switch (this.tool) {
case 'scale':
if (this.scalePoints.length === 1) {
this.scalePoints.push(p);
this.tool = 'none';
} else {
this.scalePoints = [p];
}
break;
case 'seed':
this.seed = p;
this.tool = 'none';
break;
case 'draw':
this.drawing.push(p);
break;
}
}
finishPolygon() {
if (this.drawing.length >= 3) {
if (this.manualOuter.length === 0) this.manualOuter = this.drawing;
else this.manualHoles.push(this.drawing);
this.mode = 'manual';
}
this.drawing = [];
}
clearManual() {
this.manualOuter = [];
this.manualHoles = [];
this.drawing = [];
this.mode = 'auto';
if (this.tool === 'draw') this.tool = 'none';
}
}
- Step 4: Run tests and type check
Run: npx vitest run tests/app-state.test.ts && npm run check
Expected: all PASS, 0 type errors. If $derived class fields fail under Vitest with a "rune not available" error, check that vite.config.ts has the resolve.conditions: ['browser'] block from Task 1.
- Step 5: Run the full suite
Run: npm test
Expected: everything passes, and the fixture suite is skipped.
- Step 6: Commit
git add src/lib/app-state.svelte.ts tests/app-state.test.ts
git commit -m "feat: add AppState with click routing and derived detection/export"
Task 10: Image canvas and app shell
Files:
- Create:
src/components/ImageCanvas.svelte - Modify:
src/App.svelte(replace the placeholder)
Interfaces:
- Consumes:
AppState:image,shapePx,scalePoints,lengthMm,seed,drawing,manualOuter,manualHoles,mode,tool,handleImageClick,setCv,cvError,loadFileView,fitView,screenToImage,zoomAt,loadOpenCV
- Produces: the
<ImageCanvas {app} />component.App.svelterenders<Sidebar {app} />(Task 11) and the canvas.
UI code has no automated tests. Verify it by hand in the browser (Step 4).
- Step 1: Create
src/components/ImageCanvas.svelte
<script lang="ts">
import type { AppState } from '../lib/app-state.svelte';
import type { Point, Shape } from '../lib/types';
import { fitView, screenToImage, zoomAt, type View } from '../lib/view';
let { app }: { app: AppState } = $props();
let container: HTMLDivElement;
let canvas: HTMLCanvasElement;
let size = $state({ width: 0, height: 0 });
let view = $state<View>({ scale: 1, offsetX: 0, offsetY: 0 });
let drag: { start: Point; last: Point; moved: boolean } | null = null;
const photo = $derived.by(() => {
const image = app.image;
if (!image) return null;
const c = document.createElement('canvas');
c.width = image.width;
c.height = image.height;
c.getContext('2d')!.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
return c;
});
$effect(() => {
const observer = new ResizeObserver(([entry]) => {
size = { width: Math.floor(entry.contentRect.width), height: Math.floor(entry.contentRect.height) };
});
observer.observe(container);
return () => observer.disconnect();
});
$effect(() => {
if (app.image && size.width > 0 && size.height > 0) {
view = fitView(app.image.width, app.image.height, size.width, size.height);
}
});
// Registered manually: wheel listeners must be non-passive to stop page scrolling.
$effect(() => {
const onwheel = (e: WheelEvent) => {
e.preventDefault();
view = zoomAt(view, local(e), Math.exp(-e.deltaY * 0.001));
};
canvas.addEventListener('wheel', onwheel, { passive: false });
return () => canvas.removeEventListener('wheel', onwheel);
});
$effect(() => {
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext('2d')!;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!photo) return;
ctx.setTransform(view.scale, 0, 0, view.scale, view.offsetX, view.offsetY);
ctx.drawImage(photo, 0, 0);
const px = 1 / view.scale; // one screen pixel in image units
if (app.shapePx) drawShape(ctx, app.shapePx, px);
if (app.mode === 'manual') {
for (const ring of [app.manualOuter, ...app.manualHoles]) drawPolyline(ctx, ring, true, '#ff9800', px, [6 * px, 4 * px]);
}
if (app.drawing.length > 0) drawPolyline(ctx, app.drawing, false, '#ff9800', px, []);
drawScale(ctx, app.scalePoints, app.lengthMm, px);
if (app.seed) drawSeed(ctx, app.seed, px);
});
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape, px: number) {
ctx.beginPath();
for (const ring of [shape.outer, ...shape.holes]) traceRing(ctx, ring, true);
ctx.fillStyle = 'rgba(0, 200, 83, 0.25)';
ctx.fill('evenodd');
ctx.strokeStyle = '#00c853';
ctx.lineWidth = 2 * px;
ctx.setLineDash([]);
ctx.stroke();
}
function drawPolyline(ctx: CanvasRenderingContext2D, points: Point[], closed: boolean, color: string, px: number, dash: number[]) {
ctx.beginPath();
traceRing(ctx, points, closed);
ctx.strokeStyle = color;
ctx.lineWidth = 2 * px;
ctx.setLineDash(dash);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color;
for (const p of points) {
ctx.beginPath();
ctx.arc(p.x, p.y, 3 * px, 0, Math.PI * 2);
ctx.fill();
}
}
function traceRing(ctx: CanvasRenderingContext2D, ring: Point[], closed: boolean) {
ring.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
if (closed) ctx.closePath();
}
function drawScale(ctx: CanvasRenderingContext2D, points: Point[], lengthMm: number | null, px: number) {
if (points.length === 0) return;
drawPolyline(ctx, points, false, '#e53935', px, []);
if (points.length === 2 && lengthMm) {
const mid = { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };
ctx.font = `${14 * px}px sans-serif`;
ctx.fillStyle = '#e53935';
ctx.fillText(`${lengthMm} mm`, mid.x + 6 * px, mid.y - 6 * px);
}
}
function drawSeed(ctx: CanvasRenderingContext2D, p: Point, px: number) {
const r = 8 * px;
ctx.beginPath();
ctx.moveTo(p.x - r, p.y);
ctx.lineTo(p.x + r, p.y);
ctx.moveTo(p.x, p.y - r);
ctx.lineTo(p.x, p.y + r);
ctx.strokeStyle = '#2962ff';
ctx.lineWidth = 2 * px;
ctx.stroke();
}
function local(e: PointerEvent | WheelEvent): Point {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onpointerdown(e: PointerEvent) {
canvas.setPointerCapture(e.pointerId);
const p = local(e);
drag = { start: p, last: p, moved: false };
}
function onpointermove(e: PointerEvent) {
if (!drag) return;
const p = local(e);
if (Math.hypot(p.x - drag.start.x, p.y - drag.start.y) > 4) drag.moved = true;
if (drag.moved) {
view = { ...view, offsetX: view.offsetX + p.x - drag.last.x, offsetY: view.offsetY + p.y - drag.last.y };
}
drag.last = p;
}
function onpointerup(e: PointerEvent) {
if (drag && !drag.moved) app.handleImageClick(screenToImage(local(e), view));
drag = null;
}
</script>
<div class="canvas-wrap" bind:this={container}>
<canvas
bind:this={canvas}
class:picking={app.tool !== 'none'}
{onpointerdown}
{onpointermove}
{onpointerup}
></canvas>
</div>
<style>
.canvas-wrap {
position: absolute;
inset: 0;
overflow: hidden;
}
canvas {
display: block;
cursor: grab;
touch-action: none;
}
canvas.picking {
cursor: crosshair;
}
</style>
- Step 2: Replace
src/App.svelte
Sidebar comes in Task 11. For now render a minimal file input so the canvas can be checked:
<script lang="ts">
import { onMount } from 'svelte';
import ImageCanvas from './components/ImageCanvas.svelte';
import { AppState } from './lib/app-state.svelte';
import { loadOpenCV } from './lib/opencv-loader';
const app = new AppState();
onMount(() => {
loadOpenCV().then(
({ cv }) => app.setCv(cv),
() => (app.cvError = 'Could not load image processing (OpenCV).'),
);
});
function ondrop(e: DragEvent) {
e.preventDefault();
const file = e.dataTransfer?.files[0];
if (file) app.loadFile(file);
}
</script>
<div class="layout">
<aside>
<input type="file" accept="image/*" onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) app.loadFile(file);
}} />
</aside>
<main ondragover={(e) => e.preventDefault()} {ondrop}>
{#if app.image}
<ImageCanvas {app} />
{:else}
<p class="empty">Drop a photo of a scrap piece here, or use “Load photo”.</p>
{/if}
</main>
</div>
<style>
:global(body) {
margin: 0;
font-family: system-ui, sans-serif;
color: #222;
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
height: 100vh;
}
aside {
overflow-y: auto;
border-right: 1px solid #ddd;
padding: 16px;
}
main {
position: relative;
background: #2b2b2b;
}
.empty {
color: #bbb;
text-align: center;
margin-top: 40vh;
}
</style>
- Step 3: Type check and build
Run: npm run check && npm run build
Expected: 0 errors, build succeeds. The OpenCV chunk is about 11 MB, which is expected.
- Step 4: Manual check in the browser
Run: npm run dev and open the printed URL.
-
Load a photo (any image with a light object on a dark surface). It appears fitted and centred.
-
The mouse wheel zooms around the cursor, and dragging pans. The page itself doesn't scroll.
-
After OpenCV loads (a few seconds), a green outline appears over the object.
-
Step 5: Commit
git add src/components/ImageCanvas.svelte src/App.svelte
git commit -m "feat: add zoomable image canvas with detection overlays"
Task 11: Sidebar, export, README
Files:
- Create:
src/components/Sidebar.svelte,README.md - Modify:
src/App.svelte(swap the temporary file input for<Sidebar {app} />)
Interfaces:
-
Consumes:
- all of
AppState toDxf,toSvg,scrapFilename,shapeSize,downloadText
- all of
-
Produces: the finished UI
-
Step 1: Create
src/components/Sidebar.svelte
<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>
- Step 2: Use the Sidebar in
src/App.svelte
Add import Sidebar from './components/Sidebar.svelte'; to the script, and replace the whole <aside>…</aside> element with:
<aside>
<Sidebar {app} />
</aside>
- Step 3: Create
README.md
# 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.
- Step 4: Full verification
Run: npm test && npm run check && npm run build
Expected: all tests pass (fixtures skipped), 0 type errors, build succeeds.
-
Step 5: Manual end-to-end check in the browser (
npm run dev) -
Load a portrait phone photo taken on the phone (with EXIF rotation). It displays upright.
-
Click Measure, then click two points. The red line appears and the tool turns off. Enter a length, and the px/mm value appears.
-
The green outline follows the piece. Moving the threshold slider updates it live. Invert works on a dark piece.
-
"Click to pick piece" on a second object selects that object.
-
Zoom in, then pick a point. The selection is the object under the cursor.
-
Drag the safety margin to −10. The outline shrinks and holes grow. At +5 it grows.
-
Draw manually: 4 clicks, then Finish outline. Draw a hole, then Finish hole. The orange dashed polygons and the green result both show. Back to automatic restores detection.
-
Push the threshold to 255. The "Nothing detected" warning shows, and Export is disabled.
-
Download DXF and SVG. The filename is
scrap-<W>x<H>mm.*. Open the SVG in a browser or Inkscape and check the size in mm. -
Import the DXF in RDWorks (on the laser PC). It must appear at the right size and not mirrored. Write down the result for the user.
-
Step 6: Commit
git add src/components/Sidebar.svelte src/App.svelte README.md
git commit -m "feat: add step sidebar with scale, detection, margin and export controls"