wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
297
tests/flooring.test.ts
Normal file
297
tests/flooring.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import ClipperLib from 'clipper-lib';
|
||||
import { planFlooring } from '../src/lib/flooring';
|
||||
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||
import { fireplace, rectangle } from '../src/lib/defaults';
|
||||
import { flooringCuts } from '../src/lib/cut-list';
|
||||
import type { FlooringPlan, FlooringSettings, Point, Room } from '../src/lib/types';
|
||||
|
||||
const settings: FlooringSettings = {
|
||||
length: 1200,
|
||||
width: 200,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
minLength: 200,
|
||||
minWidth: 60,
|
||||
kerf: 3,
|
||||
};
|
||||
function room(width = 4020, height = 2220, id = '1'): Room {
|
||||
const result = rectangle(id, `Room ${id}`, width, height);
|
||||
result.walls.forEach((wall) => {
|
||||
wall.doors = [];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function errors(plan: FlooringPlan) {
|
||||
return plan.issues.filter((issue) => issue.severity === 'error');
|
||||
}
|
||||
function overlap(a: Point[], b: Point[]): number {
|
||||
const clipper = new ClipperLib.Clipper();
|
||||
const path = (points: Point[]) =>
|
||||
points.map((p) => ({ X: Math.round(p.x * 1000), Y: Math.round(p.y * 1000) }));
|
||||
clipper.AddPath(path(a), ClipperLib.PolyType.ptSubject, true);
|
||||
clipper.AddPath(path(b), ClipperLib.PolyType.ptClip, true);
|
||||
const result: ClipperLib.Paths = [];
|
||||
clipper.Execute(ClipperLib.ClipType.ctIntersection, result);
|
||||
return result.reduce((sum, polygon) => sum + Math.abs(ClipperLib.Clipper.Area(polygon)) / 1e6, 0);
|
||||
}
|
||||
function expectCoverage(plan: FlooringPlan, r: Room, gap: number) {
|
||||
const inset = insetPolygon(buildGeometry(r, 1).vertices, gap);
|
||||
const expectedArea = inset.reduce((sum, p) => sum + Math.abs(signedArea(p)), 0);
|
||||
const pieces = plan.pieces.filter((p) => p.roomId === r.id);
|
||||
// Clipping rounds intersections to 0.001 mm; permit less than 1 mm² area drift.
|
||||
expect(
|
||||
Math.abs(pieces.reduce((sum, p) => sum + Math.abs(signedArea(p.polygon)), 0) - expectedArea),
|
||||
).toBeLessThan(1);
|
||||
for (let i = 0; i < pieces.length; i++) {
|
||||
expect(
|
||||
Math.abs(
|
||||
inset.reduce((sum, p) => sum + overlap(pieces[i].polygon, p), 0) -
|
||||
Math.abs(signedArea(pieces[i].polygon)),
|
||||
),
|
||||
).toBeLessThan(1);
|
||||
for (let j = i + 1; j < pieces.length; j++)
|
||||
expect(overlap(pieces[i].polygon, pieces[j].polygon)).toBeLessThan(0.01);
|
||||
}
|
||||
}
|
||||
function expectStockFits(plan: FlooringPlan, s: FlooringSettings) {
|
||||
const ids = plan.pieces.map((piece) => piece.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual([...ids].sort());
|
||||
for (const stock of plan.stocks) {
|
||||
const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!);
|
||||
const used = pieces.reduce((sum, piece) => sum + piece.length, 0);
|
||||
expect(used + Math.max(0, pieces.length - 1) * s.kerf).toBeLessThanOrEqual(s.length + 0.02);
|
||||
expect(stock.remaining).toBeGreaterThanOrEqual(0);
|
||||
expect(used + stock.remaining).toBeLessThanOrEqual(s.length + 0.02);
|
||||
}
|
||||
}
|
||||
function expectOffsets(plan: FlooringPlan, s: FlooringSettings) {
|
||||
for (let i = 1; i < plan.rows.length; i++) {
|
||||
const a = plan.rows[i - 1],
|
||||
b = plan.rows[i];
|
||||
if (a.roomId !== b.roomId) continue;
|
||||
const difference = Math.abs(a.offset - b.offset);
|
||||
const distance = Math.min(difference, s.length - difference);
|
||||
expect(distance).toBeGreaterThanOrEqual(s.minOffset - 0.01);
|
||||
expect(distance).toBeLessThanOrEqual(s.maxOffset + 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
describe('laminate planning', () => {
|
||||
it('covers a rectangle exactly, without overlap, and stays inside its expansion gap', () => {
|
||||
const r = room();
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.coveredArea).toBe(4000 * 2200);
|
||||
expectCoverage(plan, r, settings.gap);
|
||||
expect(
|
||||
plan.pieces.every((p) => p.length >= settings.minLength && p.width >= settings.minWidth),
|
||||
).toBe(true);
|
||||
expectOffsets(plan, settings);
|
||||
expectStockFits(plan, settings);
|
||||
});
|
||||
|
||||
it('splits a narrow remainder between first and last rows', () => {
|
||||
const plan = planFlooring([room(4020, 2040)], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.rows).toHaveLength(11);
|
||||
expect(plan.rows[0].width).toBe(110);
|
||||
expect(plan.rows.at(-1)?.width).toBe(110);
|
||||
expect(plan.rows.slice(1, -1).every((row) => row.width === 200)).toBe(true);
|
||||
expect(
|
||||
plan.pieces.filter((piece) => piece.row === 1).every((piece) => piece.ripEdge === 'top'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
plan.pieces.filter((piece) => piece.row === 11).every((piece) => piece.ripEdge === 'bottom'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects impossible edge widths instead of ripping factory joints off middle rows', () => {
|
||||
const plan = planFlooring([room(4020, 450)], { ...settings, minWidth: 120 }, 1);
|
||||
expect(plan.boardCount).toBe(0);
|
||||
expect(errors(plan)[0].message).toContain('minimum row width');
|
||||
});
|
||||
|
||||
it('reuses offcuts without double counting stock or ignoring saw kerf', () => {
|
||||
const r = room(4020, 2020);
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expect(plan.reusedCount).toBeGreaterThan(0);
|
||||
expectStockFits(plan, settings);
|
||||
// A 600 mm starter and 600 mm finisher cannot share a 1200 mm board with a 3 mm kerf.
|
||||
for (const stock of plan.stocks) {
|
||||
const lengths = stock.pieceIds.map((id) => plan.pieces.find((p) => p.id === id)!.length);
|
||||
expect(lengths.filter((length) => Math.abs(length - 600) < 0.01).length).toBeLessThan(2);
|
||||
}
|
||||
expect(plan.boardCount).toBeGreaterThanOrEqual(
|
||||
Math.ceil(plan.coveredArea / (settings.length * settings.width)),
|
||||
);
|
||||
expect(plan.wasteArea).toBeCloseTo(
|
||||
plan.boardCount * settings.length * settings.width - plan.coveredArea,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reuse a cut end where an interior board needs two factory ends', () => {
|
||||
const plan = planFlooring([room()], settings, 1);
|
||||
for (const row of plan.rows) {
|
||||
const pieces = row.pieceIds
|
||||
.map((id) => plan.pieces.find((piece) => piece.id === id)!)
|
||||
.sort((a, b) => a.column - b.column);
|
||||
for (const interior of pieces.slice(1, -1)) {
|
||||
expect(interior.length).toBe(settings.length);
|
||||
expect(interior.reused).toBe(false);
|
||||
expect(interior.factoryLeft).toBe(true);
|
||||
expect(interior.factoryRight).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('produces exact concave fireplace templates and keeps the gap at its external corners', () => {
|
||||
const r = fireplace('fire', 'Fireplace room');
|
||||
const plan = planFlooring([r], { ...settings, width: 190 }, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expectCoverage(plan, r, settings.gap);
|
||||
expect(plan.pieces.some((piece) => piece.shaped)).toBe(true);
|
||||
for (const piece of plan.pieces) {
|
||||
expect(
|
||||
piece.template.every(
|
||||
(p) =>
|
||||
p.x >= -0.01 && p.y >= -0.01 && p.x <= piece.length + 0.01 && p.y <= piece.width + 0.01,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expectStockFits(plan, { ...settings, width: 190 });
|
||||
});
|
||||
|
||||
it('clips an angled wall exactly and provides shaped cut coordinates', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4000, y: 0 },
|
||||
{ x: 4200, y: 2200 },
|
||||
{ x: 0, y: 2200 },
|
||||
];
|
||||
const headings = points.map(
|
||||
(p, i) =>
|
||||
(Math.atan2(
|
||||
points[(i + 1) % points.length].y - p.y,
|
||||
points[(i + 1) % points.length].x - p.x,
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
);
|
||||
const r: Room = {
|
||||
id: 'angled',
|
||||
name: 'Angled room',
|
||||
direction: 0,
|
||||
walls: points.map((p, i) => ({
|
||||
id: `w${i}`,
|
||||
length: Math.hypot(
|
||||
points[(i + 1) % points.length].x - p.x,
|
||||
points[(i + 1) % points.length].y - p.y,
|
||||
),
|
||||
turn: ((headings[(i + 1) % points.length] - headings[i] + 540) % 360) - 180,
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(errors(plan)).toEqual([]);
|
||||
expectCoverage(plan, r, settings.gap);
|
||||
expect(plan.pieces.filter((piece) => piece.shaped).length).toBeGreaterThan(2);
|
||||
expectStockFits(plan, settings);
|
||||
});
|
||||
|
||||
it('gives identical random plans for identical input and respects both stagger limits', () => {
|
||||
const s = { ...settings, pattern: 'random' as const, minOffset: 250, maxOffset: 450 };
|
||||
const a = planFlooring([room()], s, 1);
|
||||
expect(errors(a)).toEqual([]);
|
||||
expect(a).toEqual(planFlooring([room()], s, 1));
|
||||
expect(new Set(a.rows.map((row) => row.offset)).size).toBeGreaterThan(2);
|
||||
expectOffsets(a, s);
|
||||
expectStockFits(a, s);
|
||||
expectCoverage(a, room(), s.gap);
|
||||
});
|
||||
|
||||
it('rejects impossible brick stagger constraints', () => {
|
||||
const plan = planFlooring([room()], { ...settings, maxOffset: 400 }, 1);
|
||||
expect(plan.boardCount).toBe(0);
|
||||
expect(errors(plan)[0].message).toContain('half-board');
|
||||
});
|
||||
|
||||
it('plans each room independently and can reuse compatible end offcuts across directions', () => {
|
||||
const a = room(1820, 1220, '1');
|
||||
const b = room(1220, 1820, '2');
|
||||
b.direction = 90;
|
||||
const combined = planFlooring([a, b], settings, 1);
|
||||
const separately =
|
||||
planFlooring([a], settings, 1).boardCount + planFlooring([b], settings, 1).boardCount;
|
||||
expect(errors(combined)).toEqual([]);
|
||||
expect(combined.boardCount).toBeLessThanOrEqual(separately);
|
||||
expect(
|
||||
combined.stocks.some(
|
||||
(stock) =>
|
||||
new Set(stock.pieceIds.map((id) => combined.pieces.find((p) => p.id === id)!.roomId))
|
||||
.size > 1,
|
||||
),
|
||||
).toBe(true);
|
||||
expectCoverage(combined, a, settings.gap);
|
||||
expectCoverage(combined, b, settings.gap);
|
||||
expectStockFits(combined, settings);
|
||||
expectOffsets(combined, settings);
|
||||
});
|
||||
|
||||
it('reports an unclosed room and never counts a partial floor', () => {
|
||||
const r = room();
|
||||
r.walls[0].length += 100;
|
||||
const plan = planFlooring([r], settings, 1);
|
||||
expect(plan.boardCount).toBe(0);
|
||||
expect(plan.pieces).toEqual([]);
|
||||
expect(errors(plan)[0].message).toContain('closed outline');
|
||||
});
|
||||
|
||||
it('rejects zero and nonfinite settings without unbounded loops', () => {
|
||||
expect(errors(planFlooring([room()], { ...settings, width: 0 }, 1))).toHaveLength(1);
|
||||
const nonfinite = planFlooring([room()], { ...settings, length: NaN }, 1);
|
||||
expect(errors(nonfinite)).toHaveLength(1);
|
||||
expect(nonfinite.wasteArea).toBe(0);
|
||||
expect(errors(planFlooring([room()], { ...settings, length: 1e308 }, 1))).toHaveLength(1);
|
||||
expect(
|
||||
errors(planFlooring([room()], { ...settings, width: 0.001, minWidth: 0.001 }, 1)),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('numbers cuts and ready offcuts continuously while preserving all row and stock references', () => {
|
||||
const s = { ...settings, pattern: 'random' as const, minOffset: 250, maxOffset: 450 };
|
||||
const plan = planFlooring([room()], s, 1);
|
||||
const cuts = flooringCuts(plan);
|
||||
expect(plan.pieces.length).toBeGreaterThan(cuts.length);
|
||||
expect(cuts.some((piece) => piece.reused && !piece.cut)).toBe(true);
|
||||
expect(cuts.map((piece) => piece.id)).toEqual(cuts.map((_, index) => `F${index + 1}`));
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => !piece.cut && !piece.reused)
|
||||
.every((piece) => piece.id.startsWith('FU')),
|
||||
).toBe(true);
|
||||
const ids = plan.pieces.map((piece) => piece.id);
|
||||
expect(plan.rows.flatMap((row) => row.pieceIds).sort()).toEqual([...ids].sort());
|
||||
expectStockFits(plan, s);
|
||||
});
|
||||
|
||||
it('scopes room failures and shape warnings without scoping global material errors', () => {
|
||||
const valid = room(4020, 2220, 'valid');
|
||||
const open = room(4020, 2220, 'open');
|
||||
open.walls[0].length += 100;
|
||||
const shaped = fireplace('shaped', 'Fireplace room');
|
||||
const plan = planFlooring([valid, open, shaped], { ...settings, width: 190 }, 1);
|
||||
expect(plan.pieces.some((piece) => piece.roomId === valid.id)).toBe(true);
|
||||
expect(plan.pieces.some((piece) => piece.roomId === open.id)).toBe(false);
|
||||
expect(errors(plan).map((issue) => issue.roomId)).toEqual(['open']);
|
||||
expect(
|
||||
plan.issues.filter((issue) => issue.severity === 'warning').map((issue) => issue.roomId),
|
||||
).toEqual(['shaped']);
|
||||
expect(errors(planFlooring([valid], { ...settings, width: 0 }, 1))[0].roomId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue