wip
This commit is contained in:
commit
d8f2407ddf
1 changed files with 10377 additions and 0 deletions
521
src/lib/skirting.ts
Normal file
521
src/lib/skirting.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
||||
import { isSkirtingCut } from './cut-list';
|
||||
import type {
|
||||
Issue,
|
||||
Point,
|
||||
Room,
|
||||
SkirtingPiece,
|
||||
SkirtingPlan,
|
||||
SkirtingSettings,
|
||||
SkirtingStock,
|
||||
WallEdge,
|
||||
} from './types';
|
||||
|
||||
const EPS = 1e-6;
|
||||
const rad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
type End = {
|
||||
type: SkirtingPiece['leftType'];
|
||||
angle: number;
|
||||
bevel: number;
|
||||
back: number;
|
||||
front: 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 });
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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();
|
||||
if (turn < 0) {
|
||||
const extension = thickness * Math.tan(rad(-turn / 2));
|
||||
return {
|
||||
type: 'external',
|
||||
angle: -turn / 2,
|
||||
bevel: side === 'left' ? turn / 2 : -turn / 2,
|
||||
back: 0,
|
||||
front: side === 'left' ? -extension : extension,
|
||||
};
|
||||
}
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
function endCuts(piece: SkirtingPiece) {
|
||||
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
||||
}
|
||||
|
||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||
}
|
||||
|
||||
function consumedLength(piece: SkirtingPiece, 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.
|
||||
return Math.min(available, piece.length + (Math.abs(available - piece.length) <= EPS ? 0 : kerf));
|
||||
}
|
||||
|
||||
function makePiece(
|
||||
run: Run,
|
||||
from: number,
|
||||
to: number,
|
||||
first: boolean,
|
||||
last: boolean,
|
||||
): SkirtingPiece {
|
||||
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: '',
|
||||
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: SkirtingPiece) {
|
||||
return Math.min(piece.backLength, piece.template![2].x - piece.template![3].x);
|
||||
}
|
||||
|
||||
function fits(piece: SkirtingPiece, available: number) {
|
||||
// The final saw pass may extend beyond the stock's tail.
|
||||
return piece.length <= 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 +
|
||||
Math.max(run.left.back, run.left.front) -
|
||||
Math.min(run.right.back, run.right.front);
|
||||
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
|
||||
)
|
||||
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,
|
||||
): SkirtingPiece[] | 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, even for butt joints.
|
||||
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),
|
||||
);
|
||||
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;
|
||||
return 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;
|
||||
});
|
||||
}
|
||||
|
||||
/** Let useful tails set the next joint instead of fixing every wall's joints first. */
|
||||
function splitWithOffcuts(
|
||||
runs: Run[],
|
||||
target: number,
|
||||
settings: SkirtingSettings,
|
||||
): SkirtingPiece[] | null {
|
||||
const pieces: SkirtingPiece[] = [];
|
||||
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 + Math.min(run.right.back, run.right.front) - 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: SkirtingPiece; 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 >= 10000) 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;
|
||||
}
|
||||
|
||||
/** Best-fit decreasing with one-stock lookahead across all rooms and available stock sizes. */
|
||||
function pack(source: SkirtingPiece[], stockLengths: 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),
|
||||
);
|
||||
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.');
|
||||
const id = `S${stocks.length + 1}`;
|
||||
best.assigned.forEach((piece) => {
|
||||
piece.stockId = id;
|
||||
});
|
||||
stocks.push({
|
||||
id,
|
||||
length: best.length,
|
||||
used: best.used,
|
||||
waste: Math.max(0, best.waste),
|
||||
pieceIds: best.assigned.map((piece) => piece.id),
|
||||
});
|
||||
const assignedIds = new Set(best.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) };
|
||||
}
|
||||
|
||||
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 (
|
||||
!settings.stockLengths.length ||
|
||||
settings.stockLengths.some(
|
||||
(length) => !Number.isFinite(length) || length < 0.1 || length > 1e6,
|
||||
) ||
|
||||
!Number.isFinite(settings.thickness) ||
|
||||
settings.thickness < 0 ||
|
||||
settings.thickness > 10000 ||
|
||||
!Number.isFinite(settings.minLength) ||
|
||||
settings.minLength < 0.1 ||
|
||||
settings.minLength > 1e6 ||
|
||||
!Number.isFinite(settings.kerf) ||
|
||||
settings.kerf < 0 ||
|
||||
settings.kerf > 10000
|
||||
) {
|
||||
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 stockLengths = [...new Set(settings.stockLengths)].sort((a, b) => a - b);
|
||||
let runs: Run[] = [];
|
||||
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',
|
||||
)
|
||||
: square();
|
||||
const right = nextHasStart
|
||||
? corner(
|
||||
normalizeAngle(next.heading - edge.heading) * orientation,
|
||||
settings.thickness,
|
||||
'right',
|
||||
)
|
||||
: 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 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.`,
|
||||
});
|
||||
}
|
||||
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: SkirtingPiece[]) => {
|
||||
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));
|
||||
};
|
||||
// Compare full-stock, balanced, and offcut-led joints for each stock length.
|
||||
for (const target of stockLengths) {
|
||||
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 reused = splitWithOffcuts(runs, target, settings);
|
||||
if (reused) addCandidate(reused);
|
||||
}
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
|
||||
a.cuts - b.cuts ||
|
||||
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 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.',
|
||||
});
|
||||
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.',
|
||||
});
|
||||
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) })),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue