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'); }); });