Simplify
Some checks failed
Deploy to Pages / build (push) Failing after 10m45s

This commit is contained in:
Andras Schmelczer 2026-09-11 23:27:29 +01:00
commit 8bf3e3b705
40 changed files with 1443 additions and 1497 deletions

View file

@ -1,73 +1,72 @@
import type { FlooringPlan, Project, SkirtingPlan } from './types';
import { flooringCuts, skirtingCuts } from './cut-list';
import type {
FlooringPlan,
PieceKey,
PlanFocus,
PlanMaterial,
Project,
SkirtingPlan,
} from './types';
import type { CutData } from './cut-list';
export const pieceKey = (material: PlanMaterial, id: string): PieceKey => `${material}:${id}`;
export const boardKey = (material: PlanMaterial, id: string) =>
`${material === 'flooring' ? 'floor' : 'skirting'}:${id}`;
export interface BoardMatches {
pieceIds: string[];
pieceKeys: Set<PieceKey>;
roomIds: string[];
boardKeys: string[];
boardKeys: Set<string>;
materials: Set<PlanMaterial>;
}
/** Codes match exactly; ordinary text searches room names. Qualified keys identify source boards. */
/** User text searches cut codes or room names. Location is explicit, never encoded as a query. */
export function findBoardMatches(
project: Project,
flooring: FlooringPlan,
skirting: SkirtingPlan,
query: string,
focus: PlanFocus | null = null,
): BoardMatches {
const text = query.trim().toLowerCase().replace(/\s+/g, ' ');
if (!text) return { pieceIds: [], roomIds: [], boardKeys: [] };
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/.exec(text);
const codes = text.split(/\s*[+,]\s*/);
const isCode = codes.every((code) => /^[a-z]+\d+$/.test(code));
const result: BoardMatches = {
pieceKeys: new Set(),
roomIds: [],
boardKeys: new Set(),
materials: new Set(),
};
if (!text && !focus) return result;
const codes = new Set(text.split(/\s*[+,]\s*/));
const matchingRooms = new Set(
project.rooms
.filter((room) => room.name.toLowerCase().replace(/\s+/g, ' ').includes(text))
.map((room) => room.id),
);
const pieceIds: string[] = [];
const roomIds = new Set<string>();
const boardKeys = new Set<string>();
const materials = [
{ key: 'floor', pieces: flooring.pieces },
{ key: 'skirting', pieces: skirting.pieces },
];
for (const material of materials) {
for (const piece of material.pieces) {
const matches = qualified
? material.key === qualified[1] && piece.stockId.toLowerCase() === qualified[2]
: isCode
? codes.includes(piece.id.toLowerCase()) ||
(codes.length === 1 && codes.includes(piece.stockId.toLowerCase()))
: matchingRooms.has(piece.roomId);
for (const [material, pieces] of [
['flooring', flooring.pieces],
['skirting', skirting.pieces],
] as const) {
for (const piece of pieces) {
const matches = focus
? material === focus.material &&
(focus.kind === 'board' ? piece.stockId : piece.id) === focus.id
: codes.has(piece.id.toLowerCase()) || matchingRooms.has(piece.roomId);
if (!matches) continue;
pieceIds.push(piece.id);
result.pieceKeys.add(pieceKey(material, piece.id));
roomIds.add(piece.roomId);
boardKeys.add(`${material.key}:${piece.stockId}`);
result.boardKeys.add(boardKey(material, piece.stockId));
result.materials.add(material);
}
}
return {
pieceIds,
roomIds: project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id),
boardKeys: [...boardKeys],
};
result.roomIds = project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id);
return result;
}
/** Source IDs are bookkeeping; show the cut codes when locating a whole source board. */
export function boardSearchLabel(
project: Project,
flooring: FlooringPlan,
skirting: SkirtingPlan,
query: string,
): string {
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/i.exec(query.trim());
if (!qualified) return query;
const matches = new Set(findBoardMatches(project, flooring, skirting, query).pieceIds);
const codes = [...flooringCuts(flooring), ...skirtingCuts(skirting)]
.filter((piece) => matches.has(piece.id))
.map((piece) => piece.id);
return codes.length
? codes.join(' + ')
: qualified[1].toLowerCase() === 'floor'
? 'Uncut floorboard'
: 'Uncut skirting board';
export function focusLabel(focus: PlanFocus, cuts: CutData): string {
if (focus.kind === 'piece') return focus.id;
const group = (focus.material === 'flooring' ? cuts.floorGroups : cuts.skirtGroups).get(focus.id);
return (
group?.map((piece) => piece.id).join(' + ') ||
(focus.material === 'flooring' ? 'Uncut floorboard' : 'Uncut skirting board')
);
}

View file

@ -15,20 +15,7 @@ const sameLength = (a: number, b: number) => Math.abs(a - b) < PRECISION.millime
export const flooringCuts = (plan: FlooringPlan) =>
plan.pieces.filter((piece) => piece.cut || piece.reused);
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) > PRECISION.cutAngle ||
Math.abs(piece.rightMitre) > PRECISION.cutAngle
);
}
export const skirtingCuts = (plan: SkirtingPlan) =>
plan.pieces.filter((piece) => isSkirtingCut(piece, plan));
export const skirtingCuts = (plan: SkirtingPlan) => plan.pieces.filter((piece) => piece.cut);
export function uncutFlooring(plan: FlooringPlan, settings: FlooringSettings) {
const uncut = plan.pieces.filter((piece) => !piece.cut);
@ -38,13 +25,8 @@ export function uncutFlooring(plan: FlooringPlan, settings: FlooringSettings) {
return { wholeBoards, readyOffcuts: uncut.length - wholeBoards };
}
export function uncutSkirting(plan: SkirtingPlan): { length: number; count: number }[] {
const counts = new Map<number, number>();
for (const piece of plan.pieces) {
if (!isSkirtingCut(piece, plan)) counts.set(piece.length, (counts.get(piece.length) ?? 0) + 1);
}
return [...counts].sort(([a], [b]) => a - b).map(([length, count]) => ({ length, count }));
}
export const uncutSkirting = (plan: SkirtingPlan) =>
plan.pieces.filter((piece) => !piece.cut).length;
/** Ready remnants need placement instructions, never another trimming instruction. */
export function readyOffcutInstructions(piece: FlooringPiece): string {
@ -102,3 +84,37 @@ export function skirtingInstructions(piece: SkirtingPiece): string {
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
return messages.join(' ');
}
export type CutSelection =
{ material: 'flooring'; piece: FlooringPiece } | { material: 'skirting'; piece: SkirtingPiece };
/** Shared by the drawing, cut list, search, dialog and printed sheets. */
export function cutData(
flooring: FlooringPlan,
skirting: SkirtingPlan,
settings: FlooringSettings,
) {
const floorCuts = flooringCuts(flooring);
const skirtCuts = skirtingCuts(skirting);
const floorGroups = new Map(
groupByStock(floorCuts, flooring.stocks).map((pieces) => [pieces[0].stockId, pieces]),
);
const skirtGroups = new Map(
groupByStock(skirtCuts, skirting.stocks).map((pieces) => [pieces[0].stockId, pieces]),
);
const selections = new Map<string, CutSelection>();
for (const piece of floorCuts)
selections.set(`flooring:${piece.id}`, { material: 'flooring', piece });
for (const piece of skirtCuts)
selections.set(`skirting:${piece.id}`, { material: 'skirting', piece });
return {
floorCuts,
skirtCuts,
floorGroups,
skirtGroups,
selections,
wholeFloor: uncutFlooring(flooring, settings),
wholeSkirt: uncutSkirting(skirting),
};
}
export type CutData = ReturnType<typeof cutData>;

View file

@ -2,12 +2,10 @@ import type { Point } from './types';
import { cutMm, degree } from './format';
import { PRECISION } from './limits';
export interface PreviewBox {
x: number;
y: number;
width: number;
height: number;
}
export type { Rect as PreviewBox } from './drawing-geometry';
import type { Rect as PreviewBox } from './drawing-geometry';
import { overlaps, rectBounds, bounds } from './drawing-geometry';
import { signedArea } from './geometry';
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
export interface EdgeDimension {
@ -29,12 +27,6 @@ const moved = (point: Point, direction: Point, distance: number): Point => ({
x: point.x + direction.x * distance,
y: point.y + direction.y * distance,
});
const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) =>
a.x < b.x + b.width + gap &&
a.x + a.width + gap > b.x &&
a.y < b.y + b.height + gap &&
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[],
@ -43,19 +35,10 @@ export function dimensionedOutline(
) {
if (points.length < 3)
return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) };
const span = Math.max(
1,
Math.max(...points.map((point) => point.x)) - Math.min(...points.map((point) => point.x)),
Math.max(...points.map((point) => point.y)) - Math.min(...points.map((point) => point.y)),
);
const box = bounds(points);
const span = Math.max(1, box.maxX - box.minX, box.maxY - box.minY);
const font = span * 0.065;
const orientation =
Math.sign(
points.reduce((sum, point, i) => {
const next = points[(i + 1) % points.length];
return sum + point.x * next.y - next.x * point.y;
}, 0),
) || 1;
const orientation = Math.sign(signedArea(points)) || 1;
const dimensions: EdgeDimension[] = [];
const selected = (edges ?? points.map((_, i) => i))
.map((edge) => ({
@ -109,7 +92,11 @@ export function dimensionedOutline(
const shift = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? -1 : 1) * font * 1.55;
label = moved(base, direction, shift);
labelBox = { x: label.x - width / 2, y: label.y - height / 2, width, height };
if (!dimensions.some((dimension) => overlaps(labelBox, dimension.labelBox, font * 0.25))) {
if (
!dimensions.some((dimension) =>
overlaps(rectBounds(labelBox), rectBounds(dimension.labelBox), font * 0.25),
)
) {
placed = true;
break;
}

View file

@ -33,8 +33,6 @@ export function fireplace(id: string, name: string): Room {
}
export function defaultProject(): Project {
return {
version: 2,
name: 'My flooring project',
rooms: [rectangle()],
tolerance: 10,
completedBoards: [],
@ -59,6 +57,7 @@ export function defaultState(): UrlState {
view: {
roomId: 'room-1',
selectedPiece: null,
focus: null,
search: '',
collapsedSections: [],
material: 'flooring',

View file

@ -1,11 +1,8 @@
import type { Point, WallEdge } from './types';
import { mm } from './format';
export interface LabelBox {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export type { Bounds as LabelBox } from './drawing-geometry';
import type { Bounds as LabelBox } from './drawing-geometry';
import { overlaps } from './drawing-geometry';
export interface DimensionLabel {
x: number;
y: number;
@ -17,9 +14,6 @@ export interface DimensionLabel {
normal: Point;
box: LabelBox;
}
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;
/** Move dimension lines outward until their labels clear earlier labels and the skirting codes. */
export function dimensionLabels(
edges: WallEdge[],

View file

@ -0,0 +1,34 @@
import type { Point } from './types';
export interface Bounds {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export const bounds = (points: Point[]): Bounds => ({
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)),
});
export const rectBounds = (box: Rect): Bounds => ({
minX: box.x,
minY: box.y,
maxX: box.x + box.width,
maxY: box.y + box.height,
});
export const overlaps = (a: Bounds, b: Bounds, gap = 0) =>
a.minX < b.maxX + gap && a.maxX > b.minX - gap && a.minY < b.maxY + gap && a.maxY > b.minY - gap;
export const corners = (box: Bounds): Point[] => [
{ x: box.minX, y: box.minY },
{ x: box.maxX, y: box.minY },
{ x: box.maxX, y: box.maxY },
{ x: box.minX, y: box.maxY },
];

View file

@ -1,8 +1,11 @@
import { bounds } from './drawing-geometry';
import type { Bounds } from './drawing-geometry';
import ClipperLib from 'clipper-lib';
import { buildGeometry, insetPolygon } from './geometry';
import { buildGeometry, insetPolygon, signedArea } from './geometry';
import { cutLength } from './format';
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
import type {
Geometry,
FlooringPiece,
FlooringPlan,
FlooringRow,
@ -24,14 +27,15 @@ export function planFlooringOptions(
rooms: Room[],
s: FlooringSettings,
tolerance: number,
geometries = rooms.map((room) => buildGeometry(room, tolerance)),
): FlooringOptions {
const shapes: ShapeCache = new Map();
const cutsSettings = { ...s, optimisation: 'cuts' as const };
const randomSettings = { ...s, optimisation: 'random' as const };
const cuts = solveFlooring(rooms, cutsSettings, tolerance, shapes);
const cuts = solveFlooring(rooms, cutsSettings, geometries, shapes);
const variablePattern = s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON;
const candidates = [cuts];
if (variablePattern) candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes));
if (variablePattern) candidates.push(solveFlooring(rooms, randomSettings, geometries, shapes));
const errors = (solution: Solution) =>
solution.issues.filter((issue) => issue.severity === 'error').length;
const better = (settings: FlooringSettings) =>
@ -46,7 +50,7 @@ export function planFlooringOptions(
variablePattern &&
(better(randomSettings).layout.patternPenalty > 0 || errors(better(randomSettings)))
) {
candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes, false));
candidates.push(solveFlooring(rooms, randomSettings, geometries, shapes, false));
}
const cutWinner = better(cutsSettings);
const randomWinner = better(randomSettings);
@ -61,12 +65,6 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
return planFlooringOptions(rooms, s, tolerance)[s.optimisation];
}
interface Bounds {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
interface AvailableStock extends FlooringStock {
left: boolean;
right: boolean;
@ -109,25 +107,7 @@ const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
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(
points.reduce((sum, p, i) => {
const q = points[(i + 1) % points.length];
return sum + p.x * q.y - q.x * p.y;
}, 0) / 2,
);
function bounds(points: Point[]): Bounds {
return points.reduce(
(b, p) => ({
minX: Math.min(b.minX, p.x),
maxX: Math.max(b.maxX, p.x),
minY: Math.min(b.minY, p.y),
maxY: Math.max(b.maxY, p.y),
}),
{ minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity },
);
}
const area = (points: Point[]) => Math.abs(signedArea(points));
function boardPoint(p: Point, direction: 0 | 90): Point {
return direction === 0 ? p : { x: p.y, y: -p.x };
@ -434,7 +414,7 @@ function appendRow(
),
};
const pieceIds: string[] = [];
cuts.forEach((piece, column) => {
cuts.forEach((piece) => {
const id = `F${layout.pieces.length + 1}`;
const allocation = allocate(layout, piece, id, s, preserveEnds);
const length = round(piece.bounds.maxX - piece.bounds.minX);
@ -464,7 +444,6 @@ function appendRow(
id,
roomId: room.id,
row: rowIndex + 1,
column: column + 1,
polygon: piece.polygon.map((p) => worldPoint(p, room.direction)),
length,
width,
@ -541,104 +520,6 @@ function randomPatternPenalty(
return penalty;
}
/** Reorder interchangeable full-width rectangular rows without adding any cutting operations. */
function improveRowOrder(layout: Layout, rooms: Room[], s: FlooringSettings): Layout {
if (s.pattern !== 'random' || layout.patternPenalty === 0) return layout;
const rows = [...layout.rows];
const byId = new Map(layout.pieces.map((piece) => [piece.id, piece]));
const moves = new Map<string, { row: number; shift: Point }>();
let improvement = 0;
for (const room of rooms) {
const positions = rows.flatMap((row, index) => (row.roomId === room.id ? [index] : []));
const original = positions.map((index) => rows[index]);
const order = [...original];
const shapes = new Map(
original.map((row) => {
const pieces = row.pieceIds.map((id) => byId.get(id)!);
const b = bounds(
pieces.flatMap((p) => p.polygon.map((point) => boardPoint(point, room.direction))),
);
const rectangular =
pieces.every((p) => !p.shaped && !p.ripEdge && close(p.width, s.width)) &&
close(
pieces.reduce((sum, p) => sum + p.length, 0),
b.maxX - b.minX,
);
return [row, { y: b.minY, key: rectangular ? `${round(b.minX)}:${round(b.maxX)}` : '' }];
}),
);
const scoreAt = (index: number) =>
randomPatternPenalty(
order.slice(Math.max(0, index - 4), index),
room.id,
order[index].offset,
s,
);
let attempts = 0;
for (let pass = 0; pass < 4; pass++) {
let best: { i: number; j: number; delta: number } | undefined;
for (let i = 0; i < order.length; i++) {
const key = shapes.get(order[i])!.key;
if (!key) continue;
for (let j = i + 1; j < order.length; j++) {
if (++attempts > 4096) break;
if (shapes.get(order[j])!.key !== key || close(order[i].offset, order[j].offset))
continue;
const affected = [
...new Set(
[i, j].flatMap((index) =>
Array.from({ length: Math.min(5, order.length - index) }, (_, n) => index + n),
),
),
];
const before = affected.reduce((sum, index) => sum + scoreAt(index), 0);
[order[i], order[j]] = [order[j], order[i]];
const valid = [i, i + 1, j, j + 1].every(
(index) =>
index === 0 ||
index >= order.length ||
staggerAllowed(order[index - 1].offset, order[index].offset, s),
);
const delta = valid
? affected.reduce((sum, index) => sum + scoreAt(index), 0) - before
: 0;
[order[i], order[j]] = [order[j], order[i]];
if (delta < (best?.delta ?? 0)) best = { i, j, delta };
}
}
if (!best) break;
[order[best.i], order[best.j]] = [order[best.j], order[best.i]];
improvement -= best.delta;
}
order.forEach((source, index) => {
const target = original[index];
if (source === target) return;
rows[positions[index]] = { ...source, index: target.index };
const shift = worldPoint(
{ x: 0, y: shapes.get(target)!.y - shapes.get(source)!.y },
room.direction,
);
source.pieceIds.forEach((id) => moves.set(id, { row: target.index, shift }));
});
}
if (!improvement) return layout;
return {
...layout,
rows,
patternPenalty: layout.patternPenalty - improvement,
pieces: layout.pieces.map((piece) => {
const move = moves.get(piece.id);
return move
? {
...piece,
row: move.row,
polygon: piece.polygon.map((p) => ({ x: p.x + move.shift.x, y: p.y + move.shift.y })),
}
: piece;
}),
};
}
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
function fraction(seed: string): number {
let hash = 2166136261;
@ -764,7 +645,7 @@ function validateSettings(s: FlooringSettings): string | null {
function solveFlooring(
rooms: Room[],
s: FlooringSettings,
tolerance: number,
geometries: Geometry[],
shapes: ShapeCache,
preserveEnds = true,
): Solution {
@ -782,13 +663,13 @@ function solveFlooring(
const issues: Issue[] = [];
const invalid = validateSettings(s);
if (invalid) issues.push({ severity: 'error', message: invalid });
for (const room of invalid ? [] : rooms) {
const geometry = buildGeometry(room, tolerance);
for (const [roomIndex, room] of (invalid ? [] : rooms).entries()) {
const geometry = geometries[roomIndex];
if (!geometry.valid) {
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}: finish a valid, closed outline before planning flooring.`,
message: `Finish a valid, closed outline before planning flooring.`,
});
continue;
}
@ -799,7 +680,7 @@ function solveFlooring(
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}: the expansion gap leaves no usable floor area.`,
message: `The expansion gap leaves no usable floor area.`,
});
continue;
}
@ -816,8 +697,8 @@ function solveFlooring(
roomId: room.id,
message:
estimatedBoards > MAX_BOARDS
? `${room.name}: this plan exceeds the ${MAX_BOARDS}-board calculation limit. Check your millimetre measurements.`
: `${room.name}: the room width cannot meet the minimum row width using full-width interior rows. Reduce the minimum row width or change board direction/size.`,
? `This plan exceeds the ${MAX_BOARDS}-board calculation limit. Check your millimetre measurements.`
: `The room width cannot meet the minimum row width using full-width interior rows. Reduce the minimum row width or change board direction/size.`,
});
continue;
}
@ -900,7 +781,7 @@ function solveFlooring(
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}: no valid layout found within the piece-size and joint-offset limits. Try reducing minimum piece dimensions, widening the offset range, or changing board direction. No partial floor is counted.`,
message: `No valid layout found within the piece-size and joint-offset limits. Try reducing minimum piece dimensions, widening the offset range, or changing board direction. No partial floor is counted.`,
});
continue;
}
@ -908,7 +789,7 @@ function solveFlooring(
finalists = complete;
}
for (const candidate of finalists) {
const improved = improveRowOrder(repack(candidate, s), rooms, s);
const improved = repack(candidate, s);
if (compareLayouts(improved, layout, s) < 0) layout = improved;
}
for (const room of rooms) {
@ -916,7 +797,7 @@ function solveFlooring(
issues.push({
severity: 'warning',
roomId: room.id,
message: `${room.name}: angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`,
message: `Angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`,
});
}
return { layout, issues };

109
src/lib/legacy-query.ts Normal file
View file

@ -0,0 +1,109 @@
import { defaultState } from './defaults';
import { PROJECT_LIMITS } from './limits';
/** Import version 2 links. This format is never written by the application. */
export function readLegacyQuery(query: string): unknown {
const params = new URLSearchParams(query);
const defaults = defaultState();
const roomFields = [...params.keys()].filter((key) =>
/^room\.[^.]+\.(?:id|name|direction|wall\.[^.]+\.(?:id|length|turn|door\.[^.]+\.(?:id|offset|width)))$/.test(
key,
),
);
const text = (key: string, fallback?: string): string => {
const values = params.getAll(key);
if (values.length > 1) throw new Error(`${key} must appear only once.`);
if (!values.length) {
if (fallback !== undefined) return fallback;
throw new Error(`The link is missing ${key}.`);
}
return values[0];
};
const measurement = (value: string, key: string) => {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))
throw new Error(`${key} must be a finite number.`);
const result = Number(value);
if (!Number.isFinite(result)) throw new Error(`${key} must be a finite number.`);
return result;
};
const numeric = (key: string, fallback?: number) =>
measurement(text(key, fallback === undefined ? undefined : String(fallback)), key);
const indices = (prefix: string, limit: number) => {
const found = new Set<number>();
for (const key of roomFields) {
if (!key.startsWith(`${prefix}.`)) continue;
const index = key.slice(prefix.length + 1).split('.')[0];
if (!/^[1-9]\d*$/.test(index))
throw new Error(`${prefix} indices must be positive whole numbers, starting at 1.`);
const value = Number(index);
if (value > limit) throw new Error(`${prefix} supports at most ${limit} entries.`);
found.add(value);
}
const ordered = [...found].sort((a, b) => a - b);
if (ordered.some((value, index) => value !== index + 1))
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
return ordered;
};
const roomIndices = indices('room', PROJECT_LIMITS.rooms);
const rooms = roomIndices.length
? roomIndices.map((roomIndex) => {
const prefix = `room.${roomIndex}`;
const id = text(`${prefix}.id`, `room-${roomIndex}`);
return {
id,
name: text(`${prefix}.name`, `Room ${roomIndex}`),
direction: numeric(`${prefix}.direction`, 0),
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`, PROJECT_LIMITS.doorsPerWall).map((doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
return {
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
offset: numeric(`${doorPrefix}.offset`),
width: numeric(`${doorPrefix}.width`),
};
}),
};
}),
};
})
: defaults.project.rooms;
if (params.has('skirting.stock'))
throw new Error('This link uses the retired skirting stock-list format.');
const floor = defaults.project.flooring;
return {
project: {
version: numeric('version', 2),
name: text('name', ''),
tolerance: numeric('tolerance', defaults.project.tolerance),
completedBoards: params.getAll('done'),
rooms,
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
key,
typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
]),
),
skirting: {
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),
},
},
view: {
roomId: text('selectedRoom', rooms[0].id),
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
search: text('search', ''),
collapsedSections: params.getAll('collapsed'),
material: text('view', 'flooring'),
},
};
}

View file

@ -1,3 +1,4 @@
import { bounds, overlaps, corners } from './drawing-geometry';
import type { FlooringPiece, Point } from './types';
import type { LabelBox } from './dimension-labels';
@ -15,20 +16,6 @@ export interface PieceLabel {
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++) {

49
src/lib/planning.ts Normal file
View file

@ -0,0 +1,49 @@
import { buildGeometry } from './geometry';
import { planFlooringOptions } from './flooring';
import { planSkirting } from './skirting';
import type { FlooringOptions } from './flooring';
import type { Geometry, Project, SkirtingPlan } from './types';
/** One calculator per editor. Compare calculation inputs, excluding names and progress. */
export function createPlanCalculator() {
let previous: Project | undefined;
let geometries: Geometry[] = [];
let flooringOptions: FlooringOptions;
let skirting: SkirtingPlan;
return (project: Project) => {
const sameOutlines =
previous &&
previous.tolerance === project.tolerance &&
previous.rooms.length === project.rooms.length &&
project.rooms.every(
(room, i) => room.id === previous!.rooms[i].id && room.walls === previous!.rooms[i].walls,
);
const sameDirections =
sameOutlines &&
project.rooms.every((room, i) => room.direction === previous!.rooms[i].direction);
const sameFlooring =
previous &&
Object.entries(project.flooring).every(
([key, value]) => key === 'optimisation' || Reflect.get(previous!.flooring, key) === value,
);
if (!sameOutlines)
geometries = project.rooms.map((room, i) =>
previous?.tolerance === project.tolerance &&
previous.rooms[i]?.id === room.id &&
previous.rooms[i]?.walls === room.walls
? geometries[i]
: buildGeometry(room, project.tolerance),
);
if (!sameDirections || !sameFlooring)
flooringOptions = planFlooringOptions(
project.rooms,
project.flooring,
project.tolerance,
geometries,
);
if (!sameOutlines || previous?.skirting !== project.skirting)
skirting = planSkirting(project.rooms, project.skirting, project.tolerance, geometries);
previous = project;
return { geometries, flooringOptions: flooringOptions!, skirting: skirting! };
};
}

162
src/lib/project-state.ts Normal file
View file

@ -0,0 +1,162 @@
import { nextId, rectangle } from './defaults';
import { PROJECT_LIMITS } from './limits';
import type {
FlooringSettings,
PlanFocus,
PlanMaterial,
PlannerSection,
Project,
Room,
SkirtingSettings,
UrlState,
} from './types';
export type ProjectAction =
| { type: 'room'; id: string; change: (room: Room) => Room }
| { type: 'flooring'; patch: Partial<FlooringSettings> }
| { type: 'skirting'; patch: Partial<SkirtingSettings> }
| { type: 'tolerance'; value: number }
| { type: 'addRoom' }
| { type: 'removeRoom'; id: string }
| { type: 'visitRoom'; id: string }
| { type: 'material'; material: PlanMaterial }
| { type: 'select'; material: PlanMaterial; id: string; roomId?: string }
| { type: 'closePreview' }
| { type: 'search'; text: string; roomId: string; material: PlanMaterial }
| { type: 'focus'; focus: PlanFocus; roomId: string }
| { type: 'toggleBoard'; key: string }
| { type: 'toggleSection'; section: PlannerSection };
const toggle = <T>(items: T[], value: T) =>
items.includes(value) ? items.filter((item) => item !== value) : [...items, value];
/** Allocation changes invalidate progress and locations together. Renames leave them intact. */
function changedPlan(state: UrlState, project: Project): UrlState {
return {
project: { ...project, completedBoards: [] },
view: { ...state.view, selectedPiece: null, search: '', focus: null },
};
}
export function reduceProject(state: UrlState, action: ProjectAction): UrlState {
const { project, view } = state;
switch (action.type) {
case 'room': {
const room = project.rooms.find((room) => room.id === action.id);
if (!room) return state;
const next = action.change(room);
if (next === room) return state;
const updated = {
...project,
rooms: project.rooms.map((item) => (item === room ? next : item)),
};
return next.walls === room.walls && next.direction === room.direction
? { ...state, project: updated }
: changedPlan(state, updated);
}
case 'flooring':
case 'skirting': {
if (
Object.entries(action.patch).every(
([key, value]) => Reflect.get(project[action.type], key) === value,
)
)
return state;
return changedPlan(state, {
...project,
[action.type]: { ...project[action.type], ...action.patch },
});
}
case 'tolerance':
return action.value === project.tolerance
? state
: changedPlan(state, { ...project, tolerance: action.value });
case 'addRoom': {
if (project.rooms.length >= PROJECT_LIMITS.rooms) return state;
const id = nextId(
'room',
project.rooms.map((room) => room.id),
);
const next = changedPlan(state, {
...project,
rooms: [...project.rooms, rectangle(id, `Room ${project.rooms.length + 1}`)],
});
return {
...next,
view: {
...next.view,
roomId: id,
collapsedSections: view.collapsedSections.filter((section) => section !== 'measurements'),
},
};
}
case 'removeRoom': {
if (project.rooms.length <= 1) return state;
const rooms = project.rooms.filter((room) => room.id !== action.id);
if (rooms.length === project.rooms.length) return state;
const next = changedPlan(state, { ...project, rooms });
return {
...next,
view: {
...next.view,
roomId: rooms.some((room) => room.id === view.roomId) ? view.roomId : rooms[0].id,
},
};
}
case 'visitRoom':
return project.rooms.some((room) => room.id === action.id)
? { ...state, view: { ...view, roomId: action.id, selectedPiece: null } }
: state;
case 'material':
return {
...state,
view: { ...view, material: action.material, selectedPiece: null, search: '', focus: null },
};
case 'select':
return {
...state,
view: {
...view,
selectedPiece: `${action.material}:${action.id}`,
material: action.material,
roomId: action.roomId ?? view.roomId,
},
};
case 'closePreview':
return { ...state, view: { ...view, selectedPiece: null } };
case 'search':
return {
...state,
view: {
...view,
search: action.text,
roomId: action.roomId,
material: action.material,
focus: null,
selectedPiece: null,
},
};
case 'focus':
return {
...state,
view: {
...view,
focus: action.focus,
material: action.focus.material,
roomId: action.roomId,
search: '',
selectedPiece: null,
},
};
case 'toggleBoard':
return {
...state,
project: { ...project, completedBoards: toggle(project.completedBoards, action.key) },
};
case 'toggleSection':
return {
...state,
view: { ...view, collapsedSections: toggle(view.collapsedSections, action.section) },
};
}
}

View file

@ -1,5 +1,4 @@
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 {
@ -14,12 +13,11 @@ import type {
} from './types';
const EPS = PRECISION.geometry;
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength'>;
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength' | 'cut'>;
const rad = (degrees: number) => (degrees * Math.PI) / 180;
type End = {
type: SkirtingPiece['leftType'];
angle: number;
bevel: number;
back: number;
front: number;
installedBack: number;
@ -39,7 +37,6 @@ type Run = {
const square = (): End => ({
type: 'square',
angle: 0,
bevel: 0,
back: 0,
front: 0,
installedBack: 0,
@ -83,7 +80,6 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
return {
type: 'external',
angle: -turn / 2,
bevel: (sign * turn) / 2,
back: 0,
front,
installedBack: 0,
@ -96,7 +92,6 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
return {
type: 'cope',
angle: turn / 2,
bevel: (sign * turn) / 2,
back: 0,
front: faceJoint,
installedBack: (sign * thickness) / Math.sin(rad(turn)),
@ -110,7 +105,6 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
return {
type: 'internal',
angle: Math.abs(90 - turn),
bevel: sign * (turn - 90),
back: 0,
front,
installedBack: 0,
@ -172,16 +166,12 @@ function makePiece(
rightType: right.type,
stockId: '',
visibleLength: to + right.visible - from - left.visible,
leftOverlap: Math.abs(left.visible - (left.type === 'cope' ? left.back : left.front)),
rightOverlap: Math.abs(right.visible - (right.type === 'cope' ? right.back : right.front)),
installedTemplate: [
{ x: from + left.installedBack - origin, y: 0 },
{ x: to + right.installedBack - origin, y: 0 },
{ x: to + right.installedFront - origin, y: run.thickness },
{ x: from + left.installedFront - origin, y: run.thickness },
],
leftBevel: left.bevel,
rightBevel: right.bevel,
template: [
{ x: backFrom - origin, y: 0 },
{ x: backTo - origin, y: 0 },
@ -351,14 +341,9 @@ interface Packed {
waste: number;
}
/** Greedy decreasing packing, optionally reserving exact three-piece combinations. */
function pack(
source: UnpackedPiece[],
stockLength: number,
kerf: number,
exactTails = false,
): Packed {
const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0 }));
/** Greedy decreasing packing into shared stock. */
function pack(source: UnpackedPiece[], stockLength: number, kerf: number): Packed {
const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0, cut: false }));
let pending = [...pieces].sort(
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
);
@ -367,29 +352,12 @@ function pack(
while (pending.length) {
let available = stockLength;
const assigned: SkirtingPiece[] = [];
if (exactTails && pending.length > 2) {
const head = pending[0];
const tail = stockLength - consumedLength(head, stockLength, kerf);
const options = pending.slice(1, 129).filter((piece) => fits(piece, tail));
for (const second of options) {
const remaining = tail - consumedLength(second, tail, kerf);
const third = options.find(
(piece) => piece !== second && Math.abs(piece.length - remaining) <= EPS,
);
if (!third) continue;
pending = [
head,
second,
third,
...pending.filter((piece) => piece !== head && piece !== second && piece !== third),
];
break;
}
}
for (const piece of pending) {
if (!fits(piece, available)) continue;
piece.blankLength = blankLength(piece, available);
cuts += requiredCuts(piece, available);
const passes = requiredCuts(piece, available);
piece.cut = passes > 0;
cuts += passes;
available -= consumedLength(piece, available, kerf);
assigned.push(piece);
}
@ -414,6 +382,7 @@ export function planSkirting(
rooms: Room[],
settings: SkirtingSettings,
tolerance: number,
geometries = rooms.map((room) => buildGeometry(room, tolerance)),
): SkirtingPlan {
const issues: Issue[] = [];
const empty = (): SkirtingPlan => ({
@ -449,12 +418,12 @@ export function planSkirting(
let runs: Run[] = [];
const fallbackSplits = new Map<Run, UnpackedPiece[]>();
rooms.forEach((room, roomIndex) => {
const geometry = buildGeometry(room, tolerance);
const geometry = geometries[roomIndex];
if (!geometry.valid) {
issues.push({
severity: 'error',
roomId: room.id,
message: `${room.name}: finish a valid outline and door measurements before planning skirting.`,
message: `Finish a valid outline and door measurements before planning skirting.`,
});
return;
}
@ -503,7 +472,7 @@ export function planSkirting(
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.`,
message: `Wall ${index + 1}: a skirting run after corner allowances is shorter than the ${settings.minLength} mm minimum. Change the minimum or opening measurements.`,
});
} else {
// Establish a feasible rounded split per run before combining rooms.
@ -517,7 +486,7 @@ export function planSkirting(
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.`,
message: `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);
@ -551,7 +520,6 @@ export function planSkirting(
piece.id = `${prefixes.get(piece.roomId)}${count}`;
}
candidates.push(pack(source, stockLength, settings.kerf));
candidates.push(pack(source, stockLength, settings.kerf, true));
};
// Compare full-stock, balanced, and offcut-led wall joints.
{
@ -596,7 +564,7 @@ export function planSkirting(
let wholeNumber = 0;
const codes = new Map(
plan.pieces.map((piece) => {
if (!isSkirtingCut(piece, plan)) return [piece.id, `SU${++wholeNumber}`];
if (!piece.cut) 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}`];

View file

@ -40,8 +40,6 @@ export interface SkirtingSettings {
kerf: number;
}
export interface Project {
version: 2;
name: string;
rooms: Room[];
flooring: FlooringSettings;
skirting: SkirtingSettings;
@ -51,9 +49,16 @@ export interface Project {
}
export type PlannerSection = 'measurements' | 'materials' | 'cuts';
export type PlanMaterial = 'flooring' | 'skirting';
export type PieceKey = `${PlanMaterial}:${string}`;
export interface PlanFocus {
kind: 'board' | 'piece';
material: PlanMaterial;
id: string;
}
export interface ViewState {
roomId: string;
selectedPiece: string | null;
selectedPiece: PieceKey | null;
focus: PlanFocus | null;
search: string;
collapsedSections: PlannerSection[];
material: PlanMaterial;
@ -96,7 +101,6 @@ export interface FlooringPiece {
id: string;
roomId: string;
row: number;
column: number;
polygon: Point[];
length: number;
width: number;
@ -148,6 +152,7 @@ export interface SkirtingPiece {
length: number;
/** Preparation length reserved from stock; a whole fractional length stays exact. */
blankLength: number;
cut: boolean;
backLength: number;
leftMitre: number;
rightMitre: number;
@ -160,12 +165,6 @@ export interface SkirtingPiece {
installedTemplate: [Point, Point, Point, 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;
}
export interface SkirtingStock {
id: string;

View file

@ -1,3 +1,4 @@
import { readLegacyQuery } from './legacy-query';
import { defaultState } from './defaults';
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from './limits';
import type {
@ -8,6 +9,8 @@ import type {
SkirtingSettings,
UrlState,
Wall,
PieceKey,
PlanFocus,
} from './types';
const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
@ -70,7 +73,7 @@ function uniqueIds<T extends { id: string }>(items: T[], field: string): T[] {
function uniqueValues<T extends string>(items: T[], field: string): T[] {
if (new Set(items).size !== items.length) throw new Error(`${field} contains duplicate entries.`);
return items.sort();
return items;
}
function completedBoards(value: unknown): string[] {
@ -159,7 +162,8 @@ function skirting(value: unknown): SkirtingSettings {
function parseState(value: unknown): UrlState {
const data = object(value, 'Plan');
const project = object(data.project, 'Project');
if (project.version !== 2) {
const legacy = data.version === undefined && project.version === 2;
if (data.version !== 3 && !legacy) {
throw new Error(
'This project version is unsupported. Open it in the app version that created it.',
);
@ -171,10 +175,39 @@ 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 material = choice(view.material, ['flooring', 'skirting'] as const, 'Plan view');
let selectedPiece = view.selectedPiece;
let focus = view.focus ?? null;
let search = string(view.search, 'Cut search', PERSISTENCE_LIMITS.searchLength);
if (legacy) {
if (selectedPiece !== null)
selectedPiece = `${material}:${string(selectedPiece, 'Selected piece')}`;
const board = /^(floor|skirting):([a-z]+\d+)$/i.exec(search.trim());
if (board) {
focus = {
kind: 'board',
material: board[1].toLowerCase() === 'floor' ? 'flooring' : 'skirting',
id: board[2].toUpperCase(),
};
search = '';
}
}
if (
selectedPiece !== null &&
!/^(flooring|skirting):.+$/.test(string(selectedPiece, 'Selected piece'))
)
throw new Error('Selected piece needs a material and an identifier.');
let parsedFocus: PlanFocus | null = null;
if (focus !== null) {
const data = object(focus, 'Plan focus');
parsedFocus = {
kind: choice(data.kind, ['board', 'piece'], 'Focus kind'),
material: choice(data.material, ['flooring', 'skirting'], 'Focus material'),
id: identifier(data.id, 'Focus identifier'),
};
}
return {
project: {
version: 2,
name: string(project.name, 'Project name', PROJECT_LIMITS.nameLength),
rooms,
flooring: flooring(project.flooring),
skirting: skirting(project.skirting),
@ -183,176 +216,44 @@ function parseState(value: unknown): UrlState {
},
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, 'Cut search', PERSISTENCE_LIMITS.searchLength),
selectedPiece: selectedPiece as PieceKey | null,
focus: parsedFocus,
search,
collapsedSections: collapsedSections(view.collapsedSections),
material: choice(view.material, ['flooring', 'skirting'], 'Plan view'),
material,
},
};
}
/** Explicit keys in a fixed order: no compressed payload or derived results. */
/** Serialize trusted application state; imported data is validated only when read. */
export function encodeState(state: UrlState): string {
const { project, view } = parseState(state);
const params = new URLSearchParams();
const put = (key: string, value: string | number) => params.append(key, String(value));
put('version', project.version);
put('name', project.name);
put('tolerance', project.tolerance);
for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value);
put('skirting.length', project.skirting.stockLength);
put('skirting.thickness', project.skirting.thickness);
put('skirting.minLength', project.skirting.minLength);
put('skirting.kerf', project.skirting.kerf);
project.rooms.forEach((room, roomIndex) => {
const prefix = `room.${roomIndex + 1}`;
put(`${prefix}.id`, room.id);
put(`${prefix}.name`, room.name);
put(`${prefix}.direction`, room.direction);
room.walls.forEach((wall, wallIndex) => {
const wallPrefix = `${prefix}.wall.${wallIndex + 1}`;
put(`${wallPrefix}.id`, wall.id);
put(`${wallPrefix}.length`, wall.length);
put(`${wallPrefix}.turn`, wall.turn);
wall.doors.forEach((door, doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex + 1}`;
put(`${doorPrefix}.id`, door.id);
put(`${doorPrefix}.offset`, door.offset);
put(`${doorPrefix}.width`, door.width);
});
});
});
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', ':');
const query = `?plan=${encodeURIComponent(
JSON.stringify({ version: 3, ...state }, (_key, value) =>
value && typeof value === 'object' && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
: value,
),
)}`;
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 > 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);
const params = new URLSearchParams(query);
const defaults = defaultState();
const roomFields = [...params.keys()].filter((key) =>
/^room\.[^.]+\.(?:id|name|direction|wall\.[^.]+\.(?:id|length|turn|door\.[^.]+\.(?:id|offset|width)))$/.test(
key,
),
);
const text = (key: string, fallback?: string): string => {
const values = params.getAll(key);
if (values.length > 1) throw new Error(`${key} must appear only once.`);
if (!values.length) {
if (fallback !== undefined) return fallback;
throw new Error(`The link is missing ${key}.`);
}
return values[0];
};
const measurement = (value: string, key: string) => {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))
throw new Error(`${key} must be a finite number.`);
return number(Number(value), key);
};
const numeric = (key: string, fallback?: number) =>
measurement(text(key, fallback === undefined ? undefined : String(fallback)), key);
const indices = (prefix: string, limit: number) => {
const found = new Set<number>();
for (const key of roomFields) {
if (!key.startsWith(`${prefix}.`)) continue;
const index = key.slice(prefix.length + 1).split('.')[0];
if (!/^[1-9]\d*$/.test(index))
throw new Error(`${prefix} indices must be positive whole numbers, starting at 1.`);
const value = Number(index);
if (value > limit) throw new Error(`${prefix} supports at most ${limit} entries.`);
found.add(value);
}
const ordered = [...found].sort((a, b) => a - b);
if (ordered.some((value, index) => value !== index + 1))
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
return ordered;
};
const roomIndices = indices('room', PROJECT_LIMITS.rooms);
const rooms = roomIndices.length
? roomIndices.map((roomIndex) => {
const prefix = `room.${roomIndex}`;
const id = text(`${prefix}.id`, `room-${roomIndex}`);
return {
id,
name: text(`${prefix}.name`, `Room ${roomIndex}`),
direction: numeric(`${prefix}.direction`, 0),
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`, PROJECT_LIMITS.doorsPerWall).map((doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
return {
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
offset: numeric(`${doorPrefix}.offset`),
width: numeric(`${doorPrefix}.width`),
};
}),
};
}),
};
})
: defaults.project.rooms;
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', defaults.project.version),
name: text('name', defaults.project.name),
tolerance: numeric('tolerance', defaults.project.tolerance),
completedBoards: params.getAll('done'),
rooms,
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
key,
typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
]),
),
skirting: {
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),
},
},
view: {
roomId: text('selectedRoom', rooms[0].id),
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
search: text('search', ''),
collapsedSections: params.getAll('collapsed'),
material: text('view', 'flooring'),
},
});
}
/** 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 };
if (link === '' || link === '#' || link === '?' || link === '?planner')
return { state: defaultState(), error: null };
try {
if (!link.startsWith('?')) throw new Error('Use the complete plan query, starting with ?.');
return { state: parseQuery(link), error: null };
if (link.length > PERSISTENCE_LIMITS.queryLength) throw new Error('The link is too large.');
decodeURIComponent(link);
const params = new URLSearchParams(link);
if (params.getAll('plan').length > 1) throw new Error('The link must contain one plan.');
const value = params.has('plan') ? JSON.parse(params.get('plan')!) : readLegacyQuery(link);
return { state: parseState(value), error: null };
} catch (error) {
const detail =
error instanceof URIError
? 'The link has invalid URL encoding. Copy the complete plan link again.'
? 'The link has invalid URL encoding.'
: error instanceof Error
? error.message
: 'Copy the complete plan link again.';

22
src/lib/use-printing.ts Normal file
View file

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { flushSync } from 'react-dom';
/** Build print sheets for native printing (including Ctrl+P), then release them. */
export function usePrinting() {
const [printing, setPrinting] = useState(false);
useEffect(() => {
const media = window.matchMedia('print');
const start = () => flushSync(() => setPrinting(true));
const finish = () => setPrinting(false);
const changed = () => (media.matches ? start() : finish());
window.addEventListener('beforeprint', start);
window.addEventListener('afterprint', finish);
media.addEventListener('change', changed);
return () => {
window.removeEventListener('beforeprint', start);
window.removeEventListener('afterprint', finish);
media.removeEventListener('change', changed);
};
}, []);
return printing;
}

View file

@ -1,85 +1,64 @@
import { useCallback, useEffect, useState } from 'react';
import type { UrlState } from './types';
import { decodeState, encodeState } from './url-state';
import { reduceProject } from './project-state';
import type { ProjectAction } from './project-state';
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;
if (link && link !== '?') return decodeState(link);
function readLocation() {
const link = window.location.search;
if (link && link !== '?' && link !== '?planner') return decodeState(link);
try {
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
if (!saved.error) return saved;
} catch {
// Browser privacy settings can make localStorage unavailable.
/* Storage may be unavailable. */
}
return decodeState('');
};
const saveLocal = (query: string) => {
try {
window.localStorage.setItem(PROJECT_STORAGE_KEY, query);
} catch {
// Keep URL persistence working if storage is blocked or full.
}
};
const queryUrl = (query: string) => `${window.location.pathname}${query}`;
/** Persist every parameter in the URL and locally; explicit links take precedence. */
export function useProject() {
const [loaded, setLoaded] = useState(readLocation);
useEffect(() => {
const restore = () => {
const next = readLocation();
if (!next.error) {
try {
// 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));
saveLocal(query);
} catch (error) {
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
}
}
setLoaded(next);
};
window.addEventListener('popstate', restore);
restore();
return () => {
window.removeEventListener('popstate', restore);
};
}, []);
const update = useCallback((transform: (current: UrlState) => UrlState, milestone = false) => {
setLoaded((current) => {
const next = transform(current.state);
try {
const query = encodeState(next);
if (query !== window.location.search || window.location.hash)
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
saveLocal(query);
const canonical = decodeState(query).state;
if (next.project === current.state.project) canonical.project = current.state.project;
else {
// Progress changes should not rerun geometry and stock allocation.
if (next.project.rooms === current.state.project.rooms)
canonical.project.rooms = current.state.project.rooms;
if (next.project.flooring === current.state.project.flooring)
canonical.project.flooring = current.state.project.flooring;
if (next.project.skirting === current.state.project.skirting)
canonical.project.skirting = current.state.project.skirting;
}
return { state: canonical, error: null };
} catch (error) {
return {
...current,
error:
error instanceof Error ? error.message : 'This project could not be saved in the URL.',
};
}
});
}, []);
return { ...loaded, update };
}
/** React owns live state. Persistence is an output between navigations. */
export function useProject() {
const [loaded, setLoaded] = useState(() => ({ ...readLocation(), milestone: false }));
useEffect(() => {
const restore = () => setLoaded({ ...readLocation(), milestone: false });
window.addEventListener('popstate', restore);
return () => window.removeEventListener('popstate', restore);
}, []);
useEffect(() => {
if (loaded.error) return;
try {
const query = encodeState(loaded.state);
if (query !== window.location.search || window.location.hash)
window.history[loaded.milestone ? 'pushState' : 'replaceState'](
null,
'',
`${window.location.pathname}${query}`,
);
try {
window.localStorage.setItem(PROJECT_STORAGE_KEY, query);
} catch {
/* The URL remains available if storage is blocked or full. */
}
} catch (error) {
// Keep edits usable even if the browser cannot save this link.
setLoaded((current) =>
current === loaded
? {
...current,
error: error instanceof Error ? error.message : 'This plan could not be saved.',
}
: current,
);
}
}, [loaded]);
const dispatch = useCallback((action: ProjectAction) => {
setLoaded((current) => ({
state: reduceProject(current.state, action),
error: null,
milestone:
action.type === 'addRoom' || action.type === 'removeRoom' || action.type === 'visitRoom',
}));
}, []);
return { ...loaded, dispatch };
}