landing
All checks were successful
Deploy to Pages / build (push) Successful in 26s

This commit is contained in:
Andras Schmelczer 2026-09-11 20:34:36 +01:00
commit b9485e4097
32 changed files with 4692 additions and 65 deletions

144
tests/optimisation.test.ts Normal file
View file

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { plannerCases } from '../benchmarks/cases';
import {
compareFlooring,
compareSkirting,
flooringMetrics,
skirtingMetrics,
} from '../benchmarks/metrics';
import baseline from '../benchmarks/baseline.json';
import { planFlooringOptions } from '../src/lib/flooring';
import { planSkirting } from '../src/lib/skirting';
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);
const before = baseline.find((row) => row.name === name)!;
expect(compareFlooring(cuts, before.flooring)).toBeLessThanOrEqual(0);
expect(compareFlooring(cuts, random)).toBeLessThanOrEqual(0);
expect(compareFlooring(random, cuts, true)).toBeLessThanOrEqual(0);
validateFlooring(options.cuts, project);
if (options.random !== options.cuts) validateFlooring(options.random, project);
expect(compareSkirting(skirt, before.skirting)).toBeLessThanOrEqual(0);
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, pattern: 0, skirtingCuts: 0 };
for (const c of cases) {
const { options, skirt } = resultFor(c);
totals.cuts += options.cuts.cuts;
totals.randomCuts += options.random.cuts;
totals.pattern += flooringMetrics(options.random, c.project.flooring).patternPenalty;
totals.skirtingCuts += skirt.cuts;
}
expect(totals.cuts).toBeLessThanOrEqual(1840); // Original: 2177.
expect(totals.randomCuts).toBeLessThanOrEqual(2130);
expect(totals.pattern).toBeLessThanOrEqual(129); // Original: 265.
expect(totals.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447.
});
});