# 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.
- **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`:
```typescript
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', () => {
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:
* 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>
<divclass="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>
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:
```typescript
import type { Vec2, PolyWarp, Alignment } from '../types';
```
In `AlignmentDeps`, replace `getHomography: () => Mat3 | null;` with:
```typescript
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):
```typescript
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:
```typescript
getWarp: () => state.warp,
```
In `boot()`, replace `if (cfg.calibration) state.setHomography(cfg.calibration.homography);` with:
```typescript
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: 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:
```typescript
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:
```typescript
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);
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**
```bash
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.
- [ ]**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`.
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 (T3–T7), 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: `setHomography`→`setWarp`, `getHomography`→`getWarp`, `onHomography`→`onWarp`, `homography`→`warp`.