611 lines
22 KiB
TypeScript
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) })),
|
|
};
|
|
}
|