floor-planner/src/lib/skirting.ts
2026-09-11 22:14:24 +01:00

611 lines
22 KiB
TypeScript

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,
Room,
SkirtingPiece,
SkirtingPlan,
SkirtingSettings,
SkirtingStock,
WallEdge,
} from './types';
const EPS = PRECISION.geometry;
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength'>;
const rad = (degrees: number) => (degrees * Math.PI) / 180;
type End = {
type: SkirtingPiece['leftType'];
angle: number;
bevel: number;
back: number;
front: number;
installedBack: number;
installedFront: number;
visible: number;
};
type Run = {
roomId: string;
prefix: string;
edge: WallEdge;
from: number;
to: number;
left: End;
right: End;
thickness: number;
};
const square = (): End => ({
type: 'square',
angle: 0,
bevel: 0,
back: 0,
front: 0,
installedBack: 0,
installedFront: 0,
visible: 0,
});
const firstAllowance = (end: End) => Math.max(end.installedBack, end.installedFront, end.visible);
const lastAllowance = (end: End) => Math.min(end.installedBack, end.installedFront, end.visible);
function roomPrefix(index: number): string {
let value = index + 1,
result = '';
while (value > 0) {
value--;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result;
}
function pointAt(edge: WallEdge, offset: number): Point {
const ratio = offset / edge.length;
return {
x: edge.start.x + (edge.end.x - edge.start.x) * ratio,
y: edge.start.y + (edge.end.y - edge.start.y) * ratio,
};
}
/**
* Copes retain the inside-mitre long point at the wall corner. The moulding's
* actual profile is then scribed and back-cut to fit the receiving piece.
* https://www.finehomebuilding.com/pdf/021174063.pdf (Gary Katz): inside cuts use the back
* long point and fit the receiving end first. For a turn t, intersecting faces
* meet thickness*tan(t/2) from the corner; the full-depth body starts at thickness*csc(t).
*/
function corner(turn: number, thickness: number, side: 'left' | 'right', cope: boolean): End {
if (Math.abs(turn) < EPS || thickness < EPS) return square();
const sign = side === 'left' ? 1 : -1;
if (turn < 0) {
const front = sign * thickness * Math.tan(rad(turn / 2));
return {
type: 'external',
angle: -turn / 2,
bevel: (sign * turn) / 2,
back: 0,
front,
installedBack: 0,
installedFront: front,
visible: front,
};
}
const faceJoint = sign * thickness * Math.tan(rad(turn / 2));
if (cope) {
return {
type: 'cope',
angle: turn / 2,
bevel: (sign * turn) / 2,
back: 0,
front: faceJoint,
installedBack: (sign * thickness) / Math.sin(rad(turn)),
installedFront: faceJoint,
visible: faceJoint,
};
}
// A square receiving end fits a right-angle wall; an oblique wall needs a
// wall-fitting bevel. Its face extends past the visible joint under the cope.
const front = (-sign * thickness) / Math.tan(rad(turn));
return {
type: 'internal',
angle: Math.abs(90 - turn),
bevel: sign * (turn - 90),
back: 0,
front,
installedBack: 0,
installedFront: front,
visible: faceJoint,
};
}
function endCuts(piece: UnpackedPiece) {
return (
Number(Math.abs(piece.leftMitre) > EPS) +
Number(Math.abs(piece.rightMitre) > EPS) +
Number(piece.leftType === 'cope') +
Number(piece.rightType === 'cope')
);
}
function blankLength(piece: UnpackedPiece, available: number) {
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
}
function requiredCuts(piece: UnpackedPiece, available: number) {
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
}
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);
return Math.min(available, length + (Math.abs(available - length) <= EPS ? 0 : kerf));
}
function makePiece(
run: Run,
from: number,
to: number,
first: boolean,
last: boolean,
): UnpackedPiece {
const left = first ? run.left : square(),
right = last ? run.right : square();
const backFrom = from + left.back,
backTo = to + right.back;
const frontFrom = from + left.front,
frontTo = to + right.front;
const origin = Math.min(backFrom, frontFrom);
return {
id: '',
roomId: run.roomId,
wallId: run.edge.wallId,
wallIndex: run.edge.index,
start: pointAt(run.edge, backFrom),
end: pointAt(run.edge, backTo),
backLength: backTo - backFrom,
length: Math.max(backTo, frontTo) - Math.min(backFrom, frontFrom),
leftMitre: left.angle,
rightMitre: right.angle,
leftType: left.type,
rightType: right.type,
stockId: '',
visibleLength: to + right.visible - from - left.visible,
leftOverlap: Math.abs(left.visible - (left.type === 'cope' ? left.back : left.front)),
rightOverlap: Math.abs(right.visible - (right.type === 'cope' ? right.back : right.front)),
installedTemplate: [
{ x: from + left.installedBack - origin, y: 0 },
{ x: to + right.installedBack - origin, y: 0 },
{ x: to + right.installedFront - origin, y: run.thickness },
{ x: from + left.installedFront - origin, y: run.thickness },
],
leftBevel: left.bevel,
rightBevel: right.bevel,
template: [
{ x: backFrom - origin, y: 0 },
{ x: backTo - origin, y: 0 },
{ x: frontTo - origin, y: run.thickness },
{ x: frontFrom - origin, y: run.thickness },
],
};
}
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,
);
}
function fits(piece: UnpackedPiece, available: number) {
// The final saw pass may extend beyond the stock's tail.
return blankLength(piece, available) <= available + EPS;
}
/** Zero means that no number of pieces can satisfy the end allowances. */
function splitCount(run: Run, target: number, minimum: number): number {
const full = makePiece(run, run.from, run.to, true, true);
if (shortestEdge(full) < minimum - EPS) return 0;
if (fits(full, target)) return 1;
const count = Math.max(2, Math.ceil((full.length - EPS) / target));
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
if (
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
)
return 0;
return count;
}
/** Split along the wall while reserving both front and back corner allowances. */
function splitRun(
run: Run,
target: number,
settings: SkirtingSettings,
balanced: boolean,
): 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)];
const span = run.to - run.from;
// A piece's shortest edge sets its minimum span. Its longest point sets
// its maximum span. These differ at angled corners, at receiving and coped corners.
const minimums = Array.from(
{ length: count },
(_, index) =>
settings.minLength +
(index === 0 ? firstAllowance(run.left) : 0) -
(index === count - 1 ? lastAllowance(run.right) : 0),
);
const maximums = Array.from(
{ length: count },
(_, index) =>
target +
(index === 0 ? Math.min(run.left.back, run.left.front) : 0) -
(index === count - 1 ? Math.max(run.right.back, run.right.front) : 0),
);
let extra = span - minimums.reduce((sum, value) => sum + value, 0);
const spans = [...minimums];
if (balanced) {
// Equal extra length, capped by each end's allowance; at most three
// distinct capacities exist (first, middle, last).
let active = spans.map((_, index) => index);
while (extra > EPS && active.length) {
const share = extra / active.length;
for (const index of active) {
const addition = Math.min(share, maximums[index] - spans[index]);
spans[index] += addition;
extra -= addition;
}
active = active.filter((index) => maximums[index] - spans[index] > EPS);
}
} else {
for (let index = 0; index < count; index++) {
const addition = Math.min(extra, maximums[index] - spans[index]);
spans[index] += addition;
extra -= addition;
}
}
let from = run.from;
const pieces = spans.map((span, index) => {
const to = index === count - 1 ? run.to : from + span;
const piece = makePiece(run, from, to, index === 0, index === count - 1);
from = to;
return piece;
});
return pieces.every(
(piece) => fits(piece, target) && shortestEdge(piece) >= settings.minLength - EPS,
)
? pieces
: null;
}
/** Let useful tails set the next joint instead of fixing every wall's joints first. */
function splitWithOffcuts(
runs: Run[],
target: number,
settings: SkirtingSettings,
): UnpackedPiece[] | null {
const pieces: UnpackedPiece[] = [];
const offcuts: number[] = [];
for (const run of runs) {
if (!splitCount(run, target, settings.minLength)) return null;
let from = run.from;
while (from < run.to - EPS) {
const first = Math.abs(from - run.from) < EPS;
const remaining = { ...run, from, left: first ? run.left : square() };
const full = makePiece(remaining, from, run.to, true, true);
const take = (available: number) => {
if (fits(full, available)) return { piece: full, to: run.to };
const to = Math.min(
from + available + Math.min(remaining.left.back, remaining.left.front),
run.to + lastAllowance(run.right) - settings.minLength,
);
if (to <= from + EPS) return null;
const piece = makePiece(remaining, from, to, true, false);
if (shortestEdge(piece) < settings.minLength - EPS || !fits(piece, available)) return null;
// Reusing a tail must still leave a feasible remainder at the far corner.
return splitCount({ ...run, from: to, left: square() }, target, settings.minLength)
? { piece, to }
: null;
};
// Finish from the smallest fitting offcut; otherwise use the largest
// usable tail to avoid adding unnecessary joints.
offcuts.sort((a, b) => {
const aFits = fits(full, a),
bFits = fits(full, b);
return aFits !== bFits ? Number(bFits) - Number(aFits) : aFits ? a - b : b - a;
});
let next: { piece: UnpackedPiece; to: number } | null = null;
let available = target;
for (let index = 0; index < offcuts.length; index++) {
next = take(offcuts[index]);
if (next) {
available = offcuts.splice(index, 1)[0];
break;
}
}
next ??= take(target);
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);
from = next.to;
}
}
return pieces;
}
interface Packed {
pieces: SkirtingPiece[];
stocks: SkirtingStock[];
cuts: number;
waste: number;
}
/** Greedy decreasing packing, optionally reserving exact three-piece combinations. */
function pack(
source: UnpackedPiece[],
stockLength: number,
kerf: number,
exactTails = false,
): Packed {
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),
);
const stocks: SkirtingStock[] = [];
let cuts = 0;
while (pending.length) {
let available = stockLength;
const assigned: SkirtingPiece[] = [];
if (exactTails && pending.length > 2) {
const head = pending[0];
const tail = stockLength - consumedLength(head, stockLength, kerf);
const options = pending.slice(1, 129).filter((piece) => fits(piece, tail));
for (const second of options) {
const remaining = tail - consumedLength(second, tail, kerf);
const third = options.find(
(piece) => piece !== second && Math.abs(piece.length - remaining) <= EPS,
);
if (!third) continue;
pending = [
head,
second,
third,
...pending.filter((piece) => piece !== head && piece !== second && piece !== third),
];
break;
}
}
for (const piece of pending) {
if (!fits(piece, available)) continue;
piece.blankLength = blankLength(piece, available);
cuts += requiredCuts(piece, available);
available -= consumedLength(piece, available, kerf);
assigned.push(piece);
}
if (!assigned.length) throw new Error('A skirting piece does not fit the available stock.');
const id = `S${stocks.length + 1}`;
for (const piece of assigned) piece.stockId = id;
const used = assigned.reduce((sum, piece) => sum + piece.length, 0);
stocks.push({
id,
length: stockLength,
used,
waste: Math.max(0, stockLength - used),
pieceIds: assigned.map((piece) => piece.id),
});
const assignedIds = new Set(assigned.map((piece) => piece.id));
pending = pending.filter((piece) => !assignedIds.has(piece.id));
}
return { pieces, stocks, cuts, waste: stocks.reduce((sum, stock) => sum + stock.waste, 0) };
}
export function planSkirting(
rooms: Room[],
settings: SkirtingSettings,
tolerance: number,
): SkirtingPlan {
const issues: Issue[] = [];
const empty = (): SkirtingPlan => ({
pieces: [],
stocks: [],
stockCount: 0,
cuts: 0,
waste: 0,
issues,
});
if (
!Number.isFinite(settings.stockLength) ||
settings.stockLength < 0.1 ||
settings.stockLength > PROJECT_LIMITS.maxMeasurement ||
!Number.isFinite(settings.thickness) ||
settings.thickness < 0 ||
settings.thickness > PROJECT_LIMITS.maxSkirtingThickness ||
!Number.isFinite(settings.minLength) ||
settings.minLength < 0.1 ||
settings.minLength > PROJECT_LIMITS.maxMeasurement ||
!Number.isFinite(settings.kerf) ||
settings.kerf < 0 ||
settings.kerf > PROJECT_LIMITS.maxSkirtingKerf
) {
issues.push({
severity: 'error',
message:
'Use skirting stock and minimum piece lengths between 0.1 and 1,000,000 mm, with thickness and saw kerf between 0 and 10,000 mm.',
});
return empty();
}
const stockLength = settings.stockLength;
let runs: Run[] = [];
const fallbackSplits = new Map<Run, UnpackedPiece[]>();
rooms.forEach((room, roomIndex) => {
const geometry = buildGeometry(room, tolerance);
if (!geometry.valid) {
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}: finish a valid outline and door measurements before planning skirting.`,
});
return;
}
const orientation = Math.sign(signedArea(geometry.vertices));
geometry.edges.forEach((edge, index, edges) => {
const previous = edges[(index + edges.length - 1) % edges.length],
next = edges[(index + 1) % edges.length];
const previousHasEnd = !previous.doors.some(
(door) => door.offset + door.width >= previous.length - EPS,
);
const nextHasStart = !next.doors.some((door) => door.offset <= EPS);
const left = previousHasEnd
? corner(
normalizeAngle(edge.heading - previous.heading) * orientation,
settings.thickness,
'left',
index > previous.index,
)
: square();
const right = nextHasStart
? corner(
normalizeAngle(next.heading - edge.heading) * orientation,
settings.thickness,
'right',
index > next.index,
)
: square();
const doors = [...edge.doors].sort((a, b) => a.offset - b.offset);
let from = 0;
for (const door of [...doors, { id: 'end', offset: edge.length, width: 0 }]) {
if (door.offset > from + EPS) {
const run = {
roomId: room.id,
prefix: roomPrefix(roomIndex),
edge,
from,
to: door.offset,
thickness: settings.thickness,
left: from < EPS ? left : square(),
right: door.offset >= edge.length - EPS ? right : square(),
};
if (
shortestEdge(makePiece(run, from, door.offset, true, true)) <
settings.minLength - EPS
) {
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}, wall ${index + 1}: a skirting run after corner allowances is shorter than the ${settings.minLength} mm minimum. Change the minimum or opening measurements.`,
});
} else {
// Establish a feasible rounded split per run before combining rooms.
// A fractional stock length may need offcut-led extra joints.
const split =
splitRun(run, stockLength, settings, false) ??
splitRun(run, stockLength, settings, true) ??
splitWithOffcuts([run], stockLength, settings);
if (split) fallbackSplits.set(run, split);
else
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the rounded minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`,
});
}
runs.push(run);
}
from = door.offset + door.width;
}
});
});
// An unfinished or infeasible room must not erase another room's valid plan.
const invalidRooms = new Set(
issues.filter((issue) => issue.severity === 'error').map((issue) => issue.roomId),
);
runs = runs.filter((run) => !invalidRooms.has(run.roomId));
if (!runs.length) return empty();
const candidates: Packed[] = [];
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
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 (
source.some(
(piece) => !fits(piece, stockLength) || shortestEdge(piece) < settings.minLength - EPS,
)
)
return;
const counts = new Map<string, number>();
for (const piece of source) {
const count = (counts.get(piece.roomId) ?? 0) + 1;
counts.set(piece.roomId, count);
piece.id = `${prefixes.get(piece.roomId)}${count}`;
}
candidates.push(pack(source, stockLength, settings.kerf));
candidates.push(pack(source, stockLength, settings.kerf, true));
};
// Compare full-stock, balanced, and offcut-led wall joints.
{
const target = stockLength;
for (const balanced of [false, true]) {
const splits = runs.map(
(run) => splitRun(run, target, settings, balanced) ?? fallbackSplits.get(run)!,
);
addCandidate(splits.flat());
}
const reused = splitWithOffcuts(runs, target, settings);
if (reused) addCandidate(reused);
}
candidates.sort(
(a, b) =>
a.cuts - b.cuts ||
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
a.stocks.length - b.stocks.length ||
a.pieces.length - b.pieces.length,
);
const best = candidates[0];
if (!best) {
issues.push({
severity: 'error',
message:
'The stock lengths cannot fit the minimum skirting pieces, corner allowances and saw kerf. Increase stock length or reduce the minimum.',
});
return empty();
}
issues.push({
severity: 'info',
message:
'Lengths are longest-point preparation blanks, including the material retained for internal coping. The earlier wall receives each cope; install it first. A cope uses an inside mitre to expose the profile, then a scribed back-cut. The actual decorative profile must be marked from your moulding. Templates show preparation cuts; the plan shows the full-thickness installed body. Minimum length applies to the exposed face and installed body. External corners use equal mitres. Door widths include any trim to omit.',
});
issues.push({
severity: 'info',
message:
'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>();
let wholeNumber = 0;
const codes = new Map(
plan.pieces.map((piece) => {
if (!isSkirtingCut(piece, plan)) return [piece.id, `SU${++wholeNumber}`];
const number = (cutNumbers.get(piece.roomId) ?? 0) + 1;
cutNumbers.set(piece.roomId, number);
return [piece.id, `${prefixes.get(piece.roomId)}${number}`];
}),
);
const label = (id: string) => codes.get(id)!;
return {
...plan,
pieces: plan.pieces.map((piece) => ({ ...piece, id: label(piece.id) })),
stocks: plan.stocks.map((stock) => ({ ...stock, pieceIds: stock.pieceIds.map(label) })),
};
}