fix: subdivide straight moves before warping so the overlay follows lens curvature

A homography-era assumption survived the migration: projectSegments warped only
segment endpoints. The polynomial warp bends a path at its vertices, so a straight
G0/G1 move (2 points) rendered as a straight chord, hiding all distortion correction
(measured ~320px of bow collapsed to nothing on bed-spanning lines). Resample edges to
20mm in work space before warping; arcs already had dense vertices so are unaffected.
This commit is contained in:
sjat 2026-06-11 11:35:29 +02:00
parent 669815f64d
commit 8b7562786c
2 changed files with 77 additions and 4 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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 { estimatePolyWarp } from './polywarp';
import type { Vec2, Segment, Alignment, PolyWarp } from '../types'; import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
@ -37,4 +37,36 @@ describe('transform', () => {
const a = alignmentFromOrigin([7, 9], 0); const a = alignmentFromOrigin([7, 9], 0);
expect(close(applyAlignment(a, [0, 0]), [7, 9])).toBe(true); 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·(1s)), 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);
});
}); });

View file

@ -1,6 +1,15 @@
import type { Vec2, Segment, Alignment, PolyWarp } from '../types'; import type { Vec2, Segment, Alignment, PolyWarp } from '../types';
import { applyPolyWarp, applyPolyWarpInverse } from './polywarp'; 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). */ /** Apply per-job alignment to a point: rotate by rot, then translate by (tx,ty). */
export function applyAlignment(a: Alignment, p: Vec2): Vec2 { export function applyAlignment(a: Alignment, p: Vec2): Vec2 {
const c = Math.cos(a.rot); 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]]; 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) => ({ return segments.map((seg) => ({
kind: seg.kind, 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))),
})); }));
} }