166 lines
7.4 KiB
TypeScript
166 lines
7.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { plannerCases } from '../benchmarks/cases';
|
|
import { compareCurrentFlooring, flooringMetrics, skirtingMetrics } from '../benchmarks/metrics';
|
|
import baseline from '../benchmarks/baseline.json';
|
|
import { planFlooringOptions } from '../src/lib/flooring';
|
|
import { planSkirting } from '../src/lib/skirting';
|
|
import { defaultProject, fireplace, rectangle } from '../src/lib/defaults';
|
|
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
|
import type { FlooringPlan, Project } from '../src/lib/types';
|
|
|
|
function validateFlooring(plan: FlooringPlan, project: Project) {
|
|
const s = project.flooring;
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
const byId = new Map(plan.pieces.map((piece) => [piece.id, piece]));
|
|
expect(byId.size).toBe(plan.pieces.length);
|
|
const ids = [...byId.keys()].sort();
|
|
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual(ids);
|
|
expect(plan.rows.flatMap((row) => row.pieceIds).sort()).toEqual(ids);
|
|
let crosscuts = 0;
|
|
let reused = 0;
|
|
for (const stock of plan.stocks) {
|
|
let remaining = s.length;
|
|
let left = true;
|
|
let right = true;
|
|
stock.pieceIds.forEach((id, index) => {
|
|
const piece = byId.get(id)!;
|
|
expect(piece.stockId).toBe(stock.id);
|
|
expect(piece.blankLength).toBeLessThanOrEqual(remaining + 0.01);
|
|
expect(!piece.factoryLeft || left).toBe(true);
|
|
expect(!piece.factoryRight || right).toBe(true);
|
|
expect(piece.reused).toBe(index > 0);
|
|
reused += Number(piece.reused);
|
|
const cut = remaining - piece.length > 0.01;
|
|
crosscuts += Number(cut);
|
|
expect(piece.cut).toBe(cut || piece.shaped || !!piece.ripEdge);
|
|
if (cut) {
|
|
remaining = Math.round(Math.max(0, remaining - piece.blankLength - s.kerf) * 1000) / 1000;
|
|
if (piece.factoryRight) right = false;
|
|
else left = false;
|
|
} else {
|
|
remaining = 0;
|
|
left = right = false;
|
|
}
|
|
});
|
|
expect(stock.remaining).toBeCloseTo(remaining, 3);
|
|
}
|
|
const metrics = flooringMetrics(plan, s);
|
|
expect(plan.cuts).toBe(crosscuts + metrics.shapingCuts + metrics.ripCuts);
|
|
expect(plan.reusedCount).toBe(reused);
|
|
for (const room of project.rooms) {
|
|
const pieces = plan.pieces.filter((piece) => piece.roomId === room.id);
|
|
const paths = insetPolygon(buildGeometry(room, project.tolerance).vertices, s.gap);
|
|
const expected = paths.reduce((sum, path) => sum + Math.abs(signedArea(path)), 0);
|
|
const actual = pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece.polygon)), 0);
|
|
// Integer clipping rounds intersections to 0.001 mm; bound area drift by boundary length.
|
|
const perimeter = paths.reduce(
|
|
(sum, path) =>
|
|
sum +
|
|
path.reduce((length, p, i) => {
|
|
const q = path[(i + 1) % path.length];
|
|
return length + Math.hypot(p.x - q.x, p.y - q.y);
|
|
}, 0),
|
|
0,
|
|
);
|
|
expect(Math.abs(actual - expected)).toBeLessThan(Math.max(1, perimeter * 0.001));
|
|
const rows = plan.rows.filter((row) => row.roomId === room.id);
|
|
let previousEnd = -Infinity;
|
|
rows.forEach((row, index) => {
|
|
const ys = row.pieceIds.flatMap((id) =>
|
|
byId.get(id)!.polygon.map((p) => (room.direction === 0 ? p.y : -p.x)),
|
|
);
|
|
expect(Math.min(...ys)).toBeGreaterThanOrEqual(previousEnd - 0.01);
|
|
previousEnd = Math.max(...ys);
|
|
for (const id of row.pieceIds) {
|
|
const piece = byId.get(id)!;
|
|
expect(piece.row).toBe(row.index);
|
|
expect(piece.roomId).toBe(room.id);
|
|
expect(piece.length).toBeGreaterThanOrEqual(s.minLength - 0.01);
|
|
expect(piece.width).toBeGreaterThanOrEqual(s.minWidth - 0.01);
|
|
}
|
|
if (!index) return;
|
|
const delta = Math.abs(row.offset - rows[index - 1].offset);
|
|
const offset = Math.min(delta, s.length - delta);
|
|
expect(offset).toBeGreaterThanOrEqual(s.minOffset - 0.01);
|
|
expect(offset).toBeLessThanOrEqual(s.maxOffset + 0.01);
|
|
});
|
|
}
|
|
}
|
|
|
|
describe('measured optimiser quality', () => {
|
|
const cases = plannerCases();
|
|
const results = new Map<
|
|
string,
|
|
{ options: ReturnType<typeof planFlooringOptions>; skirt: ReturnType<typeof skirtingMetrics> }
|
|
>();
|
|
const resultFor = ({ name, project }: (typeof cases)[number]) => {
|
|
let result = results.get(name);
|
|
if (!result) {
|
|
result = {
|
|
options: planFlooringOptions(project.rooms, project.flooring, project.tolerance),
|
|
skirt: skirtingMetrics(planSkirting(project.rooms, project.skirting, project.tolerance)),
|
|
};
|
|
results.set(name, result);
|
|
}
|
|
return result;
|
|
};
|
|
it.each(cases)(
|
|
'$name preserves coverage, stock, factory ends and priority',
|
|
({ name, project }) => {
|
|
const input = JSON.stringify(project);
|
|
const { options, skirt } = resultFor({ name, project });
|
|
const cuts = flooringMetrics(options.cuts, project.flooring);
|
|
const random = flooringMetrics(options.random, project.flooring);
|
|
expect(compareCurrentFlooring(cuts, random)).toBeLessThanOrEqual(0);
|
|
expect(compareCurrentFlooring(random, cuts, true)).toBeLessThanOrEqual(0);
|
|
// Only this fixture's 600 mm minimum pieces force alternating starts.
|
|
if (name !== 'forced-alternation') expect(random.nearH).toBe(0);
|
|
validateFlooring(options.cuts, project);
|
|
if (options.random !== options.cuts) validateFlooring(options.random, project);
|
|
expect(skirt.errors).toBe(0);
|
|
expect(JSON.stringify(project)).toBe(input);
|
|
},
|
|
);
|
|
|
|
it('retains the measured improvements across the complete fixed corpus', () => {
|
|
const totals = { cuts: 0, randomCuts: 0, skirtingCuts: 0 };
|
|
for (const c of cases) {
|
|
const { options, skirt } = resultFor(c);
|
|
totals.cuts += options.cuts.cuts;
|
|
totals.randomCuts += options.random.cuts;
|
|
totals.skirtingCuts += skirt.cuts;
|
|
}
|
|
// Keep a substantial cuts-first gain and let appearance-first spend cuts on joint spacing.
|
|
const originalCuts = baseline.reduce((sum, row) => sum + row.flooring.cuts, 0);
|
|
const originalSkirting = baseline.reduce((sum, row) => sum + row.skirting.cuts, 0);
|
|
expect(totals.cuts).toBeLessThan(originalCuts * 0.81);
|
|
expect(totals.randomCuts).toBeLessThan(originalCuts);
|
|
expect(totals.skirtingCuts).toBeLessThan(originalSkirting);
|
|
});
|
|
|
|
it('packs reserved offcuts before applying the 2000-board cap on a 500-row floor', () => {
|
|
const project = defaultProject();
|
|
project.flooring.pattern = 'brick';
|
|
project.rooms = [rectangle('limit', 'Limit', 4520, 95020)];
|
|
const plan = planFlooringOptions(project.rooms, project.flooring, 1).cuts;
|
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
|
expect(plan.rows).toHaveLength(500);
|
|
expect(plan.boardCount).toBe(2000);
|
|
expect(plan.cuts).toBe(750);
|
|
expect(plan.coveredArea).toBe(4500 * 95000);
|
|
});
|
|
|
|
it.each(Array.from({ length: 10 }, (_, i) => i + 1))(
|
|
'avoids broad H joints with independent room seed %i',
|
|
(seed) => {
|
|
const project = defaultProject();
|
|
project.rooms = [
|
|
seed % 2 ? rectangle(`holdout-${seed}`) : fireplace(`holdout-${seed}`, 'Fireplace'),
|
|
];
|
|
const plan = planFlooringOptions(project.rooms, project.flooring, project.tolerance).random;
|
|
expect(flooringMetrics(plan, project.flooring).nearH).toBe(0);
|
|
expect(plan.boardCount).toBeLessThanOrEqual(seed % 2 ? 76 : 74);
|
|
validateFlooring(plan, project);
|
|
},
|
|
);
|
|
});
|