# 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 exactly `4.12.0-release.1`, loaded lazily with a dynamic `import()`. - **The OpenCV module object is a thenable.** Never `return cv` from an async function or `resolve(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 closed `POLYLINE` per ring, `$INSUNITS` = 4 (mm), Y axis flipped, CRLF line endings. SVG is secondary: `width`/`height` in `mm`, `viewBox` in mm. - Exported geometry is normalised so the outer bounding box's top-left is (0,0). - Filename: `scrap-xmm.`, 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 `npm` fails with `EROFS` on `~/.npm`, prefix commands with `npm_config_cache=$TMPDIR/npmcache`. ## Review Focus 1. **Phone photos.** Portrait shots with EXIF rotation, 12+ MP: the image must appear upright and be downscaled to ≤ 2000 px. `fitSize` is tested in Task 8. Orientation is checked by hand in Task 11. 2. **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.ts` round-trip tests (Task 8) and the "click outside image ignored" test (Task 9). 3. **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. 4. **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. 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 }` (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`** ```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** ```bash 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`: ```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`: ```js import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; export default { preprocess: vitePreprocess({ script: true }) }; ``` `tsconfig.json`: ```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`: ```html Scrap Tracer
``` `.gitignore`: ``` node_modules dist ``` `src/main.ts`: ```ts import { mount } from 'svelte'; import App from './App.svelte'; mount(App, { target: document.getElementById('app')! }); ``` `src/App.svelte` (placeholder, replaced in Task 10): ```svelte

Scrap Tracer

