173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
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);
|
|
});
|
|
});
|