This commit is contained in:
Andras Schmelczer 2026-09-11 22:14:24 +01:00
commit 0c0f08b350
45 changed files with 1565 additions and 1579 deletions

View file

@ -1,6 +1,7 @@
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
import { isSkirtingCut } from './cut-list';
import { cutLength } from './format';
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
import type {
Issue,
Point,
@ -12,7 +13,8 @@ import type {
WallEdge,
} from './types';
const EPS = 1e-6;
const EPS = PRECISION.geometry;
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength'>;
const rad = (degrees: number) => (degrees * Math.PI) / 180;
type End = {
type: SkirtingPiece['leftType'];
@ -117,7 +119,7 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
};
}
function endCuts(piece: SkirtingPiece) {
function endCuts(piece: UnpackedPiece) {
return (
Number(Math.abs(piece.leftMitre) > EPS) +
Number(Math.abs(piece.rightMitre) > EPS) +
@ -126,15 +128,15 @@ function endCuts(piece: SkirtingPiece) {
);
}
function blankLength(piece: SkirtingPiece, available: number) {
function blankLength(piece: UnpackedPiece, available: number) {
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
}
function requiredCuts(piece: SkirtingPiece, available: number) {
function requiredCuts(piece: UnpackedPiece, available: number) {
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
}
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
function consumedLength(piece: UnpackedPiece, available: number, kerf: number) {
// Cut a square blank, then bevel within its longest-point envelope. This leaves
// a square reusable offcut. Bevel kerfs remove material inside the blank.
const length = blankLength(piece, available);
@ -147,7 +149,7 @@ function makePiece(
to: number,
first: boolean,
last: boolean,
): SkirtingPiece {
): UnpackedPiece {
const left = first ? run.left : square(),
right = last ? run.right : square();
const backFrom = from + left.back,
@ -189,16 +191,16 @@ function makePiece(
};
}
function shortestEdge(piece: SkirtingPiece) {
const installed = piece.installedTemplate!;
function shortestEdge(piece: UnpackedPiece) {
const installed = piece.installedTemplate;
return Math.min(
installed[1].x - installed[0].x,
installed[2].x - installed[3].x,
piece.visibleLength!,
piece.visibleLength,
);
}
function fits(piece: SkirtingPiece, available: number) {
function fits(piece: UnpackedPiece, available: number) {
// The final saw pass may extend beyond the stock's tail.
return blankLength(piece, available) <= available + EPS;
}
@ -211,7 +213,7 @@ function splitCount(run: Run, target: number, minimum: number): number {
const count = Math.max(2, Math.ceil((full.length - EPS) / target));
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
if (
count > 10000 ||
count > PLANNER_LIMITS.skirtingPieces ||
run.to - run.from < minimumSpan - EPS ||
firstAllowance(run.left) - Math.min(run.left.back, run.left.front) > target - minimum + EPS ||
Math.max(run.right.back, run.right.front) - lastAllowance(run.right) > target - minimum + EPS
@ -226,7 +228,7 @@ function splitRun(
target: number,
settings: SkirtingSettings,
balanced: boolean,
): SkirtingPiece[] | null {
): UnpackedPiece[] | null {
const count = splitCount(run, target, settings.minLength);
if (!count) return null;
if (count === 1) return [makePiece(run, run.from, run.to, true, true)];
@ -291,8 +293,8 @@ function splitWithOffcuts(
runs: Run[],
target: number,
settings: SkirtingSettings,
): SkirtingPiece[] | null {
const pieces: SkirtingPiece[] = [];
): UnpackedPiece[] | null {
const pieces: UnpackedPiece[] = [];
const offcuts: number[] = [];
for (const run of runs) {
if (!splitCount(run, target, settings.minLength)) return null;
@ -322,7 +324,7 @@ function splitWithOffcuts(
bFits = fits(full, b);
return aFits !== bFits ? Number(bFits) - Number(aFits) : aFits ? a - b : b - a;
});
let next: { piece: SkirtingPiece; to: number } | null = null;
let next: { piece: UnpackedPiece; to: number } | null = null;
let available = target;
for (let index = 0; index < offcuts.length; index++) {
next = take(offcuts[index]);
@ -332,7 +334,7 @@ function splitWithOffcuts(
}
}
next ??= take(target);
if (!next || pieces.length >= 10000) return null;
if (!next || pieces.length >= PLANNER_LIMITS.skirtingPieces) return null;
pieces.push(next.piece);
const tail = available - consumedLength(next.piece, available, settings.kerf);
if (tail >= settings.minLength - EPS) offcuts.push(tail);
@ -349,14 +351,14 @@ interface Packed {
waste: number;
}
/** Best-fit decreasing across all rooms, using one purchased board length. */
/** Greedy decreasing packing, optionally reserving exact three-piece combinations. */
function pack(
source: SkirtingPiece[],
source: UnpackedPiece[],
stockLength: number,
kerf: number,
exactTails = false,
): Packed {
const pieces = source.map((piece) => ({ ...piece }));
const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0 }));
let pending = [...pieces].sort(
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
);
@ -425,16 +427,16 @@ export function planSkirting(
if (
!Number.isFinite(settings.stockLength) ||
settings.stockLength < 0.1 ||
settings.stockLength > 1e6 ||
settings.stockLength > PROJECT_LIMITS.maxMeasurement ||
!Number.isFinite(settings.thickness) ||
settings.thickness < 0 ||
settings.thickness > 10000 ||
settings.thickness > PROJECT_LIMITS.maxSkirtingThickness ||
!Number.isFinite(settings.minLength) ||
settings.minLength < 0.1 ||
settings.minLength > 1e6 ||
settings.minLength > PROJECT_LIMITS.maxMeasurement ||
!Number.isFinite(settings.kerf) ||
settings.kerf < 0 ||
settings.kerf > 10000
settings.kerf > PROJECT_LIMITS.maxSkirtingKerf
) {
issues.push({
severity: 'error',
@ -445,7 +447,7 @@ export function planSkirting(
}
const stockLength = settings.stockLength;
let runs: Run[] = [];
const fallbackSplits = new Map<Run, SkirtingPiece[]>();
const fallbackSplits = new Map<Run, UnpackedPiece[]>();
rooms.forEach((room, roomIndex) => {
const geometry = buildGeometry(room, tolerance);
if (!geometry.valid) {
@ -533,7 +535,7 @@ export function planSkirting(
const candidates: Packed[] = [];
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
const addCandidate = (source: SkirtingPiece[]) => {
const addCandidate = (source: UnpackedPiece[]) => {
// Raw spans can fit while their displayed half-millimetre blanks do not.
// Reject this split without losing other feasible joint arrangements.
if (
@ -587,7 +589,7 @@ export function planSkirting(
issues.push({
severity: 'info',
message:
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, then prefers less waste and fewer cuts; it is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, prioritising fewer cuts and then less waste. Exact combinations can save a trimming pass. This is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
});
const plan = { ...best, stockCount: best.stocks.length, issues };
const cutNumbers = new Map<string, number>();