1041 lines
39 KiB
Markdown
1041 lines
39 KiB
Markdown
# 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`:
|
||
|
||
```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', () => {
|
||
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`:
|
||
|
||
```typescript
|
||
/** 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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```typescript
|
||
/** 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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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;`):
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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):
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
import type { AppConfig, Calibration, PolyMap, PolyWarp, Vec2, RenderStyle } from './types';
|
||
```
|
||
|
||
Then replace the `parseCalibration` function (lines ~13-25) with:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
```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: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```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);
|
||
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(baseMax).toBeGreaterThan(1e-3); // affine baseline genuinely fails on barrel data
|
||
expect(polyMax).toBeLessThan(1e-6); // degree-3 fit of cubic data is near-exact everywhere
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Delete the homography files**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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.
|
||
|
||
**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**
|
||
|
||
```bash
|
||
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 (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`.
|