This commit is contained in:
parent
171fbf1927
commit
8bf3e3b705
40 changed files with 1443 additions and 1497 deletions
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
34
src/lib/drawing-geometry.ts
Normal file
34
src/lib/drawing-geometry.ts
Normal 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 },
|
||||
];
|
||||
|
|
@ -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
109
src/lib/legacy-query.ts
Normal 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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
49
src/lib/planning.ts
Normal 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
162
src/lib/project-state.ts
Normal 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) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
22
src/lib/use-printing.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue