GCodeOverlay/docs/superpowers/plans/2026-06-08-gcode-overlay.md

1737 lines
55 KiB
Markdown
Raw Permalink Normal View History

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