From 940b0b2fbe8fdbb252e89eeb682be7ae663bb6f0 Mon Sep 17 00:00:00 2001 From: sjat Date: Mon, 8 Jun 2026 22:01:49 +0200 Subject: [PATCH] Add G-code overlay implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-08-gcode-overlay.md | 1736 +++++++++++++++++ 1 file changed, 1736 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-gcode-overlay.md 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 `