diff --git a/docs/superpowers/plans/2026-06-08-gcode-overlay.md b/docs/superpowers/plans/2026-06-08-gcode-overlay.md
new file mode 100644
index 0000000..0968ced
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-gcode-overlay.md
@@ -0,0 +1,1736 @@
+# G-Code Overlay 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 overlays a G-code toolpath on a live CNC-router camera stream, aligned via a one-time camera→machine calibration and a per-job placement.
+
+**Architecture:** Pure client-side SPA. G-code is parsed to polylines in machine-mm; each point is transformed by a per-job alignment (work→machine) then a fixed homography (machine-mm→image-px) and drawn on a transparent canvas layered over the video element. No backend; the one-time calibration lives in a served `config.json`.
+
+**Tech Stack:** TypeScript, Vite, Vitest, Canvas 2D. No runtime dependencies.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|------|----------------|
+| `src/types.ts` | Shared types: `Vec2`, `Mat3`, `Segment`, `Alignment`, `Calibration`, `AppConfig` |
+| `src/geometry/arc.ts` | Flatten G2/G3 arcs to polylines within a chord tolerance |
+| `src/geometry/homography.ts` | Estimate (DLT/least-squares), apply, and invert a 3×3 homography |
+| `src/geometry/transform.ts` | Per-job alignment + full project pipeline + image→machine inverse |
+| `src/gcode/parser.ts` | Parse G-code text → `{segments, warnings}` in machine-mm |
+| `src/config.ts` | Fetch and validate `config.json` |
+| `src/render/renderer.ts` | Draw projected segments onto a 2D canvas context |
+| `src/app/layout.ts` | Size/position the overlay canvas to match the video element |
+| `src/app/state.ts` | App state container + G-code loading |
+| `src/app/calibration-ui.ts` | One-time calibration capture flow |
+| `src/app/alignment-ui.ts` | Per-job drag/rotate, click-origin, numeric controls |
+| `src/main.ts` | Wire everything; bootstrap |
+| `index.html` | Entry markup (video, canvas, panels) |
+| `public/config.json` | Served stream URL + calibration |
+
+Convention: tests live next to source as `*.test.ts`. All internal lengths are millimetres; angles are radians.
+
+---
+
+## Task 1: Project scaffold
+
+**Files:**
+- Create: `package.json`, `tsconfig.json`, `vite.config.ts`, `index.html`, `src/smoke.test.ts`, `.gitignore`
+
+- [ ] **Step 1: Create `.gitignore`**
+
+```
+node_modules
+dist
+```
+
+- [ ] **Step 2: Create `package.json`**
+
+```json
+{
+ "name": "gcode-overlay",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "devDependencies": {
+ "typescript": "^5.5.0",
+ "vite": "^5.4.0",
+ "vitest": "^2.1.0"
+ }
+}
+```
+
+- [ ] **Step 3: Create `tsconfig.json`**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vitest/globals"],
+ "skipLibCheck": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
+```
+
+- [ ] **Step 4: Create `vite.config.ts`**
+
+```ts
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ test: { globals: true, environment: 'node' },
+});
+```
+
+- [ ] **Step 5: Create minimal `index.html`**
+
+```html
+
+
+
+
+
+ G-Code Overlay
+
+
+
+
+
+```
+
+- [ ] **Step 6: Create `src/main.ts` placeholder**
+
+```ts
+console.log('gcode-overlay booting');
+```
+
+- [ ] **Step 7: Write the smoke test `src/smoke.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+
+describe('harness', () => {
+ it('runs', () => {
+ expect(1 + 1).toBe(2);
+ });
+});
+```
+
+- [ ] **Step 8: Install and run the test**
+
+Run: `npm install && npm test`
+Expected: 1 passed.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A
+git commit -m "chore: scaffold Vite + TS + Vitest project"
+```
+
+---
+
+## Task 2: Shared types + arc flattening
+
+**Files:**
+- Create: `src/types.ts`, `src/geometry/arc.ts`, `src/geometry/arc.test.ts`
+
+- [ ] **Step 1: Create `src/types.ts`**
+
+```ts
+export type Vec2 = [number, number];
+
+/** Row-major 3×3 matrix. */
+export type Mat3 = [number, number, number, number, number, number, number, number, number];
+
+export type MoveKind = 'cut' | 'rapid';
+
+/** A polyline in millimetres (machine coordinates before per-job alignment). */
+export interface Segment {
+ kind: MoveKind;
+ points: Vec2[];
+}
+
+/** Per-job placement: rotate the work coordinates by `rot`, then translate by (tx,ty). mm / radians. */
+export interface Alignment {
+ tx: number;
+ ty: number;
+ rot: number;
+}
+
+export interface Calibration {
+ /** Clicked points in the camera image, pixels. */
+ imagePoints: Vec2[];
+ /** Corresponding machine coordinates, mm. */
+ machinePoints: Vec2[];
+ /** machine-mm → image-px. */
+ homography: Mat3;
+}
+
+export interface RenderStyle {
+ cutColor: string;
+ rapidColor: string;
+ lineWidth: number;
+}
+
+export interface AppConfig {
+ streamUrl: string;
+ calibration: Calibration | null;
+ renderDefaults: RenderStyle;
+}
+```
+
+- [ ] **Step 2: Write the failing test `src/geometry/arc.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { flattenArc } from './arc';
+
+const onCircle = (p: [number, number], c: [number, number], r: number) =>
+ Math.abs(Math.hypot(p[0] - c[0], p[1] - c[1]) - r) < 1e-6;
+
+describe('flattenArc', () => {
+ it('flattens a CCW quarter circle, endpoints exact, all points on the circle', () => {
+ // centre (0,0), radius 10, from (10,0) CCW to (0,10)
+ const pts = flattenArc([10, 0], [0, 10], [0, 0], false, 0.1);
+ expect(pts[0]).toEqual([10, 0]);
+ expect(pts[pts.length - 1]).toEqual([0, 10]);
+ expect(pts.length).toBeGreaterThan(2);
+ for (const p of pts) expect(onCircle(p, [0, 0], 10)).toBe(true);
+ });
+
+ it('CW arc sweeps the other way (midpoint has negative y)', () => {
+ // centre (0,0), radius 10, from (10,0) CW to (0,10) → goes the long way through y<0
+ const pts = flattenArc([10, 0], [0, 10], [0, 0], true, 0.1);
+ const mid = pts[Math.floor(pts.length / 2)];
+ expect(mid[1]).toBeLessThan(0);
+ });
+
+ it('treats start==end as a full circle', () => {
+ const pts = flattenArc([10, 0], [10, 0], [0, 0], false, 0.5);
+ // a point roughly opposite the start should exist
+ expect(pts.some((p) => p[0] < -9)).toBe(true);
+ });
+
+ it('respects chord tolerance (tighter tolerance → more points)', () => {
+ const coarse = flattenArc([10, 0], [0, 10], [0, 0], false, 1.0);
+ const fine = flattenArc([10, 0], [0, 10], [0, 0], false, 0.01);
+ expect(fine.length).toBeGreaterThan(coarse.length);
+ });
+});
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `npx vitest run src/geometry/arc.test.ts`
+Expected: FAIL — cannot find module `./arc`.
+
+- [ ] **Step 4: Implement `src/geometry/arc.ts`**
+
+```ts
+import type { Vec2 } from '../types';
+
+/**
+ * Flatten a circular arc into a polyline.
+ * @param start arc start point (mm)
+ * @param end arc end point (mm)
+ * @param center arc centre (mm)
+ * @param clockwise true for G2, false for G3 (in a right-handed XY frame, Y up)
+ * @param tolerance max chord deviation (mm)
+ * @returns points including both start and end
+ */
+export function flattenArc(
+ start: Vec2,
+ end: Vec2,
+ center: Vec2,
+ clockwise: boolean,
+ tolerance = 0.2,
+): Vec2[] {
+ const radius = Math.hypot(start[0] - center[0], start[1] - center[1]);
+ const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]);
+ const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]);
+
+ let sweep = endAngle - startAngle;
+ if (clockwise) {
+ while (sweep >= 0) sweep -= 2 * Math.PI; // sweep in [-2π, 0)
+ } else {
+ while (sweep <= 0) sweep += 2 * Math.PI; // sweep in (0, 2π]
+ }
+
+ const maxStep = radius > tolerance ? 2 * Math.acos(1 - tolerance / radius) : Math.PI / 8;
+ const steps = Math.max(1, Math.ceil(Math.abs(sweep) / maxStep));
+
+ const pts: Vec2[] = [];
+ for (let i = 0; i <= steps; i++) {
+ const a = startAngle + (sweep * i) / steps;
+ pts.push([center[0] + radius * Math.cos(a), center[1] + radius * Math.sin(a)]);
+ }
+ pts[0] = [start[0], start[1]];
+ pts[pts.length - 1] = [end[0], end[1]]; // pin endpoints exactly
+ return pts;
+}
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `npx vitest run src/geometry/arc.test.ts`
+Expected: PASS (4 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/types.ts src/geometry/arc.ts src/geometry/arc.test.ts
+git commit -m "feat: shared types and arc flattening"
+```
+
+---
+
+## Task 3: Homography (estimate, apply, invert)
+
+**Files:**
+- Create: `src/geometry/homography.ts`, `src/geometry/homography.test.ts`
+
+- [ ] **Step 1: Write the failing test `src/geometry/homography.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { estimateHomography, applyHomography, invertMat3 } from './homography';
+import type { Vec2, Mat3 } from '../types';
+
+const close = (a: Vec2, b: Vec2, eps = 1e-6) =>
+ Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
+
+describe('homography', () => {
+ it('recovers the identity from coincident point sets', () => {
+ const pts: Vec2[] = [[0, 0], [1, 0], [1, 1], [0, 1]];
+ const H = estimateHomography(pts, pts);
+ expect(close(applyHomography(H, [0.3, 0.7]), [0.3, 0.7])).toBe(true);
+ });
+
+ it('recovers a known perspective transform', () => {
+ const H: Mat3 = [1.1, 0.2, 5, -0.1, 0.9, 3, 0.001, 0.002, 1];
+ const src: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [40, 60]];
+ const dst = src.map((p) => applyHomography(H, p));
+ const est = estimateHomography(src, dst);
+ for (const p of [[10, 20], [70, 30], [55, 90]] as Vec2[]) {
+ expect(close(applyHomography(est, p), applyHomography(H, p), 1e-4)).toBe(true);
+ }
+ });
+
+ it('invertMat3 round-trips a point', () => {
+ const H: Mat3 = [1.1, 0.2, 5, -0.1, 0.9, 3, 0.001, 0.002, 1];
+ const inv = invertMat3(H);
+ const p: Vec2 = [37, 91];
+ expect(close(applyHomography(inv, applyHomography(H, p)), p, 1e-6)).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/geometry/homography.test.ts`
+Expected: FAIL — cannot find module `./homography`.
+
+- [ ] **Step 3: Implement `src/geometry/homography.ts`**
+
+```ts
+import type { Vec2, Mat3 } from '../types';
+
+/** Apply a homography to a point (projective divide). */
+export function applyHomography(H: Mat3, p: Vec2): Vec2 {
+ const [a, b, c, d, e, f, g, h, i] = H;
+ const w = g * p[0] + h * p[1] + i;
+ return [(a * p[0] + b * p[1] + c) / w, (d * p[0] + e * p[1] + f) / w];
+}
+
+/** Solve a square linear system A x = b by Gaussian elimination with partial pivoting. */
+function solveLinear(A: number[][], b: number[]): number[] {
+ const n = b.length;
+ const M = A.map((row, i) => [...row, b[i]!]);
+ for (let col = 0; col < n; col++) {
+ let pivot = col;
+ for (let r = col + 1; r < n; r++) {
+ if (Math.abs(M[r]![col]!) > Math.abs(M[pivot]![col]!)) pivot = r;
+ }
+ if (Math.abs(M[pivot]![col]!) < 1e-12) throw new Error('Singular system in homography fit');
+ [M[col], M[pivot]] = [M[pivot]!, M[col]!];
+ const pivRow = M[col]!;
+ for (let r = 0; r < n; r++) {
+ if (r === col) continue;
+ const factor = M[r]![col]! / pivRow[col]!;
+ for (let k = col; k <= n; k++) M[r]![k]! -= factor * pivRow[k]!;
+ }
+ }
+ return M.map((row, i) => row[n]! / row[i]!);
+}
+
+/**
+ * Estimate a homography mapping src → dst from ≥4 point pairs.
+ * Solves for 8 DoF (h33 fixed to 1) via least squares (normal equations).
+ */
+export function estimateHomography(src: Vec2[], dst: Vec2[]): Mat3 {
+ if (src.length !== dst.length || src.length < 4) {
+ throw new Error('Need at least 4 matching point pairs');
+ }
+ // Build 2N×8 design matrix M and target vector t.
+ const rows: number[][] = [];
+ const t: number[] = [];
+ for (let i = 0; i < src.length; i++) {
+ const [x, y] = src[i]!;
+ const [u, v] = dst[i]!;
+ rows.push([x, y, 1, 0, 0, 0, -x * u, -y * u]);
+ t.push(u);
+ rows.push([0, 0, 0, x, y, 1, -x * v, -y * v]);
+ t.push(v);
+ }
+ // Normal equations: (Mᵀ M) h = Mᵀ t → 8×8 solve.
+ const ata: number[][] = Array.from({ length: 8 }, () => new Array(8).fill(0));
+ const atb: number[] = new Array(8).fill(0);
+ for (let r = 0; r < rows.length; r++) {
+ const row = rows[r]!;
+ for (let i = 0; i < 8; i++) {
+ atb[i]! += row[i]! * t[r]!;
+ for (let j = 0; j < 8; j++) ata[i]![j]! += row[i]! * row[j]!;
+ }
+ }
+ const h = solveLinear(ata, atb);
+ return [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!, h[5]!, h[6]!, h[7]!, 1];
+}
+
+/** Invert a 3×3 matrix. */
+export function invertMat3(m: Mat3): Mat3 {
+ const [a, b, c, d, e, f, g, h, i] = m;
+ const A = e * i - f * h;
+ const B = -(d * i - f * g);
+ const C = d * h - e * g;
+ const det = a * A + b * B + c * C;
+ if (Math.abs(det) < 1e-12) throw new Error('Non-invertible matrix');
+ const invDet = 1 / det;
+ return [
+ A * invDet, (c * h - b * i) * invDet, (b * f - c * e) * invDet,
+ B * invDet, (a * i - c * g) * invDet, (c * d - a * f) * invDet,
+ C * invDet, (b * g - a * h) * invDet, (a * e - b * d) * invDet,
+ ];
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/geometry/homography.test.ts`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/geometry/homography.ts src/geometry/homography.test.ts
+git commit -m "feat: homography estimate/apply/invert"
+```
+
+---
+
+## Task 4: Transform pipeline
+
+**Files:**
+- Create: `src/geometry/transform.ts`, `src/geometry/transform.test.ts`
+
+- [ ] **Step 1: Write the failing test `src/geometry/transform.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { applyAlignment, projectSegments, imageToMachine, alignmentFromOrigin } from './transform';
+import type { Vec2, Mat3, Segment, Alignment } from '../types';
+
+const close = (a: Vec2, b: Vec2, eps = 1e-9) =>
+ Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
+
+const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
+
+describe('transform', () => {
+ it('applyAlignment rotates then translates', () => {
+ const a: Alignment = { tx: 5, ty: 1, rot: Math.PI / 2 };
+ // (1,0) rotated 90° → (0,1), then +(5,1) → (5,2)
+ expect(close(applyAlignment(a, [1, 0]), [5, 2])).toBe(true);
+ });
+
+ it('projectSegments applies alignment then homography to every point', () => {
+ const segs: Segment[] = [{ kind: 'cut', points: [[0, 0], [10, 0]] }];
+ const a: Alignment = { tx: 2, ty: 3, rot: 0 };
+ const out = projectSegments(segs, a, IDENT);
+ expect(out[0]!.kind).toBe('cut');
+ expect(close(out[0]!.points[0]!, [2, 3])).toBe(true);
+ expect(close(out[0]!.points[1]!, [12, 3])).toBe(true);
+ });
+
+ it('imageToMachine is the inverse of the homography', () => {
+ const H: Mat3 = [2, 0, 10, 0, 2, 20, 0, 0, 1];
+ // machine (5,5) → image (20,30); invert should return (5,5)
+ expect(close(imageToMachine(H, [20, 30]), [5, 5])).toBe(true);
+ });
+
+ it('alignmentFromOrigin places work-origin at the given machine point', () => {
+ const a = alignmentFromOrigin([7, 9], 0);
+ expect(close(applyAlignment(a, [0, 0]), [7, 9])).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/geometry/transform.test.ts`
+Expected: FAIL — cannot find module `./transform`.
+
+- [ ] **Step 3: Implement `src/geometry/transform.ts`**
+
+```ts
+import type { Vec2, Mat3, Segment, Alignment } from '../types';
+import { applyHomography, invertMat3 } from './homography';
+
+/** Apply per-job alignment to a point: rotate by rot, then translate by (tx,ty). */
+export function applyAlignment(a: Alignment, p: Vec2): Vec2 {
+ const c = Math.cos(a.rot);
+ const s = Math.sin(a.rot);
+ return [a.tx + c * p[0] - s * p[1], a.ty + s * p[0] + c * p[1]];
+}
+
+/** Project work-coordinate segments to image pixels: work → (alignment) → machine → (H) → image. */
+export function projectSegments(segments: Segment[], a: Alignment, H: Mat3): Segment[] {
+ return segments.map((seg) => ({
+ kind: seg.kind,
+ points: seg.points.map((p) => applyHomography(H, applyAlignment(a, p))),
+ }));
+}
+
+/** Convert an image-pixel point to machine mm using the inverse homography. */
+export function imageToMachine(H: Mat3, imagePoint: Vec2): Vec2 {
+ return applyHomography(invertMat3(H), imagePoint);
+}
+
+/** Build an alignment that places the work origin (0,0) at the given machine point. */
+export function alignmentFromOrigin(machinePoint: Vec2, rot: number): Alignment {
+ return { tx: machinePoint[0], ty: machinePoint[1], rot };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/geometry/transform.test.ts`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/geometry/transform.ts src/geometry/transform.test.ts
+git commit -m "feat: alignment + projection pipeline"
+```
+
+---
+
+## Task 5: G-code parser
+
+**Files:**
+- Create: `src/gcode/parser.ts`, `src/gcode/parser.test.ts`
+
+- [ ] **Step 1: Write the failing test `src/gcode/parser.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { parseGcode } from './parser';
+
+describe('parseGcode', () => {
+ it('parses linear cuts and rapids', () => {
+ const { segments } = parseGcode(`
+ G21 G90
+ G0 X0 Y0
+ G1 X10 Y0
+ G1 X10 Y10
+ `);
+ expect(segments).toEqual([
+ { kind: 'rapid', points: [[0, 0], [0, 0]] },
+ { kind: 'cut', points: [[0, 0], [10, 0]] },
+ { kind: 'cut', points: [[10, 0], [10, 10]] },
+ ]);
+ });
+
+ it('honours modal motion (bare coordinate lines repeat last motion)', () => {
+ const { segments } = parseGcode(`G1 X0 Y0\nX5 Y0\nX5 Y5`);
+ expect(segments.map((s) => s.kind)).toEqual(['cut', 'cut', 'cut']);
+ expect(segments[2]!.points[1]).toEqual([5, 5]);
+ });
+
+ it('applies inch units (G20) by scaling to mm', () => {
+ const { segments } = parseGcode(`G20 G90\nG1 X1 Y0`);
+ expect(segments[0]!.points[1]![0]).toBeCloseTo(25.4, 6);
+ });
+
+ it('supports incremental mode (G91)', () => {
+ const { segments } = parseGcode(`G21 G91\nG1 X10\nG1 X10`);
+ expect(segments[1]!.points[1]![0]).toBeCloseTo(20, 6);
+ });
+
+ it('flattens a G2 arc via I/J into a cut polyline', () => {
+ // quarter circle, centre at (0,0): (10,0) CW... use G3 (CCW) to (0,10)
+ const { segments } = parseGcode(`G21 G90\nG0 X10 Y0\nG3 X0 Y10 I-10 J0`);
+ const arc = segments[1]!;
+ expect(arc.kind).toBe('cut');
+ expect(arc.points[0]).toEqual([10, 0]);
+ expect(arc.points[arc.points.length - 1]).toEqual([0, 10]);
+ expect(arc.points.length).toBeGreaterThan(2);
+ });
+
+ it('ignores comments and unknown words, records no warnings for M-codes', () => {
+ const { segments, warnings } = parseGcode(`(setup)\nG21 G90\nM3 S1000 ; spindle on\nG1 X5 Y0 F600`);
+ expect(segments).toHaveLength(1);
+ expect(warnings).toHaveLength(0);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/gcode/parser.test.ts`
+Expected: FAIL — cannot find module `./parser`.
+
+- [ ] **Step 3: Implement `src/gcode/parser.ts`**
+
+```ts
+import type { Vec2, Segment } from '../types';
+import { flattenArc } from '../geometry/arc';
+
+export interface ParseResult {
+ segments: Segment[];
+ warnings: string[];
+}
+
+interface State {
+ x: number;
+ y: number;
+ z: number;
+ absolute: boolean;
+ scale: number; // 1 for mm, 25.4 for inch
+ motion: number; // 0,1,2,3
+}
+
+const WORD_RE = /([A-Za-z])\s*([-+]?[0-9]*\.?[0-9]+)/g;
+
+function stripComments(line: string): string {
+ return line.replace(/\(.*?\)/g, '').replace(/;.*$/, '').trim();
+}
+
+export function parseGcode(text: string): ParseResult {
+ const segments: Segment[] = [];
+ const warnings: string[] = [];
+ const st: State = { x: 0, y: 0, z: 0, absolute: true, scale: 1, motion: 0 };
+
+ const lines = text.split(/\r?\n/);
+ for (let lineNo = 0; lineNo < lines.length; lineNo++) {
+ const clean = stripComments(lines[lineNo]!);
+ if (!clean) continue;
+
+ const words: Array<[string, number]> = [];
+ let m: RegExpExecArray | null;
+ WORD_RE.lastIndex = 0;
+ while ((m = WORD_RE.exec(clean)) !== null) {
+ words.push([m[1]!.toUpperCase(), parseFloat(m[2]!)]);
+ }
+
+ const axis: Record = {};
+ let motionThisLine: number | null = null;
+ for (const [letter, value] of words) {
+ switch (letter) {
+ case 'G':
+ if (value === 0 || value === 1 || value === 2 || value === 3) {
+ st.motion = value;
+ motionThisLine = value;
+ } else if (value === 20) st.scale = 25.4;
+ else if (value === 21) st.scale = 1;
+ else if (value === 90) st.absolute = true;
+ else if (value === 91) st.absolute = false;
+ // G17/G18/G19 plane select and others: ignored (G17/XY assumed)
+ break;
+ case 'X': case 'Y': case 'Z': case 'I': case 'J': case 'R':
+ axis[letter] = value * st.scale;
+ break;
+ // F, S, T, M, N etc. ignored
+ default:
+ break;
+ }
+ }
+
+ const hasCoord = 'X' in axis || 'Y' in axis || 'Z' in axis;
+ if (motionThisLine === null && !hasCoord) continue; // no movement on this line
+
+ const start: Vec2 = [st.x, st.y];
+ const target = resolveTarget(st, axis);
+
+ switch (st.motion) {
+ case 0:
+ segments.push({ kind: 'rapid', points: [start, target] });
+ break;
+ case 1:
+ segments.push({ kind: 'cut', points: [start, target] });
+ break;
+ case 2:
+ case 3: {
+ const center = arcCenter(start, target, axis, st.motion === 2);
+ if (!center) {
+ warnings.push(`Line ${lineNo + 1}: could not resolve arc centre`);
+ segments.push({ kind: 'cut', points: [start, target] });
+ } else {
+ segments.push({ kind: 'cut', points: flattenArc(start, target, center, st.motion === 2) });
+ }
+ break;
+ }
+ }
+ st.x = target[0];
+ st.y = target[1];
+ if ('Z' in axis) st.z = axis['Z']!;
+ }
+
+ return { segments, warnings };
+}
+
+function resolveTarget(st: State, axis: Record): Vec2 {
+ if (st.absolute) {
+ return ['X' in axis ? axis['X']! : st.x, 'Y' in axis ? axis['Y']! : st.y] as Vec2;
+ }
+ return [st.x + (axis['X'] ?? 0), st.y + (axis['Y'] ?? 0)] as Vec2;
+}
+
+function arcCenter(start: Vec2, end: Vec2, axis: Record, clockwise: boolean): Vec2 | null {
+ if ('I' in axis || 'J' in axis) {
+ // I/J are offsets from the start point (already unit-scaled).
+ return [start[0] + (axis['I'] ?? 0), start[1] + (axis['J'] ?? 0)];
+ }
+ if ('R' in axis) {
+ return centerFromRadius(start, end, axis['R']!, clockwise);
+ }
+ return null;
+}
+
+/** GRBL-style centre-from-radius. R>0 selects the minor arc, R<0 the major arc. */
+function centerFromRadius(start: Vec2, end: Vec2, r: number, clockwise: boolean): Vec2 | null {
+ const x = end[0] - start[0];
+ const y = end[1] - start[1];
+ const d2 = x * x + y * y;
+ const disc = 4 * r * r - d2;
+ if (disc < 0 || d2 === 0) return null;
+ let h = Math.sqrt(disc) / Math.sqrt(d2);
+ if (clockwise) h = -h;
+ if (r < 0) h = -h;
+ return [start[0] + 0.5 * (x - y * h), start[1] + 0.5 * (y + x * h)];
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/gcode/parser.test.ts`
+Expected: PASS (6 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/gcode/parser.ts src/gcode/parser.test.ts
+git commit -m "feat: G-code parser with arcs, units, modal motion"
+```
+
+---
+
+## Task 6: Config loader
+
+**Files:**
+- Create: `src/config.ts`, `src/config.test.ts`
+
+- [ ] **Step 1: Write the failing test `src/config.test.ts`**
+
+```ts
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { loadConfig, DEFAULT_RENDER } from './config';
+
+function mockFetch(body: unknown, ok = true) {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
+ ok,
+ json: () => Promise.resolve(body),
+ }));
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('loadConfig', () => {
+ it('loads stream URL and fills render defaults', async () => {
+ mockFetch({ streamUrl: 'http://cam/stream' });
+ const cfg = await loadConfig('config.json');
+ expect(cfg.streamUrl).toBe('http://cam/stream');
+ expect(cfg.renderDefaults).toEqual(DEFAULT_RENDER);
+ expect(cfg.calibration).toBeNull();
+ });
+
+ it('keeps a valid calibration', async () => {
+ const calibration = {
+ imagePoints: [[0, 0], [1, 0], [1, 1], [0, 1]],
+ machinePoints: [[0, 0], [1, 0], [1, 1], [0, 1]],
+ homography: [1, 0, 0, 0, 1, 0, 0, 0, 1],
+ };
+ mockFetch({ streamUrl: 'x', calibration });
+ const cfg = await loadConfig('config.json');
+ expect(cfg.calibration).toEqual(calibration);
+ });
+
+ it('drops a malformed calibration to null', async () => {
+ mockFetch({ streamUrl: 'x', calibration: { homography: [1, 2, 3] } });
+ const cfg = await loadConfig('config.json');
+ expect(cfg.calibration).toBeNull();
+ });
+
+ it('throws when the request fails', async () => {
+ mockFetch({}, false);
+ await expect(loadConfig('config.json')).rejects.toThrow();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/config.test.ts`
+Expected: FAIL — cannot find module `./config`.
+
+- [ ] **Step 3: Implement `src/config.ts`**
+
+```ts
+import type { AppConfig, Calibration, Mat3, Vec2 } from './types';
+
+export const DEFAULT_RENDER = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 };
+
+function isVec2Array(v: unknown): v is Vec2[] {
+ return Array.isArray(v) && v.every((p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === 'number'));
+}
+
+function parseCalibration(c: unknown): Calibration | null {
+ if (!c || typeof c !== 'object') return null;
+ const obj = c as Record;
+ if (!isVec2Array(obj.imagePoints) || !isVec2Array(obj.machinePoints)) return null;
+ if (!Array.isArray(obj.homography) || obj.homography.length !== 9) return null;
+ if (!obj.homography.every((n) => typeof n === 'number')) return null;
+ if (obj.imagePoints.length !== obj.machinePoints.length || obj.imagePoints.length < 4) return null;
+ return {
+ imagePoints: obj.imagePoints,
+ machinePoints: obj.machinePoints,
+ homography: obj.homography as Mat3,
+ };
+}
+
+export async function loadConfig(url: string): Promise {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
+ const raw = (await res.json()) as Record;
+ return {
+ streamUrl: typeof raw.streamUrl === 'string' ? raw.streamUrl : '',
+ calibration: parseCalibration(raw.calibration),
+ renderDefaults: { ...DEFAULT_RENDER, ...(raw.renderDefaults as object | undefined) },
+ };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/config.test.ts`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/config.ts src/config.test.ts
+git commit -m "feat: config loader with calibration validation"
+```
+
+---
+
+## Task 7: Renderer
+
+**Files:**
+- Create: `src/render/renderer.ts`, `src/render/renderer.test.ts`
+
+- [ ] **Step 1: Write the failing test `src/render/renderer.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { drawOverlay } from './renderer';
+import type { Segment, RenderStyle } from '../types';
+
+class FakeCtx {
+ calls: string[] = [];
+ strokeStyle = '';
+ lineWidth = 0;
+ beginPath() { this.calls.push('begin'); }
+ setLineDash(d: number[]) { this.calls.push(`dash:${d.length}`); }
+ moveTo(x: number, y: number) { this.calls.push(`move:${x},${y}`); }
+ lineTo(x: number, y: number) { this.calls.push(`line:${x},${y}`); }
+ stroke() { this.calls.push(`stroke:${this.strokeStyle}`); }
+}
+
+const STYLE: RenderStyle = { cutColor: '#cut', rapidColor: '#rapid', lineWidth: 2 };
+
+describe('drawOverlay', () => {
+ it('strokes a cut as a solid polyline in the cut colour', () => {
+ const ctx = new FakeCtx();
+ const segs: Segment[] = [{ kind: 'cut', points: [[0, 0], [5, 5], [10, 0]] }];
+ drawOverlay(ctx as unknown as CanvasRenderingContext2D, segs, STYLE);
+ expect(ctx.calls).toEqual(['begin', 'dash:0', 'move:0,0', 'line:5,5', 'line:10,0', 'stroke:#cut']);
+ });
+
+ it('strokes a rapid dashed in the rapid colour', () => {
+ const ctx = new FakeCtx();
+ const segs: Segment[] = [{ kind: 'rapid', points: [[0, 0], [5, 0]] }];
+ drawOverlay(ctx as unknown as CanvasRenderingContext2D, segs, STYLE);
+ expect(ctx.calls).toContain('dash:2');
+ expect(ctx.calls).toContain('stroke:#rapid');
+ });
+
+ it('skips degenerate segments', () => {
+ const ctx = new FakeCtx();
+ drawOverlay(ctx as unknown as CanvasRenderingContext2D, [{ kind: 'cut', points: [[1, 1]] }], STYLE);
+ expect(ctx.calls).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/render/renderer.test.ts`
+Expected: FAIL — cannot find module `./renderer`.
+
+- [ ] **Step 3: Implement `src/render/renderer.ts`**
+
+```ts
+import type { Segment, RenderStyle } from '../types';
+
+/** Draw already-projected (image-pixel) segments onto a 2D context. */
+export function drawOverlay(
+ ctx: CanvasRenderingContext2D,
+ projected: Segment[],
+ style: RenderStyle,
+): void {
+ for (const seg of projected) {
+ if (seg.points.length < 2) continue;
+ ctx.beginPath();
+ ctx.setLineDash(seg.kind === 'rapid' ? [6, 4] : []);
+ ctx.strokeStyle = seg.kind === 'rapid' ? style.rapidColor : style.cutColor;
+ ctx.lineWidth = style.lineWidth;
+ const first = seg.points[0]!;
+ ctx.moveTo(first[0], first[1]);
+ for (let i = 1; i < seg.points.length; i++) {
+ ctx.lineTo(seg.points[i]![0], seg.points[i]![1]);
+ }
+ ctx.stroke();
+ }
+}
+
+/** Clear the whole canvas. */
+export function clearCanvas(ctx: CanvasRenderingContext2D, width: number, height: number): void {
+ ctx.setLineDash([]);
+ ctx.clearRect(0, 0, width, height);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/render/renderer.test.ts`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/render/renderer.ts src/render/renderer.test.ts
+git commit -m "feat: canvas overlay renderer"
+```
+
+---
+
+## Task 8: Page layout + overlay canvas sizing
+
+This task introduces the DOM. The pure sizing math is unit-tested; the visual result is verified manually.
+
+**Files:**
+- Modify: `index.html`
+- Create: `src/styles.css`, `src/app/layout.ts`, `src/app/layout.test.ts`
+
+- [ ] **Step 1: Write the failing test `src/app/layout.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { computeOverlayRect } from './layout';
+
+describe('computeOverlayRect', () => {
+ it('matches the displayed video box (letterboxed: wide video in tall box)', () => {
+ // video is 16:9 (1600×900 native) shown in a 800×800 box → fits to width, 450 tall, centred
+ const r = computeOverlayRect(1600, 900, 800, 800);
+ expect(r.width).toBeCloseTo(800, 3);
+ expect(r.height).toBeCloseTo(450, 3);
+ expect(r.left).toBeCloseTo(0, 3);
+ expect(r.top).toBeCloseTo(175, 3);
+ });
+
+ it('falls back to the container when the video has no intrinsic size yet', () => {
+ const r = computeOverlayRect(0, 0, 640, 480);
+ expect(r).toEqual({ left: 0, top: 0, width: 640, height: 480 });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/app/layout.test.ts`
+Expected: FAIL — cannot find module `./layout`.
+
+- [ ] **Step 3: Implement `src/app/layout.ts`**
+
+```ts
+export interface Rect {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Compute the on-screen rectangle of a media element shown with object-fit: contain
+ * inside a container. Returns container bounds if the media size is unknown.
+ */
+export function computeOverlayRect(
+ mediaW: number,
+ mediaH: number,
+ containerW: number,
+ containerH: number,
+): Rect {
+ if (mediaW <= 0 || mediaH <= 0) {
+ return { left: 0, top: 0, width: containerW, height: containerH };
+ }
+ const scale = Math.min(containerW / mediaW, containerH / mediaH);
+ const width = mediaW * scale;
+ const height = mediaH * scale;
+ return {
+ left: (containerW - width) / 2,
+ top: (containerH - height) / 2,
+ width,
+ height,
+ };
+}
+
+/** Position and size a canvas to overlay a media element inside a container. */
+export function syncOverlay(canvas: HTMLCanvasElement, rect: Rect): void {
+ canvas.style.left = `${rect.left}px`;
+ canvas.style.top = `${rect.top}px`;
+ canvas.style.width = `${rect.width}px`;
+ canvas.style.height = `${rect.height}px`;
+ canvas.width = Math.round(rect.width);
+ canvas.height = Math.round(rect.height);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/app/layout.test.ts`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Replace `index.html` body with the app markup**
+
+```html
+
+
+
+
+
+ G-Code Overlay
+
+
+
+
+
![CNC camera stream]()
+
+
+
+
+
+
+```
+
+- [ ] **Step 6: Create `src/styles.css`**
+
+```css
+* { box-sizing: border-box; }
+body { margin: 0; display: flex; height: 100vh; font-family: system-ui, sans-serif; background: #111; color: #eee; }
+#stage { position: relative; flex: 1; overflow: hidden; background: #000; }
+#stream { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
+#overlay { position: absolute; pointer-events: none; }
+#overlay.interactive { pointer-events: auto; cursor: crosshair; }
+#panel { width: 320px; padding: 16px; overflow-y: auto; background: #1b1b1b; }
+#panel h1 { font-size: 16px; }
+.filebtn { display: inline-block; padding: 8px 12px; background: #2962ff; border-radius: 4px; cursor: pointer; }
+button { padding: 6px 10px; margin: 2px 0; background: #333; color: #eee; border: 1px solid #555; border-radius: 4px; cursor: pointer; }
+input[type="number"] { width: 80px; background: #222; color: #eee; border: 1px solid #555; border-radius: 4px; padding: 4px; }
+section { margin-top: 16px; border-top: 1px solid #333; padding-top: 12px; }
+.row { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
+small { color: #999; }
+```
+
+- [ ] **Step 7: Update `src/main.ts` to confirm layout wiring**
+
+```ts
+import './styles.css';
+import { computeOverlayRect, syncOverlay } from './app/layout';
+
+const stage = document.getElementById('stage') as HTMLDivElement;
+const stream = document.getElementById('stream') as HTMLImageElement;
+const overlay = document.getElementById('overlay') as HTMLCanvasElement;
+
+function resize(): void {
+ const rect = computeOverlayRect(
+ stream.naturalWidth,
+ stream.naturalHeight,
+ stage.clientWidth,
+ stage.clientHeight,
+ );
+ syncOverlay(overlay, rect);
+}
+
+window.addEventListener('resize', resize);
+stream.addEventListener('load', resize);
+resize();
+
+console.log('layout ready');
+```
+
+- [ ] **Step 8: Manual verification**
+
+Run: `npm run dev`, open the served URL.
+Expected: a dark page with a side panel; the `#overlay` canvas is positioned over the (empty) stage. Resize the window — the canvas tracks the stage. No console errors. (No stream yet — that arrives in Task 9.)
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add index.html src/styles.css src/app/layout.ts src/app/layout.test.ts src/main.ts
+git commit -m "feat: page layout and overlay canvas sizing"
+```
+
+---
+
+## Task 9: App state, stream, and G-code rendering
+
+Wires config → stream, file open → parse → render. The render loop uses `projectSegments` (Task 4) and `drawOverlay` (Task 7).
+
+**Files:**
+- Create: `src/app/state.ts`, `src/app/state.test.ts`, `public/config.json`
+- Modify: `src/main.ts`
+
+- [ ] **Step 1: Write the failing test `src/app/state.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { createState } from './state';
+import type { Mat3 } from '../types';
+
+const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
+
+describe('app state', () => {
+ it('starts with no segments and a zero alignment', () => {
+ const s = createState();
+ expect(s.segments).toEqual([]);
+ expect(s.alignment).toEqual({ tx: 0, ty: 0, rot: 0 });
+ });
+
+ it('loadGcode stores parsed segments and returns warning count', () => {
+ const s = createState();
+ const warnings = s.loadGcode('G21 G90\nG1 X10 Y0');
+ expect(s.segments).toHaveLength(1);
+ expect(warnings).toEqual([]);
+ });
+
+ it('projected() returns image-space segments when calibrated', () => {
+ const s = createState();
+ s.setHomography(IDENT);
+ s.loadGcode('G21 G90\nG1 X10 Y0');
+ s.alignment.tx = 2;
+ const proj = s.projected();
+ expect(proj[0]!.points[0]).toEqual([2, 0]);
+ expect(proj[0]!.points[1]).toEqual([12, 0]);
+ });
+
+ it('projected() returns [] when not calibrated', () => {
+ const s = createState();
+ s.loadGcode('G1 X10 Y0');
+ expect(s.projected()).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/app/state.test.ts`
+Expected: FAIL — cannot find module `./state`.
+
+- [ ] **Step 3: Implement `src/app/state.ts`**
+
+```ts
+import type { Segment, Alignment, Mat3 } from '../types';
+import { parseGcode } from '../gcode/parser';
+import { projectSegments } from '../geometry/transform';
+
+export interface AppState {
+ segments: Segment[];
+ alignment: Alignment;
+ homography: Mat3 | null;
+ loadGcode(text: string): string[];
+ setHomography(H: Mat3 | null): void;
+ projected(): Segment[];
+}
+
+export function createState(): AppState {
+ const state: AppState = {
+ segments: [],
+ alignment: { tx: 0, ty: 0, rot: 0 },
+ homography: null,
+ loadGcode(text: string): string[] {
+ const { segments, warnings } = parseGcode(text);
+ state.segments = segments;
+ return warnings;
+ },
+ setHomography(H: Mat3 | null): void {
+ state.homography = H;
+ },
+ projected(): Segment[] {
+ if (!state.homography) return [];
+ return projectSegments(state.segments, state.alignment, state.homography);
+ },
+ };
+ return state;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/app/state.test.ts`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Create `public/config.json`** (placeholder; real stream URL filled in Task 12)
+
+```json
+{
+ "streamUrl": "",
+ "calibration": null,
+ "renderDefaults": { "cutColor": "#00e5ff", "rapidColor": "#ff9800", "lineWidth": 1.5 }
+}
+```
+
+- [ ] **Step 6: Replace `src/main.ts` with the wired bootstrap**
+
+```ts
+import './styles.css';
+import { loadConfig } from './config';
+import { createState } from './app/state';
+import { computeOverlayRect, syncOverlay } from './app/layout';
+import { drawOverlay, clearCanvas } from './render/renderer';
+import type { RenderStyle } from './types';
+
+const stage = document.getElementById('stage') as HTMLDivElement;
+const stream = document.getElementById('stream') as HTMLImageElement;
+const overlay = document.getElementById('overlay') as HTMLCanvasElement;
+const statusEl = document.getElementById('status') as HTMLParagraphElement;
+const fileInput = document.getElementById('gcode-file') as HTMLInputElement;
+const ctx = overlay.getContext('2d')!;
+
+const state = createState();
+let style: RenderStyle = { cutColor: '#00e5ff', rapidColor: '#ff9800', lineWidth: 1.5 };
+
+function render(): void {
+ clearCanvas(ctx, overlay.width, overlay.height);
+ const proj = state.projected();
+ drawOverlay(ctx, proj, style);
+}
+
+function resize(): void {
+ const rect = computeOverlayRect(stream.naturalWidth, stream.naturalHeight, stage.clientWidth, stage.clientHeight);
+ syncOverlay(overlay, rect);
+ render();
+}
+
+window.addEventListener('resize', resize);
+stream.addEventListener('load', resize);
+
+fileInput.addEventListener('change', async () => {
+ const file = fileInput.files?.[0];
+ if (!file) return;
+ const text = await file.text();
+ const warnings = state.loadGcode(text);
+ statusEl.textContent = `${file.name}: ${state.segments.length} moves` + (warnings.length ? ` (${warnings.length} warnings)` : '');
+ render();
+});
+
+// expose for the UI modules added in later tasks
+export const app = { state, render, resize, overlay, stage, stream };
+
+async function boot(): Promise {
+ try {
+ const cfg = await loadConfig('config.json');
+ style = cfg.renderDefaults;
+ if (cfg.streamUrl) stream.src = cfg.streamUrl;
+ if (cfg.calibration) state.setHomography(cfg.calibration.homography);
+ statusEl.textContent = cfg.calibration ? 'Ready. Open a G-code file.' : 'Not calibrated — calibrate first.';
+ } catch (e) {
+ statusEl.textContent = `Config error: ${(e as Error).message}`;
+ }
+ resize();
+}
+
+void boot();
+```
+
+- [ ] **Step 7: Run the full test suite**
+
+Run: `npm test`
+Expected: all suites pass.
+
+- [ ] **Step 8: Manual verification**
+
+Run: `npm run dev`. Open the page. Open any `.gcode`/`.nc` file via the button.
+Expected: status shows the move count. (Toolpath will only *draw* once a calibration exists — added in Task 10. To sanity-check rendering now, temporarily set `calibration.homography` in `public/config.json` to `[1,0,0,0,1,0,0,0,1]` and a small file's lines should appear top-left; revert afterward.)
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/app/state.ts src/app/state.test.ts public/config.json src/main.ts
+git commit -m "feat: app state, stream embedding, G-code render wiring"
+```
+
+---
+
+## Task 10: Calibration UI
+
+Lets the operator capture image↔machine point pairs and compute the homography. Uses `estimateHomography` and `applyHomography` (Task 3).
+
+**Files:**
+- Create: `src/app/calibration-ui.ts`, `src/app/calibration-ui.test.ts`
+- Modify: `src/main.ts`
+
+- [ ] **Step 1: Write the failing test `src/app/calibration-ui.test.ts`**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { residuals } from './calibration-ui';
+import { estimateHomography } from '../geometry/homography';
+import type { Vec2 } from '../types';
+
+describe('calibration residuals', () => {
+ it('reports near-zero error for a clean fit', () => {
+ const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100]];
+ const image: Vec2[] = [[10, 10], [210, 12], [208, 210], [8, 208]];
+ const H = estimateHomography(machine, image);
+ const errs = residuals(H, machine, image);
+ expect(Math.max(...errs)).toBeLessThan(1e-6);
+ });
+
+ it('flags a mismatched point with a large residual', () => {
+ const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50]];
+ const image: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [80, 20]]; // last is wrong
+ const H = estimateHomography(machine, image);
+ const errs = residuals(H, machine, image);
+ expect(errs[4]!).toBeGreaterThan(5);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/app/calibration-ui.test.ts`
+Expected: FAIL — cannot find module `./calibration-ui`.
+
+- [ ] **Step 3: Implement `src/app/calibration-ui.ts`**
+
+```ts
+import type { Vec2, Mat3 } from '../types';
+import { estimateHomography, applyHomography } from '../geometry/homography';
+
+/** Per-point reprojection error in pixels: |H·machine − image|. */
+export function residuals(H: Mat3, machine: Vec2[], image: Vec2[]): number[] {
+ return machine.map((m, i) => {
+ const p = applyHomography(H, m);
+ return Math.hypot(p[0] - image[i]![0], p[1] - image[i]![1]);
+ });
+}
+
+interface CalibPoint {
+ machine: Vec2;
+ image: Vec2;
+}
+
+export interface CalibrationDeps {
+ panel: HTMLElement;
+ overlay: HTMLCanvasElement;
+ /** Convert a click on the displayed canvas to native image pixels (canvas is object-fit:contain matched). */
+ onHomography: (H: Mat3, points: CalibPoint[]) => void;
+}
+
+/**
+ * Renders the calibration panel. Flow: click "Add point" → type machine X/Y →
+ * click the spindle tip in the video → repeat ≥4× → "Compute".
+ */
+export function mountCalibration(deps: CalibrationDeps): void {
+ const { panel, overlay } = deps;
+ const points: CalibPoint[] = [];
+ let pendingMachine: Vec2 | null = null;
+
+ panel.innerHTML = `
+ Calibration
+ Jog the spindle to a known X/Y, enter it, then click the tip in the video.
+
+ X
+ Y
+
+
+
+
+
+
+
+
+ `;
+
+ const xEl = panel.querySelector('#cal-x') as HTMLInputElement;
+ const yEl = panel.querySelector('#cal-y') as HTMLInputElement;
+ const armBtn = panel.querySelector('#cal-arm') as HTMLButtonElement;
+ const list = panel.querySelector('#cal-list') as HTMLUListElement;
+ const jsonEl = panel.querySelector('#cal-json') as HTMLTextAreaElement;
+
+ const renderList = (errs?: number[]) => {
+ list.innerHTML = points
+ .map((p, i) => `(${p.machine[0]}, ${p.machine[1]}) → px(${p.image[0].toFixed(0)}, ${p.image[1].toFixed(0)})${errs ? ` err ${errs[i]!.toFixed(1)}px` : ''}`)
+ .join('');
+ };
+
+ armBtn.addEventListener('click', () => {
+ pendingMachine = [parseFloat(xEl.value), parseFloat(yEl.value)];
+ if (Number.isNaN(pendingMachine[0]) || Number.isNaN(pendingMachine[1])) {
+ pendingMachine = null;
+ return;
+ }
+ overlay.classList.add('interactive');
+ });
+
+ overlay.addEventListener('click', (ev) => {
+ if (!pendingMachine || !overlay.classList.contains('interactive')) return;
+ const rect = overlay.getBoundingClientRect();
+ // Map click to native image pixels: canvas backing store == displayed media box.
+ const px: Vec2 = [
+ ((ev.clientX - rect.left) / rect.width) * overlay.width,
+ ((ev.clientY - rect.top) / rect.height) * overlay.height,
+ ];
+ points.push({ machine: pendingMachine, image: px });
+ pendingMachine = null;
+ overlay.classList.remove('interactive');
+ renderList();
+ });
+
+ (panel.querySelector('#cal-clear') as HTMLButtonElement).addEventListener('click', () => {
+ points.length = 0;
+ jsonEl.value = '';
+ renderList();
+ });
+
+ (panel.querySelector('#cal-compute') as HTMLButtonElement).addEventListener('click', () => {
+ if (points.length < 4) {
+ jsonEl.value = 'Need at least 4 points.';
+ return;
+ }
+ const machine = points.map((p) => p.machine);
+ const image = points.map((p) => p.image);
+ const H = estimateHomography(machine, image);
+ const errs = residuals(H, machine, image);
+ renderList(errs);
+ jsonEl.value = JSON.stringify(
+ { imagePoints: image, machinePoints: machine, homography: H },
+ null,
+ 2,
+ );
+ deps.onHomography(H, [...points]);
+ });
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run src/app/calibration-ui.test.ts`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Wire calibration into `src/main.ts`** — add after the `app` export:
+
+```ts
+import { mountCalibration } from './app/calibration-ui';
+
+mountCalibration({
+ panel: document.getElementById('calib-panel') as HTMLElement,
+ overlay,
+ onHomography: (H) => {
+ state.setHomography(H);
+ statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
+ render();
+ },
+});
+```
+
+- [ ] **Step 6: Manual verification**
+
+Run: `npm run dev`. With a stream image present (or a static test image temporarily set as `#stream` src), enter an X/Y, click "Click point in video", click on the image; repeat 4×; press "Compute".
+Expected: each point lists with a residual; JSON appears in the textarea; residuals are small for consistent clicks.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/app/calibration-ui.ts src/app/calibration-ui.test.ts src/main.ts
+git commit -m "feat: calibration capture UI with reprojection residuals"
+```
+
+---
+
+## Task 11: Alignment UI
+
+Per-job placement: numeric entry, click-to-set-origin, and drag/rotate. The pure conversions are already tested in Task 4 (`alignmentFromOrigin`, `imageToMachine`).
+
+**Files:**
+- Create: `src/app/alignment-ui.ts`
+- Modify: `src/main.ts`
+
+- [ ] **Step 1: Implement `src/app/alignment-ui.ts`**
+
+```ts
+import type { Vec2, Mat3, Alignment } from '../types';
+import { imageToMachine, alignmentFromOrigin } from '../geometry/transform';
+
+export interface AlignmentDeps {
+ panel: HTMLElement;
+ overlay: HTMLCanvasElement;
+ getAlignment: () => Alignment;
+ getHomography: () => Mat3 | null;
+ onChange: () => void;
+}
+
+function canvasPxFromEvent(overlay: HTMLCanvasElement, ev: MouseEvent): Vec2 {
+ const rect = overlay.getBoundingClientRect();
+ return [
+ ((ev.clientX - rect.left) / rect.width) * overlay.width,
+ ((ev.clientY - rect.top) / rect.height) * overlay.height,
+ ];
+}
+
+export function mountAlignment(deps: AlignmentDeps): void {
+ const { panel, overlay } = deps;
+ panel.innerHTML = `
+ Alignment
+ X Y
+ Rotation°
+
+ Or drag the toolpath to move; Shift-drag to rotate. Arrow keys nudge 1 mm.
+ `;
+
+ const xEl = panel.querySelector('#al-x') as HTMLInputElement;
+ const yEl = panel.querySelector('#al-y') as HTMLInputElement;
+ const rotEl = panel.querySelector('#al-rot') as HTMLInputElement;
+
+ const sync = () => {
+ const a = deps.getAlignment();
+ xEl.value = a.tx.toFixed(1);
+ yEl.value = a.ty.toFixed(1);
+ rotEl.value = ((a.rot * 180) / Math.PI).toFixed(1);
+ };
+ sync();
+
+ const commitNumeric = () => {
+ const a = deps.getAlignment();
+ a.tx = parseFloat(xEl.value) || 0;
+ a.ty = parseFloat(yEl.value) || 0;
+ a.rot = ((parseFloat(rotEl.value) || 0) * Math.PI) / 180;
+ deps.onChange();
+ };
+ for (const el of [xEl, yEl, rotEl]) el.addEventListener('change', commitNumeric);
+
+ // Click-to-set-origin
+ let armOrigin = false;
+ (panel.querySelector('#al-origin') as HTMLButtonElement).addEventListener('click', () => {
+ armOrigin = true;
+ overlay.classList.add('interactive');
+ });
+
+ // Drag to move / Shift-drag to rotate
+ let dragging = false;
+ let lastMachine: Vec2 | null = null;
+
+ overlay.addEventListener('mousedown', (ev) => {
+ const H = deps.getHomography();
+ if (!H) return;
+ if (armOrigin) {
+ const a = deps.getAlignment();
+ const m = imageToMachine(H, canvasPxFromEvent(overlay, ev));
+ Object.assign(a, alignmentFromOrigin(m, a.rot));
+ armOrigin = false;
+ overlay.classList.remove('interactive');
+ sync();
+ deps.onChange();
+ return;
+ }
+ dragging = true;
+ overlay.classList.add('interactive');
+ lastMachine = imageToMachine(H, canvasPxFromEvent(overlay, ev));
+ });
+
+ window.addEventListener('mousemove', (ev) => {
+ if (!dragging) return;
+ const H = deps.getHomography();
+ if (!H || !lastMachine) return;
+ const a = deps.getAlignment();
+ const cur = imageToMachine(H, canvasPxFromEvent(overlay, ev));
+ if (ev.shiftKey) {
+ // rotate about the work origin's current machine position (tx,ty)
+ const angle = (p: Vec2) => Math.atan2(p[1] - a.ty, p[0] - a.tx);
+ a.rot += angle(cur) - angle(lastMachine);
+ } else {
+ a.tx += cur[0] - lastMachine[0];
+ a.ty += cur[1] - lastMachine[1];
+ }
+ lastMachine = cur;
+ sync();
+ deps.onChange();
+ });
+
+ window.addEventListener('mouseup', () => {
+ dragging = false;
+ overlay.classList.remove('interactive');
+ });
+
+ window.addEventListener('keydown', (ev) => {
+ const a = deps.getAlignment();
+ const step = 1;
+ if (ev.key === 'ArrowLeft') a.tx -= step;
+ else if (ev.key === 'ArrowRight') a.tx += step;
+ else if (ev.key === 'ArrowUp') a.ty -= step;
+ else if (ev.key === 'ArrowDown') a.ty += step;
+ else return;
+ ev.preventDefault();
+ sync();
+ deps.onChange();
+ });
+}
+```
+
+- [ ] **Step 2: Wire alignment into `src/main.ts`** — add near the other UI mounts:
+
+```ts
+import { mountAlignment } from './app/alignment-ui';
+
+mountAlignment({
+ panel: document.getElementById('align-panel') as HTMLElement,
+ overlay,
+ getAlignment: () => state.alignment,
+ getHomography: () => state.homography,
+ onChange: render,
+});
+```
+
+- [ ] **Step 3: Run the full suite (no regressions)**
+
+Run: `npm test`
+Expected: all suites pass.
+
+- [ ] **Step 4: Manual verification**
+
+Run: `npm run dev` with a temporary identity homography in `config.json` and a small G-code file loaded.
+Expected: numeric X/Y/rotation move the toolpath; "Set origin by clicking video" then a click moves the path origin under the cursor; plain drag translates; Shift-drag rotates; arrow keys nudge. Revert the temporary calibration afterward.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/alignment-ui.ts src/main.ts
+git commit -m "feat: per-job alignment (numeric, click-origin, drag/rotate)"
+```
+
+---
+
+## Task 12: Real config, README, and final pass
+
+**Files:**
+- Modify: `public/config.json`
+- Create: `README.md`
+
+- [ ] **Step 1: Set the real stream URL in `public/config.json`**
+
+Replace `streamUrl` with the confirmed internal stream URL (the format check from the design's Open Questions). For an MJPEG stream the existing `
` works directly. If the confirmed format is HLS/WebRTC, change `#stream` in `index.html` from `
` to `