import f8Fixture from './fixtures/angled-room-f8.json'; 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 { cutLength } from '../src/lib/format'; import type { FlooringPlan, FlooringSettings, Point, Project, Room } from '../src/lib/types'; const settings: FlooringSettings = { length: 1200, width: 200, gap: 10, pattern: 'brick', optimisation: 'cuts', 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); // Crosscuts cannot create factory joints: each purchased board supplies one of each end. for (const end of ['factoryLeft', 'factoryRight'] as const) { expect( pieces.filter((piece) => piece[end]).length, `${stock.id} allocates its ${end} more than once`, ).toBeLessThanOrEqual(1); } for (const piece of pieces.filter((piece) => piece.factoryLeft && piece.factoryRight)) { expect(piece.length).toBeCloseTo(s.length, 3); expect(piece.reused).toBe(false); expect(pieces).toHaveLength(1); } } } 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, ); }); describe.each([ ['brick', 'cuts'], ['random', 'cuts'], ['random', 'random'], ] as const)('%s pattern, %s optimisation: factory short ends', (pattern, optimisation) => { const s = { ...settings, pattern, optimisation }; it.each([0, 90] as const)( 'never packs three row ends onto one board, with boards running at %i degrees', (direction) => { const r = direction === 0 ? room(3920, 1620) : room(1620, 3920); r.direction = direction; const plan = planFlooring([r], s, 1); expect(errors(plan)).toEqual([]); expect(plan.rows).toHaveLength(8); expect(plan.reusedCount).toBeGreaterThan(0); for (const row of plan.rows) { const position = (polygon: Point[]) => Math.min(...polygon.map((point) => (direction === 0 ? point.x : point.y))); const pieces = row.pieceIds .map((id) => plan.pieces.find((piece) => piece.id === id)!) .sort((a, b) => position(a.polygon) - position(b.polygon)); expect(pieces.length).toBeGreaterThan(2); // Derive the required joints from installed positions, independently of allocation flags. pieces.forEach((piece, index) => { expect(piece.factoryLeft, `${piece.id}: left joint`).toBe(index > 0); expect(piece.factoryRight, `${piece.id}: right joint`).toBe(index < pieces.length - 1); }); } const shortestEnds = plan.pieces .filter((piece) => piece.factoryLeft !== piece.factoryRight) .map((piece) => piece.length) .sort((a, b) => a - b) .slice(0, 3); expect(shortestEnds).toHaveLength(3); // Three ends fit by length, including kerf, but cannot share only two factory joints. expect(shortestEnds.reduce((sum, length) => sum + length, 0) + 2 * s.kerf).toBeLessThan( s.length, ); for (const stock of plan.stocks) expect(stock.pieceIds.length).toBeLessThanOrEqual(2); expectStockFits(plan, s); expectCoverage(plan, r, s.gap); }, ); it('allows three pieces when the middle cut spans wall to wall and the outer cuts keep opposite ends', () => { const first = room(1620, 220, 'first'); const middle = room(220, 414, 'middle'); middle.direction = 90; const last = room(1620, 220, 'last'); const rooms = [first, middle, last]; const plan = planFlooring(rooms, s, 1); expect(errors(plan)).toEqual([]); const triples = plan.stocks.filter((stock) => stock.pieceIds.length === 3); expect(triples).toHaveLength(1); const pieces = triples[0].pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!); expect( pieces.map((piece) => ({ room: piece.roomId, length: piece.length, left: piece.factoryLeft, right: piece.factoryRight, })), ).toEqual([ { room: 'first', length: 400, left: true, right: false }, { room: 'middle', length: 394, left: false, right: false }, { room: 'last', length: 400, left: false, right: true }, ]); expect(plan.pieces.filter((piece) => piece.roomId === middle.id)).toEqual([pieces[1]]); expect(pieces.map((piece) => piece.reused)).toEqual([false, true, true]); expect(plan.boardCount).toBe(3); expect(plan.cuts).toBe(2); expect(triples[0].remaining).toBe(0); expectStockFits(plan, s); for (const r of rooms) expectCoverage(plan, r, s.gap); }); }); 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.each([ ['rectangle', room(4800, 3600, 'room-1'), 76, 42], ['fireplace', fireplace('room-2', 'Fireplace room'), 74, 48], ] as const)( 'breaks random staircases and near-brick repeats in a common %s', (_, r, boardBudget, cutBudget) => { const s = { ...settings, width: 190, pattern: 'random' as const, optimisation: 'random' as const, }; const plan = planFlooring([r], s, 1); expect(errors(plan)).toEqual([]); expect(plan).toEqual(planFlooring([r], s, 1)); const phases = plan.rows.map((row) => row.offset); const signedDistance = (a: number, b: number) => { let difference = b - a; if (difference >= s.length / 2) difference -= s.length; if (difference < -s.length / 2) difference += s.length; return difference; }; const steps = phases.slice(1).map((phase, i) => signedDistance(phases[i], phase)); // This case actually crosses the board boundary, so checking raw differences would miss steps. expect( phases.some((phase, i) => i > 0 && Math.abs(phase - phases[i - 1]) > s.length / 2), ).toBe(true); for (let i = 1; i < steps.length; i++) { // Similar signed steps across three rows produce a visible staircase, even with small kerf drift. expect(Math.abs(steps[i] - steps[i - 1])).toBeGreaterThan(36); // Returning to almost the same joint line two rows later produces an H/brick repeat. expect(Math.abs(signedDistance(phases[i - 1], phases[i + 1]))).toBeGreaterThanOrEqual( s.width, ); } // Breaking the old regular pattern must not increase the stock requirement for these rooms. expect(plan.boardCount).toBeLessThanOrEqual(boardBudget); expect(plan.cuts).toBeLessThanOrEqual(cutBudget); expect(plan.reusedCount).toBeGreaterThan(0); expect( plan.pieces.every((piece) => piece.length >= s.minLength && piece.width >= s.minWidth), ).toBe(true); expectOffsets(plan, s); expectStockFits(plan, s); expectCoverage(plan, r, s.gap); }, ); it('keeps a constrained random layout valid when only alternating starts can fit', () => { const r = room(2420, 1620); const s = { ...settings, pattern: 'random' as const, minLength: 600 }; const plan = planFlooring([r], s, 1); expect(errors(plan)).toEqual([]); expect(plan.rows).toHaveLength(8); expect(new Set(plan.rows.map((row) => row.offset))).toEqual(new Set([0, 600])); expect(plan.rows[0].offset).toBe(plan.rows[2].offset); expectOffsets(plan, s); expectStockFits(plan, s); expectCoverage(plan, r, s.gap); }); it.each([400, 600])( 'respects an exact %i mm random offset even when repetition is unavoidable', (offset) => { const s = { ...settings, pattern: 'random' as const, minOffset: offset, maxOffset: offset }; const plan = planFlooring([room()], s, 1); expect(errors(plan)).toEqual([]); expect(plan).toEqual(planFlooring([room()], s, 1)); expectOffsets(plan, s); expectStockFits(plan, s); }, ); 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(620, 220, '1'); // Its ends complement the first room's remnants after the 3 mm saw kerf. const b = room(220, 617, '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(); }); }); describe('angled room cut allocation', () => { const project = f8Fixture.project as Project; const plan = planFlooring(project.rooms, project.flooring, project.tolerance); it('aligns a row seam with the inward corner that previously left F8 a thin toe', () => { expect(errors(plan)).toEqual([]); expect(project.rooms[0].walls).toHaveLength(13); expect(project.rooms[0].walls.flatMap((wall) => wall.doors)).toHaveLength(2); const corner = { x: 7607.513, y: 2160.34 }; const adjoining = plan.pieces.filter((piece) => piece.polygon.some((point) => Math.hypot(point.x - corner.x, point.y - corner.y) < 0.01), ); expect(adjoining.length).toBeGreaterThanOrEqual(2); expect(new Set(adjoining.map((piece) => piece.row)).size).toBe(2); for (const piece of adjoining) { // Boards run north/south: an x-boundary is the uncut factory long-edge seam. const xs = piece.polygon.map((point) => point.x); expect( Math.min(Math.abs(Math.min(...xs) - corner.x), Math.abs(Math.max(...xs) - corner.x)), ).toBeLessThan(0.01); const turns = piece.polygon.map((point, index, polygon) => { const previous = polygon[(index + polygon.length - 1) % polygon.length]; const next = polygon[(index + 1) % polygon.length]; return ( (point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) ); }); expect(turns.every((turn) => turn >= -0.01) || turns.every((turn) => turn <= 0.01)).toBe( true, ); } expectCoverage(plan, project.rooms[0], project.flooring.gap); expectOffsets(plan, project.flooring); expectStockFits(plan, project.flooring); expect(plan).toEqual(planFlooring(project.rooms, project.flooring, project.tolerance)); }); it('fits all displayed half-millimetre blanks and their kerfs on each purchased board', () => { expect(errors(plan)).toEqual([]); expect(plan.pieces.some((piece) => cutLength(piece.length) > piece.length + 0.01)).toBe(true); for (const stock of plan.stocks) { const pieces = stock.pieceIds.map((id) => plan.pieces.find((piece) => piece.id === id)!); const rounded = pieces.reduce((sum, piece) => sum + cutLength(piece.length), 0); expect( rounded + (pieces.length - 1) * project.flooring.kerf, `${stock.id}: ${pieces.map((piece) => `${piece.id}=${cutLength(piece.length)}`).join(', ')}`, ).toBeLessThanOrEqual(project.flooring.length + 0.001); } }); it('preserves whole factory boards whose purchased length is not a half millimetre', () => { const s = { ...settings, length: 1200.2, minOffset: 0, maxOffset: 1200.2, pattern: 'random' as const, }; const r = room(1220.2, 220); const fitted = planFlooring([r], s, 1); expect(errors(fitted)).toEqual([]); expect(fitted.boardCount).toBe(1); expect(fitted.cuts).toBe(0); expect(fitted.pieces[0].length).toBe(1200.2); expect(fitted.pieces[0].blankLength).toBe(1200.2); expect(fitted.stocks[0].remaining).toBe(0); expectCoverage(fitted, r, s.gap); }); it('uses an exact-fit fractional offcut without pretending it needs a new cut', () => { const s = { ...settings, length: 1200.2, pattern: 'random' as const }; const fitted = planFlooring([room(620, 220, 'first'), room(617.2, 220, 'second')], s, 1); expect(errors(fitted)).toEqual([]); expect(fitted.boardCount).toBe(1); expect(fitted.cuts).toBe(1); expect(fitted.pieces.map((piece) => piece.length)).toEqual([600, 597.2]); expect(fitted.pieces.map((piece) => piece.blankLength)).toEqual([600, 597.2]); expect(fitted.pieces[1].reused).toBe(true); expect(fitted.pieces[1].cut).toBe(false); expect(fitted.stocks[0].remaining).toBe(0); expectStockFits(fitted, s); }); it('keeps a finishing cut visible when its rounded blank consumes a complete board', () => { const s = { ...settings, pattern: 'random' as const }; const fitted = planFlooring([room(1219.6, 220)], s, 1); expect(errors(fitted)).toEqual([]); expect(fitted.boardCount).toBe(1); expect(fitted.pieces[0].length).toBe(1199.6); expect(fitted.pieces[0].cut).toBe(true); expect(fitted.cuts).toBe(1); expect(fitted.stocks[0].remaining).toBe(0); }); it('caps a rounded rip blank at the available fractional factory width', () => { const s = { ...settings, width: 200.2, pattern: 'random' as const }; const fitted = planFlooring([room(1220, 220.1)], s, 1); expect(errors(fitted)).toEqual([]); expect(fitted.boardCount).toBe(1); expect(fitted.pieces[0].width).toBe(200.1); expect(fitted.pieces[0].blankWidth).toBe(200.2); expect(fitted.pieces[0].cut).toBe(true); }); });