import { describe, expect, it } from 'vitest'; import ClipperLib from 'clipper-lib'; import { fireplace, rectangle } from '../src/lib/defaults'; import { buildGeometry, insetPolygon, normalizeAngle, signedArea } from '../src/lib/geometry'; import { planSkirting } from '../src/lib/skirting'; import { skirtingPolygon } from '../src/lib/skirting-polygon'; import type { Point, Room } from '../src/lib/types'; const settings = { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 }; function overlap(a: Point[], b: Point[]): number { const clipper = new ClipperLib.Clipper(); const path = (points: Point[]) => points.map((point) => ({ X: Math.round(point.x * 1000), Y: Math.round(point.y * 1000), })); clipper.AddPath(path(a), ClipperLib.PolyType.ptSubject, true); clipper.AddPath(path(b), ClipperLib.PolyType.ptClip, true); const intersection: ClipperLib.Paths = []; clipper.Execute(ClipperLib.ClipType.ctIntersection, intersection); return intersection.reduce( (sum, polygon) => sum + Math.abs(ClipperLib.Clipper.Area(polygon)) / 1e6, 0, ); } function expectCompleteSkirting(room: Room) { room.walls.forEach((wall) => { wall.doors = []; }); const geometry = buildGeometry(room, 1); const plan = planSkirting([room], settings, 1); expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]); const polygons = plan.pieces.map((piece) => skirtingPolygon(piece, settings.thickness, signedArea(geometry.vertices)), ); const inner = insetPolygon(geometry.vertices, settings.thickness); // Exact area of the mitred perimeter band; unlike clipping, this does not // round angled intersections to the nearest 0.001 mm. const orientation = Math.sign(signedArea(geometry.vertices)); const cornerArea = geometry.edges.reduce((sum, edge, index, edges) => { const turn = normalizeAngle(edges[(index + 1) % edges.length].heading - edge.heading) * orientation; return sum + settings.thickness ** 2 * Math.tan((turn * Math.PI) / 360); }, 0); const expectedArea = geometry.perimeter * settings.thickness - cornerArea; expect(polygons.reduce((sum, polygon) => sum + Math.abs(signedArea(polygon)), 0)).toBeCloseTo( expectedArea, 5, ); for (let i = 0; i < polygons.length; i++) { const polygon = polygons[i]; // Inset and intersection coordinates each round to a 0.001 mm grid. const clippingTolerance = polygon.reduce((sum, point, index) => { const next = polygon[(index + 1) % polygon.length]; return sum + Math.hypot(next.x - point.x, next.y - point.y) / 1000; }, 0); const piece = plan.pieces[i]; const installed = piece.installedTemplate!; expect(Math.hypot(polygon[0].x - piece.start.x, polygon[0].y - piece.start.y)).toBeCloseTo( Math.abs(installed[0].x - piece.template![0].x), ); expect(Math.hypot(polygon[1].x - piece.end.x, polygon[1].y - piece.end.y)).toBeCloseTo( Math.abs(installed[1].x - piece.template![1].x), ); expect( Math.abs(overlap(polygon, geometry.vertices) - Math.abs(signedArea(polygon))), ).toBeLessThan(clippingTolerance); for (const innerPolygon of inner) expect(overlap(polygon, innerPolygon)).toBeLessThan(clippingTolerance); for (let j = i + 1; j < polygons.length; j++) expect(overlap(polygon, polygons[j])).toBeLessThan(clippingTolerance); } return plan; } describe('installed skirting polygons', () => { it.each([1, -1])( 'covers a rectangle including uncut stock without overlapping coped bodies (orientation %i)', (orientation) => { const room = rectangle(); room.walls.forEach((wall) => { wall.turn *= orientation; }); const plan = expectCompleteSkirting(room); expect(plan.pieces.some((piece) => piece.id.startsWith('SU'))).toBe(true); }, ); it.each([1, -1])( 'joins fireplace mitres without gaps or overlaps (orientation %i)', (orientation) => { const room = fireplace('fire', 'Fireplace'); room.walls.forEach((wall) => { wall.turn *= orientation; }); const plan = expectCompleteSkirting(room); expect( plan.pieces.reduce( (sum, piece) => sum + Number(piece.leftType === 'external') + Number(piece.rightType === 'external'), 0, ), ).toBe(4); }, ); it.each([60, -60, 120, -120])( 'matches the room boundary at angled coped internal joints (%i degrees)', (turn) => { expectCompleteSkirting({ id: 'triangle', name: 'Triangle', direction: 0, walls: Array.from({ length: 360 / Math.abs(turn) }, (_, index) => ({ id: `w${index}`, length: 3000, turn, doors: [], })), }); }, ); });