diff --git a/src/app/alignment-ui.ts b/src/app/alignment-ui.ts
new file mode 100644
index 0000000..e891ce2
--- /dev/null
+++ b/src/app/alignment-ui.ts
@@ -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 = `
+
Alignment
+ X Y
+ Rotation°
+
+ Or drag the toolpath to move; Shift-drag to rotate. Arrow keys nudge 1 mm.
+ `;
+
+ 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();
+ });
+}
diff --git a/src/main.ts b/src/main.ts
index 1fd429e..36f6f2c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,6 +4,7 @@ import { createState } from './app/state';
import { computeOverlayRect, syncOverlay } from './app/layout';
import { drawOverlay, clearCanvas } from './render/renderer';
import { mountCalibration } from './app/calibration-ui';
+import { mountAlignment } from './app/alignment-ui';
import type { RenderStyle } from './types';
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 {
try {
const cfg = await loadConfig('config.json');