reviewed
This commit is contained in:
parent
62a385e4c9
commit
7959fe85cc
29 changed files with 1679 additions and 498 deletions
|
|
@ -20,6 +20,9 @@ type End = {
|
|||
bevel: number;
|
||||
back: number;
|
||||
front: number;
|
||||
installedBack: number;
|
||||
installedFront: number;
|
||||
visible: number;
|
||||
};
|
||||
type Run = {
|
||||
roomId: string;
|
||||
|
|
@ -31,7 +34,18 @@ type Run = {
|
|||
right: End;
|
||||
thickness: number;
|
||||
};
|
||||
const square = (): End => ({ type: 'square', angle: 0, bevel: 0, back: 0, front: 0 });
|
||||
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,
|
||||
|
|
@ -52,39 +66,64 @@ function pointAt(edge: WallEdge, offset: number): Point {
|
|||
};
|
||||
}
|
||||
|
||||
/** At internal corners the previous wall runs through; the next butts against its face. */
|
||||
function corner(turn: number, thickness: number, side: 'left' | 'right'): End {
|
||||
if (Math.abs(turn) < EPS) return square();
|
||||
/**
|
||||
* 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 extension = thickness * Math.tan(rad(-turn / 2));
|
||||
const front = sign * thickness * Math.tan(rad(turn / 2));
|
||||
return {
|
||||
type: 'external',
|
||||
angle: -turn / 2,
|
||||
bevel: side === 'left' ? turn / 2 : -turn / 2,
|
||||
bevel: (sign * turn) / 2,
|
||||
back: 0,
|
||||
front: side === 'left' ? -extension : extension,
|
||||
front,
|
||||
installedBack: 0,
|
||||
installedFront: front,
|
||||
visible: front,
|
||||
};
|
||||
}
|
||||
const angle = Math.abs(90 - turn);
|
||||
return side === 'left'
|
||||
? {
|
||||
type: 'internal',
|
||||
angle,
|
||||
bevel: turn - 90,
|
||||
back: thickness / Math.sin(rad(turn)),
|
||||
front: thickness * Math.tan(rad(turn / 2)),
|
||||
}
|
||||
: {
|
||||
type: 'internal',
|
||||
angle,
|
||||
bevel: 90 - turn,
|
||||
back: 0,
|
||||
front: thickness / Math.tan(rad(turn)),
|
||||
};
|
||||
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: SkirtingPiece) {
|
||||
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
||||
return (
|
||||
Number(Math.abs(piece.leftMitre) > EPS) +
|
||||
Number(Math.abs(piece.rightMitre) > EPS) +
|
||||
Number(piece.leftType === 'cope') +
|
||||
Number(piece.rightType === 'cope')
|
||||
);
|
||||
}
|
||||
|
||||
function blankLength(piece: SkirtingPiece, available: number) {
|
||||
|
|
@ -92,7 +131,7 @@ function blankLength(piece: SkirtingPiece, available: number) {
|
|||
}
|
||||
|
||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||
return endCuts(piece) + Number(Math.abs(available - blankLength(piece, available)) > EPS);
|
||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||
}
|
||||
|
||||
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
||||
|
|
@ -130,6 +169,15 @@ function makePiece(
|
|||
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: [
|
||||
|
|
@ -142,7 +190,12 @@ function makePiece(
|
|||
}
|
||||
|
||||
function shortestEdge(piece: SkirtingPiece) {
|
||||
return Math.min(piece.backLength, piece.template![2].x - piece.template![3].x);
|
||||
const installed = piece.installedTemplate!;
|
||||
return Math.min(
|
||||
installed[1].x - installed[0].x,
|
||||
installed[2].x - installed[3].x,
|
||||
piece.visibleLength!,
|
||||
);
|
||||
}
|
||||
|
||||
function fits(piece: SkirtingPiece, available: number) {
|
||||
|
|
@ -156,15 +209,12 @@ function splitCount(run: Run, target: number, minimum: number): number {
|
|||
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 +
|
||||
Math.max(run.left.back, run.left.front) -
|
||||
Math.min(run.right.back, run.right.front);
|
||||
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
|
||||
if (
|
||||
count > 10000 ||
|
||||
run.to - run.from < minimumSpan - EPS ||
|
||||
Math.abs(run.left.back - run.left.front) > target - minimum + EPS ||
|
||||
Math.abs(run.right.back - run.right.front) > target - minimum + 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;
|
||||
|
|
@ -183,13 +233,13 @@ function splitRun(
|
|||
|
||||
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, even for butt joints.
|
||||
// its maximum span. These differ at angled corners, at receiving and coped corners.
|
||||
const minimums = Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
settings.minLength +
|
||||
(index === 0 ? Math.max(run.left.back, run.left.front) : 0) -
|
||||
(index === count - 1 ? Math.min(run.right.back, run.right.front) : 0),
|
||||
(index === 0 ? firstAllowance(run.left) : 0) -
|
||||
(index === count - 1 ? lastAllowance(run.right) : 0),
|
||||
);
|
||||
const maximums = Array.from(
|
||||
{ length: count },
|
||||
|
|
@ -223,12 +273,17 @@ function splitRun(
|
|||
}
|
||||
|
||||
let from = run.from;
|
||||
return spans.map((span, index) => {
|
||||
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. */
|
||||
|
|
@ -250,7 +305,7 @@ function splitWithOffcuts(
|
|||
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 + Math.min(run.right.back, run.right.front) - settings.minLength,
|
||||
run.to + lastAllowance(run.right) - settings.minLength,
|
||||
);
|
||||
if (to <= from + EPS) return null;
|
||||
const piece = makePiece(remaining, from, to, true, false);
|
||||
|
|
@ -294,8 +349,8 @@ interface Packed {
|
|||
waste: number;
|
||||
}
|
||||
|
||||
/** Best-fit decreasing with one-stock lookahead across all rooms and available stock sizes. */
|
||||
function pack(source: SkirtingPiece[], stockLengths: number[], kerf: number): Packed {
|
||||
/** Best-fit decreasing across all rooms, using one purchased board length. */
|
||||
function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packed {
|
||||
const pieces = source.map((piece) => ({ ...piece }));
|
||||
let pending = [...pieces].sort(
|
||||
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
|
|
@ -303,47 +358,28 @@ function pack(source: SkirtingPiece[], stockLengths: number[], kerf: number): Pa
|
|||
const stocks: SkirtingStock[] = [];
|
||||
let cuts = 0;
|
||||
while (pending.length) {
|
||||
const options = stockLengths
|
||||
.filter((length) => fits(pending[0], length))
|
||||
.map((length) => {
|
||||
let available = length,
|
||||
optionCuts = 0;
|
||||
const assigned: SkirtingPiece[] = [];
|
||||
for (const piece of pending) {
|
||||
if (fits(piece, available)) {
|
||||
const pieceCuts = requiredCuts(piece, available);
|
||||
available -= consumedLength(piece, available, kerf);
|
||||
optionCuts += pieceCuts;
|
||||
assigned.push(piece);
|
||||
}
|
||||
}
|
||||
const used = assigned.reduce((sum, piece) => sum + piece.length, 0);
|
||||
return { length, assigned, used, cuts: optionCuts, waste: length - used };
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.waste / a.used - b.waste / b.used ||
|
||||
a.waste - b.waste ||
|
||||
a.cuts - b.cuts ||
|
||||
a.length - b.length,
|
||||
);
|
||||
const best = options[0];
|
||||
// Candidates are pre-checked against a stock length, so this is an invariant.
|
||||
if (!best) throw new Error('A skirting piece does not fit the available stock.');
|
||||
let available = stockLength;
|
||||
const assigned: SkirtingPiece[] = [];
|
||||
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}`;
|
||||
best.assigned.forEach((piece) => {
|
||||
piece.stockId = id;
|
||||
});
|
||||
for (const piece of assigned) piece.stockId = id;
|
||||
const used = assigned.reduce((sum, piece) => sum + piece.length, 0);
|
||||
stocks.push({
|
||||
id,
|
||||
length: best.length,
|
||||
used: best.used,
|
||||
waste: Math.max(0, best.waste),
|
||||
pieceIds: best.assigned.map((piece) => piece.id),
|
||||
length: stockLength,
|
||||
used,
|
||||
waste: Math.max(0, stockLength - used),
|
||||
pieceIds: assigned.map((piece) => piece.id),
|
||||
});
|
||||
const assignedIds = new Set(best.assigned.map((piece) => piece.id));
|
||||
const assignedIds = new Set(assigned.map((piece) => piece.id));
|
||||
pending = pending.filter((piece) => !assignedIds.has(piece.id));
|
||||
cuts += best.cuts;
|
||||
}
|
||||
return { pieces, stocks, cuts, waste: stocks.reduce((sum, stock) => sum + stock.waste, 0) };
|
||||
}
|
||||
|
|
@ -363,10 +399,9 @@ export function planSkirting(
|
|||
issues,
|
||||
});
|
||||
if (
|
||||
!settings.stockLengths.length ||
|
||||
settings.stockLengths.some(
|
||||
(length) => !Number.isFinite(length) || length < 0.1 || length > 1e6,
|
||||
) ||
|
||||
!Number.isFinite(settings.stockLength) ||
|
||||
settings.stockLength < 0.1 ||
|
||||
settings.stockLength > 1e6 ||
|
||||
!Number.isFinite(settings.thickness) ||
|
||||
settings.thickness < 0 ||
|
||||
settings.thickness > 10000 ||
|
||||
|
|
@ -384,8 +419,9 @@ export function planSkirting(
|
|||
});
|
||||
return empty();
|
||||
}
|
||||
const stockLengths = [...new Set(settings.stockLengths)].sort((a, b) => a - b);
|
||||
const stockLength = settings.stockLength;
|
||||
let runs: Run[] = [];
|
||||
const fallbackSplits = new Map<Run, SkirtingPiece[]>();
|
||||
rooms.forEach((room, roomIndex) => {
|
||||
const geometry = buildGeometry(room, tolerance);
|
||||
if (!geometry.valid) {
|
||||
|
|
@ -409,6 +445,7 @@ export function planSkirting(
|
|||
normalizeAngle(edge.heading - previous.heading) * orientation,
|
||||
settings.thickness,
|
||||
'left',
|
||||
index > previous.index,
|
||||
)
|
||||
: square();
|
||||
const right = nextHasStart
|
||||
|
|
@ -416,6 +453,7 @@ export function planSkirting(
|
|||
normalizeAngle(next.heading - edge.heading) * orientation,
|
||||
settings.thickness,
|
||||
'right',
|
||||
index > next.index,
|
||||
)
|
||||
: square();
|
||||
const doors = [...edge.doors].sort((a, b) => a.offset - b.offset);
|
||||
|
|
@ -441,12 +479,20 @@ export function planSkirting(
|
|||
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 if (!splitCount(run, stockLengths.at(-1)!, settings.minLength)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
roomId: room.id,
|
||||
message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`,
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
|
|
@ -464,19 +510,30 @@ export function planSkirting(
|
|||
const candidates: Packed[] = [];
|
||||
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
|
||||
const addCandidate = (source: SkirtingPiece[]) => {
|
||||
// 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, stockLengths, settings.kerf));
|
||||
candidates.push(pack(source, stockLength, settings.kerf));
|
||||
};
|
||||
// Compare full-stock, balanced, and offcut-led joints for each stock length.
|
||||
for (const target of stockLengths) {
|
||||
// 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));
|
||||
if (splits.every((split) => split !== null)) addCandidate(splits.flat());
|
||||
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);
|
||||
|
|
@ -500,12 +557,12 @@ export function planSkirting(
|
|||
issues.push({
|
||||
severity: 'info',
|
||||
message:
|
||||
'Lengths are longest-point cuts; back lengths locate pieces on walls. Minimum length applies to both front and back edges. Internal corners use butt joints: the previous wall runs through and the next is shortened by the skirting thickness (adjusted for angled walls). Coping is excluded. External corners use equal mitres. Templates show the back at y=0 and the front at y=thickness, with x following the wall direction. Positive bevel means the front cut point is farther right than the back; use the template to orient the cut for your saw setup. Door widths include any trim to omit.',
|
||||
'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, 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 bevel their ends. 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, 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.',
|
||||
});
|
||||
const plan = { ...best, stockCount: best.stocks.length, issues };
|
||||
const cutNumbers = new Map<string, number>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue