diff --git a/src/geometry/transform.test.ts b/src/geometry/transform.test.ts index 1617c98..8b75e6a 100644 --- a/src/geometry/transform.test.ts +++ b/src/geometry/transform.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { applyAlignment, projectSegments, imageToMachine, alignmentFromOrigin } from './transform'; +import { applyAlignment, projectSegments, imageToMachine, alignmentFromOrigin, subdivideSegment } from './transform'; import { estimatePolyWarp } from './polywarp'; import type { Vec2, Segment, Alignment, PolyWarp } from '../types'; @@ -37,4 +37,36 @@ describe('transform', () => { const a = alignmentFromOrigin([7, 9], 0); expect(close(applyAlignment(a, [0, 0]), [7, 9])).toBe(true); }); + + it('subdivideSegment splits a long edge into <=stepMm pieces, preserving endpoints', () => { + const seg: Segment = { kind: 'cut', points: [[0, 0], [100, 0]] }; + const out = subdivideSegment(seg, 25); + expect(out.points.length).toBe(5); // 0,25,50,75,100 + expect(close(out.points[0]!, [0, 0])).toBe(true); + expect(close(out.points[4]!, [100, 0])).toBe(true); + expect(close(out.points[2]!, [50, 0])).toBe(true); + expect(out.kind).toBe('cut'); + }); + + it('subdivideSegment leaves a short edge as just its endpoints', () => { + const out = subdivideSegment({ kind: 'cut', points: [[0, 0], [5, 0]] }, 25); + expect(out.points.length).toBe(2); + }); + + it('projectSegments subdivides straight moves so they follow the warp curvature', () => { + // A warp where straight machine lines bow in image-y: image = (x/100, y/100 + 0.3·s·(1−s)), s=x/100. + const grid: Vec2[] = [[0, 0], [100, 0], [100, 100], [0, 100], [50, 50], [50, 0], [0, 50]]; + const curved = (p: Vec2): Vec2 => { + const s = p[0] / 100; + return [s, p[1] / 100 + 0.3 * s * (1 - s)]; + }; + const warp = estimatePolyWarp(grid, grid.map(curved), 2); + const a: Alignment = { tx: 0, ty: 0, rot: 0 }; + const out = projectSegments([{ kind: 'cut', points: [[0, 0], [100, 0]] }], a, warp); + // The single straight move must become a multi-vertex polyline... + expect(out[0]!.points.length).toBeGreaterThan(2); + // ...whose interior bows well away from the straight chord (endpoints both have y≈0). + const maxY = Math.max(...out[0]!.points.map((p) => Math.abs(p[1]))); + expect(maxY).toBeGreaterThan(0.05); + }); }); diff --git a/src/geometry/transform.ts b/src/geometry/transform.ts index ea73451..21c8e37 100644 --- a/src/geometry/transform.ts +++ b/src/geometry/transform.ts @@ -1,6 +1,15 @@ import type { Vec2, Segment, Alignment, PolyWarp } from '../types'; import { applyPolyWarp, applyPolyWarpInverse } from './polywarp'; +/** + * Max length (machine mm) of a sub-edge before the warp is applied. The polynomial warp + * bends a path only at its vertices, so a straight 2-point move would render as a straight + * chord — hiding the lens curvature. Resampling long edges to this step makes the warped + * polyline follow the curve. 20 mm keeps the per-sub-edge deviation well under a pixel even + * on the most strongly-bowed lines, while leaving already-short moves (most toolpaths) untouched. + */ +const SUBDIVIDE_STEP_MM = 20; + /** 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); @@ -8,11 +17,43 @@ export function applyAlignment(a: Alignment, p: Vec2): Vec2 { 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[] { +/** + * Resample a polyline (work mm) so no sub-edge exceeds `stepMm`. Endpoints and existing + * vertices are preserved; edges already shorter than the step are left as-is. This is what + * lets the nonlinear warp curve a straight machine move instead of drawing a chord. + */ +export function subdivideSegment(seg: Segment, stepMm: number = SUBDIVIDE_STEP_MM): Segment { + const src = seg.points; + if (src.length < 2) return seg; + const out: Vec2[] = []; + for (let i = 0; i < src.length - 1; i++) { + const a = src[i]!; + const b = src[i + 1]!; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + const n = Math.max(1, Math.ceil(len / stepMm)); + for (let k = 0; k < n; k++) { + const t = k / n; + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + } + out.push(src[src.length - 1]!); + return { kind: seg.kind, points: out }; +} + +/** + * Project work-coordinate segments to image: work → (subdivide) → (alignment) → machine → + * (warp) → image. Subdivision happens in work space so the warp can bend each straight move + * along the camera's distortion instead of collapsing it to a chord. + */ +export function projectSegments( + segments: Segment[], + a: Alignment, + warp: PolyWarp, + stepMm: number = SUBDIVIDE_STEP_MM, +): Segment[] { return segments.map((seg) => ({ kind: seg.kind, - points: seg.points.map((p) => applyPolyWarp(warp, applyAlignment(a, p))), + points: subdivideSegment(seg, stepMm).points.map((p) => applyPolyWarp(warp, applyAlignment(a, p))), })); }