This commit is contained in:
Andras Schmelczer 2026-09-11 17:57:03 +01:00
commit 7959fe85cc
29 changed files with 1679 additions and 498 deletions

View file

@ -6,6 +6,8 @@ import type {
SkirtingPlan,
} from './types';
import { cutMm, degree } from './format';
const sameLength = (a: number, b: number) => Math.abs(a - b) < 0.01;
/** A ready offcut still needs a placement code; only fresh, uncut whole boards are omitted. */
@ -15,6 +17,8 @@ export const flooringCuts = (plan: FlooringPlan) =>
export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean {
const stock = plan.stocks.find((candidate) => candidate.id === piece.stockId);
return (
piece.leftType === 'cope' ||
piece.rightType === 'cope' ||
!stock ||
!sameLength(piece.length, stock.length) ||
Math.abs(piece.leftMitre) > 0.001 ||
@ -69,3 +73,32 @@ export const groupFlooringCuts = (plan: FlooringPlan) =>
groupByStock(flooringCuts(plan), plan.stocks);
export const groupSkirtingCuts = (plan: SkirtingPlan) =>
groupByStock(skirtingCuts(plan), plan.stocks);
export function skirtingEndLabel(piece: SkirtingPiece, side: 'left' | 'right'): string {
const type = side === 'left' ? piece.leftType : piece.rightType;
const angle = side === 'left' ? piece.leftMitre : piece.rightMitre;
if (type === 'cope') return `Cope · prepare ${degree(angle)}`;
if (type === 'internal')
return `Receive cope · ${Math.abs(angle) < 0.001 ? 'square' : `wall-fit ${degree(angle)}`}`;
return type === 'external' ? `Mitre ${degree(angle)}` : 'Square';
}
/** The profile is supplied by the user's real moulding, not by its thickness. */
export function skirtingInstructions(piece: SkirtingPiece): string {
const copes = [
piece.leftType === 'cope' ? 'left' : '',
piece.rightType === 'cope' ? 'right' : '',
].filter(Boolean);
const receiving = piece.leftType === 'internal' || piece.rightType === 'internal';
const messages = [
'Measure the longest points of the preparation blank. Angles are measured from square.',
];
if (receiving) messages.push('Fit the receiving end before the adjoining coped piece.');
if (copes.length)
messages.push(
`Prepare the ${copes.join(' and ')} inside mitre, mark your moulding’s exposed profile, then cope and back-cut to fit the receiving face. Keep the front profile line; the dashed end shows the full-thickness body after coping, not the decorative profile.`,
);
if (piece.visibleLength !== undefined)
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
return messages.join(' ');
}

View file

@ -35,7 +35,11 @@ const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) =>
a.y + a.height + gap > b.y;
/** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */
export function dimensionedOutline(points: Point[], edges?: number[]) {
export function dimensionedOutline(
points: Point[],
edges?: number[],
blank: { length?: number; width?: number } = {},
) {
if (points.length < 3)
return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) };
const span = Math.max(
@ -72,7 +76,13 @@ export function dimensionedOutline(points: Point[], edges?: number[]) {
};
const normal = { x: orientation * direction.y, y: -orientation * direction.x };
const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 };
const text = cutMm(edge.length);
const limit =
Math.abs(direction.y) < 1e-6
? blank.length
: Math.abs(direction.x) < 1e-6
? blank.width
: undefined;
const text = cutMm(edge.length, limit);
const angle =
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
@ -178,18 +188,28 @@ export function fullPreviewBox(length: number, width: number): PreviewBox {
export function skirtingEndBoxes(
template: Point[],
thickness: number,
installed?: Point[],
): { left: PreviewBox; right: PreviewBox } {
const [backLeft, backRight, frontRight, frontLeft] = template;
const left = [
template[0].x,
template[3].x,
...(installed ? [installed[0].x, installed[3].x] : []),
];
const right = [
template[1].x,
template[2].x,
...(installed ? [installed[1].x, installed[2].x] : []),
];
const side = Math.max(
1,
thickness * 3,
Math.abs(backLeft.x - frontLeft.x) / 0.76,
Math.abs(backRight.x - frontRight.x) / 0.76,
(Math.max(...left) - Math.min(...left)) / 0.76,
(Math.max(...right) - Math.min(...right)) / 0.76,
);
const padding = side * 0.12;
const common = { y: (thickness - side) / 2, width: side, height: side };
return {
left: { ...common, x: Math.min(backLeft.x, frontLeft.x) - padding },
right: { ...common, x: Math.max(backRight.x, frontRight.x) - side + padding },
left: { ...common, x: Math.min(...left) - padding },
right: { ...common, x: Math.max(...right) - side + padding },
};
}

View file

@ -49,7 +49,7 @@ export function defaultProject(): Project {
minWidth: 60,
kerf: 3,
},
skirting: { stockLengths: [2400], thickness: 18, minLength: 200, kerf: 3 },
skirting: { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 },
};
}
export function defaultState(): UrlState {

View file

@ -1,5 +1,6 @@
import ClipperLib from 'clipper-lib';
import { buildGeometry, insetPolygon } from './geometry';
import { cutLength } from './format';
import type {
FlooringPiece,
FlooringPlan,
@ -50,6 +51,10 @@ interface RowShape {
const round = (n: number) => Math.round(n * SCALE) / SCALE;
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
// A complete board or an exact-fit remnant needs no crosscut, even for stock
// supplied in fractions of a millimetre. New blanks reserve the displayed saw mark.
const blankLength = (length: number, available: number) =>
close(length, available) ? available : cutLength(length);
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const area = (points: Point[]) =>
Math.abs(
@ -112,7 +117,11 @@ function intersect(paths: Point[][], rectangle: Bounds): Point[][] {
}
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: number[] = []): number[][] {
function rowWidthOptions(
height: number,
s: FlooringSettings,
cornerOffsets: number[] = [],
): number[][] {
const count = Math.ceil((height - EPSILON) / s.width);
if (count <= 0 || count > 500) return [];
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
@ -149,11 +158,16 @@ function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: num
}
function inwardCorners(paths: Point[][]): Point[] {
return paths.flatMap(path => path.filter((point, index) => {
const previous = path[(index + path.length - 1) % path.length];
const next = path[(index + 1) % path.length];
return (point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) < -EPSILON;
}));
return paths.flatMap((path) =>
path.filter((point, index) => {
const previous = path[(index + path.length - 1) % path.length];
const next = path[(index + 1) % path.length];
return (
(point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) <
-EPSILON
);
}),
);
}
function hasVerticalEdge(path: Point[], x: number): boolean {
@ -189,6 +203,7 @@ function cutRow(
const length = b.maxX - b.minX;
const width = b.maxY - b.minY;
if (length < s.minLength - EPSILON || width < s.minWidth - EPSILON) return null;
if (blankLength(round(length), s.length) > s.length + EPSILON) return null;
// An edge on a board-grid line needs its factory end, unless it is the room boundary.
const needsLeft = left > shape.bounds.minX + EPSILON && hasVerticalEdge(polygon, left);
const needsRight = right < shape.bounds.maxX - EPSILON && hasVerticalEdge(polygon, right);
@ -210,12 +225,12 @@ function allocate(
piece: CutPiece,
pieceId: string,
s: FlooringSettings,
): { stockId: string; reused: boolean; crosscut: boolean } {
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
const length = round(piece.bounds.maxX - piece.bounds.minX);
let stock: AvailableStock | undefined;
for (const candidate of layout.stocks) {
if (
candidate.remaining < length - EPSILON ||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
(piece.needsLeft && !candidate.left) ||
(piece.needsRight && !candidate.right)
)
@ -235,6 +250,7 @@ function allocate(
layout.stocks.push(stock);
}
const reused = stock.pieceIds.length > 0;
const reserved = blankLength(length, stock.remaining);
const crosscut = stock.remaining - length > EPSILON;
stock.pieceIds.push(pieceId);
if (!crosscut) {
@ -242,13 +258,13 @@ function allocate(
stock.left = false;
stock.right = false;
} else {
stock.remaining = round(Math.max(0, stock.remaining - length - s.kerf));
stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf));
// Take starters from the right; take finishers from the left. The remainder has one new cut end.
if (piece.needsRight) stock.right = false;
else stock.left = false;
}
if (reused) layout.reusedCount++;
return { stockId: stock.id, reused, crosscut };
return { stockId: stock.id, reused, crosscut, blankLength: reserved };
}
function appendRow(
@ -301,6 +317,8 @@ function appendRow(
polygon: piece.polygon.map((p) => worldPoint(p, room.direction)),
length,
width,
blankLength: allocation.blankLength,
blankWidth: Math.min(cutLength(width), s.width),
stockId: allocation.stockId,
reused: allocation.reused,
cut: passes > 0,
@ -511,8 +529,11 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
continue;
}
const roomBounds = bounds(paths.flat());
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s,
inwardCorners(paths).map(point => point.y - roomBounds.minY));
const widthOptions = rowWidthOptions(
roomBounds.maxY - roomBounds.minY,
s,
inwardCorners(paths).map((point) => point.y - roomBounds.minY),
);
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
issues.push({

View file

@ -4,7 +4,9 @@ export const mm = (value: number) =>
: '—';
/** Round cutting marks up without letting floating-point noise add a half millimetre. */
export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
export const cutMm = (value: number) => mm(cutLength(value));
/** An exact-fit stock edge or remnant needs no rounded-up preparation cut. */
export const cutMm = (value: number, available = Infinity) =>
mm(Math.min(cutLength(value), available));
export const area = (value: number) =>
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
value / 1e6,

163
src/lib/piece-labels.ts Normal file
View file

@ -0,0 +1,163 @@
import type { FlooringPiece, Point } from './types';
import type { LabelBox } from './dimension-labels';
export interface PieceLabel {
id: string;
text: string;
x: number;
y: number;
angle: number;
width: number;
height: number;
font: number;
box: LabelBox;
anchor: Point;
leader: boolean;
}
const bounds = (points: Point[]): LabelBox => ({
minX: Math.min(...points.map((point) => point.x)),
maxX: Math.max(...points.map((point) => point.x)),
minY: Math.min(...points.map((point) => point.y)),
maxY: Math.max(...points.map((point) => point.y)),
});
const overlaps = (a: LabelBox, b: LabelBox, gap: number) =>
a.minX < b.maxX + gap && a.maxX > b.minX - gap && a.minY < b.maxY + gap && a.maxY > b.minY - gap;
const corners = (box: LabelBox) => [
{ x: box.minX, y: box.minY },
{ x: box.maxX, y: box.minY },
{ x: box.maxX, y: box.maxY },
{ x: box.minX, y: box.maxY },
];
const inside = (point: Point, polygon: Point[]) => {
let result = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i],
b = polygon[j];
if (
a.y > point.y !== b.y > point.y &&
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
)
result = !result;
}
return result;
};
/** A midpoint of an interior scanline stays on the piece, including concave cut shapes. */
function anchorPoint(polygon: Point[], box: LabelBox): Point {
const y = (box.minY + box.maxY) / 2;
const xs: number[] = [];
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i],
b = polygon[(i + 1) % polygon.length];
if (a.y > y !== b.y > y) xs.push(a.x + ((y - a.y) * (b.x - a.x)) / (b.y - a.y));
}
xs.sort((a, b) => a - b);
let widest = -1,
x = (box.minX + box.maxX) / 2;
for (let i = 0; i + 1 < xs.length; i += 2) {
if (xs[i + 1] - xs[i] > widest) {
widest = xs[i + 1] - xs[i];
x = (xs[i] + xs[i + 1]) / 2;
}
}
return { x, y };
}
/** Short pieces borrow nearby empty label space; a pointer keeps their exact location clear. */
export function floorPieceLabels(
pieces: FlooringPiece[],
room: Point[],
direction: 0 | 90,
font: number,
reserved: LabelBox[] = [],
completedBoards: string[] = [],
): PieceLabel[] {
const occupied = [...reserved];
const roomBox = bounds(room.length ? room : [{ x: 0, y: 0 }]);
const result: PieceLabel[] = [];
const cuts = pieces.filter((piece) => (piece.cut || piece.reused) && piece.polygon.length >= 3);
for (const piece of cuts) {
const box = bounds(piece.polygon);
const anchor = anchorPoint(piece.polygon, box);
const text = piece.id + (completedBoards.includes(`floor:${piece.stockId}`) ? ' ✓' : '');
const textSize = font * 0.76;
const width = Math.max(font * 1.8, text.length * textSize * 0.64 + font * 0.55);
const height = font * 1.15;
const halfX = (direction === 90 ? height : width) / 2;
const halfY = (direction === 90 ? width : height) / 2;
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const preferred = {
x: clamp(anchor.x, roomBox.minX + halfX + font * 0.15, roomBox.maxX - halfX - font * 0.15),
y: clamp(anchor.y, roomBox.minY + halfY + font * 0.15, roomBox.maxY - halfY - font * 0.15),
};
const candidate = (x: number, y: number) => {
const labelBox = { minX: x - halfX, maxX: x + halfX, minY: y - halfY, maxY: y + halfY };
const inPiece = corners(labelBox).every((point) => inside(point, piece.polygon));
const inRoom = corners(labelBox).every((point) => inside(point, room));
const coversAnchor =
anchor.x >= labelBox.minX &&
anchor.x <= labelBox.maxX &&
anchor.y >= labelBox.minY &&
anchor.y <= labelBox.maxY;
return {
x,
y,
box: labelBox,
inPiece,
score:
Math.hypot(x - anchor.x, y - anchor.y) +
(inRoom ? 0 : font * 30) +
(!inPiece && coversAnchor ? font * 40 : 0),
};
};
const candidates = [candidate(anchor.x, anchor.y), candidate(preferred.x, preferred.y)];
for (const sign of [-1, 1]) {
candidates.push(
candidate(
direction === 0 ? anchor.x + sign * (halfX + font * 0.4) : preferred.x,
direction === 90 ? anchor.y + sign * (halfY + font * 0.4) : preferred.y,
),
);
}
for (let across = -3; across <= 3; across++) {
for (let along = -4; along <= 4; along++) {
const alongStep = width + font * 0.2;
const acrossStep = Math.max(
height + font * 0.2,
direction === 90 ? box.maxX - box.minX : box.maxY - box.minY,
);
candidates.push(
candidate(
preferred.x + (direction === 90 ? across * acrossStep : along * alongStep),
preferred.y + (direction === 90 ? along * alongStep : across * acrossStep),
),
);
}
}
candidates.sort((a, b) => a.score - b.score);
let placed = candidates.find(
(item) => !occupied.some((other) => overlaps(item.box, other, font * 0.12)),
);
// Very crowded outlines get a clear outside label; its bounds expand the SVG viewport.
for (let lane = 1; !placed; lane++) {
const item = candidate(roomBox.maxX + halfX + lane * (width + font), anchor.y);
if (!occupied.some((other) => overlaps(item.box, other, font * 0.12))) placed = item;
}
occupied.push(placed.box);
result.push({
id: piece.id,
text,
x: placed.x,
y: placed.y,
angle: direction,
width,
height,
font: textSize,
box: placed.box,
anchor,
leader: !placed.inPiece || Math.hypot(placed.x - anchor.x, placed.y - anchor.y) > font * 0.1,
});
}
return result;
}

View file

@ -1,6 +1,6 @@
import type { Point, SkirtingPiece } from './types';
/** Place the cutting template against its wall, preserving mitres and butt setbacks. */
/** Place the cutting template against its wall, preserving mitres and the coped body setbacks. */
export function skirtingPolygon(
piece: SkirtingPiece,
thickness: number,
@ -21,7 +21,8 @@ export function skirtingPolygon(
{ x: backLength, y: thickness },
{ x: 0, y: thickness },
];
return template.map((point) => ({
const installed = piece.installedTemplate?.length ? piece.installedTemplate : template;
return installed.map((point) => ({
x: piece.start.x + tangent.x * (point.x - template[0].x) + inward.x * point.y,
y: piece.start.y + tangent.y * (point.x - template[0].x) + inward.y * point.y,
}));

View file

@ -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>();

View file

@ -32,7 +32,7 @@ export interface FlooringSettings {
kerf: number;
}
export interface SkirtingSettings {
stockLengths: number[];
stockLength: number;
thickness: number;
minLength: number;
kerf: number;
@ -96,6 +96,9 @@ export interface FlooringPiece {
polygon: Point[];
length: number;
width: number;
/** Preparation dimensions reserved from stock, including exact-fit remnants. */
blankLength?: number;
blankWidth?: number;
stockId: string;
reused: boolean;
cut: boolean;
@ -139,14 +142,23 @@ export interface SkirtingPiece {
start: Point;
end: Point;
length: number;
/** Preparation length reserved from stock; a whole fractional length stays exact. */
blankLength?: number;
backLength: number;
leftMitre: number;
rightMitre: number;
leftType: 'square' | 'external' | 'internal';
rightType: 'square' | 'external' | 'internal';
leftType: 'square' | 'external' | 'internal' | 'cope';
rightType: 'square' | 'external' | 'internal' | 'cope';
stockId: string;
/** Plan section: x follows installation direction, back y=0, front y=thickness. */
template?: Point[];
/** Full-thickness installed footprint after coping, separate from the cutting blank. */
installedTemplate?: Point[];
/** Exposed front run used to enforce the minimum installed piece length. */
visibleLength?: number;
/** Extra blank length retained at each end for a coped internal joint. */
leftOverlap?: number;
rightOverlap?: number;
/** Positive means the front cut point is farther right than the back point. */
leftBevel?: number;
rightBevel?: number;

View file

@ -18,7 +18,7 @@ export const URL_LIMITS = {
rooms: 20,
walls: 100,
doors: 30,
stockLengths: 30,
legacyStockLengths: 30,
identifierLength: 120,
nameLength: 200,
completedBoards: 12_000,
@ -155,15 +155,28 @@ function flooring(value: unknown): FlooringSettings {
};
}
function skirting(value: unknown): SkirtingSettings {
function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculations: boolean } {
const data = object(value, 'Skirting settings');
// Earlier links allowed several purchased lengths. The first remains the chosen size.
const legacyLengths =
data.stockLength === undefined
? array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.legacyStockLengths).map(
(value) => number(value, 'Skirting stock length'),
)
: [];
return {
stockLengths: array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.stockLengths).map(
(value) => number(value, 'Skirting stock length'),
),
thickness: number(data.thickness, 'Skirting thickness'),
minLength: number(data.minLength, 'Minimum skirting length'),
kerf: number(data.kerf, 'Skirting saw kerf'),
settings: {
stockLength:
data.stockLength === undefined
? (legacyLengths[0] ?? 0)
: number(data.stockLength, 'Skirting board length'),
thickness: number(data.thickness, 'Skirting thickness'),
minLength: number(data.minLength, 'Minimum skirting length'),
kerf: number(data.kerf, 'Skirting saw kerf'),
},
// This format predates coping blanks and rounded stock allocation for both
// materials. Even a single stock size can now assign different cuts to an ID.
legacyCalculations: data.stockLength === undefined,
};
}
@ -183,15 +196,17 @@ function parseState(value: unknown): UrlState {
if (rooms.length === 0) throw new Error('A project needs at least one room.');
const view = object(data.view, 'View settings');
const roomId = string(view.roomId, 'Selected room');
const skirt = skirting(project.skirting);
const completed = completedBoards(project.completedBoards);
return {
project: {
version: 1,
name: string(project.name, 'Project name', URL_LIMITS.nameLength),
rooms,
flooring: flooring(project.flooring),
skirting: skirting(project.skirting),
skirting: skirt.settings,
tolerance: number(project.tolerance, 'Measurement tolerance'),
completedBoards: completedBoards(project.completedBoards),
completedBoards: skirt.legacyCalculations ? [] : completed,
},
view: {
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
@ -216,9 +231,7 @@ export function encodeState(state: UrlState): string {
put('name', project.name);
put('tolerance', project.tolerance);
for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value);
// An empty value explicitly represents an empty stock list during editing.
if (!project.skirting.stockLengths.length) put('skirting.stock', '');
for (const length of project.skirting.stockLengths) put('skirting.stock', length);
put('skirting.length', project.skirting.stockLength);
put('skirting.thickness', project.skirting.thickness);
put('skirting.minLength', project.skirting.minLength);
put('skirting.kerf', project.skirting.kerf);
@ -325,14 +338,19 @@ function parseQuery(query: string): UrlState {
})
: defaults.project.rooms;
const stockValues = params.getAll('skirting.stock');
if (stockValues.length > URL_LIMITS.stockLengths)
throw new Error(`Skirting stock lengths supports at most ${URL_LIMITS.stockLengths} entries.`);
const stockLengths =
stockValues.length === 1 && stockValues[0] === ''
? []
: stockValues.length
? stockValues.map((value) => measurement(value, 'skirting.stock'))
: defaults.project.skirting.stockLengths;
if (stockValues.length > URL_LIMITS.legacyStockLengths)
throw new Error(
`Skirting stock lengths supports at most ${URL_LIMITS.legacyStockLengths} entries.`,
);
const stock = params.has('skirting.length')
? { stockLength: numeric('skirting.length') }
: {
stockLengths: !stockValues.length
? [defaults.project.skirting.stockLength]
: stockValues.length === 1 && stockValues[0] === ''
? []
: stockValues.map((value) => measurement(value, 'skirting.stock')),
};
const floor = defaults.project.flooring;
return parseState({
project: {
@ -350,7 +368,7 @@ function parseQuery(query: string): UrlState {
]),
),
skirting: {
stockLengths,
...stock,
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),