This commit is contained in:
parent
7959fe85cc
commit
b9485e4097
32 changed files with 4692 additions and 65 deletions
144
tests/optimisation.test.ts
Normal file
144
tests/optimisation.test.ts
Normal 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.
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue