feat: per-job alignment (numeric, click-origin, drag/rotate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-08 22:47:08 +02:00
parent d1577dd569
commit 5d80273717
2 changed files with 126 additions and 0 deletions

117
src/app/alignment-ui.ts Normal file
View file

@ -0,0 +1,117 @@
import type { Vec2, Mat3, Alignment } from '../types';
import { imageToMachine, alignmentFromOrigin } from '../geometry/transform';
export interface AlignmentDeps {
panel: HTMLElement;
overlay: HTMLCanvasElement;
getAlignment: () => Alignment;
getHomography: () => Mat3 | null;
onChange: () => void;
}
/** Convert a mouse event to normalized [0,1] coords over the overlay canvas box. */
function normFromEvent(overlay: HTMLCanvasElement, ev: MouseEvent): Vec2 {
const rect = overlay.getBoundingClientRect();
return [
(ev.clientX - rect.left) / rect.width,
(ev.clientY - rect.top) / rect.height,
];
}
export function mountAlignment(deps: AlignmentDeps): void {
const { panel, overlay } = deps;
panel.innerHTML = `
<h2>Alignment</h2>
<div class="row">X <input id="al-x" type="number" step="1" /> Y <input id="al-y" type="number" step="1" /></div>
<div class="row">Rotation° <input id="al-rot" type="number" step="0.5" /></div>
<div class="row"><button id="al-origin">Set origin by clicking video</button></div>
<div class="row"><small>Or drag the toolpath to move; Shift-drag to rotate. Arrow keys nudge 1&nbsp;mm.</small></div>
`;
const xEl = panel.querySelector('#al-x') as HTMLInputElement;
const yEl = panel.querySelector('#al-y') as HTMLInputElement;
const rotEl = panel.querySelector('#al-rot') as HTMLInputElement;
const sync = () => {
const a = deps.getAlignment();
xEl.value = a.tx.toFixed(1);
yEl.value = a.ty.toFixed(1);
rotEl.value = ((a.rot * 180) / Math.PI).toFixed(1);
};
sync();
const commitNumeric = () => {
const a = deps.getAlignment();
a.tx = parseFloat(xEl.value) || 0;
a.ty = parseFloat(yEl.value) || 0;
a.rot = ((parseFloat(rotEl.value) || 0) * Math.PI) / 180;
deps.onChange();
};
for (const el of [xEl, yEl, rotEl]) el.addEventListener('change', commitNumeric);
// Click-to-set-origin
let armOrigin = false;
(panel.querySelector('#al-origin') as HTMLButtonElement).addEventListener('click', () => {
armOrigin = true;
overlay.classList.add('interactive');
});
// Drag to move / Shift-drag to rotate
let dragging = false;
let lastMachine: Vec2 | null = null;
overlay.addEventListener('mousedown', (ev) => {
const H = deps.getHomography();
if (!H) return;
if (armOrigin) {
const a = deps.getAlignment();
const m = imageToMachine(H, normFromEvent(overlay, ev));
Object.assign(a, alignmentFromOrigin(m, a.rot));
armOrigin = false;
overlay.classList.remove('interactive');
sync();
deps.onChange();
return;
}
dragging = true;
overlay.classList.add('interactive');
lastMachine = imageToMachine(H, normFromEvent(overlay, ev));
});
window.addEventListener('mousemove', (ev) => {
if (!dragging) return;
const H = deps.getHomography();
if (!H || !lastMachine) return;
const a = deps.getAlignment();
const cur = imageToMachine(H, normFromEvent(overlay, ev));
if (ev.shiftKey) {
// rotate about the work origin's current machine position (tx,ty)
const angle = (p: Vec2) => Math.atan2(p[1] - a.ty, p[0] - a.tx);
a.rot += angle(cur) - angle(lastMachine);
} else {
a.tx += cur[0] - lastMachine[0];
a.ty += cur[1] - lastMachine[1];
}
lastMachine = cur;
sync();
deps.onChange();
});
window.addEventListener('mouseup', () => {
dragging = false;
overlay.classList.remove('interactive');
});
window.addEventListener('keydown', (ev) => {
const a = deps.getAlignment();
const step = 1;
if (ev.key === 'ArrowLeft') a.tx -= step;
else if (ev.key === 'ArrowRight') a.tx += step;
else if (ev.key === 'ArrowUp') a.ty -= step;
else if (ev.key === 'ArrowDown') a.ty += step;
else return;
ev.preventDefault();
sync();
deps.onChange();
});
}

View file

@ -4,6 +4,7 @@ import { createState } from './app/state';
import { computeOverlayRect, syncOverlay } from './app/layout'; import { computeOverlayRect, syncOverlay } from './app/layout';
import { drawOverlay, clearCanvas } from './render/renderer'; import { drawOverlay, clearCanvas } from './render/renderer';
import { mountCalibration } from './app/calibration-ui'; import { mountCalibration } from './app/calibration-ui';
import { mountAlignment } from './app/alignment-ui';
import type { RenderStyle } from './types'; import type { RenderStyle } from './types';
const stage = document.getElementById('stage') as HTMLDivElement; const stage = document.getElementById('stage') as HTMLDivElement;
@ -59,6 +60,14 @@ mountCalibration({
}, },
}); });
mountAlignment({
panel: document.getElementById('align-panel') as HTMLElement,
overlay,
getAlignment: () => state.alignment,
getHomography: () => state.homography,
onChange: render,
});
async function boot(): Promise<void> { async function boot(): Promise<void> {
try { try {
const cfg = await loadConfig('config.json'); const cfg = await loadConfig('config.json');