feat: add geometry helpers for mm conversion, simplification and export prep
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a39122c504
commit
b142c4f766
2 files changed
+180
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import type { Point, Shape } from './types';
|
||||
|
||||
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||
|
||||
const MIN_HOLE_MM = 3;
|
||||
|
||||
export function bounds(points: Point[]): Bounds {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const p of points) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
export function shapeSize(shape: Shape): { width: number; height: number } {
|
||||
const b = bounds(shape.outer);
|
||||
return { width: b.maxX - b.minX, height: b.maxY - b.minY };
|
||||
}
|
||||
|
||||
export function simplifyRing(ring: Point[], tolerance: number): Point[] {
|
||||
if (ring.length <= 3) return ring.slice();
|
||||
// Split at the vertex farthest from the start so each half is an open polyline.
|
||||
let far = 0;
|
||||
let farDist = -1;
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const d = Math.hypot(ring[i].x - ring[0].x, ring[i].y - ring[0].y);
|
||||
if (d > farDist) {
|
||||
farDist = d;
|
||||
far = i;
|
||||
}
|
||||
}
|
||||
const a = douglasPeucker(ring.slice(0, far + 1), tolerance);
|
||||
const b = douglasPeucker([...ring.slice(far), ring[0]], tolerance);
|
||||
return [...a.slice(0, -1), ...b.slice(0, -1)];
|
||||
}
|
||||
|
||||
function douglasPeucker(points: Point[], tolerance: number): Point[] {
|
||||
if (points.length < 3) return points;
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
let index = 0;
|
||||
let maxDist = 0;
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const d = distanceToSegment(points[i], first, last);
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
if (maxDist <= tolerance) return [first, last];
|
||||
const left = douglasPeucker(points.slice(0, index + 1), tolerance);
|
||||
const right = douglasPeucker(points.slice(index), tolerance);
|
||||
return [...left.slice(0, -1), ...right];
|
||||
}
|
||||
|
||||
function distanceToSegment(p: Point, a: Point, b: Point): number {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
||||
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
|
||||
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
||||
}
|
||||
|
||||
export function prepareForExport(shapePx: Shape, pxPerMm: number, toleranceMm = 0.5): Shape | null {
|
||||
const toMm = (ring: Point[]) =>
|
||||
simplifyRing(ring.map((p) => ({ x: p.x / pxPerMm, y: p.y / pxPerMm })), toleranceMm);
|
||||
const outer = toMm(shapePx.outer);
|
||||
if (outer.length < 3) return null;
|
||||
const holes = shapePx.holes.map(toMm).filter((hole) => {
|
||||
if (hole.length < 3) return false;
|
||||
const { width, height } = shapeSize({ outer: hole, holes: [] });
|
||||
return width >= MIN_HOLE_MM || height >= MIN_HOLE_MM;
|
||||
});
|
||||
const { minX, minY } = bounds(outer);
|
||||
const shift = (ring: Point[]) => ring.map((p) => ({ x: p.x - minX, y: p.y - minY }));
|
||||
return { outer: shift(outer), holes: holes.map(shift) };
|
||||
}
|
||||
|
||||
export function scrapFilename(shapeMm: Shape, ext: 'dxf' | 'svg'): string {
|
||||
const { width, height } = shapeSize(shapeMm);
|
||||
return `scrap-${Math.round(width)}x${Math.round(height)}mm.${ext}`;
|
||||
}
|
||||
|
||||
export function formatMm(n: number): string {
|
||||
const rounded = Number(n.toFixed(3));
|
||||
return String(rounded === 0 ? 0 : rounded);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
bounds, formatMm, prepareForExport, scrapFilename, shapeSize, simplifyRing,
|
||||
} from '../src/lib/geometry';
|
||||
import type { Point } from '../src/lib/types';
|
||||
|
||||
/** Square ring from (x0,y0) with side `size`, one point every `step` units. */
|
||||
function denseSquare(x0: number, y0: number, size: number, step = 1): Point[] {
|
||||
const pts: Point[] = [];
|
||||
for (let i = 0; i < size; i += step) pts.push({ x: x0 + i, y: y0 });
|
||||
for (let i = 0; i < size; i += step) pts.push({ x: x0 + size, y: y0 + i });
|
||||
for (let i = 0; i < size; i += step) pts.push({ x: x0 + size - i, y: y0 + size });
|
||||
for (let i = 0; i < size; i += step) pts.push({ x: x0, y: y0 + size - i });
|
||||
return pts;
|
||||
}
|
||||
|
||||
describe('bounds / shapeSize', () => {
|
||||
it('measures the outer ring', () => {
|
||||
const shape = { outer: denseSquare(5, 10, 20), holes: [] };
|
||||
expect(bounds(shape.outer)).toEqual({ minX: 5, minY: 10, maxX: 25, maxY: 30 });
|
||||
expect(shapeSize(shape)).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('simplifyRing', () => {
|
||||
it('reduces collinear points of a square to its 4 corners', () => {
|
||||
const result = simplifyRing(denseSquare(0, 0, 10), 0.1);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(bounds(result)).toEqual({ minX: 0, minY: 0, maxX: 10, maxY: 10 });
|
||||
});
|
||||
|
||||
it('removes wiggles below tolerance but keeps bumps above it', () => {
|
||||
const ring = denseSquare(0, 0, 100);
|
||||
ring[150] = { x: 100.3, y: 50 }; // on the right edge, 0.3 out
|
||||
expect(simplifyRing(ring, 0.5)).toHaveLength(4);
|
||||
ring[150] = { x: 102, y: 50 }; // 2 out
|
||||
expect(simplifyRing(ring, 0.5).some((p) => p.x === 102)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves tiny rings alone', () => {
|
||||
const tri = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }];
|
||||
expect(simplifyRing(tri, 5)).toEqual(tri);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareForExport', () => {
|
||||
it('converts px to mm and moves the outer bounding box to the origin', () => {
|
||||
const shapePx = { outer: denseSquare(100, 50, 200), holes: [denseSquare(150, 100, 40)] };
|
||||
const mm = prepareForExport(shapePx, 4)!; // 4 px per mm
|
||||
expect(bounds(mm.outer)).toEqual({ minX: 0, minY: 0, maxX: 50, maxY: 50 });
|
||||
expect(mm.holes).toHaveLength(1);
|
||||
expect(bounds(mm.holes[0])).toEqual({ minX: 12.5, minY: 12.5, maxX: 22.5, maxY: 22.5 });
|
||||
expect(mm.outer).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('drops holes smaller than 3 mm in both directions (glare/speckle)', () => {
|
||||
const shapePx = {
|
||||
outer: denseSquare(0, 0, 400),
|
||||
holes: [denseSquare(100, 100, 8), denseSquare(200, 200, 40)], // 2 mm and 10 mm at 4 px/mm
|
||||
};
|
||||
const mm = prepareForExport(shapePx, 4)!;
|
||||
expect(mm.holes).toHaveLength(1);
|
||||
expect(shapeSize({ outer: mm.holes[0], holes: [] }).width).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it('keeps a long thin hole that is under 3 mm in only one direction', () => {
|
||||
const slot: Point[] = [{ x: 100, y: 100 }, { x: 300, y: 100 }, { x: 300, y: 104 }, { x: 100, y: 104 }];
|
||||
const mm = prepareForExport({ outer: denseSquare(0, 0, 400), holes: [slot] }, 4)!;
|
||||
expect(mm.holes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null when the outer ring degenerates', () => {
|
||||
expect(prepareForExport({ outer: [{ x: 0, y: 0 }, { x: 1, y: 1 }], holes: [] }, 4)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrapFilename / formatMm', () => {
|
||||
it('names the file after the rounded size', () => {
|
||||
const shape = { outer: [{ x: 0, y: 0 }, { x: 412.4, y: 0 }, { x: 412.4, y: 286.6 }, { x: 0, y: 286.6 }], holes: [] };
|
||||
expect(scrapFilename(shape, 'dxf')).toBe('scrap-412x287mm.dxf');
|
||||
expect(scrapFilename(shape, 'svg')).toBe('scrap-412x287mm.svg');
|
||||
});
|
||||
|
||||
it('formats numbers compactly', () => {
|
||||
expect(formatMm(1.23456)).toBe('1.235');
|
||||
expect(formatMm(2)).toBe('2');
|
||||
expect(formatMm(-0.0001)).toBe('0');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user