GCodeOverlay/docs/superpowers/plans/2026-06-11-wide-angle-distortion.md

38 KiB
Raw Blame History

Wide-Angle Distortion Compensation 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: Replace the single homography with a bivariate polynomial warp so the G-code overlay curves to match the wide-angle camera's barrel distortion.

Architecture: A PolyWarp stores two independently least-squares-fit polynomial maps — forward (machine-mm → normalized image) for drawing and inverse (normalized image → machine-mm) for click interactions. Machine inputs are normalized (center+scale) before building monomials to keep the normal equations well-conditioned. Degree 2 is the default; degree 3 is selectable. The homography path is fully retired.

Tech Stack: TypeScript, Vite, Vitest. No new dependencies.

Reference spec: docs/superpowers/specs/2026-06-11-wide-angle-distortion-design.md


Notes for the implementer

  • Vitest uses esbuild, which strips types without whole-program type-checking. So per-file tests run green even while other files still reference the old homography/Mat3 API mid-migration. The final tsc --noEmit (via npm run build, Task 8) is the gate that catches cross-file type holes — do not skip it.
  • Run a single test file with: npx vitest run src/path/to/file.test.ts
  • Run everything with: npm test
  • Image coordinates are normalized [0,1] camera-frame fractions throughout (unchanged from today).

Task 1: Linear-algebra helper (linalg.ts)

Extract the existing Gaussian-elimination solver into its own module and add a least-squares helper that both polynomial fits will share.

Files:

  • Create: src/geometry/linalg.ts

  • Test: src/geometry/linalg.test.ts

  • Step 1: Write the failing test

Create src/geometry/linalg.test.ts:

import { describe, it, expect } from 'vitest';
import { solveLinear, leastSquares } from './linalg';

