refactor: alignment UI and main wiring use PolyWarp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-11 09:32:54 +02:00
parent 103e96f097
commit 7614590b03
3 changed files with 22 additions and 19 deletions

View file

@ -1,11 +1,11 @@
import type { Vec2, Mat3, Alignment } from '../types'; import type { Vec2, PolyWarp, Alignment } from '../types';
import { imageToMachine, alignmentFromOrigin } from '../geometry/transform'; import { imageToMachine, alignmentFromOrigin } from '../geometry/transform';
export interface AlignmentDeps { export interface AlignmentDeps {
panel: HTMLElement; panel: HTMLElement;
overlay: HTMLCanvasElement; overlay: HTMLCanvasElement;
getAlignment: () => Alignment; getAlignment: () => Alignment;
getHomography: () => Mat3 | null; getWarp: () => PolyWarp | null;
onChange: () => void; onChange: () => void;
} }
@ -61,13 +61,13 @@ export function mountAlignment(deps: AlignmentDeps): void {
let lastMachine: Vec2 | null = null; let lastMachine: Vec2 | null = null;
overlay.addEventListener('mousedown', (ev) => { overlay.addEventListener('mousedown', (ev) => {
const H = deps.getHomography(); const warp = deps.getWarp();
if (!H) return; if (!warp) return;
// Another module (e.g. calibration) has armed the overlay for this click; don't hijack it. // Another module (e.g. calibration) has armed the overlay for this click; don't hijack it.
if (!armOrigin && overlay.classList.contains('interactive')) return; if (!armOrigin && overlay.classList.contains('interactive')) return;
if (armOrigin) { if (armOrigin) {
const a = deps.getAlignment(); const a = deps.getAlignment();
const m = imageToMachine(H, normFromEvent(overlay, ev)); const m = imageToMachine(warp, normFromEvent(overlay, ev));
Object.assign(a, alignmentFromOrigin(m, a.rot)); Object.assign(a, alignmentFromOrigin(m, a.rot));
armOrigin = false; armOrigin = false;
overlay.classList.remove('interactive'); overlay.classList.remove('interactive');
@ -77,15 +77,15 @@ export function mountAlignment(deps: AlignmentDeps): void {
} }
dragging = true; dragging = true;
overlay.classList.add('interactive'); overlay.classList.add('interactive');
lastMachine = imageToMachine(H, normFromEvent(overlay, ev)); lastMachine = imageToMachine(warp, normFromEvent(overlay, ev));
}); });
window.addEventListener('mousemove', (ev) => { window.addEventListener('mousemove', (ev) => {
if (!dragging) return; if (!dragging) return;
const H = deps.getHomography(); const warp = deps.getWarp();
if (!H || !lastMachine) return; if (!warp || !lastMachine) return;
const a = deps.getAlignment(); const a = deps.getAlignment();
const cur = imageToMachine(H, normFromEvent(overlay, ev)); const cur = imageToMachine(warp, normFromEvent(overlay, ev));
if (ev.shiftKey) { if (ev.shiftKey) {
// rotate about the work origin's current machine position (tx,ty) // 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); const angle = (p: Vec2) => Math.atan2(p[1] - a.ty, p[0] - a.tx);

View file

@ -2,11 +2,14 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { mountAlignment } from './alignment-ui'; import { mountAlignment } from './alignment-ui';
import { mountCalibration } from './calibration-ui'; import { mountCalibration } from './calibration-ui';
import type { Mat3, Alignment } from '../types'; import type { Vec2, PolyWarp, Alignment } from '../types';
import { estimatePolyWarp } from '../geometry/polywarp';
// Identity homography: machine coords == normalized coords for this test, so a // Identity warp: machine coords == normalized coords for this test, so a
// normalized click at fraction f over the 100px box maps to machine value f. // normalized click at fraction f over the 100px box maps to machine value f.
const IDENT: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // We use degree 1 fitted on a unit square so the map is a near-perfect identity.
const square: Vec2[] = [[0, 0], [1, 0], [1, 1], [0, 1]];
const IDENT: PolyWarp = estimatePolyWarp(square, square, 1);
function makeOverlay(): HTMLCanvasElement { function makeOverlay(): HTMLCanvasElement {
const c = document.createElement('canvas'); const c = document.createElement('canvas');
@ -39,7 +42,7 @@ describe('overlay interaction', () => {
panel, panel,
overlay, overlay,
getAlignment: () => alignment, getAlignment: () => alignment,
getHomography: () => IDENT, getWarp: () => IDENT,
onChange: () => {}, onChange: () => {},
}); });
overlay.dispatchEvent(mouse('mousedown', 50, 50)); overlay.dispatchEvent(mouse('mousedown', 50, 50));
@ -53,12 +56,12 @@ describe('overlay interaction', () => {
it('while calibration is armed, a click is captured by calibration and does NOT drag the alignment', () => { it('while calibration is armed, a click is captured by calibration and does NOT drag the alignment', () => {
const calibPanel = document.createElement('div'); const calibPanel = document.createElement('div');
const alignPanel = document.createElement('div'); const alignPanel = document.createElement('div');
mountCalibration({ panel: calibPanel, overlay, onHomography: () => {} }); mountCalibration({ panel: calibPanel, overlay, onWarp: () => {} });
mountAlignment({ mountAlignment({
panel: alignPanel, panel: alignPanel,
overlay, overlay,
getAlignment: () => alignment, getAlignment: () => alignment,
getHomography: () => IDENT, getWarp: () => IDENT,
onChange: () => {}, onChange: () => {},
}); });
(calibPanel.querySelector('#cal-x') as HTMLInputElement).value = '10'; (calibPanel.querySelector('#cal-x') as HTMLInputElement).value = '10';

View file

@ -51,8 +51,8 @@ fileInput.addEventListener('change', async () => {
mountCalibration({ mountCalibration({
panel: document.getElementById('calib-panel') as HTMLElement, panel: document.getElementById('calib-panel') as HTMLElement,
overlay, overlay,
onHomography: (H) => { onWarp: (w) => {
state.setHomography(H); state.setWarp(w);
statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).'; statusEl.textContent = 'Calibrated (unsaved — paste JSON into config.json to persist).';
render(); render();
}, },
@ -62,7 +62,7 @@ mountAlignment({
panel: document.getElementById('align-panel') as HTMLElement, panel: document.getElementById('align-panel') as HTMLElement,
overlay, overlay,
getAlignment: () => state.alignment, getAlignment: () => state.alignment,
getHomography: () => state.homography, getWarp: () => state.warp,
onChange: render, onChange: render,
}); });
@ -71,7 +71,7 @@ async function boot(): Promise<void> {
const cfg = await loadConfig('config.json'); const cfg = await loadConfig('config.json');
style = cfg.renderDefaults; style = cfg.renderDefaults;
if (cfg.streamUrl) stream.src = cfg.streamUrl; if (cfg.streamUrl) stream.src = cfg.streamUrl;
if (cfg.calibration) state.setHomography(cfg.calibration.homography); if (cfg.calibration) state.setWarp(cfg.calibration.warp);
statusEl.textContent = cfg.calibration ? 'Ready. Open a G-code file.' : 'Not calibrated — calibrate first.'; statusEl.textContent = cfg.calibration ? 'Ready. Open a G-code file.' : 'Not calibrated — calibrate first.';
} catch (e) { } catch (e) {
statusEl.textContent = `Config error: ${(e as Error).message}`; statusEl.textContent = `Config error: ${(e as Error).message}`;