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

View file

@ -223,7 +223,12 @@ describe('laminate planning', () => {
] 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 };
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));
@ -296,8 +301,9 @@ describe('laminate planning', () => {
});
it('plans each room independently and can reuse compatible end offcuts across directions', () => {
const a = room(1820, 1220, '1');
const b = room(1220, 1820, '2');
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 =

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.
});
});

View file

@ -221,13 +221,13 @@ describe('skirting cutting plan', () => {
expect(failures.every((issue) => issue.roomId === invalid.id)).toBe(true);
});
it('lets reused tails set wall joints while counting preparation and coping operations', () => {
it('prefers one fewer saw pass over saving a stock board on a square room', () => {
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(13);
expect(plan.waste).toBeCloseTo(7 * 2400 - 16000);
expect(plan.stockCount).toBe(8); // Seven boards require thirteen preparation/coping passes.
expect(plan.cuts).toBe(12);
expect(plan.waste).toBeCloseTo(8 * 2400 - 16000);
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(4000);

View file

@ -393,7 +393,9 @@ describe('URL state', () => {
};
expect(decodeState(rawLink(legacy))).toEqual({ state, error: null });
expect(
decodeState('?skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6'),
decodeState(
'?flooring.optimisation=cuts&skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6',
),
).toEqual({ state, error: null });
});
@ -523,7 +525,9 @@ describe('URL state', () => {
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'floor:B4'];
expect(() => encodeState(state)).toThrow(/duplicate entries/);
const distinct = decodeState('?skirting.length=2400&done=floor:B1&done=skirting:S1');
const distinct = decodeState(
'?flooring.optimisation=cuts&skirting.length=2400&done=floor:B1&done=skirting:S1',
);
expect(distinct.error).toBeNull();
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
});
@ -573,6 +577,51 @@ describe('URL state', () => {
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
});
it.each(['cuts', 'random'] as const)(
'saves the %s optimisation and current completion marks',
(optimisation) => {
const state = defaultState();
state.project.flooring.optimisation = optimisation;
state.project.completedBoards = ['floor:B4'];
const query = encodeState(state);
expect(query).toContain(`flooring.optimisation=${optimisation}`);
expect(decodeState(query)).toEqual({ state, error: null });
},
);
it('migrates the previous optimiser without carrying completion onto a different allocation', () => {
const state = defaultState();
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
const query = new URLSearchParams(encodeState(state));
query.delete('flooring.optimisation');
const old = {
...state,
project: {
...state.project,
flooring: { ...state.project.flooring, optimisation: undefined },
},
};
for (const link of [`?${query}`, rawLink(old)]) {
const migrated = decodeState(link);
expect(migrated.error).toBeNull();
expect(migrated.state.project.flooring.optimisation).toBe('cuts');
expect(migrated.state.project.completedBoards).toEqual([]);
migrated.state.project.completedBoards = ['floor:B1'];
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
}
});
it.each([null, 42, true, {}, 'waste'])('rejects unsupported optimisation %j', (optimisation) => {
const state = defaultState();
expectInvalid(
rawLink({
...state,
project: { ...state.project, flooring: { ...state.project.flooring, optimisation } },
}),
/Flooring optimisation/,
);
});
it('rejects oversized compressed and expanded payloads', () => {
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
expectInvalid(