From 4284f2533b78347d84849ed7ce6327fb0ca46fe3 Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 14:35:10 +0200 Subject: [PATCH] Add Scrap Tracer implementation plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-15-scrap-tracer.md | 2312 +++++++++++++++++ 1 file changed, 2312 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-15-scrap-tracer.md diff --git a/docs/superpowers/plans/2026-10-15-scrap-tracer.md b/docs/superpowers/plans/2026-10-15-scrap-tracer.md new file mode 100644 index 0000000..ec2e13a --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-scrap-tracer.md @@ -0,0 +1,2312 @@ +# 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" +```