125 lines
4.7 KiB
TypeScript
125 lines
4.7 KiB
TypeScript
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: [],
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
});
|