This commit is contained in:
Andras Schmelczer 2026-09-11 22:14:24 +01:00
commit 0c0f08b350
45 changed files with 1565 additions and 1579 deletions

View file

@ -30,7 +30,7 @@ export const initAnalytics = () => {
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: ANALYTICS_LOGGING,
// Query strings and legacy fragments contain the saved plan, including room names.
// Query strings contain the saved plan, including room names.
transformRequest: (payload) => ({
...payload,
u: payload.u.split(/[?#]/)[0],

View file

@ -7,8 +7,9 @@ import type {
} from './types';
import { cutMm, degree } from './format';
import { PRECISION } from './limits';
const sameLength = (a: number, b: number) => Math.abs(a - b) < 0.01;
const sameLength = (a: number, b: number) => Math.abs(a - b) < PRECISION.millimetres;
/** A ready offcut still needs a placement code; only fresh, uncut whole boards are omitted. */
export const flooringCuts = (plan: FlooringPlan) =>
@ -21,8 +22,8 @@ export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean
piece.rightType === 'cope' ||
!stock ||
!sameLength(piece.length, stock.length) ||
Math.abs(piece.leftMitre) > 0.001 ||
Math.abs(piece.rightMitre) > 0.001
Math.abs(piece.leftMitre) > PRECISION.cutAngle ||
Math.abs(piece.rightMitre) > PRECISION.cutAngle
);
}
@ -79,7 +80,7 @@ export function skirtingEndLabel(piece: SkirtingPiece, side: 'left' | 'right'):
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 `Receive cope · ${Math.abs(angle) < PRECISION.cutAngle ? 'square' : `wall-fit ${degree(angle)}`}`;
return type === 'external' ? `Mitre ${degree(angle)}` : 'Square';
}
@ -98,7 +99,6 @@ export function skirtingInstructions(piece: SkirtingPiece): string {
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.`);
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
return messages.join(' ');
}

View file

@ -1,5 +1,6 @@
import type { FlooringPiece, Point, SkirtingPiece } from './types';
import type { Point } from './types';
import { cutMm, degree } from './format';
import { PRECISION } from './limits';
export interface PreviewBox {
x: number;
@ -66,7 +67,7 @@ export function dimensionedOutline(
...edge,
length: Math.hypot(edge.end.x - edge.start.x, edge.end.y - edge.start.y),
}))
.filter((edge) => edge.length > 0.000001)
.filter((edge) => edge.length > PRECISION.geometry)
.sort((a, b) => b.length - a.length || a.edge - b.edge);
for (const edge of selected) {
@ -77,14 +78,14 @@ export function dimensionedOutline(
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 limit =
Math.abs(direction.y) < 1e-6
Math.abs(direction.y) < PRECISION.geometry
? blank.length
: Math.abs(direction.x) < 1e-6
: Math.abs(direction.x) < PRECISION.geometry
? blank.width
: undefined;
const text = cutMm(edge.length, limit);
const angle =
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
Math.abs(direction.x) > PRECISION.geometry && Math.abs(direction.y) > PRECISION.geometry
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
: undefined;
const angleText = angle === undefined ? '' : degree(angle);
@ -155,29 +156,6 @@ export function dimensionedOutline(
};
}
export function flooringTemplate(piece: FlooringPiece): Point[] {
if (piece.template.length) return piece.template;
return piece.shaped
? []
: [
{ x: 0, y: 0 },
{ x: piece.length, y: 0 },
{ x: piece.length, y: piece.width },
{ x: 0, y: piece.width },
];
}
export function skirtingTemplate(piece: SkirtingPiece, thickness: number): Point[] {
if (piece.template?.length === 4) return piece.template;
if (piece.leftMitre !== 0 || piece.rightMitre !== 0) return [];
return [
{ x: 0, y: 0 },
{ x: piece.length, y: 0 },
{ x: piece.length, y: thickness },
{ x: 0, y: thickness },
];
}
/** Padding changes the viewport only; all vertices stay in their original millimetres. */
export function fullPreviewBox(length: number, width: number): PreviewBox {
const padding = Math.max(length, width, 1) * 0.06;

View file

@ -33,7 +33,7 @@ export function fireplace(id: string, name: string): Room {
}
export function defaultProject(): Project {
return {
version: 1,
version: 2,
name: 'My flooring project',
rooms: [rectangle()],
tolerance: 10,
@ -42,7 +42,7 @@ export function defaultProject(): Project {
length: 1200,
width: 190,
gap: 10,
pattern: 'brick',
pattern: 'random',
optimisation: 'cuts',
minOffset: 300,
maxOffset: 600,
@ -56,7 +56,13 @@ export function defaultProject(): Project {
export function defaultState(): UrlState {
return {
project: defaultProject(),
view: { roomId: 'room-1', selectedPiece: null, search: '', collapsedSections: [] },
view: {
roomId: 'room-1',
selectedPiece: null,
search: '',
collapsedSections: [],
material: 'flooring',
},
};
}
export function nextId(prefix: string, existing: string[]): string {

View file

@ -1,6 +1,7 @@
import ClipperLib from 'clipper-lib';
import { buildGeometry, insetPolygon } from './geometry';
import { cutLength } from './format';
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
import type {
FlooringPiece,
FlooringPlan,
@ -12,9 +13,9 @@ import type {
Room,
} from './types';
const SCALE = 1000;
const EPSILON = 0.01;
const MAX_BOARDS = 2000;
const SCALE = PRECISION.clipperScale;
const EPSILON = PRECISION.millimetres;
const MAX_BOARDS = PLANNER_LIMITS.flooringBoards;
export type FlooringOptions = Record<'cuts' | 'random', FlooringPlan>;
@ -28,14 +29,25 @@ export function planFlooringOptions(
const cutsSettings = { ...s, optimisation: 'cuts' as const };
const randomSettings = { ...s, optimisation: 'random' as const };
const cuts = solveFlooring(rooms, cutsSettings, tolerance, shapes);
const random =
s.pattern === 'random' ? solveFlooring(rooms, randomSettings, tolerance, shapes) : cuts;
const variablePattern = s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON;
const candidates = [cuts];
if (variablePattern) candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes));
const errors = (solution: Solution) =>
solution.issues.filter((issue) => issue.severity === 'error').length;
const better = (settings: FlooringSettings) =>
(errors(random) - errors(cuts) || compareLayouts(random.layout, cuts.layout, settings)) < 0
? random
: cuts;
candidates.reduce((best, candidate) =>
(errors(candidate) - errors(best) ||
compareLayouts(candidate.layout, best.layout, settings)) < 0
? candidate
: best,
);
// If repetition remains, try immediate reuse too. Keep the best completed result from either route.
if (
variablePattern &&
(better(randomSettings).layout.patternPenalty > 0 || errors(better(randomSettings)))
) {
candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes, false));
}
const cutWinner = better(cutsSettings);
const randomWinner = better(randomSettings);
const cutPlan = finishFlooring(cutWinner, s);
@ -46,7 +58,7 @@ export function planFlooringOptions(
}
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
return planFlooringOptions(rooms, s, tolerance)[s.optimisation ?? 'cuts'];
return planFlooringOptions(rooms, s, tolerance)[s.optimisation];
}
interface Bounds {
@ -164,7 +176,7 @@ function rowWidthOptions(
cornerOffsets: number[] = [],
): number[][] {
const count = Math.ceil((height - EPSILON) / s.width);
if (count <= 0 || count > 500) return [];
if (count <= 0 || count > PLANNER_LIMITS.flooringRowsPerRoom) return [];
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
const edgeTotal = height - (count - 2) * s.width;
if (edgeTotal + EPSILON < 2 * s.minWidth) return [];
@ -260,17 +272,21 @@ function cutRow(
return pieces;
}
/** A cut end cannot replace a factory joint. Offcuts retain their original left/right end flags. */
/** Preserve factory-ended tails for exact fits; final packing can combine blanks that need trimming. */
function allocate(
layout: Layout,
piece: CutPiece,
pieceId: string,
s: FlooringSettings,
preserveEnds: 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 piece.needsLeft && piece.needsRight ? [] : layout.stocks) {
if (
(preserveEnds &&
(piece.needsLeft || piece.needsRight) &&
!close(candidate.remaining, length)) ||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
(piece.needsLeft && !candidate.left) ||
(piece.needsRight && !candidate.right)
@ -403,6 +419,7 @@ function appendRow(
shape: RowShape,
phase: number,
s: FlooringSettings,
preserveEnds: boolean,
): Layout {
const layout: Layout = {
pieces: [...base.pieces],
@ -419,7 +436,7 @@ function appendRow(
const pieceIds: string[] = [];
cuts.forEach((piece, column) => {
const id = `F${layout.pieces.length + 1}`;
const allocation = allocate(layout, piece, id, s);
const allocation = allocate(layout, piece, id, s, preserveEnds);
const length = round(piece.bounds.maxX - piece.bounds.minX);
const width = round(piece.bounds.maxY - piece.bounds.minY);
// Count straight saw passes. For a shape, every additional non-bounding edge needs a pass.
@ -705,7 +722,11 @@ function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number {
function validateSettings(s: FlooringSettings): string | null {
if (Object.values(s).some((value) => typeof value === 'number' && !Number.isFinite(value)))
return 'All flooring measurements must be finite numbers.';
if (Object.values(s).some((value) => typeof value === 'number' && Math.abs(value) > 1e6))
if (
Object.values(s).some(
(value) => typeof value === 'number' && Math.abs(value) > PROJECT_LIMITS.maxMeasurement,
)
)
return 'Flooring measurements must not exceed 1,000,000 mm. Check your units.';
if (s.length < 0.1 || s.width < 0.1 || s.minLength < 0.1 || s.minWidth < 0.1)
return 'Board and minimum piece dimensions must be at least 0.1 mm.';
@ -725,9 +746,9 @@ function validateSettings(s: FlooringSettings): string | null {
}
/**
* Deterministic bounded beam search: boards consumed first; random layouts then
* prefer less visual repetition before fewer saw passes. This can spend an extra
* crosscut to break a staircase without buying an extra board.
* Deterministic bounded beam search with selectable cut-first or pattern-first priorities.
* Easier cuts break saw-count ties, then visual repetition and finally purchased boards.
* Completed candidates are repacked and interchangeable rows can be reordered without new cuts.
* Stock is shared between rooms, but layouts and joint phases are independent.
* Only full-width end remnants are reused; ripped strips are conservatively waste.
* This is a practical cutting plan, not a claim of a global packing optimum.
@ -737,6 +758,7 @@ function solveFlooring(
s: FlooringSettings,
tolerance: number,
shapes: ShapeCache,
preserveEnds = true,
): Solution {
let layout: Layout = {
pieces: [],
@ -835,7 +857,8 @@ function solveFlooring(
cache.set(phase, cuts);
}
if (!cuts?.length) continue;
const candidate = appendRow(base, cuts, room, row, shape, phase, s);
let candidate = appendRow(base, cuts, room, row, shape, phase, s, preserveEnds);
if (candidate.stocks.length > MAX_BOARDS) candidate = repack(candidate, s);
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
}
}

View file

@ -1,9 +1,11 @@
import { PRECISION } from './limits';
export const mm = (value: number) =>
Number.isFinite(value)
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
: '—';
/** 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 cutLength = (value: number) => Math.ceil(value * 2 - PRECISION.roundingNoise) / 2 + 0;
/** 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));

View file

@ -1,9 +1,10 @@
import ClipperLib from 'clipper-lib';
import { PRECISION, PROJECT_LIMITS } from './limits';
import type { Geometry, Issue, Point, Room, WallEdge } from './types';
const EPS = 1e-6;
const SCALE = 1000;
const MAX_WALL = 1_000_000;
const EPS = PRECISION.geometry;
const SCALE = PRECISION.clipperScale;
const MAX_WALL = PROJECT_LIMITS.maxMeasurement;
const radians = (degrees: number) => (degrees * Math.PI) / 180;
const degrees = (angle: number) => (angle * 180) / Math.PI;
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
@ -50,11 +51,13 @@ export function buildGeometry(room: Room, tolerance: number): Geometry {
let position = origin;
let heading = 0;
const allowedGap =
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= 50 ? tolerance : 0;
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 50) {
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= PROJECT_LIMITS.maxTolerance
? tolerance
: 0;
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > PROJECT_LIMITS.maxTolerance) {
issues.push({
severity: 'error',
message: 'Measurement tolerance must be between 0 and 50 mm.',
message: `Measurement tolerance must be between 0 and ${PROJECT_LIMITS.maxTolerance} mm.`,
});
}
@ -68,7 +71,7 @@ export function buildGeometry(room: Room, tolerance: number): Geometry {
// The last stored turn only applies if another wall is subsequently added.
if (
index < room.walls.length - 1 &&
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= 180)
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= PROJECT_LIMITS.maxTurn)
) {
issues.push({
severity: 'error',

35
src/lib/limits.ts Normal file
View file

@ -0,0 +1,35 @@
/** Limits shared by editing, persisted state validation and the planners. */
export const PROJECT_LIMITS = {
rooms: 20,
wallsPerRoom: 100,
doorsPerWall: 30,
nameLength: 200,
maxMeasurement: 1_000_000,
maxTolerance: 50,
maxTurn: 180,
maxSkirtingThickness: 10_000,
maxSkirtingKerf: 10_000,
} as const;
export const PERSISTENCE_LIMITS = {
queryLength: 100_000,
identifierLength: 120,
completedBoards: 12_000,
searchLength: 200,
} as const;
export const PLANNER_LIMITS = {
flooringBoards: 2_000,
flooringRowsPerRoom: 500,
skirtingPieces: 10_000,
} as const;
/** Named numeric tolerances; each represents a distinct calculation boundary. */
export const PRECISION = {
geometry: 1e-6,
millimetres: 0.01,
cutAngle: 0.001,
clipping: 0.001,
clipperScale: 1_000,
roundingNoise: 1e-9,
} as const;

View file

@ -1,29 +1,17 @@
import type { Point, SkirtingPiece } from './types';
import { PRECISION } from './limits';
/** Place the cutting template against its wall, preserving mitres and the coped body setbacks. */
export function skirtingPolygon(
piece: SkirtingPiece,
thickness: number,
orientation: number,
): Point[] {
export function skirtingPolygon(piece: SkirtingPiece, orientation: number): Point[] {
const dx = piece.end.x - piece.start.x;
const dy = piece.end.y - piece.start.y;
const backLength = Math.hypot(dx, dy);
if (backLength < 1e-6) return [];
if (backLength < PRECISION.geometry) return [];
const tangent = { x: dx / backLength, y: dy / backLength };
const side = orientation < 0 ? -1 : 1;
const inward = { x: -tangent.y * side, y: tangent.x * side };
const template = piece.template?.length
? piece.template
: [
{ x: 0, y: 0 },
{ x: backLength, y: 0 },
{ x: backLength, y: thickness },
{ x: 0, y: thickness },
];
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,
return piece.installedTemplate.map((point) => ({
x: piece.start.x + tangent.x * (point.x - piece.template[0].x) + inward.x * point.y,
y: piece.start.y + tangent.y * (point.x - piece.template[0].x) + inward.y * point.y,
}));
}

View file

@ -1,6 +1,7 @@
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,
@ -12,7 +13,8 @@ import type {
WallEdge,
} from './types';
const EPS = 1e-6;
const EPS = PRECISION.geometry;
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength'>;
const rad = (degrees: number) => (degrees * Math.PI) / 180;
type End = {
type: SkirtingPiece['leftType'];
@ -117,7 +119,7 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
};
}
function endCuts(piece: SkirtingPiece) {
function endCuts(piece: UnpackedPiece) {
return (
Number(Math.abs(piece.leftMitre) > EPS) +
Number(Math.abs(piece.rightMitre) > EPS) +
@ -126,15 +128,15 @@ function endCuts(piece: SkirtingPiece) {
);
}
function blankLength(piece: SkirtingPiece, available: number) {
function blankLength(piece: UnpackedPiece, available: number) {
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
}
function requiredCuts(piece: SkirtingPiece, available: number) {
function requiredCuts(piece: UnpackedPiece, available: number) {
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
}
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
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);
@ -147,7 +149,7 @@ function makePiece(
to: number,
first: boolean,
last: boolean,
): SkirtingPiece {
): UnpackedPiece {
const left = first ? run.left : square(),
right = last ? run.right : square();
const backFrom = from + left.back,
@ -189,16 +191,16 @@ function makePiece(
};
}
function shortestEdge(piece: SkirtingPiece) {
const installed = piece.installedTemplate!;
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!,
piece.visibleLength,
);
}
function fits(piece: SkirtingPiece, available: number) {
function fits(piece: UnpackedPiece, available: number) {
// The final saw pass may extend beyond the stock's tail.
return blankLength(piece, available) <= available + EPS;
}
@ -211,7 +213,7 @@ function splitCount(run: Run, target: number, minimum: number): number {
const count = Math.max(2, Math.ceil((full.length - EPS) / target));
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
if (
count > 10000 ||
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
@ -226,7 +228,7 @@ function splitRun(
target: number,
settings: SkirtingSettings,
balanced: boolean,
): SkirtingPiece[] | null {
): 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)];
@ -291,8 +293,8 @@ function splitWithOffcuts(
runs: Run[],
target: number,
settings: SkirtingSettings,
): SkirtingPiece[] | null {
const pieces: SkirtingPiece[] = [];
): UnpackedPiece[] | null {
const pieces: UnpackedPiece[] = [];
const offcuts: number[] = [];
for (const run of runs) {
if (!splitCount(run, target, settings.minLength)) return null;
@ -322,7 +324,7 @@ function splitWithOffcuts(
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 next: { piece: UnpackedPiece; to: number } | null = null;
let available = target;
for (let index = 0; index < offcuts.length; index++) {
next = take(offcuts[index]);
@ -332,7 +334,7 @@ function splitWithOffcuts(
}
}
next ??= take(target);
if (!next || pieces.length >= 10000) return null;
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);
@ -349,14 +351,14 @@ interface Packed {
waste: number;
}
/** Best-fit decreasing across all rooms, using one purchased board length. */
/** Greedy decreasing packing, optionally reserving exact three-piece combinations. */
function pack(
source: SkirtingPiece[],
source: UnpackedPiece[],
stockLength: number,
kerf: number,
exactTails = false,
): Packed {
const pieces = source.map((piece) => ({ ...piece }));
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),
);
@ -425,16 +427,16 @@ export function planSkirting(
if (
!Number.isFinite(settings.stockLength) ||
settings.stockLength < 0.1 ||
settings.stockLength > 1e6 ||
settings.stockLength > PROJECT_LIMITS.maxMeasurement ||
!Number.isFinite(settings.thickness) ||
settings.thickness < 0 ||
settings.thickness > 10000 ||
settings.thickness > PROJECT_LIMITS.maxSkirtingThickness ||
!Number.isFinite(settings.minLength) ||
settings.minLength < 0.1 ||
settings.minLength > 1e6 ||
settings.minLength > PROJECT_LIMITS.maxMeasurement ||
!Number.isFinite(settings.kerf) ||
settings.kerf < 0 ||
settings.kerf > 10000
settings.kerf > PROJECT_LIMITS.maxSkirtingKerf
) {
issues.push({
severity: 'error',
@ -445,7 +447,7 @@ export function planSkirting(
}
const stockLength = settings.stockLength;
let runs: Run[] = [];
const fallbackSplits = new Map<Run, SkirtingPiece[]>();
const fallbackSplits = new Map<Run, UnpackedPiece[]>();
rooms.forEach((room, roomIndex) => {
const geometry = buildGeometry(room, tolerance);
if (!geometry.valid) {
@ -533,7 +535,7 @@ export function planSkirting(
const candidates: Packed[] = [];
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
const addCandidate = (source: SkirtingPiece[]) => {
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 (
@ -587,7 +589,7 @@ export function planSkirting(
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 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.',
'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>();

View file

@ -26,7 +26,7 @@ export interface FlooringSettings {
gap: number;
pattern: 'brick' | 'random';
/** Random flooring can prioritise fewer saw passes or less visual repetition. */
optimisation?: 'cuts' | 'random';
optimisation: 'cuts' | 'random';
minOffset: number;
maxOffset: number;
minLength: number;
@ -40,7 +40,7 @@ export interface SkirtingSettings {
kerf: number;
}
export interface Project {
version: 1;
version: 2;
name: string;
rooms: Room[];
flooring: FlooringSettings;
@ -50,11 +50,13 @@ export interface Project {
completedBoards: string[];
}
export type PlannerSection = 'measurements' | 'materials' | 'cuts';
export type PlanMaterial = 'flooring' | 'skirting';
export interface ViewState {
roomId: string;
selectedPiece: string | null;
search: string;
collapsedSections: PlannerSection[];
material: PlanMaterial;
}
export interface UrlState {
project: Project;
@ -99,8 +101,8 @@ export interface FlooringPiece {
length: number;
width: number;
/** Preparation dimensions reserved from stock, including exact-fit remnants. */
blankLength?: number;
blankWidth?: number;
blankLength: number;
blankWidth: number;
stockId: string;
reused: boolean;
cut: boolean;
@ -108,9 +110,9 @@ export interface FlooringPiece {
/** Cutting template, relative to the bounding-box origin in board coordinates. */
template: Point[];
/** Ends that must retain factory joints; ripEdge names the board-axis long edge(s) to trim. */
factoryLeft?: boolean;
factoryRight?: boolean;
ripEdge?: 'top' | 'bottom' | 'both' | null;
factoryLeft: boolean;
factoryRight: boolean;
ripEdge: 'top' | 'bottom' | 'both' | null;
}
export interface FlooringRow {
roomId: string;
@ -145,7 +147,7 @@ export interface SkirtingPiece {
end: Point;
length: number;
/** Preparation length reserved from stock; a whole fractional length stays exact. */
blankLength?: number;
blankLength: number;
backLength: number;
leftMitre: number;
rightMitre: number;
@ -153,17 +155,17 @@ export interface SkirtingPiece {
rightType: 'square' | 'external' | 'internal' | 'cope';
stockId: string;
/** Plan section: x follows installation direction, back y=0, front y=thickness. */
template?: Point[];
template: [Point, Point, Point, Point];
/** Full-thickness installed footprint after coping, separate from the cutting blank. */
installedTemplate?: Point[];
installedTemplate: [Point, Point, Point, Point];
/** Exposed front run used to enforce the minimum installed piece length. */
visibleLength?: number;
visibleLength: number;
/** Extra blank length retained at each end for a coped internal joint. */
leftOverlap?: number;
rightOverlap?: number;
leftOverlap: number;
rightOverlap: number;
/** Positive means the front cut point is farther right than the back point. */
leftBevel?: number;
rightBevel?: number;
leftBevel: number;
rightBevel: number;
}
export interface SkirtingStock {
id: string;

View file

@ -1,5 +1,5 @@
import LZString from 'lz-string';
import { defaultState } from './defaults';
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from './limits';
import type {
Door,
FlooringSettings,
@ -10,21 +10,6 @@ import type {
Wall,
} from './types';
/** Resource limits apply to links as well as edits made inside the app. */
export const URL_LIMITS = {
queryLength: 100_000,
hashLength: 100_000,
jsonLength: 1_000_000,
rooms: 20,
walls: 100,
doors: 30,
legacyStockLengths: 30,
identifierLength: 120,
nameLength: 200,
completedBoards: 12_000,
searchLength: 200,
} as const;
const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
function object(value: unknown, field: string): Record<string, unknown> {
@ -46,7 +31,7 @@ function number(value: unknown, field: string): number {
function string(
value: unknown,
field: string,
maxLength: number = URL_LIMITS.identifierLength,
maxLength: number = PERSISTENCE_LIMITS.identifierLength,
): string {
if (typeof value !== 'string' || value.length > maxLength) {
throw new Error(`${field} must be text of at most ${maxLength} characters.`);
@ -89,22 +74,20 @@ function uniqueValues<T extends string>(items: T[], field: string): T[] {
}
function completedBoards(value: unknown): string[] {
const entries = array(
value === undefined ? [] : value,
'Completed boards',
URL_LIMITS.completedBoards,
).map((value) => {
const key = string(value, 'Completed board');
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
return key;
});
const entries = array(value, 'Completed boards', PERSISTENCE_LIMITS.completedBoards).map(
(value) => {
const key = string(value, 'Completed board');
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
return key;
},
);
return uniqueValues(entries, 'Completed boards');
}
function collapsedSections(value: unknown): PlannerSection[] {
return uniqueValues(
array(value === undefined ? [] : value, 'Collapsed sections', SECTIONS.length).map((value) =>
array(value, 'Collapsed sections', SECTIONS.length).map((value) =>
choice(value, SECTIONS, 'Collapsed section'),
),
'Collapsed sections',
@ -126,7 +109,10 @@ function wall(value: unknown): Wall {
id: identifier(data.id, 'Wall identifier'),
length: number(data.length, 'Wall length'),
turn: number(data.turn, 'Wall turn'),
doors: uniqueIds(array(data.doors, 'Wall doors', URL_LIMITS.doors).map(door), 'Wall doors'),
doors: uniqueIds(
array(data.doors, 'Wall doors', PROJECT_LIMITS.doorsPerWall).map(door),
'Wall doors',
),
};
}
@ -134,8 +120,11 @@ function room(value: unknown): Room {
const data = object(value, 'Room');
return {
id: identifier(data.id, 'Room identifier'),
name: string(data.name, 'Room name', URL_LIMITS.nameLength),
walls: uniqueIds(array(data.walls, 'Room walls', URL_LIMITS.walls).map(wall), 'Room walls'),
name: string(data.name, 'Room name', PROJECT_LIMITS.nameLength),
walls: uniqueIds(
array(data.walls, 'Room walls', PROJECT_LIMITS.wallsPerRoom).map(wall),
'Room walls',
),
direction: choice(data.direction, [0, 90], 'Flooring direction'),
};
}
@ -147,11 +136,7 @@ function flooring(value: unknown): FlooringSettings {
width: number(data.width, 'Board width'),
gap: number(data.gap, 'Expansion gap'),
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
optimisation: choice(
data.optimisation === undefined ? 'cuts' : data.optimisation,
['cuts', 'random'] as const,
'Flooring optimisation',
),
optimisation: choice(data.optimisation, ['cuts', 'random'] as const, 'Flooring optimisation'),
minOffset: number(data.minOffset, 'Minimum offset'),
maxOffset: number(data.maxOffset, 'Maximum offset'),
minLength: number(data.minLength, 'Minimum board length'),
@ -160,28 +145,13 @@ function flooring(value: unknown): FlooringSettings {
};
}
function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculations: boolean } {
function skirting(value: unknown): SkirtingSettings {
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 {
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,
stockLength: 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'),
};
}
@ -189,42 +159,35 @@ function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculati
function parseState(value: unknown): UrlState {
const data = object(value, 'Plan');
const project = object(data.project, 'Project');
if (project.version !== 1) {
if (project.version !== 2) {
throw new Error(
'This project version is unsupported. Open it in the app version that created it.',
);
}
const rooms = uniqueIds(
array(project.rooms, 'Project rooms', URL_LIMITS.rooms).map(room),
array(project.rooms, 'Project rooms', PROJECT_LIMITS.rooms).map(room),
'Project rooms',
);
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);
const previousOptimiser =
object(project.flooring, 'Flooring settings').optimisation === undefined;
return {
project: {
version: 1,
name: string(project.name, 'Project name', URL_LIMITS.nameLength),
version: 2,
name: string(project.name, 'Project name', PROJECT_LIMITS.nameLength),
rooms,
flooring: flooring(project.flooring),
skirting: skirt.settings,
skirting: skirting(project.skirting),
tolerance: number(project.tolerance, 'Measurement tolerance'),
completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed,
completedBoards: completedBoards(project.completedBoards),
},
view: {
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
selectedPiece:
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
search: string(
view.search === undefined ? '' : view.search,
'Cut search',
URL_LIMITS.searchLength,
),
search: string(view.search, 'Cut search', PERSISTENCE_LIMITS.searchLength),
collapsedSections: collapsedSections(view.collapsedSections),
material: choice(view.material, ['flooring', 'skirting'], 'Plan view'),
},
};
}
@ -262,17 +225,18 @@ export function encodeState(state: UrlState): string {
});
for (const board of project.completedBoards) put('done', board);
put('selectedRoom', view.roomId);
put('view', view.material);
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
if (view.search) put('search', view.search);
for (const section of view.collapsedSections) put('collapsed', section);
const query = `?${params}`.replaceAll('%3A', ':');
if (query.length > URL_LIMITS.queryLength)
if (query.length > PERSISTENCE_LIMITS.queryLength)
throw new Error('This plan link is too large. Split it into smaller projects.');
return query;
}
function parseQuery(query: string): UrlState {
if (query.length > URL_LIMITS.queryLength)
if (query.length > PERSISTENCE_LIMITS.queryLength)
throw new Error('The link is too large. Use a smaller project link.');
// URLSearchParams otherwise replaces damaged percent escapes silently.
decodeURIComponent(query);
@ -315,7 +279,7 @@ function parseQuery(query: string): UrlState {
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
return ordered;
};
const roomIndices = indices('room', URL_LIMITS.rooms);
const roomIndices = indices('room', PROJECT_LIMITS.rooms);
const rooms = roomIndices.length
? roomIndices.map((roomIndex) => {
const prefix = `room.${roomIndex}`;
@ -324,14 +288,14 @@ function parseQuery(query: string): UrlState {
id,
name: text(`${prefix}.name`, `Room ${roomIndex}`),
direction: numeric(`${prefix}.direction`, 0),
walls: indices(`${prefix}.wall`, URL_LIMITS.walls).map((wallIndex) => {
walls: indices(`${prefix}.wall`, PROJECT_LIMITS.wallsPerRoom).map((wallIndex) => {
const wallPrefix = `${prefix}.wall.${wallIndex}`;
const wallId = text(`${wallPrefix}.id`, `${id}-w${wallIndex}`);
return {
id: wallId,
length: numeric(`${wallPrefix}.length`),
turn: numeric(`${wallPrefix}.turn`, 90),
doors: indices(`${wallPrefix}.door`, URL_LIMITS.doors).map((doorIndex) => {
doors: indices(`${wallPrefix}.door`, PROJECT_LIMITS.doorsPerWall).map((doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
return {
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
@ -344,24 +308,12 @@ function parseQuery(query: string): UrlState {
};
})
: defaults.project.rooms;
const stockValues = params.getAll('skirting.stock');
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')),
};
if (params.has('skirting.stock'))
throw new Error('This link uses the retired skirting stock-list format.');
const floor = defaults.project.flooring;
return parseState({
project: {
version: numeric('version', 1),
version: numeric('version', defaults.project.version),
name: text('name', defaults.project.name),
tolerance: numeric('tolerance', defaults.project.tolerance),
completedBoards: params.getAll('done'),
@ -369,15 +321,13 @@ function parseQuery(query: string): UrlState {
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
key,
key === 'optimisation' && !params.has('flooring.optimisation')
? undefined
: typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
]),
),
skirting: {
...stock,
stockLength: numeric('skirting.length', defaults.project.skirting.stockLength),
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),
@ -388,42 +338,17 @@ function parseQuery(query: string): UrlState {
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
search: text('search', ''),
collapsedSections: params.getAll('collapsed'),
material: text('view', 'flooring'),
},
});
}
/** Read existing shared links so the hook can replace them with the readable query format. */
function parseLegacy(hash: string): UrlState {
if (hash.length > URL_LIMITS.hashLength)
throw new Error('The link is too large. Use a smaller project link.');
if (!hash.startsWith('#plan='))
throw new Error('Use a complete plan query or an existing #plan= link.');
const compressed = decodeURIComponent(hash.slice(6));
if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed))
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
const json = LZString.decompressFromEncodedURIComponent(compressed);
if (!json)
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
if (json.length > URL_LIMITS.jsonLength)
throw new Error('The project in this link is too large. Use a smaller project link.');
if (LZString.compressToEncodedURIComponent(json) !== compressed)
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
let value: unknown;
try {
value = JSON.parse(json);
} catch {
throw new Error('The project data is damaged. Copy the complete plan link again.');
}
return parseState(value);
}
/** Accept a readable query or a legacy fragment; never throw for an untrusted link. */
/** Accept a readable query and never throw for an untrusted link. */
export function decodeState(link: string): { state: UrlState; error: string | null } {
if (link === '' || link === '#' || link === '?') return { state: defaultState(), error: null };
try {
if (!link.startsWith('?') && !link.startsWith('#'))
throw new Error('Use the complete plan query, starting with ?.');
return { state: link.startsWith('?') ? parseQuery(link) : parseLegacy(link), error: null };
if (!link.startsWith('?')) throw new Error('Use the complete plan query, starting with ?.');
return { state: parseQuery(link), error: null };
} catch (error) {
const detail =
error instanceof URIError

View file

@ -6,9 +6,8 @@ export const PROJECT_STORAGE_KEY = 'floor-planner.project';
const readLocation = () => {
// The landing-page CTA opens the last project; it is not a project override.
const link =
window.location.search === '?planner' ? '' : window.location.search || window.location.hash;
if (link && link !== '?' && link !== '#') return decodeState(link);
const link = window.location.search === '?planner' ? '' : window.location.search;
if (link && link !== '?') return decodeState(link);
try {
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
if (!saved.error) return saved;
@ -36,8 +35,7 @@ export function useProject() {
const next = readLocation();
if (!next.error) {
try {
// Canonicalize editable queries and migrate existing fragment links
// without creating an extra entry in browser history.
// Canonicalize editable queries without creating an extra history entry.
const query = encodeState(next.state);
if (query !== window.location.search || window.location.hash)
window.history.replaceState(null, '', queryUrl(query));
@ -48,11 +46,9 @@ export function useProject() {
}
setLoaded(next);
};
window.addEventListener('hashchange', restore);
window.addEventListener('popstate', restore);
restore();
return () => {
window.removeEventListener('hashchange', restore);
window.removeEventListener('popstate', restore);
};
}, []);

View file

@ -1,10 +1,11 @@
import { nextId } from './defaults';
import { PROJECT_LIMITS } from './limits';
import type { Room } from './types';
/** Inserting a segment never renumbers walls or moves their attached doors. */
export function insertWallAfter(room: Room, wallId: string): Room {
const index = room.walls.findIndex((wall) => wall.id === wallId);
if (index < 0 || room.walls.length >= 100) return room;
if (index < 0 || room.walls.length >= PROJECT_LIMITS.wallsPerRoom) return room;
const wall = {
id: nextId(
`${room.id}-w`,