import { describe, expect, it } from 'vitest'; import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry'; import type { Point, Room } from '../src/lib/types'; const rectangle = (last = 3000): Room => ({ id: 'r1', name: 'Room', direction: 0, walls: [4000, 3000, 4000, last].map((length, index) => ({ id: `w${index}`, length, turn: 90, doors: [], })), }); function roomFromPoints(vertices: Point[]): Room { const headings = vertices.map((point, index) => { const next = vertices[(index + 1) % vertices.length]; return (Math.atan2(next.y - point.y, next.x - point.x) * 180) / Math.PI; }); return { id: 'shape', name: 'Shape', direction: 0, walls: vertices.map((point, index) => { const next = vertices[(index + 1) % vertices.length]; const turn = ((headings[(index + 1) % headings.length] - headings[index] + 540) % 360) - 180; return { id: `w${index}`, length: Math.hypot(next.x - point.x, next.y - point.y), turn, doors: [], }; }), }; } describe('measured room geometry', () => { it('closes a rectangle and ignores the unused final turn', () => { const room = rectangle(); room.walls[3].turn = 0; const result = buildGeometry(room, 5); expect(result.valid).toBe(true); expect(result.vertices).toHaveLength(4); expect(result.area).toBeCloseTo(12_000_000); expect(result.perimeter).toBeCloseTo(14_000); expect(result.finalLengthError).toBeCloseTo(0); expect(result.finalAngleError).toBeCloseTo(0); }); it('predicts the next closing length and signed turn for an unfinished room', () => { const room = rectangle(); room.walls.pop(); const result = buildGeometry(room, 5); expect(result.closed).toBe(false); expect(result.valid).toBe(false); expect(result.vertices).toHaveLength(4); expect(result.closingLength).toBeCloseTo(3000); expect(result.closingTurn).toBeCloseTo(90); }); it('reports the measured final wall mismatch, accepting only residuals inside tolerance', () => { const result = buildGeometry(rectangle(3003), 5); expect(result.valid).toBe(true); expect(result.gap).toBeCloseTo(3); expect(result.finalLengthError).toBeCloseTo(3); expect(result.edges[3].length).toBeCloseTo(3000); expect( result.issues.some( (issue) => issue.severity === 'warning' && issue.message.includes('remeasure'), ), ).toBe(true); expect(buildGeometry(rectangle(3020), 5).valid).toBe(false); expect(buildGeometry(rectangle(3020), 1e308).closed).toBe(false); expect(buildGeometry(rectangle(), 51).valid).toBe(false); }); it('reports direction mismatch rather than assigning the residual to a bowed wall', () => { const room = rectangle(); room.walls[2].turn = 88; const result = buildGeometry(room, 5); expect(result.valid).toBe(false); expect(Math.abs(result.finalAngleError)).toBeCloseTo(2); expect(result.gap).toBeGreaterThan(100); }); it('supports a concave fireplace and a slanted outer wall', () => { const room = roomFromPoints([ { x: 0, y: 0 }, { x: 5000, y: 0 }, { x: 5000, y: 4000 }, { x: 3000, y: 4000 }, { x: 3000, y: 3400 }, { x: 2000, y: 3400 }, { x: 2000, y: 4000 }, { x: 0, y: 3500 }, ]); const result = buildGeometry(room, 0.1); expect(result.valid).toBe(true); expect(result.vertices).toHaveLength(8); expect(result.area).toBeCloseTo(18_900_000); const inset = insetPolygon(result.vertices, 10); expect(inset).toHaveLength(1); expect(Math.abs(signedArea(inset[0]))).toBeLessThan(result.area); }); it('rejects crossing outlines, invalid lengths and impossible turns', () => { const crossed = roomFromPoints([ { x: 0, y: 0 }, { x: 4000, y: 0 }, { x: 0, y: 3000 }, { x: 4000, y: 3000 }, ]); expect(buildGeometry(crossed, 5).issues.some((issue) => issue.message.includes('cross'))).toBe( true, ); const room = rectangle(); room.walls[0].length = -1; room.walls[1].turn = 180; expect(buildGeometry(room, 5).valid).toBe(false); room.walls[0].length = 1e308; expect(Number.isFinite(buildGeometry(room, 5).area)).toBe(true); }); it('validates door boundaries and overlapping openings', () => { const room = rectangle(); room.walls[0].doors = [ { id: 'd1', offset: 300, width: 900 }, { id: 'd2', offset: 1000, width: 900 }, ]; expect(buildGeometry(room, 5).issues.some((issue) => issue.message.includes('overlap'))).toBe( true, ); room.walls[0].doors = [{ id: 'd1', offset: 3500, width: 900 }]; expect(buildGeometry(room, 5).valid).toBe(false); room.walls[0].doors = [{ id: 'd1', offset: 0, width: 900 }]; expect(buildGeometry(room, 5).valid).toBe(true); }); it('insets clockwise and anticlockwise rectangles and detects an erased room', () => { const vertices = buildGeometry(rectangle(), 5).vertices; for (const points of [vertices, [...vertices].reverse()]) { const inset = insetPolygon(points, 10); expect(inset).toHaveLength(1); expect(Math.abs(signedArea(inset[0]))).toBeCloseTo(3980 * 2980); } expect(insetPolygon(vertices, 1600)).toEqual([]); expect(insetPolygon(vertices, 1e308)).toEqual([]); }); it('returns separate floor regions when a narrow connecting passage disappears', () => { const inset = insetPolygon( [ { x: 0, y: 0 }, { x: 2000, y: 0 }, { x: 2000, y: 900 }, { x: 3000, y: 900 }, { x: 3000, y: 0 }, { x: 5000, y: 0 }, { x: 5000, y: 2000 }, { x: 3000, y: 2000 }, { x: 3000, y: 1100 }, { x: 2000, y: 1100 }, { x: 2000, y: 2000 }, { x: 0, y: 2000 }, ], 150, ); expect(inset).toHaveLength(2); expect(inset.every((polygon) => signedArea(polygon) > 0)).toBe(true); }); });