# 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 `