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

55 KiB
Raw Permalink Blame 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
{
  "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
{
  "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
import { defineConfig } from 'vite';

export default defineConfig({
  test: { globals: true, environment: 'node' },
});
  • Step 5: Create minimal index.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
console.log('gcode-overlay booting');
  • Step 7: Write the smoke test src/smoke.test.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
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

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

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

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

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

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

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

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
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
<!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
* { 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
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
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

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
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)
{
  "streamUrl": "",
  "calibration": null,
  "renderDefaults": { "cutColor": "#00e5ff", "rapidColor": "#ff9800", "lineWidth": 1.5 }
}
  • Step 6: Replace src/main.ts with the wired bootstrap
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
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

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
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:
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
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

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:
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
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>.

{
  "streamUrl": "REPLACE_WITH_CONFIRMED_STREAM_URL",
  "calibration": null,
  "renderDefaults": { "cutColor": "#00e5ff", "rapidColor": "#ff9800", "lineWidth": 1.5 }
}
  • Step 2: Create README.md
# 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
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.