``` - [ ] **Step 4: Create `src/lib/types.ts`** ```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 }; 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`** ```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`** ```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** ```bash 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`, `Shape` from `src/lib/types.ts` - Produces: - `type Bounds = { minX: number; minY: number; maxX: number; maxY: number }` - `bounds(points: Point[]): Bounds` - `shapeSize(shape: Shape): { width: number; height: number }`, the size of the outer ring's bounding box - `simplifyRing(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'): string` - `formatMm(n: number): string`, at most 3 decimals, no trailing zeros, never `-0` - [ ] **Step 1: Write the failing test `tests/geometry.test.ts`** ```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`** ```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** ```bash 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, which `prepareForExport` guarantees), plus `shapeSize` and `formatMm` from `geometry.ts` - Produces: `toSvg(shapeMm: Shape): string`, `toDxf(shapeMm: Shape): string` - [ ] **Step 1: Write the failing test `tests/export-svg.test.ts`** ```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`** ```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`** ```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 [ '', ``, ` `, '', '', ].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`** ```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** ```bash 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`, `Point` from `types.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–255 - `traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null`. `mask` is 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.MatVector` and kernel you create must be `.delete()`d in a `finally`, because they live on the WASM heap. That includes Mats returned by `contours.get(i)`. - If the bundled OpenCV typings reject a call that works at runtime, cast that single argument. Don't loosen `CV` to `any`. - [ ] **Step 1: Implement `src/lib/opencv-loader.ts`** ```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((resolve) => { (cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve(); }); } return { cv }; })(); return loading; } ``` - [ ] **Step 2: Create `tests/helpers/images.ts`** ```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`** ```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`** ```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** ```bash 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` (add `detect` and `pickComponent`) - 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: 1. Threshold the blurred grayscale image (`THRESH_BINARY`, or `THRESH_BINARY_INV` when `invert`). 2. Morphological open, then close, with a 5×5 ellipse. 3. Connected components (8-connectivity). 4. Pick a component: - If `seed` is 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. 5. `traceMask` on the selected component with `marginPx`. - [ ] **Step 1: Write the failing test `tests/detect.test.ts`** ```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 => ({ 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 `detect` to `src/lib/detect.ts`** Change the type import at the top to `import type { DetectParams, Point, RasterImage, Shape } from './types';` and append: ```ts 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** ```bash 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)` from `detect.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`** ```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`** ```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** ```bash 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`** ```json [] ``` - [ ] **Step 2: Create `tests/fixtures/README.md`** ```markdown # 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`** ```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** ```bash 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 is `image * scale + offset`. - `screenToImage(p: Point, view: View): Point` - `zoomAt(view: View, screenPoint: Point, factor: number): View`, which keeps the image point under `screenPoint` fixed, with the scale clamped to [0.05, 20] - `fitView(imageWidth: number, imageHeight: number, viewWidth: number, viewHeight: number, padding?: number): View`, which centres the image - `MAX_SIDE = 2000`, `fitSize(width: number, height: number, maxSide: number): { width: number; height: number }`, which never upscales - `loadPhoto(file: Blob): Promise`, which applies EXIF orientation and downscales to `MAX_SIDE` - `downloadText(filename: string, text: string, mime: string): void` - [ ] **Step 1: Write the failing test `tests/view.test.ts`** ```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`** ```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`** ```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`** ```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 { 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`** ```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** ```bash 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 the `AppState` class: - Fields (reactive): - `cv: CV | null`, `cvError: string | null`, `image: RasterImage | null`, `loadError: string | null` - `tool: Tool` - `scalePoints: Point[]`, `lengthMm: number | null` - `threshold: number`, `invert: boolean`, `seed: Point | null` - `marginMm: number` (default −2) - `mode: 'auto' | 'manual'` - `manualOuter: Point[]`, `manualHoles: Point[][]`, `drawing: Point[]` - Derived: - `pxPerMm: number | null` - `scaleInvalid: boolean`, true when two points are placed and a length is entered but the scale is invalid - `marginPx: number`, which is 0 until the scale is set - `shapePx: Shape | null` - `exportShape: Shape | null` - Methods: `setCv(cv)`, `setImage(image)`, `loadFile(file: Blob): Promise`, `handleImageClick(p: Point)`, `finishPolygon()`, `clearManual()`, `autoThreshold()` Click routing: - **`scale`:** the first click sets `[p]`, the second appends and switches the tool to `none`. A click when 2 points already exist restarts with `[p]`. - **`seed`:** sets `seed`, switches the tool to `none`. - **`draw`:** appends to `drawing`. - **Any tool:** clicks outside `0 ≤ x < width`, `0 ≤ y < height` are 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`** ```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`** ```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(null); cvError = $state(null); image = $state.raw(null); loadError = $state(null); tool = $state('none'); scalePoints = $state([]); lengthMm = $state(null); threshold = $state(DEFAULT_THRESHOLD); invert = $state(false); seed = $state(null); marginMm = $state(-2); mode = $state<'auto' | 'manual'>('auto'); manualOuter = $state([]); manualHoles = $state([]); drawing = $state([]); 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** ```bash 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`, `loadFile` - `View`, `fitView`, `screenToImage`, `zoomAt`, `loadOpenCV` - Produces: the `` component. `App.svelte` renders `` (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`** ```svelte
``` - [ ] **Step 2: Replace `src/App.svelte`** Sidebar comes in Task 11. For now render a minimal file input so the canvas can be checked: ```svelte
e.preventDefault()} {ondrop}> {#if app.image} {:else}

Drop a photo of a scrap piece here, or use “Load photo”.

{/if}
``` - [ ] **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** ```bash 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 ``) **Interfaces:** - Consumes: - all of `AppState` - `toDxf`, `toSvg`, `scrapFilename`, `shapeSize`, `downloadText` - Produces: the finished UI - [ ] **Step 1: Create `src/components/Sidebar.svelte`** ```svelte

Scrap Tracer

{#if app.cvError}

{app.cvError}

{:else if !app.cv}

Loading image processing…

{/if}

1. Photo

{#if app.loadError}

{app.loadError}

{/if}

2. Scale

Click both ends of something whose length you know.

{#if app.scaleInvalid}

Enter a length above 0 and pick two different points.

{:else if app.pxPerMm}

{app.pxPerMm.toFixed(2)} px/mm

{/if}

3. Outline

{#if app.mode === 'auto'} {/if} {#if app.drawing.length > 0} {/if} {#if app.mode === 'manual' || app.drawing.length > 0} {/if} {#if nothingDetected}

Nothing detected, try adjusting the threshold or invert, or draw manually.

{/if}

4. Safety margin

{#if !app.pxPerMm}

Set the scale first.

{/if}

5. Export

{#if size}

Piece: {Math.round(size.width)} × {Math.round(size.height)} mm

{/if} {#if !app.exportShape}

{app.pxPerMm ? 'No outline to export yet.' : 'Set the scale first.'}

{/if}
``` - [ ] **Step 2: Use the Sidebar in `src/App.svelte`** Add `import Sidebar from './components/Sidebar.svelte';` to the script, and replace the whole `` element with: ```svelte ``` - [ ] **Step 3: Create `README.md`** ```markdown # 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-xmm.*`. 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** ```bash 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" ```