describe('linalg', () => {
  it('solveLinear solves a 2x2 system', () => {
    // 2x + y = 5 ; x + 3y = 10  → x = 1, y = 3
    const x = solveLinear([[2, 1], [1, 3]], [5, 10]);
    expect(x[0]!).toBeCloseTo(1, 9);
    expect(x[1]!).toBeCloseTo(3, 9);
  });

  it('solveLinear throws on a singular system', () => {
    expect(() => solveLinear([[1, 2], [2, 4]], [3, 6])).toThrow();
  });

  it('leastSquares recovers exact coefficients of an over-determined linear fit', () => {
    // model: t = 2*a + 3*b ; rows are [a, b]
    const rows = [[1, 0], [0, 1], [1, 1], [2, 1]];
    const targets = rows.map((r) => 2 * r[0]! + 3 * r[1]!);
    const c = leastSquares(rows, targets);
    expect(c[0]!).toBeCloseTo(2, 9);
    expect(c[1]!).toBeCloseTo(3, 9);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/geometry/linalg.test.ts Expected: FAIL — cannot find module ./linalg.

  • Step 3: Write minimal implementation

Create src/geometry/linalg.ts:

/** Solve a square linear system A x = b by Gaussian elimination with partial pivoting. */
export 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');
    [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]!);
}

/**
 * Least-squares solve for c minimizing ‖rows·c  targets‖ via the normal equations
 * (rowsᵀ rows) c = rowsᵀ targets. `rows` is M×N (M ≥ N), `targets` is length M.
 */
export function leastSquares(rows: number[][], targets: number[]): number[] {
  const n = rows[0]!.length;
  const ata: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
  const atb: number[] = new Array(n).fill(0);
  for (let r = 0; r < rows.length; r++) {
    const row = rows[r]!;
    for (let i = 0; i < n; i++) {
      atb[i]! += row[i]! * targets[r]!;
      for (let j = 0; j < n; j++) ata[i]![j]! += row[i]! * row[j]!;
    }
  }
  return solveLinear(ata, atb);
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/geometry/linalg.test.ts Expected: PASS (3 tests).

  • Step 5: Commit
git add src/geometry/linalg.ts src/geometry/linalg.test.ts
git commit -m "feat: extract linalg solver + add least-squares helper"

Task 2: Polynomial warp model (polywarp.ts)

The core of the feature: fit and apply the bivariate polynomial maps.

Files:

  • Create: src/geometry/polywarp.ts

  • Modify: src/types.ts (add PolyMap, PolyWarp — pure types only)

  • Test: src/geometry/polywarp.test.ts

  • Step 1: Add the shared types

In src/types.ts, add after the Mat3 type (leave Mat3 itself in place; it becomes unused but harmless):

/** One fitted polynomial map: dst = Σ cᵢ · monomialᵢ(normalized src). */
export interface PolyMap {
  degree: number;
  /** Input normalization applied before evaluating monomials. */
  norm: { off: Vec2; scl: Vec2 };
  /** Coefficients for the u/x output channel. */
  cu: number[];
  /** Coefficients for the v/y output channel. */
  cv: number[];
}

/** A bidirectional polynomial warp: forward machine→image, inverse image→machine. */
export interface PolyWarp {
  degree: number;
  fwd: PolyMap;
  inv: PolyMap;
}
  • Step 2: Write the failing test

Create src/geometry/polywarp.test.ts:

import { describe, it, expect } from 'vitest';
import { estimatePolyWarp, applyPolyWarp, applyPolyWarpInverse } from './polywarp';
import { estimateHomography, applyHomography } from './homography';
import type { Vec2 } from '../types';

const dist = (a: Vec2, b: Vec2) => Math.hypot(a[0] - b[0], a[1] - b[1]);

// 16-point "box + #" calibration layout on a 2440x1220 bed (thirds in each axis).
const XS = [0, 813.3, 1626.7, 2440];
const YS = [0, 406.7, 813.3, 1220];
const machineGrid: Vec2[] = XS.flatMap((x) => YS.map((y): Vec2 => [x, y]));

// "True" camera map: affine into ~[0.1,0.9] then barrel distortion about the image centre.
function trueMap(p: Vec2): Vec2 {
  const u0 = 0.1 + 0.8 * (p[0] / 2440);
  const v0 = 0.1 + 0.8 * (p[1] / 1220);
  const cx = 0.5, cy = 0.5, k = 0.25;
  const dx = u0 - cx, dy = v0 - cy, r2 = dx * dx + dy * dy;
  const f = 1 + k * r2;
  return [cx + dx * f, cy + dy * f];
}

describe('polywarp', () => {
  it('recovers an exact quadratic map at unseen points', () => {
    // dst is an exact degree-2 polynomial of src → fit must reproduce it.
    const quad = (p: Vec2): Vec2 => {
      const x = p[0] / 2440, y = p[1] / 1220;
      return [0.2 + 0.5 * x + 0.1 * y + 0.3 * x * x, 0.1 + 0.6 * y + 0.2 * x * y];
    };
    const image = machineGrid.map(quad);
    const w = estimatePolyWarp(machineGrid, image, 2);
    for (const m of [[400, 200], [1200, 900], [2000, 300]] as Vec2[]) {
      expect(dist(applyPolyWarp(w, m), quad(m))).toBeLessThan(1e-6);
    }
  });

  it('beats a homography on barrel-distorted data', () => {
    const image = machineGrid.map(trueMap);
    const w = estimatePolyWarp(machineGrid, image, 2);
    const H = estimateHomography(machineGrid, image);
    // Evaluate on off-grid interior points (fifths), where barrel error is real.
    const test: Vec2[] = [[488, 244], [1464, 732], [1952, 488], [976, 976]];
    const polyMax = Math.max(...test.map((m) => dist(applyPolyWarp(w, m), trueMap(m))));
    const homoMax = Math.max(...test.map((m) => dist(applyHomography(H, m), trueMap(m))));
    expect(polyMax).toBeLessThan(homoMax * 0.5);
  });

  it('inverse round-trips machine points within a small tolerance', () => {
    const image = machineGrid.map(trueMap);
    const w = estimatePolyWarp(machineGrid, image, 3);
    for (const m of [[400, 200], [1200, 900], [2000, 300]] as Vec2[]) {
      expect(dist(applyPolyWarpInverse(w, applyPolyWarp(w, m)), m)).toBeLessThan(2); // mm
    }
  });

  it('throws when given fewer points than the degree needs', () => {
    const five = machineGrid.slice(0, 5);
    const img = five.map(trueMap);
    expect(() => estimatePolyWarp(five, img, 2)).toThrow(); // degree 2 needs 6
  });
});
  • Step 3: Run test to verify it fails

Run: npx vitest run src/geometry/polywarp.test.ts Expected: FAIL — cannot find module ./polywarp.

  • Step 4: Write minimal implementation

Create src/geometry/polywarp.ts:

import type { Vec2, PolyMap, PolyWarp } from '../types';
import { leastSquares } from './linalg';

/** Number of monomial terms of total degree ≤ d in two variables. */
function termCount(degree: number): number {
  return ((degree + 1) * (degree + 2)) / 2;
}

/** Monomials x^i·y^j with i+j ≤ degree, in a fixed order. */
function monomials(degree: number, x: number, y: number): number[] {
  const terms: number[] = [];
  for (let i = 0; i <= degree; i++) {
    for (let j = 0; j <= degree - i; j++) terms.push(x ** i * y ** j);
  }
  return terms;
}

/** Center+scale so normalized inputs sit roughly in [-1,1] (conditions the fit). */
function computeNorm(pts: Vec2[]): { off: Vec2; scl: Vec2 } {
  const xs = pts.map((p) => p[0]);
  const ys = pts.map((p) => p[1]);
  const off: Vec2 = [xs.reduce((a, b) => a + b, 0) / xs.length, ys.reduce((a, b) => a + b, 0) / ys.length];
  const sclx = Math.max(...xs.map((x) => Math.abs(x - off[0]))) || 1;
  const scly = Math.max(...ys.map((y) => Math.abs(y - off[1]))) || 1;
  return { off, scl: [sclx, scly] };
}

function applyNorm(norm: { off: Vec2; scl: Vec2 }, p: Vec2): Vec2 {
  return [(p[0] - norm.off[0]) / norm.scl[0], (p[1] - norm.off[1]) / norm.scl[1]];
}

/** Fit one polynomial map src → dst of the given degree. */
function fitPolyMap(src: Vec2[], dst: Vec2[], degree: number): PolyMap {
  if (src.length !== dst.length) throw new Error('fitPolyMap: src/dst length mismatch');
  const need = termCount(degree);
  if (src.length < need) throw new Error(`Need at least ${need} points for degree ${degree}`);
  const norm = computeNorm(src);
  const rows = src.map((p) => {
    const [x, y] = applyNorm(norm, p);
    return monomials(degree, x, y);
  });
  const cu = leastSquares(rows, dst.map((d) => d[0]));
  const cv = leastSquares(rows, dst.map((d) => d[1]));
  return { degree, norm, cu, cv };
}

/** Evaluate a fitted polynomial map at a point. */
export function applyPolyMap(m: PolyMap, p: Vec2): Vec2 {
  const [x, y] = applyNorm(m.norm, p);
  const mon = monomials(m.degree, x, y);
  let u = 0;
  let v = 0;
  for (let i = 0; i < mon.length; i++) {
    u += m.cu[i]! * mon[i]!;
    v += m.cv[i]! * mon[i]!;
  }
  return [u, v];
}

/** Fit a bidirectional warp from matching machine↔image point pairs. */
export function estimatePolyWarp(machine: Vec2[], image: Vec2[], degree = 2): PolyWarp {
  return {
    degree,
    fwd: fitPolyMap(machine, image, degree),
    inv: fitPolyMap(image, machine, degree),
  };
}

/** machine-mm → normalized image. */
export function applyPolyWarp(w: PolyWarp, machine: Vec2): Vec2 {
  return applyPolyMap(w.fwd, machine);
}

/** normalized image → machine-mm. */
export function applyPolyWarpInverse(w: PolyWarp, image: Vec2): Vec2 {
  return applyPolyMap(w.inv, image);
}
  • Step 5: Run test to verify it passes

Run: npx vitest run src/geometry/polywarp.test.ts Expected: PASS (4 tests). homography.ts still exists, so the test's homography import resolves.

  • Step 6: Commit
git add src/types.ts src/geometry/polywarp.ts src/geometry/polywarp.test.ts
git commit -m "feat: bivariate polynomial warp model"

Task 3: Migrate the transform layer (transform.ts)

Switch projectSegments and imageToMachine from homography to PolyWarp.

Files:

  • Modify: src/geometry/transform.ts

  • Test: src/geometry/transform.test.ts

  • Step 1: Update the test

Replace the entire contents of src/geometry/transform.test.ts:

import { describe, it, expect } from 'vitest';
import { applyAlignment, projectSegments, imageToMachine, alignmentFromOrigin } from './transform';
import { estimatePolyWarp } from './polywarp';
import type { Vec2, Segment, Alignment, PolyWarp } 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;

// Identity warp: image == machine. A degree-1 fit on a non-degenerate quad is exact.
const square: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
const IDENT: PolyWarp = estimatePolyWarp(square, square, 1);

describe('transform', () => {
  it('applyAlignment rotates then translates', () => {
    const a: Alignment = { tx: 5, ty: 1, rot: Math.PI / 2 };
    expect(close(applyAlignment(a, [1, 0]), [5, 2])).toBe(true);
  });

  it('projectSegments applies alignment then the warp 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 inverts the warp', () => {
    // Warp scales machine→image by 0.5 (offset 0): machine (4,8) → image (2,4).
    const m: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
    const img: Vec2[] = m.map((p): Vec2 => [p[0] * 0.5, p[1] * 0.5]);
    const w = estimatePolyWarp(m, img, 1);
    expect(close(imageToMachine(w, [2, 4]), [4, 8], 1e-4)).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 — projectSegments/imageToMachine still expect a Mat3; imageToMachine call signature mismatch produces wrong results or runtime error.

  • Step 3: Update the implementation

Replace the entire contents of src/geometry/transform.ts:

import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
import { applyPolyWarp, applyPolyWarpInverse } from './polywarp';

/** 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: work → (alignment) → machine → (warp) → image. */
export function projectSegments(segments: Segment[], a: Alignment, warp: PolyWarp): Segment[] {
  return segments.map((seg) => ({
    kind: seg.kind,
    points: seg.points.map((p) => applyPolyWarp(warp, applyAlignment(a, p))),
  }));
}

/** Convert a normalized image point to machine mm using the warp's inverse map. */
export function imageToMachine(warp: PolyWarp, imagePoint: Vec2): Vec2 {
  return applyPolyWarpInverse(warp, 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 "refactor: transform layer uses PolyWarp"

Task 4: Migrate app state (state.ts)

Rename homography/setHomography to warp/setWarp.

Files:

  • Modify: src/app/state.ts

  • Test: src/app/state.test.ts

  • Step 1: Update the test

Replace the entire contents of src/app/state.test.ts:

import { describe, it, expect } from 'vitest';
import { createState } from './state';
import { estimatePolyWarp } from '../geometry/polywarp';
import type { Vec2 } from '../types';

// Identity warp: image == machine.
const square: Vec2[] = [[0, 0], [10, 0], [10, 10], [0, 10]];
const IDENT = estimatePolyWarp(square, square, 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.setWarp(IDENT);
    s.loadGcode('G21 G90\nG1 X10 Y0');
    s.alignment.tx = 2;
    const proj = s.projected();
    expect(proj[0]!.points[0]![0]).toBeCloseTo(2, 4);
    expect(proj[0]!.points[1]![0]).toBeCloseTo(12, 4);
  });

  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 — s.setWarp is not a function.

  • Step 3: Update the implementation

Replace the entire contents of src/app/state.ts:

import type { Segment, Alignment, PolyWarp } from '../types';
import { parseGcode } from '../gcode/parser';
import { projectSegments } from '../geometry/transform';

export interface AppState {
  segments: Segment[];
  alignment: Alignment;
  warp: PolyWarp | null;
  loadGcode(text: string): string[];
  setWarp(w: PolyWarp | null): void;
  projected(): Segment[];
}

export function createState(): AppState {
  const state: AppState = {
    segments: [],
    alignment: { tx: 0, ty: 0, rot: 0 },
    warp: null,
    loadGcode(text: string): string[] {
      const { segments, warnings } = parseGcode(text);
      state.segments = segments;
      return warnings;
    },
    setWarp(w: PolyWarp | null): void {
      state.warp = w;
    },
    projected(): Segment[] {
      if (!state.warp) return [];
      return projectSegments(state.segments, state.alignment, state.warp);
    },
  };
  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: Commit
git add src/app/state.ts src/app/state.test.ts
git commit -m "refactor: app state stores PolyWarp"

Task 5: Migrate config load/validate (config.ts)

Validate a warp object instead of a 9-element homography.

Files:

  • Modify: src/config.ts, src/types.ts (Calibration field)

  • Test: src/config.test.ts

  • Step 1: Update the Calibration type

In src/types.ts, change the Calibration interface (it currently ends with homography: Mat3;):

export interface Calibration {
  /** Calibration points in normalized [0,1] camera-frame coordinates. */
  imagePoints: Vec2[];
  /** Corresponding machine coordinates, mm. */
  machinePoints: Vec2[];
  /** Bidirectional polynomial warp (machine-mm ↔ normalized image). */
  warp: PolyWarp;
}
  • Step 2: Update the test

Replace the two homography-specific test cases in src/config.test.ts. Replace the 'keeps a valid calibration' test (lines ~22-31), the 'drops a malformed calibration to null' test (lines ~33-37), and the 'rejects a calibration whose homography contains NaN' test (lines ~53-62) with:

  it('keeps a valid calibration', async () => {
    const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][];
    const warp = estimatePolyWarp(square, square, 1);
    const calibration = { imagePoints: square, machinePoints: square, warp };
    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: { warp: { degree: 2 } } });
    const cfg = await loadConfig('config.json');
    expect(cfg.calibration).toBeNull();
  });

  it('rejects a calibration whose warp coefficients contain NaN', async () => {
    const square = [[0, 0], [10, 0], [10, 10], [0, 10]] as [number, number][];
    const warp = estimatePolyWarp(square, square, 1);
    warp.fwd.cu[0] = NaN;
    const calibration = { imagePoints: square, machinePoints: square, warp };
    mockFetch({ streamUrl: 'x', calibration });
    const cfg = await loadConfig('config.json');
    expect(cfg.calibration).toBeNull();
  });

Add this import near the top of src/config.test.ts (after the existing imports):

import { estimatePolyWarp } from './geometry/polywarp';
  • Step 3: Run test to verify it fails

Run: npx vitest run src/config.test.ts Expected: FAIL — config.ts still validates homography, so the valid-warp calibration is dropped to null.

  • Step 4: Update the implementation

In src/config.ts, update the import line and replace parseCalibration. First change the type import:

import type { AppConfig, Calibration, PolyMap, PolyWarp, Vec2, RenderStyle } from './types';

Then replace the parseCalibration function (lines ~13-25) with:

function isNumArray(v: unknown, len: number): v is number[] {
  return Array.isArray(v) && v.length === len && v.every(isFiniteNumber);
}

function isVec2(v: unknown): v is Vec2 {
  return Array.isArray(v) && v.length === 2 && v.every(isFiniteNumber);
}

function termCount(degree: number): number {
  return ((degree + 1) * (degree + 2)) / 2;
}

function parsePolyMap(m: unknown, degree: number): PolyMap | null {
  if (!m || typeof m !== 'object') return null;
  const o = m as Record<string, unknown>;
  const norm = o.norm as Record<string, unknown> | undefined;
  if (!norm || !isVec2(norm.off) || !isVec2(norm.scl)) return null;
  const n = termCount(degree);
  if (!isNumArray(o.cu, n) || !isNumArray(o.cv, n)) return null;
  return { degree, norm: { off: norm.off as Vec2, scl: norm.scl as Vec2 }, cu: o.cu as number[], cv: o.cv as number[] };
}

function parseWarp(w: unknown): PolyWarp | null {
  if (!w || typeof w !== 'object') return null;
  const o = w as Record<string, unknown>;
  if (!isFiniteNumber(o.degree)) return null;
  const fwd = parsePolyMap(o.fwd, o.degree);
  const inv = parsePolyMap(o.inv, o.degree);
  if (!fwd || !inv) return null;
  return { degree: o.degree, fwd, inv };
}

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 (obj.imagePoints.length !== obj.machinePoints.length || obj.imagePoints.length < 4) return null;
  const warp = parseWarp(obj.warp);
  if (!warp) return null;
  return { imagePoints: obj.imagePoints, machinePoints: obj.machinePoints, warp };
}
  • Step 5: Run test to verify it passes

Run: npx vitest run src/config.test.ts Expected: PASS (all cases).

  • Step 6: Commit
git add src/config.ts src/config.test.ts src/types.ts
git commit -m "refactor: config validates PolyWarp calibration"

Task 6: Migrate the calibration UI (calibration-ui.ts)

Compute a PolyWarp (with a degree selector), update the residual readout and JSON output.

Files:

  • Modify: src/app/calibration-ui.ts

  • Test: src/app/calibration-ui.test.ts

  • Step 1: Update the test

Replace the entire contents of src/app/calibration-ui.test.ts:

import { describe, it, expect } from 'vitest';
import { residuals } from './calibration-ui';
import { estimatePolyWarp } from '../geometry/polywarp';
import type { Vec2 } from '../types';

describe('calibration residuals', () => {
  it('reports near-zero error for a clean degree-1 fit', () => {
    const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100]];
    const image: Vec2[] = machine.map((p): Vec2 => [p[0] * 0.5 + 5, p[1] * 0.5 + 5]);
    const w = estimatePolyWarp(machine, image, 1);
    const errs = residuals(w, machine, image);
    expect(Math.max(...errs)).toBeLessThan(1e-4);
  });

  it('flags a mismatched point with a large residual', () => {
    const machine: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50], [25, 75], [75, 25]];
    const image: Vec2[] = [[0, 0], [50, 0], [50, 50], [0, 50], [25, 25], [12.5, 37.5], [90, 5]]; // last is wrong
    const w = estimatePolyWarp(machine, image, 1);
    const errs = residuals(w, machine, image);
    expect(errs[6]!).toBeGreaterThan(5);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/app/calibration-ui.test.ts Expected: FAIL — residuals still takes a Mat3 and calls applyHomography.

  • Step 3: Update the implementation

Replace the entire contents of src/app/calibration-ui.ts:

import type { Vec2, PolyWarp } from '../types';
import { estimatePolyWarp, applyPolyWarp } from '../geometry/polywarp';

/** Per-point reprojection error in normalized units: |warp(machine)  image|. */
export function residuals(warp: PolyWarp, machine: Vec2[], image: Vec2[]): number[] {
  if (machine.length !== image.length) throw new Error('residuals: machine/image length mismatch');
  return machine.map((m, i) => {
    const p = applyPolyWarp(warp, 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;
  onWarp: (w: PolyWarp, points: CalibPoint[]) => void;
}

/**
 * Renders the calibration panel. Flow: enter machine X/Y → click "Click point in video" →
 * click the spindle tip in the video → repeat ≥10× → choose degree → "Compute".
 *
 * Image points and the resulting warp are in normalized [0,1] camera-frame coordinates
 * (machine-mm ↔ normalized image), so they are display-size independent.
 */
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. ≥10 points (degree 3); spread them out incl. corners.</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">
      Degree <select id="cal-degree"><option value="2">2</option><option value="3" selected>3</option></select>
      <button id="cal-compute">Compute warp</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 degreeEl = panel.querySelector('#cal-degree') as HTMLSelectElement;
  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]}) → img(${p.image[0].toFixed(3)}, ${p.image[1].toFixed(3)})${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();
    const px: Vec2 = [
      (ev.clientX - rect.left) / rect.width,
      (ev.clientY - rect.top) / rect.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', () => {
    const degree = parseInt(degreeEl.value, 10);
    const need = ((degree + 1) * (degree + 2)) / 2;
    if (points.length < need) {
      jsonEl.value = `Need at least ${need} points for degree ${degree}.`;
      return;
    }
    const machine = points.map((p) => p.machine);
    const image = points.map((p) => p.image);
    let warp: PolyWarp;
    try {
      warp = estimatePolyWarp(machine, image, degree);
    } catch (e) {
      jsonEl.value = `Fit failed: ${(e as Error).message}`;
      return;
    }
    // Per-point pixel residual (normalized error scaled by canvas size) — for misclick display.
    const errsPx = machine.map((m, i) => {
      const p = applyPolyWarp(warp, m);
      return Math.hypot((p[0] - image[i]![0]) * overlay.width, (p[1] - image[i]![1]) * overlay.height);
    });
    renderList(errsPx);
    jsonEl.value = JSON.stringify({ imagePoints: image, machinePoints: machine, warp }, null, 2);
    deps.onWarp(warp, [...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: Commit
git add src/app/calibration-ui.ts src/app/calibration-ui.test.ts
git commit -m "feat: calibration UI computes PolyWarp with degree selector"

Task 7: Migrate alignment UI + main wiring

Update the two remaining consumers of the old API.

Files:

  • Modify: src/app/alignment-ui.ts, src/main.ts

  • Test: src/app/overlay-interaction.test.ts (read first; update any getHomography/setHomography/Mat3 references)

  • Step 1: Inspect the interaction test for old-API references

Run: grep -n "Homography\|homography\|Mat3\|setWarp\|getWarp" src/app/overlay-interaction.test.ts

For each match, update it to the new API: getHomography dep → getWarp, Mat3 identity → a PolyWarp built with estimatePolyWarp(square, square, 1) (import from ../geometry/polywarp), where const square: Vec2[] = [[0,0],[10,0],[10,10],[0,10]]. If there are no matches, this test needs no change — proceed.

  • Step 2: Run the interaction test to see the current state

Run: npx vitest run src/app/overlay-interaction.test.ts Expected: After Step 1 edits (if any), it should FAIL only because alignment-ui.ts still exposes getHomography. If it already passes (test didn't reference the renamed dep), that's fine — proceed to Step 3.

  • Step 3: Update alignment-ui.ts

In src/app/alignment-ui.ts, change the import (line 1) and the AlignmentDeps interface, then rename the local H usages. Apply these edits:

Change line 1:

import type { Vec2, PolyWarp, Alignment } from '../types';

In AlignmentDeps, replace getHomography: () => Mat3 | null; with:

  getWarp: () => PolyWarp | null;

Replace every const H = deps.getHomography(); with const warp = deps.getWarp();, every if (!H) / if (!H || ...) guard's H with warp, and every imageToMachine(H, ...) with imageToMachine(warp, ...). There are three getHomography call sites (mousedown, mousemove, plus the guard in mousemove) — update all. imageToMachine itself is already PolyWarp-typed from Task 3, so only the variable name and dep call change.

  • Step 4: Update main.ts

In src/main.ts, apply these edits:

Replace the mountCalibration({...}) call (lines ~51-59):

mountCalibration({
  panel: document.getElementById('calib-panel') as HTMLElement,
  overlay,
  onWarp: (w) => {
    state.setWarp(w);
    statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
    render();
  },
});

In the mountAlignment({...}) call, replace getHomography: () => state.homography, with:

  getWarp: () => state.warp,

In boot(), replace if (cfg.calibration) state.setHomography(cfg.calibration.homography); with:

    if (cfg.calibration) state.setWarp(cfg.calibration.warp);
  • Step 5: Run the interaction test to verify it passes

Run: npx vitest run src/app/overlay-interaction.test.ts Expected: PASS.

  • Step 6: Commit
git add src/app/alignment-ui.ts src/main.ts src/app/overlay-interaction.test.ts
git commit -m "refactor: alignment UI and main wiring use PolyWarp"

Task 8: Retire the homography module and verify the whole build

Delete the now-unused homography code and confirm the project type-checks and all tests pass.

Files:

  • Delete: src/geometry/homography.ts, src/geometry/homography.test.ts

  • Step 1: Confirm there are no remaining importers

Run: grep -rn "from './homography'\|from '../geometry/homography'\|geometry/homography" src/ Expected: only matches inside homography.ts/homography.test.ts themselves (which we delete) and possibly the polywarp.test.ts import — see Step 2.

  • Step 2: Drop the homography import from the polywarp test

src/geometry/polywarp.test.ts (Task 2) imports estimateHomography, applyHomography for the "beats a homography" test. Since homography.ts is being deleted, inline a minimal homography in that test instead. At the top of polywarp.test.ts, remove the line:

import { estimateHomography, applyHomography } from './homography';

and replace the 'beats a homography on barrel-distorted data' test body's homography use with a self-contained least-squares affine baseline (an affine map is the best a no-distortion linear model can do, and like a homography it cannot represent barrel curvature — the comparison still demonstrates the polynomial's advantage). Replace that whole test with:

  it('beats an affine/linear baseline on barrel-distorted data', () => {
    // Barrel distortion is degree-3 in machine coords, so degree 3 fits it essentially
    // exactly while a degree-1 (affine, no curvature) baseline cannot.
    const image = machineGrid.map(trueMap);
    const w = estimatePolyWarp(machineGrid, image, 3);
    const baseline = estimatePolyWarp(machineGrid, image, 1); // degree-1 = affine, no curvature
    const test: Vec2[] = [[488, 244], [1464, 732], [1952, 488], [976, 976]];
    const polyMax = Math.max(...test.map((m) => dist(applyPolyWarp(w, m), trueMap(m))));
    const baseMax = Math.max(...test.map((m) => dist(applyPolyWarp(baseline, m), trueMap(m))));
    expect(polyMax).toBeLessThan(baseMax * 0.05);
  });
  • Step 3: Delete the homography files
git rm src/geometry/homography.ts src/geometry/homography.test.ts
  • Step 4: Run the full test suite

Run: npm test Expected: PASS — all suites green, no reference to the deleted module.

  • Step 5: Type-check + build (the cross-file gate)

Run: npm run build Expected: tsc --noEmit passes with no errors, Vite build succeeds. If tsc flags an unused Mat3 import anywhere, remove that import. (The Mat3 type may remain defined in types.ts unused — that is not a compile error.)

  • Step 6: Commit
git add -A
git commit -m "refactor: retire homography model in favour of PolyWarp"

Task 9: Regenerate the default calibration

The committed dist/config.json still holds the old homography, which parseCalibration now rejects (→ "Not calibrated"). This is expected per the spec's migration note. The real recalibration happens on the machine with the box+# target, but verify the app boots cleanly without a valid calibration.

Files:

  • Inspect: dist/config.json, public/config.json (if present)

  • Step 1: Check which config files carry a homography

Run: grep -rln "homography" dist public 2>/dev/null

  • Step 2: Confirm graceful handling

The app already shows "Not calibrated — calibrate first." when cfg.calibration is null (main.ts boot). No code change needed — the stale homography config simply parses to null. Leave the file as-is OR, to avoid a confusing dead field, remove the homography/calibration block from dist/config.json and public/config.json so the file is clean. Do whichever the repo convention prefers; if unsure, leave it (harmless).

  • Step 3: Capture the real calibration on the machine (manual, runtime)

This is the on-hardware step, not a code change:

  1. Draw the box + "#" target (lines at thirds) by jog-and-mark: jog the spindle to each of the 16 targets (every combination of X ∈ {0, 813.3, 1626.7, 2440}, Y ∈ {0, 406.7, 813.3, 1220}) and mark at the tip.
  2. In the app: for each mark, enter its X/Y, click "Click point in video", click the mark. Repeat for all 16.
  3. Leave degree at 2, click "Compute warp". Check the per-point err …px readout — if max error is large, switch degree to 3 and recompute.
  4. Copy the JSON from the textarea into dist/config.json under calibration, redeploy per memory/deployment-mf01.md.
  • Step 4: Commit any config cleanup
git add dist/config.json public/config.json 2>/dev/null
git commit -m "chore: clear stale homography calibration from config" || echo "nothing to commit"

Self-review notes

  • Spec coverage: model (T2), forward+inverse maps (T2/T3), normalization/conditioning (T2), linalg extraction (T1), transform/state/config/calib/alignment/main migration (T3T7), homography retirement (T8), 16-point box+# target & jog-and-mark + degree escalation (T6 UI + T9 procedure), migration of stale config (T9), tests incl. synthetic-barrel regression guard (T2). All spec sections map to a task.
  • Inverse independence caveat (from spec/user review) is realized by fitting inv separately in estimatePolyWarp; the round-trip test uses a 2 mm tolerance accordingly rather than asserting exact inversion.
  • Type consistency: PolyWarp/PolyMap shapes (degree, norm.off, norm.scl, cu, cv) are identical across types.ts, polywarp.ts, and config.ts validation. Renames are consistent: setHomographysetWarp, getHomographygetWarp, onHomographyonWarp, homographywarp.