This commit is contained in:
Andras Schmelczer 2026-09-11 16:09:28 +01:00
commit d8f2407ddf
44 changed files with 10377 additions and 0 deletions

173
tests/geometry.test.ts Normal file
View file

@ -0,0 +1,173 @@
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);
});
});