GCodeOverlay/docs/superpowers/plans/2026-06-08-gcode-overlay.md
sjat 940b0b2fbe Add G-code overlay implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:01:49 +02:00

1736 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>G-Code Overlay</title>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
```
- [ ] **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<string, number> = {};
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<string, number>): 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<string, number>, 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<string, unknown>;
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<AppConfig> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
const raw = (await res.json()) as Record<string, unknown>;
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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>G-Code Overlay</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="stage">
<img id="stream" alt="CNC camera stream" />
<canvas id="overlay"></canvas>
</div>
<aside id="panel">
<h1>G-Code Overlay</h1>
<section>
<label class="filebtn">Open G-code<input id="gcode-file" type="file" accept=".nc,.gcode,.tap,.txt,.ngc" hidden /></label>
<p id="status">No file loaded.</p>
</section>
<section id="align-panel"></section>
<section id="calib-panel"></section>
</aside>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
```
- [ ] **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<void> {
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 = `
<h2>Calibration</h2>
<div class="row"><small>Jog the spindle to a known X/Y, enter it, then click the tip in the video.</small></div>
<div class="row">
X <input id="cal-x" type="number" step="0.1" />
Y <input id="cal-y" type="number" step="0.1" />
<button id="cal-arm">Click point in video</button>
</div>
<ul id="cal-list"></ul>
<div class="row">
<button id="cal-compute">Compute homography</button>
<button id="cal-clear">Clear</button>
</div>
<textarea id="cal-json" rows="6" style="width:100%" readonly placeholder="Calibration JSON appears here"></textarea>
`;
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) => `<li>(${p.machine[0]}, ${p.machine[1]}) → px(${p.image[0].toFixed(0)}, ${p.image[1].toFixed(0)})${errs ? ` <small>err ${errs[i]!.toFixed(1)}px</small>` : ''}</li>`)
.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 = `
<h2>Alignment</h2>
<div class="row">X <input id="al-x" type="number" step="1" /> Y <input id="al-y" type="number" step="1" /></div>
<div class="row">Rotation° <input id="al-rot" type="number" step="0.5" /></div>
<div class="row"><button id="al-origin">Set origin by clicking video</button></div>
<div class="row"><small>Or drag the toolpath to move; Shift-drag to rotate. Arrow keys nudge 1&nbsp;mm.</small></div>
`;
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 `<img id="stream">` works directly. If the confirmed format is HLS/WebRTC, change `#stream` in `index.html` from `<img>` to `<video>` and (for HLS) add the playback wiring; otherwise leave as `<img>`.
```json
{
"streamUrl": "REPLACE_WITH_CONFIRMED_STREAM_URL",
"calibration": null,
"renderDefaults": { "cutColor": "#00e5ff", "rapidColor": "#ff9800", "lineWidth": 1.5 }
}
```
- [ ] **Step 2: Create `README.md`**
```markdown
# G-Code Overlay
Overlays a G-code toolpath on the CNC router's live camera stream.
## Develop
- `npm install`
- `npm run dev` — local dev server
- `npm test` — run unit tests
- `npm run build` — type-check + production build to `dist/`
## Deploy
Serve the built `dist/` as static files. Edit `public/config.json` (bundled into the build) to set:
- `streamUrl` — the camera stream URL (MJPEG `<img>` by default).
- `calibration` — produced once via the in-app Calibration panel; paste the generated JSON here to persist it for all viewers.
## Use
1. Open a local G-code file.
2. If not yet calibrated: jog the spindle to ≥4 known points, enter each X/Y, click the tip in the video, then Compute and paste the JSON into `config.json`.
3. Align the toolpath to the material (drag, click-origin, or numeric), and reality-check against the live cut.
```
- [ ] **Step 3: Full verification**
Run: `npm test && npm run build`
Expected: all tests pass; build completes with no type errors.
- [ ] **Step 4: Commit**
```bash
git add public/config.json README.md
git commit -m "docs: README and deployment config"
```
---
## Self-Review Notes
- **Spec coverage:** stream layer (T8/T9), overlay canvas (T8), homography calibration via jog-to-points (T3/T10), per-job alignment drag/click/numeric (T4/T11), cut vs rapid rendering (T7), G-code parse incl. arcs/units/modal (T5), config persistence (T6/T9/T12), error handling for bad G-code (T5 warnings) and missing calibration (T9 status). Lens distortion correction is intentionally deferred per spec.
- **Type consistency:** `estimateHomography(src, dst)` is called as `(machine, image)` everywhere; `homography` is `machine→image`; `imageToMachine` uses its inverse; `Alignment` fields `tx/ty/rot` are consistent across state, transform, and UI.
- **Deferred to implementation:** confirming the stream protocol (Task 12 Step 1) — the only external unknown.
```