This commit is contained in:
parent
7959fe85cc
commit
b9485e4097
32 changed files with 4692 additions and 65 deletions
|
|
@ -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
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.
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue