2313 lines
81 KiB
Markdown
2313 lines
81 KiB
Markdown
# 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-<W>x<H>mm.<dxf|svg>`, with W and H rounded to whole mm.
|
||
- Detection-failure message text, verbatim: `Nothing detected, try adjusting the threshold or invert, or draw manually.`
|
||
- Deploy target: Cloudflare Pages, static output (`npm run build` → `dist/`).
|
||
- Environment note: if `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<ArrayBuffer> }` (RGBA, ImageData-compatible)
|
||
- `type DetectParams = { threshold: number; invert: boolean; seed: Point | null; marginPx: number }`
|
||
- `scaleFromLine(a: Point, b: Point, lengthMm: number): number | null`, which returns px per mm, or null if the input is invalid
|
||
|
||
- [ ] **Step 1: Create `package.json`**
|
||
|
||
```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
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Scrap Tracer</title>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
<script type="module" src="/src/main.ts"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
`.gitignore`:
|
||
```
|
||
node_modules
|
||
dist
|
||
```
|
||
|
||
`src/main.ts`:
|
||
```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
|
||
<h1>Scrap Tracer</h1>
|
||
```
|
||
|
||
- [ ] **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<ArrayBuffer> };
|
||
|
||
export type DetectParams = {
|
||
threshold: number;
|
||
invert: boolean;
|
||
seed: Point | null;
|
||
/** Negative shrinks the piece (and grows holes). */
|
||
marginPx: number;
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Write the failing test `tests/scale.test.ts`**
|
||
|
||
```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 [
|
||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" height="${h}mm" viewBox="0 0 ${w} ${h}">`,
|
||
` <path d="${d}" fill="none" fill-rule="evenodd" stroke="#ff0000" stroke-width="0.1"/>`,
|
||
'</svg>',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
function ringPath(ring: Point[]): string {
|
||
return 'M' + ring.map((p) => `${formatMm(p.x)},${formatMm(p.y)}`).join(' L') + ' Z';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement `src/lib/export-dxf.ts`**
|
||
|
||
```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<void>((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> = {}): 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<RasterImage>`, 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<RasterImage> {
|
||
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||
try {
|
||
const { width, height } = fitSize(bitmap.width, bitmap.height, MAX_SIDE);
|
||
const canvas = new OffscreenCanvas(width, height);
|
||
const ctx = canvas.getContext('2d')!;
|
||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||
return { width, height, data: ctx.getImageData(0, 0, width, height).data };
|
||
} finally {
|
||
bitmap.close();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Implement `src/lib/download.ts`**
|
||
|
||
```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<void>`, `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<CV | null>(null);
|
||
cvError = $state<string | null>(null);
|
||
image = $state.raw<RasterImage | null>(null);
|
||
loadError = $state<string | null>(null);
|
||
|
||
tool = $state<Tool>('none');
|
||
scalePoints = $state<Point[]>([]);
|
||
lengthMm = $state<number | null>(null);
|
||
threshold = $state(DEFAULT_THRESHOLD);
|
||
invert = $state(false);
|
||
seed = $state<Point | null>(null);
|
||
marginMm = $state(-2);
|
||
mode = $state<'auto' | 'manual'>('auto');
|
||
manualOuter = $state<Point[]>([]);
|
||
manualHoles = $state<Point[][]>([]);
|
||
drawing = $state<Point[]>([]);
|
||
|
||
pxPerMm = $derived(
|
||
this.scalePoints.length === 2 && this.lengthMm !== null
|
||
? scaleFromLine(this.scalePoints[0], this.scalePoints[1], this.lengthMm)
|
||
: null,
|
||
);
|
||
scaleInvalid = $derived(this.scalePoints.length === 2 && this.lengthMm !== null && this.pxPerMm === null);
|
||
marginPx = $derived(this.pxPerMm ? this.marginMm * this.pxPerMm : 0);
|
||
|
||
shapePx = $derived.by((): Shape | null => {
|
||
const { cv, image } = this;
|
||
if (!cv || !image) return null;
|
||
if (this.mode === 'manual') {
|
||
return shapeFromPolygons(cv, image.width, image.height, { outer: this.manualOuter, holes: this.manualHoles }, this.marginPx);
|
||
}
|
||
return detect(cv, image, { threshold: this.threshold, invert: this.invert, seed: this.seed, marginPx: this.marginPx });
|
||
});
|
||
|
||
exportShape = $derived(this.shapePx && this.pxPerMm ? prepareForExport(this.shapePx, this.pxPerMm) : null);
|
||
|
||
setCv(cv: CV) {
|
||
this.cv = cv;
|
||
if (this.image) this.threshold = otsuThreshold(cv, this.image);
|
||
}
|
||
|
||
setImage(image: RasterImage) {
|
||
this.image = image;
|
||
this.tool = 'none';
|
||
this.scalePoints = [];
|
||
this.seed = null;
|
||
this.clearManual();
|
||
this.threshold = this.cv ? otsuThreshold(this.cv, image) : DEFAULT_THRESHOLD;
|
||
}
|
||
|
||
async loadFile(file: Blob) {
|
||
try {
|
||
this.loadError = null;
|
||
this.setImage(await loadPhoto(file));
|
||
} catch {
|
||
this.loadError = 'Could not read this image file.';
|
||
}
|
||
}
|
||
|
||
autoThreshold() {
|
||
if (this.cv && this.image) this.threshold = otsuThreshold(this.cv, this.image);
|
||
}
|
||
|
||
handleImageClick(p: Point) {
|
||
const image = this.image;
|
||
if (!image || p.x < 0 || p.y < 0 || p.x >= image.width || p.y >= image.height) return;
|
||
switch (this.tool) {
|
||
case 'scale':
|
||
if (this.scalePoints.length === 1) {
|
||
this.scalePoints.push(p);
|
||
this.tool = 'none';
|
||
} else {
|
||
this.scalePoints = [p];
|
||
}
|
||
break;
|
||
case 'seed':
|
||
this.seed = p;
|
||
this.tool = 'none';
|
||
break;
|
||
case 'draw':
|
||
this.drawing.push(p);
|
||
break;
|
||
}
|
||
}
|
||
|
||
finishPolygon() {
|
||
if (this.drawing.length >= 3) {
|
||
if (this.manualOuter.length === 0) this.manualOuter = this.drawing;
|
||
else this.manualHoles.push(this.drawing);
|
||
this.mode = 'manual';
|
||
}
|
||
this.drawing = [];
|
||
}
|
||
|
||
clearManual() {
|
||
this.manualOuter = [];
|
||
this.manualHoles = [];
|
||
this.drawing = [];
|
||
this.mode = 'auto';
|
||
if (this.tool === 'draw') this.tool = 'none';
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests and type check**
|
||
|
||
Run: `npx vitest run tests/app-state.test.ts && npm run check`
|
||
Expected: all PASS, 0 type errors. If `$derived` class fields fail under Vitest with a "rune not available" error, check that `vite.config.ts` has the `resolve.conditions: ['browser']` block from Task 1.
|
||
|
||
- [ ] **Step 5: Run the full suite**
|
||
|
||
Run: `npm test`
|
||
Expected: everything passes, and the fixture suite is skipped.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```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 `<ImageCanvas {app} />` component. `App.svelte` renders `<Sidebar {app} />` (Task 11) and the canvas.
|
||
|
||
UI code has no automated tests. Verify it by hand in the browser (Step 4).
|
||
|
||
- [ ] **Step 1: Create `src/components/ImageCanvas.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import type { AppState } from '../lib/app-state.svelte';
|
||
import type { Point, Shape } from '../lib/types';
|
||
import { fitView, screenToImage, zoomAt, type View } from '../lib/view';
|
||
|
||
let { app }: { app: AppState } = $props();
|
||
|
||
let container: HTMLDivElement;
|
||
let canvas: HTMLCanvasElement;
|
||
let size = $state({ width: 0, height: 0 });
|
||
let view = $state<View>({ scale: 1, offsetX: 0, offsetY: 0 });
|
||
let drag: { start: Point; last: Point; moved: boolean } | null = null;
|
||
|
||
const photo = $derived.by(() => {
|
||
const image = app.image;
|
||
if (!image) return null;
|
||
const c = document.createElement('canvas');
|
||
c.width = image.width;
|
||
c.height = image.height;
|
||
c.getContext('2d')!.putImageData(new ImageData(image.data, image.width, image.height), 0, 0);
|
||
return c;
|
||
});
|
||
|
||
$effect(() => {
|
||
const observer = new ResizeObserver(([entry]) => {
|
||
size = { width: Math.floor(entry.contentRect.width), height: Math.floor(entry.contentRect.height) };
|
||
});
|
||
observer.observe(container);
|
||
return () => observer.disconnect();
|
||
});
|
||
|
||
$effect(() => {
|
||
if (app.image && size.width > 0 && size.height > 0) {
|
||
view = fitView(app.image.width, app.image.height, size.width, size.height);
|
||
}
|
||
});
|
||
|
||
// Registered manually: wheel listeners must be non-passive to stop page scrolling.
|
||
$effect(() => {
|
||
const onwheel = (e: WheelEvent) => {
|
||
e.preventDefault();
|
||
view = zoomAt(view, local(e), Math.exp(-e.deltaY * 0.001));
|
||
};
|
||
canvas.addEventListener('wheel', onwheel, { passive: false });
|
||
return () => canvas.removeEventListener('wheel', onwheel);
|
||
});
|
||
|
||
$effect(() => {
|
||
canvas.width = size.width;
|
||
canvas.height = size.height;
|
||
const ctx = canvas.getContext('2d')!;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
if (!photo) return;
|
||
|
||
ctx.setTransform(view.scale, 0, 0, view.scale, view.offsetX, view.offsetY);
|
||
ctx.drawImage(photo, 0, 0);
|
||
const px = 1 / view.scale; // one screen pixel in image units
|
||
|
||
if (app.shapePx) drawShape(ctx, app.shapePx, px);
|
||
if (app.mode === 'manual') {
|
||
for (const ring of [app.manualOuter, ...app.manualHoles]) drawPolyline(ctx, ring, true, '#ff9800', px, [6 * px, 4 * px]);
|
||
}
|
||
if (app.drawing.length > 0) drawPolyline(ctx, app.drawing, false, '#ff9800', px, []);
|
||
drawScale(ctx, app.scalePoints, app.lengthMm, px);
|
||
if (app.seed) drawSeed(ctx, app.seed, px);
|
||
});
|
||
|
||
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape, px: number) {
|
||
ctx.beginPath();
|
||
for (const ring of [shape.outer, ...shape.holes]) traceRing(ctx, ring, true);
|
||
ctx.fillStyle = 'rgba(0, 200, 83, 0.25)';
|
||
ctx.fill('evenodd');
|
||
ctx.strokeStyle = '#00c853';
|
||
ctx.lineWidth = 2 * px;
|
||
ctx.setLineDash([]);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawPolyline(ctx: CanvasRenderingContext2D, points: Point[], closed: boolean, color: string, px: number, dash: number[]) {
|
||
ctx.beginPath();
|
||
traceRing(ctx, points, closed);
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2 * px;
|
||
ctx.setLineDash(dash);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = color;
|
||
for (const p of points) {
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, 3 * px, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
function traceRing(ctx: CanvasRenderingContext2D, ring: Point[], closed: boolean) {
|
||
ring.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
|
||
if (closed) ctx.closePath();
|
||
}
|
||
|
||
function drawScale(ctx: CanvasRenderingContext2D, points: Point[], lengthMm: number | null, px: number) {
|
||
if (points.length === 0) return;
|
||
drawPolyline(ctx, points, false, '#e53935', px, []);
|
||
if (points.length === 2 && lengthMm) {
|
||
const mid = { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };
|
||
ctx.font = `${14 * px}px sans-serif`;
|
||
ctx.fillStyle = '#e53935';
|
||
ctx.fillText(`${lengthMm} mm`, mid.x + 6 * px, mid.y - 6 * px);
|
||
}
|
||
}
|
||
|
||
function drawSeed(ctx: CanvasRenderingContext2D, p: Point, px: number) {
|
||
const r = 8 * px;
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x - r, p.y);
|
||
ctx.lineTo(p.x + r, p.y);
|
||
ctx.moveTo(p.x, p.y - r);
|
||
ctx.lineTo(p.x, p.y + r);
|
||
ctx.strokeStyle = '#2962ff';
|
||
ctx.lineWidth = 2 * px;
|
||
ctx.stroke();
|
||
}
|
||
|
||
function local(e: PointerEvent | WheelEvent): Point {
|
||
const rect = canvas.getBoundingClientRect();
|
||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
}
|
||
|
||
function onpointerdown(e: PointerEvent) {
|
||
canvas.setPointerCapture(e.pointerId);
|
||
const p = local(e);
|
||
drag = { start: p, last: p, moved: false };
|
||
}
|
||
|
||
function onpointermove(e: PointerEvent) {
|
||
if (!drag) return;
|
||
const p = local(e);
|
||
if (Math.hypot(p.x - drag.start.x, p.y - drag.start.y) > 4) drag.moved = true;
|
||
if (drag.moved) {
|
||
view = { ...view, offsetX: view.offsetX + p.x - drag.last.x, offsetY: view.offsetY + p.y - drag.last.y };
|
||
}
|
||
drag.last = p;
|
||
}
|
||
|
||
function onpointerup(e: PointerEvent) {
|
||
if (drag && !drag.moved) app.handleImageClick(screenToImage(local(e), view));
|
||
drag = null;
|
||
}
|
||
</script>
|
||
|
||
<div class="canvas-wrap" bind:this={container}>
|
||
<canvas
|
||
bind:this={canvas}
|
||
class:picking={app.tool !== 'none'}
|
||
{onpointerdown}
|
||
{onpointermove}
|
||
{onpointerup}
|
||
></canvas>
|
||
</div>
|
||
|
||
<style>
|
||
.canvas-wrap {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
}
|
||
canvas {
|
||
display: block;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
}
|
||
canvas.picking {
|
||
cursor: crosshair;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `src/App.svelte`**
|
||
|
||
Sidebar comes in Task 11. For now render a minimal file input so the canvas can be checked:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import ImageCanvas from './components/ImageCanvas.svelte';
|
||
import { AppState } from './lib/app-state.svelte';
|
||
import { loadOpenCV } from './lib/opencv-loader';
|
||
|
||
const app = new AppState();
|
||
|
||
onMount(() => {
|
||
loadOpenCV().then(
|
||
({ cv }) => app.setCv(cv),
|
||
() => (app.cvError = 'Could not load image processing (OpenCV).'),
|
||
);
|
||
});
|
||
|
||
function ondrop(e: DragEvent) {
|
||
e.preventDefault();
|
||
const file = e.dataTransfer?.files[0];
|
||
if (file) app.loadFile(file);
|
||
}
|
||
</script>
|
||
|
||
<div class="layout">
|
||
<aside>
|
||
<input type="file" accept="image/*" onchange={(e) => {
|
||
const file = e.currentTarget.files?.[0];
|
||
if (file) app.loadFile(file);
|
||
}} />
|
||
</aside>
|
||
<main ondragover={(e) => e.preventDefault()} {ondrop}>
|
||
{#if app.image}
|
||
<ImageCanvas {app} />
|
||
{:else}
|
||
<p class="empty">Drop a photo of a scrap piece here, or use “Load photo”.</p>
|
||
{/if}
|
||
</main>
|
||
</div>
|
||
|
||
<style>
|
||
:global(body) {
|
||
margin: 0;
|
||
font-family: system-ui, sans-serif;
|
||
color: #222;
|
||
}
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
height: 100vh;
|
||
}
|
||
aside {
|
||
overflow-y: auto;
|
||
border-right: 1px solid #ddd;
|
||
padding: 16px;
|
||
}
|
||
main {
|
||
position: relative;
|
||
background: #2b2b2b;
|
||
}
|
||
.empty {
|
||
color: #bbb;
|
||
text-align: center;
|
||
margin-top: 40vh;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: Type check and build**
|
||
|
||
Run: `npm run check && npm run build`
|
||
Expected: 0 errors, build succeeds. The OpenCV chunk is about 11 MB, which is expected.
|
||
|
||
- [ ] **Step 4: Manual check in the browser**
|
||
|
||
Run: `npm run dev` and open the printed URL.
|
||
- Load a photo (any image with a light object on a dark surface). It appears fitted and centred.
|
||
- The mouse wheel zooms around the cursor, and dragging pans. The page itself doesn't scroll.
|
||
- After OpenCV loads (a few seconds), a green outline appears over the object.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```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 `<Sidebar {app} />`)
|
||
|
||
**Interfaces:**
|
||
- Consumes:
|
||
- all of `AppState`
|
||
- `toDxf`, `toSvg`, `scrapFilename`, `shapeSize`, `downloadText`
|
||
- Produces: the finished UI
|
||
|
||
- [ ] **Step 1: Create `src/components/Sidebar.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import type { AppState, Tool } from '../lib/app-state.svelte';
|
||
import { downloadText } from '../lib/download';
|
||
import { toDxf } from '../lib/export-dxf';
|
||
import { toSvg } from '../lib/export-svg';
|
||
import { scrapFilename, shapeSize } from '../lib/geometry';
|
||
|
||
let { app }: { app: AppState } = $props();
|
||
|
||
const size = $derived(app.exportShape ? shapeSize(app.exportShape) : null);
|
||
const nothingDetected = $derived(app.cv !== null && app.image !== null && app.shapePx === null);
|
||
|
||
function toggle(tool: Tool) {
|
||
app.tool = app.tool === tool ? 'none' : tool;
|
||
}
|
||
|
||
function onFile(e: Event & { currentTarget: HTMLInputElement }) {
|
||
const file = e.currentTarget.files?.[0];
|
||
if (file) app.loadFile(file);
|
||
}
|
||
|
||
function exportAs(ext: 'dxf' | 'svg') {
|
||
const shape = app.exportShape;
|
||
if (!shape) return;
|
||
const text = ext === 'dxf' ? toDxf(shape) : toSvg(shape);
|
||
downloadText(scrapFilename(shape, ext), text, ext === 'dxf' ? 'application/dxf' : 'image/svg+xml');
|
||
}
|
||
</script>
|
||
|
||
<h1>Scrap Tracer</h1>
|
||
|
||
{#if app.cvError}
|
||
<p class="error">{app.cvError} <button onclick={() => location.reload()}>Reload</button></p>
|
||
{:else if !app.cv}
|
||
<p class="muted">Loading image processing…</p>
|
||
{/if}
|
||
|
||
<section>
|
||
<h2>1. Photo</h2>
|
||
<label class="file">Load photo <input type="file" accept="image/*" onchange={onFile} /></label>
|
||
{#if app.loadError}<p class="error">{app.loadError}</p>{/if}
|
||
</section>
|
||
|
||
<section class:disabled={!app.image}>
|
||
<h2>2. Scale</h2>
|
||
<p class="muted">Click both ends of something whose length you know.</p>
|
||
<button class:active={app.tool === 'scale'} onclick={() => toggle('scale')} disabled={!app.image}>
|
||
{app.scalePoints.length === 2 ? 'Re-measure' : 'Measure'}
|
||
</button>
|
||
<label>Length <input type="number" min="1" step="any" bind:value={app.lengthMm} /> mm</label>
|
||
{#if app.scaleInvalid}
|
||
<p class="error">Enter a length above 0 and pick two different points.</p>
|
||
{:else if app.pxPerMm}
|
||
<p class="muted">{app.pxPerMm.toFixed(2)} px/mm</p>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class:disabled={!app.image}>
|
||
<h2>3. Outline</h2>
|
||
{#if app.mode === 'auto'}
|
||
<label>Threshold <input type="range" min="0" max="255" bind:value={app.threshold} /> {app.threshold}</label>
|
||
<button onclick={() => app.autoThreshold()} disabled={!app.cv || !app.image}>Auto</button>
|
||
<label><input type="checkbox" bind:checked={app.invert} /> Piece is darker than background</label>
|
||
<button class:active={app.tool === 'seed'} onclick={() => toggle('seed')} disabled={!app.image}>Click to pick piece</button>
|
||
{/if}
|
||
<button class:active={app.tool === 'draw'} onclick={() => toggle('draw')} disabled={!app.image}>
|
||
{app.manualOuter.length === 0 ? 'Draw outline manually' : 'Draw a hole'}
|
||
</button>
|
||
{#if app.drawing.length > 0}
|
||
<button onclick={() => app.finishPolygon()}>Finish {app.manualOuter.length === 0 ? 'outline' : 'hole'}</button>
|
||
{/if}
|
||
{#if app.mode === 'manual' || app.drawing.length > 0}
|
||
<button onclick={() => app.clearManual()}>Back to automatic</button>
|
||
{/if}
|
||
{#if nothingDetected}
|
||
<p class="warning">Nothing detected, try adjusting the threshold or invert, or draw manually.</p>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class:disabled={!app.pxPerMm}>
|
||
<h2>4. Safety margin</h2>
|
||
<label>
|
||
<input type="range" min="-10" max="5" step="0.5" bind:value={app.marginMm} disabled={!app.pxPerMm} />
|
||
{app.marginMm} mm
|
||
</label>
|
||
{#if !app.pxPerMm}<p class="muted">Set the scale first.</p>{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h2>5. Export</h2>
|
||
{#if size}<p>Piece: {Math.round(size.width)} × {Math.round(size.height)} mm</p>{/if}
|
||
<button class="primary" onclick={() => exportAs('dxf')} disabled={!app.exportShape}>Download DXF</button>
|
||
<button onclick={() => exportAs('svg')} disabled={!app.exportShape}>Download SVG</button>
|
||
{#if !app.exportShape}
|
||
<p class="muted">{app.pxPerMm ? 'No outline to export yet.' : 'Set the scale first.'}</p>
|
||
{/if}
|
||
</section>
|
||
|
||
<style>
|
||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||
h2 { font-size: 1rem; margin: 0 0 6px; }
|
||
section { padding: 12px 0; border-top: 1px solid #eee; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
||
section.disabled { opacity: 0.5; }
|
||
label { display: flex; gap: 6px; align-items: center; }
|
||
input[type='number'] { width: 80px; }
|
||
.file input { max-width: 200px; }
|
||
button.active { outline: 2px solid #2962ff; }
|
||
button.primary { font-weight: bold; }
|
||
.muted { color: #777; margin: 0; font-size: 0.9rem; }
|
||
.error { color: #c62828; margin: 0; }
|
||
.warning { color: #e65100; margin: 0; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Use the Sidebar in `src/App.svelte`**
|
||
|
||
Add `import Sidebar from './components/Sidebar.svelte';` to the script, and replace the whole `<aside>…</aside>` element with:
|
||
|
||
```svelte
|
||
<aside>
|
||
<Sidebar {app} />
|
||
</aside>
|
||
```
|
||
|
||
- [ ] **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-<W>x<H>mm.*`. Open the SVG in a browser or Inkscape and check the size in mm.
|
||
- **Import the DXF in RDWorks** (on the laser PC). It must appear at the right size and not mirrored. Write down the result for the user.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```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"
|
||
```
|