diff --git a/src/geometry/arc.test.ts b/src/geometry/arc.test.ts new file mode 100644 index 0000000..d5adebe --- /dev/null +++ b/src/geometry/arc.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { flattenArc } from './arc'; + +const onCircle = (p: [number, number], c: [number, number], r: number) => + Math.abs(Math.hypot(p[0] - c[0], p[1] - c[1]) - r) < 1e-6; + +describe('flattenArc', () => { + it('flattens a CCW quarter circle, endpoints exact, all points on the circle', () => { + // centre (0,0), radius 10, from (10,0) CCW to (0,10) + const pts = flattenArc([10, 0], [0, 10], [0, 0], false, 0.1); + expect(pts[0]).toEqual([10, 0]); + expect(pts[pts.length - 1]).toEqual([0, 10]); + expect(pts.length).toBeGreaterThan(2); + for (const p of pts) expect(onCircle(p, [0, 0], 10)).toBe(true); + }); + + it('CW arc sweeps the other way (midpoint has negative y)', () => { + // centre (0,0), radius 10, from (10,0) CW to (0,10) → goes the long way through y<0 + const pts = flattenArc([10, 0], [0, 10], [0, 0], true, 0.1); + const mid = pts[Math.floor(pts.length / 2)]; + expect(mid[1]).toBeLessThan(0); + }); + + it('treats start==end as a full circle', () => { + const pts = flattenArc([10, 0], [10, 0], [0, 0], false, 0.5); + // a point roughly opposite the start should exist + expect(pts.some((p) => p[0] < -9)).toBe(true); + }); + + it('respects chord tolerance (tighter tolerance → more points)', () => { + const coarse = flattenArc([10, 0], [0, 10], [0, 0], false, 1.0); + const fine = flattenArc([10, 0], [0, 10], [0, 0], false, 0.01); + expect(fine.length).toBeGreaterThan(coarse.length); + }); +}); diff --git a/src/geometry/arc.ts b/src/geometry/arc.ts new file mode 100644 index 0000000..326eb15 --- /dev/null +++ b/src/geometry/arc.ts @@ -0,0 +1,41 @@ +import type { Vec2 } from '../types'; + +/** + * Flatten a circular arc into a polyline. + * @param start arc start point (mm) + * @param end arc end point (mm) + * @param center arc centre (mm) + * @param clockwise true for G2, false for G3 (in a right-handed XY frame, Y up) + * @param tolerance max chord deviation (mm) + * @returns points including both start and end + */ +export function flattenArc( + start: Vec2, + end: Vec2, + center: Vec2, + clockwise: boolean, + tolerance = 0.2, +): Vec2[] { + const radius = Math.hypot(start[0] - center[0], start[1] - center[1]); + const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]); + const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]); + + let sweep = endAngle - startAngle; + if (clockwise) { + while (sweep >= 0) sweep -= 2 * Math.PI; // sweep in [-2π, 0) + } else { + while (sweep <= 0) sweep += 2 * Math.PI; // sweep in (0, 2π] + } + + const maxStep = radius > tolerance ? 2 * Math.acos(1 - tolerance / radius) : Math.PI / 8; + const steps = Math.max(1, Math.ceil(Math.abs(sweep) / maxStep)); + + const pts: Vec2[] = []; + for (let i = 0; i <= steps; i++) { + const a = startAngle + (sweep * i) / steps; + pts.push([center[0] + radius * Math.cos(a), center[1] + radius * Math.sin(a)]); + } + pts[0] = [start[0], start[1]]; + pts[pts.length - 1] = [end[0], end[1]]; // pin endpoints exactly + return pts; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5224bca --- /dev/null +++ b/src/types.ts @@ -0,0 +1,40 @@ +export type Vec2 = [number, number]; + +/** Row-major 3×3 matrix. */ +export type Mat3 = [number, number, number, number, number, number, number, number, number]; + +export type MoveKind = 'cut' | 'rapid'; + +/** A polyline in millimetres (machine coordinates before per-job alignment). */ +export interface Segment { + kind: MoveKind; + points: Vec2[]; +} + +/** Per-job placement: rotate the work coordinates by `rot`, then translate by (tx,ty). mm / radians. */ +export interface Alignment { + tx: number; + ty: number; + rot: number; +} + +export interface Calibration { + /** Clicked points in the camera image, pixels. */ + imagePoints: Vec2[]; + /** Corresponding machine coordinates, mm. */ + machinePoints: Vec2[]; + /** machine-mm → image-px. */ + homography: Mat3; +} + +export interface RenderStyle { + cutColor: string; + rapidColor: string; + lineWidth: number; +} + +export interface AppConfig { + streamUrl: string; + calibration: Calibration | null; + renderDefaults: RenderStyle; +}