diff --git a/src/lib/geometry.ts b/src/lib/geometry.ts new file mode 100644 index 0000000..4131774 --- /dev/null +++ b/src/lib/geometry.ts @@ -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); +} diff --git a/tests/geometry.test.ts b/tests/geometry.test.ts new file mode 100644 index 0000000..75a7492 --- /dev/null +++ b/tests/geometry.test.ts @@ -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'); + }); +});