wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
323
tests/skirting.test.ts
Normal file
323
tests/skirting.test.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { planSkirting } from '../src/lib/skirting';
|
||||
import { skirtingCuts } from '../src/lib/cut-list';
|
||||
import type { Room, SkirtingSettings } from '../src/lib/types';
|
||||
|
||||
const settings: SkirtingSettings = { stockLengths: [2400], thickness: 18, minLength: 300, kerf: 3 };
|
||||
const rectangle = (width = 4000, height = 3000, id = 'r1'): Room => ({
|
||||
id,
|
||||
name: id,
|
||||
direction: 0,
|
||||
walls: [width, height, width, height].map((length, index) => ({
|
||||
id: `w${index}`,
|
||||
length,
|
||||
turn: 90,
|
||||
doors: [],
|
||||
})),
|
||||
});
|
||||
|
||||
describe('skirting cutting plan', () => {
|
||||
it('subtracts internal butt thickness and preserves every installed wall run', () => {
|
||||
const plan = planSkirting([rectangle()], settings, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(
|
||||
14_000 - 4 * 18,
|
||||
);
|
||||
expect(plan.pieces.every((piece) => piece.length >= settings.minLength)).toBe(true);
|
||||
expect(skirtingCuts(plan)[0].id).toBe('A1');
|
||||
for (let wall = 0; wall < 4; wall++) {
|
||||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wall);
|
||||
expect(pieces[0].leftType).toBe('internal');
|
||||
expect(pieces.at(-1)?.rightType).toBe('internal');
|
||||
expect(
|
||||
pieces.every(
|
||||
(piece) => Math.abs(piece.leftMitre) < 1e-6 && Math.abs(piece.rightMitre) < 1e-6,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes doors and cuts square at each opening', () => {
|
||||
const room = rectangle();
|
||||
room.walls[0].doors = [{ id: 'd1', offset: 1000, width: 900 }];
|
||||
const plan = planSkirting([room], settings, 5);
|
||||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === 0);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000 - 900 - 18);
|
||||
expect(
|
||||
pieces.some((piece) => Math.abs(piece.end.x - 1000) < 1e-6 && piece.rightType === 'square'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pieces.some((piece) => Math.abs(piece.start.x - 1900) < 1e-6 && piece.leftType === 'square'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
pieces.every((piece) => piece.end.x <= 1000 + 1e-6 || piece.start.x >= 1900 - 1e-6),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not silently produce undersized pieces next to doors', () => {
|
||||
const room = rectangle();
|
||||
room.walls[0].doors = [{ id: 'd1', offset: 200, width: 900 }];
|
||||
const plan = planSkirting([room], settings, 5);
|
||||
expect(plan.pieces).toEqual([]);
|
||||
expect(
|
||||
plan.issues.some((issue) => issue.severity === 'error' && issue.message.includes('minimum')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects crossing angled end cuts even when the back length meets the minimum', () => {
|
||||
const room: Room = {
|
||||
id: 'acute',
|
||||
name: 'Acute corner',
|
||||
direction: 0,
|
||||
walls: [1000, 1000, 2000 * Math.sin((5 * Math.PI) / 180)].map((length, index) => ({
|
||||
id: `w${index}`,
|
||||
length,
|
||||
turn: [170, 95, 95][index],
|
||||
doors: index === 1 ? [{ id: 'd1', offset: 200, width: 300 }] : [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, minLength: 50 }, 5);
|
||||
expect(plan.pieces).toEqual([]);
|
||||
expect(
|
||||
plan.issues.some((issue) => issue.message.includes('shorter than the 50 mm minimum')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('extends the long point at external fireplace corners and specifies 45° mitres', () => {
|
||||
const room: Room = {
|
||||
id: 'r1',
|
||||
name: 'Fireplace',
|
||||
direction: 0,
|
||||
walls: [5000, 4000, 2000, 600, 1000, 600, 2000, 4000].map((length, index) => ({
|
||||
id: `w${index}`,
|
||||
length,
|
||||
turn: [90, 90, 90, -90, -90, 90, 90, 90][index],
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [6000] }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
const front = plan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
expect(front.backLength).toBeCloseTo(1000);
|
||||
expect(front.length).toBeCloseTo(1036);
|
||||
expect(front.leftMitre).toBeCloseTo(45);
|
||||
expect(front.rightMitre).toBeCloseTo(45);
|
||||
expect(front.leftType).toBe('external');
|
||||
expect(front.leftBevel).toBeCloseTo(-45);
|
||||
expect(front.rightBevel).toBeCloseTo(45);
|
||||
expect(front.template?.map((point) => Math.round(point.x))).toEqual([18, 1018, 1036, 0]);
|
||||
const reversed = { ...room, walls: room.walls.map((wall) => ({ ...wall, turn: -wall.turn })) };
|
||||
const reversePlan = planSkirting([reversed], { ...settings, stockLengths: [6000] }, 5);
|
||||
const reverseFront = reversePlan.pieces.find((piece) => piece.wallIndex === 4)!;
|
||||
expect(reverseFront.length).toBeCloseTo(1036);
|
||||
expect(reverseFront.leftBevel).toBeCloseTo(-45);
|
||||
expect(reverseFront.rightBevel).toBeCloseTo(45);
|
||||
});
|
||||
|
||||
it('packs across rooms with deterministic unique IDs and accounts for kerf', () => {
|
||||
const rooms = [rectangle(1800, 1800, 'r1'), rectangle(1800, 1800, 'r2')];
|
||||
const options = { ...settings, stockLengths: [1800, 3600], thickness: 0, kerf: 3 };
|
||||
const plan = planSkirting(rooms, options, 5);
|
||||
expect(plan).toEqual(planSkirting(rooms, options, 5));
|
||||
expect(new Set(plan.pieces.map((piece) => piece.id)).size).toBe(plan.pieces.length);
|
||||
expect(plan.pieces.every((piece) => piece.id.startsWith('SU'))).toBe(true);
|
||||
expect(plan.stockCount).toBe(8);
|
||||
expect(plan.cuts).toBe(0);
|
||||
expect(plan.waste).toBeCloseTo(0);
|
||||
const trimmed = planSkirting(
|
||||
[rectangle(1199, 1199)],
|
||||
{ ...settings, stockLengths: [2400], thickness: 0, minLength: 1199 },
|
||||
5,
|
||||
);
|
||||
expect(trimmed.stockCount).toBe(4); // 1199 + 3 + 1199 cannot fit into 2400.
|
||||
expect(trimmed.cuts).toBe(4);
|
||||
for (const stock of trimmed.stocks) expect(stock.used + stock.waste).toBeCloseTo(stock.length);
|
||||
});
|
||||
|
||||
it('allows the final saw pass to extend beyond a short stock tail', () => {
|
||||
const plan = planSkirting(
|
||||
[rectangle(1199, 1199)],
|
||||
{ ...settings, stockLengths: [1200], thickness: 0 },
|
||||
5,
|
||||
);
|
||||
expect(plan.pieces).toHaveLength(4);
|
||||
expect(plan.stockCount).toBe(4);
|
||||
expect(plan.waste).toBeCloseTo(4);
|
||||
expect(plan.cuts).toBe(4);
|
||||
});
|
||||
|
||||
it('looks ahead when choosing from different stock lengths', () => {
|
||||
const plan = planSkirting(
|
||||
[rectangle(1200, 1200)],
|
||||
{ ...settings, stockLengths: [1800, 2400], thickness: 0, kerf: 0 },
|
||||
5,
|
||||
);
|
||||
expect(plan.stockCount).toBe(2);
|
||||
expect(plan.stocks.every((stock) => stock.length === 2400)).toBe(true);
|
||||
expect(plan.waste).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('lets reused tails set wall joints to save a stock length without extra saw cuts', () => {
|
||||
const room = rectangle(4000, 4000);
|
||||
const plan = planSkirting([room], settings, 5);
|
||||
expect(plan).toEqual(planSkirting([room], settings, 5));
|
||||
expect(plan.stockCount).toBe(7); // Fixed joints used eight lengths for this simple square.
|
||||
expect(plan.cuts).toBe(4);
|
||||
expect(plan.waste).toBeCloseTo(7 * 2400 - (16000 - 4 * 18));
|
||||
expect(plan.pieces.some((piece) => Math.abs(piece.length - 815) < 1e-6)).toBe(true);
|
||||
for (let wallIndex = 0; wallIndex < 4; wallIndex++) {
|
||||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wallIndex);
|
||||
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(3982);
|
||||
pieces.slice(1).forEach((piece, index) => {
|
||||
expect(piece.start.x).toBeCloseTo(pieces[index].end.x);
|
||||
expect(piece.start.y).toBeCloseTo(pieces[index].end.y);
|
||||
});
|
||||
}
|
||||
for (const stock of plan.stocks) {
|
||||
let available = stock.length;
|
||||
for (const id of stock.pieceIds) {
|
||||
const piece = plan.pieces.find((piece) => piece.id === id)!;
|
||||
expect(piece.length).toBeLessThanOrEqual(available + 1e-6);
|
||||
expect(piece.backLength).toBeGreaterThanOrEqual(settings.minLength - 1e-6);
|
||||
available = Math.max(0, available - piece.length - settings.kerf);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reserves the shorter front edge when splitting a run at angled internal corners', () => {
|
||||
const room: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: [0, 1, 2].map((index) => ({
|
||||
id: `w${index}`,
|
||||
length: 3100,
|
||||
turn: 120,
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [311] }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces).toHaveLength(30);
|
||||
for (const piece of plan.pieces) {
|
||||
expect(piece.length).toBeLessThanOrEqual(311 + 1e-6);
|
||||
expect(piece.backLength).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThanOrEqual(300 - 1e-6);
|
||||
}
|
||||
for (let wallIndex = 0; wallIndex < 3; wallIndex++) {
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => piece.wallIndex === wallIndex)
|
||||
.reduce((sum, piece) => sum + piece.backLength, 0),
|
||||
).toBeCloseTo(3100 - 18 / Math.sin((120 * Math.PI) / 180));
|
||||
}
|
||||
});
|
||||
|
||||
it('calculates angled butt cuts and back setbacks for non-square internal corners', () => {
|
||||
const room: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: [0, 1, 2].map((index) => ({
|
||||
id: `w${index}`,
|
||||
length: 3000,
|
||||
turn: 120,
|
||||
doors: [],
|
||||
})),
|
||||
};
|
||||
const plan = planSkirting([room], { ...settings, stockLengths: [4000] }, 5);
|
||||
expect(plan.pieces).toHaveLength(3);
|
||||
expect(plan.pieces[0].backLength).toBeCloseTo(3000 - 18 / Math.sin((120 * Math.PI) / 180));
|
||||
for (const piece of plan.pieces) {
|
||||
expect(piece.leftMitre).toBeCloseTo(30);
|
||||
expect(piece.rightMitre).toBeCloseTo(30);
|
||||
expect(piece.leftType).toBe('internal');
|
||||
expect(piece.leftBevel).toBeCloseTo(30);
|
||||
expect(piece.rightBevel).toBeCloseTo(-30);
|
||||
expect(piece.template![2].x - piece.template![3].x).toBeGreaterThan(settings.minLength);
|
||||
}
|
||||
expect(plan.cuts).toBe(9); // Three blank crosscuts and six end bevels.
|
||||
});
|
||||
|
||||
it('can leave a minimum-sized final piece without making a tiny join', () => {
|
||||
const plan = planSkirting([rectangle(2500, 2000)], { ...settings, thickness: 0 }, 5);
|
||||
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||
expect(plan.pieces.every((piece) => piece.backLength >= 300 - 1e-6)).toBe(true);
|
||||
for (const wall of [0, 2])
|
||||
expect(
|
||||
plan.pieces
|
||||
.filter((piece) => piece.wallIndex === wall)
|
||||
.reduce((sum, piece) => sum + piece.backLength, 0),
|
||||
).toBeCloseTo(2500);
|
||||
});
|
||||
|
||||
it('refuses invalid outlines and stock settings', () => {
|
||||
const room = rectangle();
|
||||
room.walls[3].length += 100;
|
||||
expect(planSkirting([room], settings, 5).pieces).toEqual([]);
|
||||
expect(
|
||||
planSkirting([rectangle()], { ...settings, stockLengths: [] }, 5).issues[0].severity,
|
||||
).toBe('error');
|
||||
expect(
|
||||
planSkirting([rectangle()], { ...settings, minLength: 1e-10 }, 5).issues[0].severity,
|
||||
).toBe('error');
|
||||
});
|
||||
|
||||
it('numbers visible cuts continuously per room and remaps stock references', () => {
|
||||
const rooms = [rectangle(4000, 3000, 'one'), rectangle(4000, 3000, 'two')];
|
||||
const plan = planSkirting(rooms, settings, 5);
|
||||
const cuts = skirtingCuts(plan);
|
||||
expect(plan.pieces.length).toBeGreaterThan(cuts.length);
|
||||
for (const [roomIndex, room] of rooms.entries()) {
|
||||
const ids = cuts.filter((piece) => piece.roomId === room.id).map((piece) => piece.id);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
expect(ids).toEqual(ids.map((_, index) => `${roomIndex === 0 ? 'A' : 'B'}${index + 1}`));
|
||||
}
|
||||
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());
|
||||
expect(plan).toEqual(planSkirting(rooms, settings, 5));
|
||||
});
|
||||
|
||||
it('keeps valid rooms when another room has an undersized run or unclosed outline', () => {
|
||||
const valid = rectangle(4000, 3000, 'valid');
|
||||
const small = rectangle(4000, 3000, 'small');
|
||||
small.walls[0].doors = [{ id: 'door', offset: 200, width: 900 }];
|
||||
const open = rectangle(4000, 3000, 'open');
|
||||
open.walls[0].length += 100;
|
||||
const plan = planSkirting([valid, small, open], settings, 5);
|
||||
expect(plan.pieces.length).toBeGreaterThan(0);
|
||||
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
||||
expect(
|
||||
plan.issues.filter((issue) => issue.severity === 'error').map((issue) => issue.roomId),
|
||||
).toEqual(['small', 'open']);
|
||||
expect(plan.stockCount).toBe(planSkirting([valid], settings, 5).stockCount);
|
||||
expect(
|
||||
planSkirting([valid], { ...settings, stockLengths: [] }, 5).issues[0].roomId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attributes corner stock-fit failures to the affected room', () => {
|
||||
const valid = rectangle(4000, 3000, 'valid');
|
||||
const triangle: Room = {
|
||||
id: 'triangle',
|
||||
name: 'Triangle',
|
||||
direction: 0,
|
||||
walls: [0, 1, 2].map((index) => ({ id: `w${index}`, length: 3000, turn: 120, doors: [] })),
|
||||
};
|
||||
const plan = planSkirting(
|
||||
[valid, triangle],
|
||||
{ ...settings, stockLengths: [500], minLength: 490 },
|
||||
5,
|
||||
);
|
||||
expect(plan.pieces.length).toBeGreaterThan(0);
|
||||
expect(plan.pieces.every((piece) => piece.roomId === valid.id)).toBe(true);
|
||||
const failures = plan.issues.filter((issue) => issue.severity === 'error');
|
||||
expect(failures.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
failures.every(
|
||||
(issue) => issue.roomId === triangle.id && issue.message.includes('stock lengths'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue