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,17 +1,16 @@
import { useMemo, useRef } from 'react';
import { useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Copy, Plus, Printer, Search, X } from 'lucide-react';
import { usePrinting } from './lib/use-printing';
import { useProject } from './lib/use-project';
import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics';
import { rectangle, nextId } from './lib/defaults';
import { buildGeometry } from './lib/geometry';
import { planFlooringOptions } from './lib/flooring';
import { planSkirting } from './lib/skirting';
import { createPlanCalculator } from './lib/planning';
import { encodeState } from './lib/url-state';
import { area, mm } from './lib/format';
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
import type { PlanMaterial, PlannerSection, Project, Room } from './lib/types';
import { boardSearchLabel, findBoardMatches } from './lib/board-tracking';
import { PRECISION, PROJECT_LIMITS } from './lib/limits';
import { cutData } from './lib/cut-list';
import type { PlanMaterial, PlanFocus, PlannerSection, Room } from './lib/types';
import { focusLabel, findBoardMatches, pieceKey } from './lib/board-tracking';
import { PROJECT_LIMITS } from './lib/limits';
import { OutlineEditor } from './components/OutlineEditor';
import { MaterialsEditor } from './components/MaterialsEditor';
import { FloorPlan } from './components/FloorPlan';
@ -64,173 +63,80 @@ function PlannerPanel({
);
}
// Completion refers to this exact allocation; names and view preferences do not change it.
const allocationInputs = (project: Project) =>
JSON.stringify({
rooms: project.rooms.map(({ id, walls, direction }) => ({ id, walls, direction })),
flooring: project.flooring,
skirting: project.skirting,
tolerance: project.tolerance,
});
export default function App() {
const { state, error, update } = useProject();
const { state, error, dispatch } = useProject();
const { project, view } = state;
const printing = usePrinting();
const copyStatus = useRef<HTMLOutputElement>(null);
const room = project.rooms.find((r) => r.id === view.roomId) ?? project.rooms[0];
const geometries = useMemo(
() => project.rooms.map((r) => buildGeometry(r, project.tolerance)),
[project.rooms, project.tolerance],
const [calculate] = useState(createPlanCalculator);
const { geometries, flooringOptions, skirting } = useMemo(
() => calculate(project),
[calculate, project],
);
const room = project.rooms.find((room) => room.id === view.roomId) ?? project.rooms[0];
const geometry = geometries[project.rooms.indexOf(room)];
// Switching between already calculated options only selects a result.
const flooringInputs = JSON.stringify({ ...project.flooring, optimisation: undefined });
const flooringOptions = useMemo(
() => planFlooringOptions(project.rooms, project.flooring, project.tolerance),
[project.rooms, flooringInputs, project.tolerance],
);
const flooring = flooringOptions[project.flooring.optimisation];
const skirting = useMemo(
() => planSkirting(project.rooms, project.skirting, project.tolerance),
[project.rooms, project.skirting, project.tolerance],
const cuts = useMemo(
() => cutData(flooring, skirting, project.flooring),
[flooring, skirting, project.flooring],
);
const floorErrors = flooring.issues.filter((i) => i.severity === 'error');
const skirtErrors = skirting.issues.filter((i) => i.severity === 'error');
const roomNames = useMemo(
() => new Map(project.rooms.map((room) => [room.id, room.name])),
[project.rooms],
);
const floorErrors = flooring.issues.filter((issue) => issue.severity === 'error');
const skirtErrors = skirting.issues.filter((issue) => issue.severity === 'error');
const roomErrors = [...floorErrors, ...skirtErrors].filter(
(issue) => !issue.roomId || issue.roomId === room.id,
);
const totalArea = geometries.reduce((sum, g) => sum + (g.valid ? g.area : 0), 0);
const wholeBoards = flooring.pieces.filter(
(p) =>
!p.cut &&
Math.abs(p.length - project.flooring.length) < PRECISION.millimetres &&
Math.abs(p.width - project.flooring.width) < PRECISION.millimetres,
).length;
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
const changeProject = (fn: (p: Project) => Project) =>
update((s) => {
const project = fn(s.project);
const changed = allocationInputs(project) !== allocationInputs(s.project);
return {
...s,
project: changed ? { ...project, completedBoards: [] } : project,
view: { ...s.view, selectedPiece: null, search: changed ? '' : s.view.search },
};
});
const matches = useMemo(
() => findBoardMatches(project, flooring, skirting, view.search),
[project, flooring, skirting, view.search],
const totalArea = geometries.reduce(
(sum, geometry) => sum + (geometry.valid ? geometry.area : 0),
0,
);
const searchLabel = boardSearchLabel(project, flooring, skirting, view.search);
const matches = useMemo(
() => findBoardMatches(project, flooring, skirting, view.search, view.focus),
[project.rooms, flooring, skirting, view.search, view.focus],
);
const searchActive = !!view.search.trim() || view.focus !== null;
const searchLabel = view.focus ? focusLabel(view.focus, cuts) : view.search;
const visibleMatchCount = (view.material === 'flooring' ? flooring : skirting).pieces.filter(
(piece) => matches.pieceIds.includes(piece.id),
(piece) => matches.pieceKeys.has(pieceKey(view.material, piece.id)),
).length;
const materialForPieces = (ids: string[], current: PlanMaterial): PlanMaterial => {
const hasFloor = flooring.pieces.some((piece) => ids.includes(piece.id));
const hasSkirting = skirting.pieces.some((piece) => ids.includes(piece.id));
return hasFloor && !hasSkirting ? 'flooring' : hasSkirting && !hasFloor ? 'skirting' : current;
};
const setSearch = (search: string) =>
update((s) => {
const found = findBoardMatches(s.project, flooring, skirting, search);
const roomId = found.roomIds.includes(s.view.roomId)
? s.view.roomId
: (found.roomIds[0] ?? s.view.roomId);
return {
...s,
view: {
...s.view,
search,
roomId,
selectedPiece: null,
material: materialForPieces(found.pieceIds, s.view.material),
},
};
const setSearch = (text: string) => {
const found = findBoardMatches(project, flooring, skirting, text);
dispatch({
type: 'search',
text,
roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId),
material: found.materials.size === 1 ? [...found.materials][0] : view.material,
});
const togglePanel = (section: PlannerSection) =>
update((s) => ({
...s,
view: {
...s.view,
collapsedSections: s.view.collapsedSections.includes(section)
? s.view.collapsedSections.filter((item) => item !== section)
: [...s.view.collapsedSections, section],
},
}));
const toggleBoard = (key: string) =>
update((s) => ({
...s,
project: {
...s.project,
completedBoards: s.project.completedBoards.includes(key)
? s.project.completedBoards.filter((item) => item !== key)
: [...s.project.completedBoards, key],
},
}));
const locatePiece = (id: string, roomId: string) =>
update((s) => ({
...s,
view: {
...s.view,
search: id,
roomId,
selectedPiece: null,
material: materialForPieces([id], s.view.material),
},
}));
const changeRoom = (fn: (r: Room) => Room) =>
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
const select = (id: string | null, roomId?: string) =>
update((s) => ({
...s,
view: {
...s.view,
roomId: roomId ?? s.view.roomId,
selectedPiece: id,
material: id ? materialForPieces([id], s.view.material) : s.view.material,
},
}));
};
const locate = (focus: PlanFocus) => {
const found = findBoardMatches(project, flooring, skirting, '', focus);
dispatch({
type: 'focus',
focus,
roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId),
});
};
const select = (material: PlanMaterial, id: string, roomId?: string) =>
dispatch({ type: 'select', material, id, roomId });
const togglePanel = (section: PlannerSection) => dispatch({ type: 'toggleSection', section });
const changeRoom = (change: (room: Room) => Room) =>
dispatch({ type: 'room', id: room.id, change });
const addRoom = () => {
update((s) => {
const id = nextId(
'room',
s.project.rooms.map((r) => r.id),
);
return {
...s,
project: {
...s.project,
completedBoards: [],
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
},
view: {
...s.view,
roomId: id,
selectedPiece: null,
search: '',
collapsedSections: s.view.collapsedSections.filter(
(section) => section !== 'measurements',
),
},
};
}, true);
dispatch({ type: 'addRoom' });
trackRoomAdded();
};
const removeRoom = () => {
update((s) => {
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
return {
...s,
project: { ...s.project, rooms, completedBoards: [] },
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
};
}, true);
dispatch({ type: 'removeRoom', id: room.id });
trackRoomRemoved();
};
const copyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
await navigator.clipboard.writeText(
`${window.location.origin}${window.location.pathname}${encodeState(state)}`,
);
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
trackCopyLink();
} catch {
@ -282,12 +188,7 @@ export default function App() {
key={r.id}
className={`room-tab ${r.id === room.id ? 'active' : ''}`}
aria-pressed={r.id === room.id}
onClick={() =>
update(
(s) => ({ ...s, view: { ...s.view, roomId: r.id, selectedPiece: null } }),
true,
)
}
onClick={() => dispatch({ type: 'visitRoom', id: r.id })}
>
{r.name || 'Untitled room'}
<small>
@ -334,7 +235,7 @@ export default function App() {
geometry={geometry}
tolerance={project.tolerance}
changeRoom={changeRoom}
changeProject={changeProject}
setTolerance={(value) => dispatch({ type: 'tolerance', value })}
remove={removeRoom}
canRemove={project.rooms.length > 1}
/>
@ -359,7 +260,8 @@ export default function App() {
flooringOptions={flooringOptions}
room={room}
changeRoom={changeRoom}
changeProject={changeProject}
setFlooring={(patch) => dispatch({ type: 'flooring', patch })}
setSkirting={(patch) => dispatch({ type: 'skirting', patch })}
/>
</section>
<section className="purchase-summary" aria-label="Materials to buy">
@ -375,7 +277,7 @@ export default function App() {
</strong>
<p>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '}
{wholeBoards} used whole
{cuts.wholeFloor.wholeBoards} used whole
</p>
</div>
<span className="waste-stat">
@ -390,7 +292,7 @@ export default function App() {
</strong>
<p>
{skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '}
{wholeSkirting} used whole
{cuts.wholeSkirt} used whole
</p>
</div>
<span className="waste-stat">
@ -414,11 +316,14 @@ export default function App() {
skirting={skirting}
select={select}
completedBoards={project.completedBoards}
toggleBoard={toggleBoard}
toggleBoard={(key) => dispatch({ type: 'toggleBoard', key })}
cuts={cuts}
matches={matches}
searchActive={searchActive}
roomNames={roomNames}
search={view.search}
setSearch={setSearch}
locateBoard={setSearch}
locatePiece={locatePiece}
locate={locate}
/>
</div>
</PlannerPanel>
@ -436,12 +341,7 @@ export default function App() {
key={material}
className={view.material === material ? 'active' : ''}
aria-pressed={view.material === material}
onClick={() =>
update((s) => ({
...s,
view: { ...s.view, material, selectedPiece: null, search: '' },
}))
}
onClick={() => dispatch({ type: 'material', material })}
>
<i
className={`material-key ${material === 'flooring' ? 'floor-key' : 'skirt-key'}`}
@ -452,13 +352,13 @@ export default function App() {
))}
</div>
</div>
{view.search.trim() && (
{searchActive && (
<div className="plan-search-status" role="status">
<div className="search-mode-heading">
<div className="search-mode-text">
<strong>
<Search size={15} aria-hidden="true" />
Search active
{view.focus ? 'Location active' : 'Search active'}
</strong>
<p title={searchLabel}>
{searchLabel} ·{' '}
@ -480,12 +380,7 @@ export default function App() {
<button
key={r.id}
aria-pressed={r.id === room.id}
onClick={() =>
update((s) => ({
...s,
view: { ...s.view, roomId: r.id, selectedPiece: null },
}))
}
onClick={() => dispatch({ type: 'visitRoom', id: r.id })}
>
{r.name}
</button>
@ -500,27 +395,30 @@ export default function App() {
flooring={flooring}
skirting={skirting}
selectedPiece={view.selectedPiece}
select={(id) => select(id)}
select={select}
completedBoards={project.completedBoards}
highlightedPieces={matches.pieceIds}
searchActive={!!view.search.trim()}
highlightedPieces={matches.pieceKeys}
searchActive={searchActive}
material={view.material}
/>
</aside>
</div>
</main>
<CutDialog
floor={flooringCuts(flooring).find((piece) => piece.id === view.selectedPiece)}
skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)}
selection={view.selectedPiece ? cuts.selections.get(view.selectedPiece) : undefined}
project={project}
close={() => select(null)}
/>
<PrintPlan
project={project}
geometries={geometries}
flooring={flooring}
skirting={skirting}
close={() => dispatch({ type: 'closePreview' })}
/>
{printing && (
<PrintPlan
cuts={cuts}
roomNames={roomNames}
project={project}
geometries={geometries}
flooring={flooring}
skirting={skirting}
/>
)}
</>
);
}

View file

@ -1,18 +1,20 @@
import type { CutSelection } from '../lib/cut-list';
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types';
import type { Project } from '../lib/types';
import { flooringInstructions } from '../lib/cut-instructions';
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
interface Props {
floor?: FlooringPiece;
skirt?: SkirtingPiece;
selection?: CutSelection;
project: Project;
close: () => void;
}
export function CutDialog({ floor, skirt, project, close }: Props) {
export function CutDialog({ selection, project, close }: Props) {
const floor = selection?.material === 'flooring' ? selection.piece : undefined;
const skirt = selection?.material === 'skirting' ? selection.piece : undefined;
const dialog = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLElement | null>(null);
const selected = floor ?? skirt;
@ -27,7 +29,7 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
element.close();
if (opener.current?.isConnected) opener.current.focus({ preventScroll: true });
}
}, [selected?.id]);
}, [selection?.material, selected?.id]);
const room =
selected &&

View file

@ -1,16 +1,10 @@
import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import type { FlooringPlan, Project, SkirtingPlan, PlanMaterial, PlanFocus } from '../lib/types';
import { cutMm, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
skirtingEndLabel,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
import { skirtingEndLabel } from '../lib/cut-list';
import type { CutData } from '../lib/cut-list';
import { pieceKey } from '../lib/board-tracking';
import type { BoardMatches } from '../lib/board-tracking';
import { PERSISTENCE_LIMITS } from '../lib/limits';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
@ -60,13 +54,16 @@ interface Props {
project: Project;
flooring: FlooringPlan;
skirting: SkirtingPlan;
select: (id: string | null, roomId?: string) => void;
select: (material: PlanMaterial, id: string, roomId?: string) => void;
completedBoards: string[];
toggleBoard: (key: string) => void;
search: string;
setSearch: (query: string) => void;
locateBoard: (key: string) => void;
locatePiece: (id: string, roomId: string) => void;
locate: (focus: PlanFocus) => void;
cuts: CutData;
matches: BoardMatches;
searchActive: boolean;
roomNames: Map<string, string>;
}
export function CutList({
@ -78,23 +75,15 @@ export function CutList({
toggleBoard,
search,
setSearch,
locateBoard,
locatePiece,
locate,
cuts,
matches,
searchActive: searching,
roomNames,
}: Props) {
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
const wholeFloor = uncutFlooring(flooring, project.flooring),
wholeSkirt = uncutSkirting(skirting);
const floorGroups = new Map(
groupFlooringCuts(flooring).map((group) => [group[0].stockId, group]),
);
const skirtGroups = new Map(
groupSkirtingCuts(skirting).map((group) => [group[0].stockId, group]),
);
const searching = search.trim().length > 0;
const matches = findBoardMatches(project, flooring, skirting, search);
const matchedBoards = new Set(matches.boardKeys),
matchedPieces = new Set(matches.pieceIds);
const { floorCuts, skirtCuts, floorGroups, skirtGroups, wholeFloor, wholeSkirt } = cuts;
const matchedBoards = matches.boardKeys,
matchedPieces = matches.pieceKeys;
const completed = new Set(completedBoards);
// Keep every cut from a matching source together, even when only one piece or room matched.
const floorStocks = flooring.stocks.filter((stock) =>
@ -103,7 +92,7 @@ export function CutList({
const skirtStocks = skirting.stocks.filter((stock) =>
searching ? matchedBoards.has(`skirting:${stock.id}`) : skirtGroups.has(stock.id),
);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const roomName = (id: string) => roomNames.get(id) ?? id;
return (
<section className="cut-list-card" aria-labelledby="cut-plan-title">
<div className="cut-list-header">
@ -120,7 +109,7 @@ export function CutList({
<input
id="board-search"
type="search"
value={boardSearchLabel(project, flooring, skirting, search)}
value={search}
maxLength={PERSISTENCE_LIMITS.searchLength}
onChange={(event) => setSearch(event.target.value)}
placeholder="F12, A2, or a room name"
@ -139,8 +128,8 @@ export function CutList({
</div>
{searching && (
<p className="search-results" role="status">
{matches.boardKeys.length
? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
{matches.boardKeys.size
? `${matches.boardKeys.size} matching board${matches.boardKeys.size === 1 ? '' : 's'}`
: 'No matching boards or cuts.'}
</p>
)}
@ -175,13 +164,13 @@ export function CutList({
size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
locate={() => locate({ kind: 'board', material: 'flooring', id: stock.id })}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(pieceKey('flooring', piece.id)) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
@ -189,7 +178,7 @@ export function CutList({
className="cut-preview-button flooring-preview-button"
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
onClick={() => select('flooring', piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
@ -216,7 +205,9 @@ export function CutList({
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
onClick={() =>
locate({ kind: 'piece', material: 'flooring', id: piece.id })
}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
@ -253,8 +244,7 @@ export function CutList({
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
</h3>
<p className="cut-summary">
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
Buy {skirting.stockCount} lengths · {wholeSkirt} installed uncut
</p>
{skirtStocks.map((stock) => {
const key = `skirting:${stock.id}`,
@ -276,13 +266,13 @@ export function CutList({
size={`${mm(stock.length)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
locate={() => locate({ kind: 'board', material: 'skirting', id: stock.id })}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(pieceKey('skirting', piece.id)) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
@ -290,7 +280,7 @@ export function CutList({
className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
onClick={() => select('skirting', piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
@ -319,7 +309,9 @@ export function CutList({
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
onClick={() =>
locate({ kind: 'piece', material: 'skirting', id: piece.id })
}
>
<MapPin size={14} aria-hidden="true" />
Show on plan

View file

@ -9,46 +9,44 @@ const numberFromDraft = (text: string): number | null => {
return Number.isFinite(number) ? number : null;
};
/** Only unfinished typing is local; every complete numeric value is committed immediately. */
/** Measurements remain local until blur or Enter; Escape discards the draft. */
export function NumericInput({
value,
onChange,
onBlur,
onKeyDown,
signed = false,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> & {
}: Omit<
InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange' | 'type' | 'min' | 'max' | 'step'
> & {
value: number;
onChange: (value: number) => void;
signed?: boolean;
}) {
const [draft, setDraft] = useState(String(value));
const emitted = useRef(value);
useEffect(() => {
if (value !== emitted.current) setDraft(String(value));
emitted.current = value;
}, [value]);
const cancel = useRef(false);
useEffect(() => setDraft(String(value)), [value]);
return (
<input
{...props}
type="text"
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')}
inputMode={signed ? 'text' : 'decimal'}
value={draft}
onChange={(event) => {
const text = event.target.value;
setDraft(text);
const number = numberFromDraft(text);
if (number !== null && number !== value) {
emitted.current = number;
onChange(number);
}
}}
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => {
setDraft(String(value));
const next = cancel.current ? value : (numberFromDraft(event.currentTarget.value) ?? value);
cancel.current = false;
setDraft(String(next));
if (next !== value) onChange(next);
onBlur?.(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
event.preventDefault();
cancel.current = event.key === 'Escape';
event.currentTarget.blur();
}
}}
@ -61,9 +59,6 @@ export function NumberField({
value,
onChange,
unit = 'mm',
min = 0,
max,
step = 1,
hint,
tooltip,
}: {
@ -71,9 +66,6 @@ export function NumberField({
value: number;
onChange: (n: number) => void;
unit?: string;
min?: number;
max?: number;
step?: number;
hint?: string;
tooltip?: string;
}) {
@ -112,9 +104,6 @@ export function NumberField({
<NumericInput
id={id}
value={value}
min={min}
max={max}
step={step}
aria-describedby={hint ? `${id}-hint` : undefined}
onChange={onChange}
/>
@ -125,9 +114,11 @@ export function NumberField({
);
}
export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) {
const unique = issues.filter(
(issue, i) => issues.findIndex((item) => item.message === issue.message) === i,
);
const unique = [
...new Map(
issues.map((issue) => [`${issue.roomId}:${issue.severity}:${issue.message}`, issue]),
).values(),
];
return (
<div className={`issues ${compact ? 'compact' : ''}`} role="status">
{unique.map((issue, i) => (

View file

@ -1,6 +1,8 @@
import { useId } from 'react';
import { overlaps } from '../lib/drawing-geometry';
import { useId, useMemo } from 'react';
import type {
FlooringPlan,
PieceKey,
Geometry,
PlanMaterial,
Point,
@ -10,7 +12,7 @@ import type {
} from '../lib/types';
import { area, cutMm, mm } from '../lib/format';
import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { pieceKey } from '../lib/board-tracking';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import { floorPieceLabels } from '../lib/piece-labels';
@ -122,11 +124,11 @@ interface Props {
geometry: Geometry;
flooring: FlooringPlan;
skirting: SkirtingPlan;
selectedPiece: string | null;
select?: (id: string) => void;
selectedPiece: PieceKey | null;
select?: (material: PlanMaterial, id: string) => void;
interactive?: boolean;
completedBoards?: string[];
highlightedPieces?: string[];
highlightedPieces?: Set<PieceKey>;
searchActive?: boolean;
material?: PlanMaterial;
}
@ -140,75 +142,95 @@ export function FloorPlan({
select,
interactive = true,
completedBoards = [],
highlightedPieces = [],
highlightedPieces = new Set(),
searchActive = false,
material,
}: Props) {
const hatch = useId().replaceAll(':', '');
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
const minX = Math.min(0, ...allPoints.map((p) => p.x)),
maxX = Math.max(100, ...allPoints.map((p) => p.x));
const minY = Math.min(0, ...allPoints.map((p) => p.y)),
maxY = Math.max(100, ...allPoints.map((p) => p.y));
const span = Math.max(maxX - minX, maxY - minY, 1000),
font = span * 0.024;
const orientation = Math.sign(signedArea(geometry.vertices)) || 1;
// Printing includes both materials; the interactive drawing shows the chosen view.
const floorPieces =
material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces =
material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
if (!isSkirtingCut(piece, skirting)) continue;
const center = midpoint(piece.start, piece.end);
const length = Math.hypot(piece.end.x - piece.start.x, piece.end.y - piece.start.y) || 1;
const text = piece.id + (completedBoards.includes(`skirting:${piece.stockId}`) ? ' ✓' : '');
const width = Math.max(font * 1.8, text.length * font * 0.8 * 0.64 + font * 0.55);
let box!: LabelBox;
for (let lane = 0; lane < 24; lane++) {
const offset = span * (0.032 + lane * 0.035);
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * offset;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * offset;
box = {
minX: x - width / 2,
maxX: x + width / 2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
};
skirtLabels.set(piece.id, { x, y, width, text });
if (
!reserved.some(
(other) =>
box.minX < other.maxX &&
box.maxX > other.minX &&
box.minY < other.maxY &&
box.maxY > other.minY,
)
)
break;
}
reserved.push(box);
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const floorLabels = floorPieceLabels(
floorPieces,
geometry.vertices,
room.direction,
const {
span,
font,
[...reserved, ...labels.map((label) => label.box)],
completedBoards,
);
const boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
...floorLabels.map((label) => label.box),
];
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top;
orientation,
floorPieces,
skirtPieces,
skirtLabels,
labels,
floorLabels,
left,
top,
width,
height,
} = useMemo(() => {
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
const minX = Math.min(0, ...allPoints.map((p) => p.x)),
maxX = Math.max(100, ...allPoints.map((p) => p.x));
const minY = Math.min(0, ...allPoints.map((p) => p.y)),
maxY = Math.max(100, ...allPoints.map((p) => p.y));
const span = Math.max(maxX - minX, maxY - minY, 1000),
font = span * 0.024;
const orientation = Math.sign(signedArea(geometry.vertices)) || 1;
// Printing includes both materials; the interactive drawing shows the chosen view.
const floorPieces =
material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces =
material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
if (!piece.cut) continue;
const center = midpoint(piece.start, piece.end);
const length = Math.hypot(piece.end.x - piece.start.x, piece.end.y - piece.start.y) || 1;
const text = piece.id + (completedBoards.includes(`skirting:${piece.stockId}`) ? ' ✓' : '');
const width = Math.max(font * 1.8, text.length * font * 0.8 * 0.64 + font * 0.55);
let box!: LabelBox;
for (let lane = 0; lane < 24; lane++) {
const offset = span * (0.032 + lane * 0.035);
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * offset;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * offset;
box = {
minX: x - width / 2,
maxX: x + width / 2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
};
skirtLabels.set(piece.id, { x, y, width, text });
if (!reserved.some((other) => overlaps(box, other))) break;
}
reserved.push(box);
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const floorLabels = floorPieceLabels(
floorPieces,
geometry.vertices,
room.direction,
font,
[...reserved, ...labels.map((label) => label.box)],
completedBoards,
);
const boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
...floorLabels.map((label) => label.box),
];
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top;
return {
span,
font,
orientation,
floorPieces,
skirtPieces,
skirtLabels,
labels,
floorLabels,
left,
top,
width,
height,
};
}, [geometry, room.id, room.direction, flooring, skirting, completedBoards, material]);
return (
<section
className="drawing-card"
@ -272,7 +294,7 @@ export function FloorPlan({
{floorPieces.map((piece) => {
const cut = piece.cut || piece.reused;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
const highlighted = highlightedPieces.has(pieceKey('flooring', piece.id));
const x =
(Math.min(...piece.polygon.map((p) => p.x)) +
Math.max(...piece.polygon.map((p) => p.x))) /
@ -294,14 +316,14 @@ export function FloorPlan({
tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select flooring cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
aria-pressed={cut ? selectedPiece === pieceKey('flooring', piece.id) : undefined}
onClick={cut && interactive ? () => select?.('flooring', piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select?.(piece.id);
select?.('flooring', piece.id);
}
}
: undefined
@ -313,13 +335,15 @@ export function FloorPlan({
done
? '#dbe9e7'
: cut
? selectedPiece === piece.id
? selectedPiece === pieceKey('flooring', piece.id)
? '#ceddf9'
: `url(#${hatch})`
: '#f5f6f8'
}
stroke={cut ? '#1647ab' : '#9baabd'}
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
strokeWidth={
span * (selectedPiece === pieceKey('flooring', piece.id) ? 0.003 : 0.0012)
}
/>
{done && !cut && (
<text
@ -349,9 +373,9 @@ export function FloorPlan({
<Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} />
))}
{skirtPieces.map((piece) => {
const cut = isSkirtingCut(piece, skirting);
const cut = piece.cut;
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
const highlighted = highlightedPieces.has(pieceKey('skirting', piece.id));
// Each template uses its true wall-facing and room-facing edges, including mitres.
const polygon = skirtingPolygon(piece, orientation);
return (
@ -367,14 +391,14 @@ export function FloorPlan({
tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select skirting cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
aria-pressed={cut ? selectedPiece === pieceKey('skirting', piece.id) : undefined}
onClick={cut && interactive ? () => select?.('skirting', piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select?.(piece.id);
select?.('skirting', piece.id);
}
}
: undefined
@ -386,7 +410,7 @@ export function FloorPlan({
done
? '#27655d'
: cut
? selectedPiece === piece.id
? selectedPiece === pieceKey('skirting', piece.id)
? '#17263f'
: '#b74900'
: '#d8d5cd'
@ -442,14 +466,14 @@ export function FloorPlan({
const label = skirtLabels.get(piece.id);
if (!label) return null;
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
const highlighted = highlightedPieces.has(pieceKey('skirting', piece.id));
return (
<g
key={piece.id}
className={`skirting-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={piece.id}
transform={`translate(${label.x} ${label.y})`}
onClick={interactive ? () => select?.(piece.id) : undefined}
onClick={interactive ? () => select?.('skirting', piece.id) : undefined}
>
<rect
x={-label.width / 2}
@ -477,13 +501,13 @@ export function FloorPlan({
{floorLabels.map((label) => {
const piece = floorPieces.find((piece) => piece.id === label.id)!;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(label.id);
const highlighted = highlightedPieces.has(pieceKey('flooring', label.id));
return (
<g
key={label.id}
className={`floor-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={label.id}
onClick={interactive ? () => select?.(label.id) : undefined}
onClick={interactive ? () => select?.('flooring', label.id) : undefined}
>
{label.leader && (
<>

View file

@ -8,7 +8,8 @@ interface Props {
project: Project;
room: Room;
flooringOptions: FlooringOptions;
changeProject: (fn: (p: Project) => Project) => void;
setFlooring: (patch: Partial<FlooringSettings>) => void;
setSkirting: (patch: Partial<SkirtingSettings>) => void;
changeRoom: (fn: (r: Room) => Room) => void;
}
@ -19,15 +20,15 @@ export function MaterialsEditor({
project,
room,
flooringOptions,
changeProject,
setFlooring,
setSkirting,
changeRoom,
}: Props) {
const f = project.flooring;
const s = project.skirting;
const floor = (key: keyof FlooringSettings, value: number | string) =>
changeProject((p) => ({ ...p, flooring: { ...p.flooring, [key]: value } }));
const skirt = (key: keyof SkirtingSettings, value: number) =>
changeProject((p) => ({ ...p, skirting: { ...p.skirting, [key]: value } }));
const floor = <K extends keyof FlooringSettings>(key: K, value: FlooringSettings[K]) =>
setFlooring({ [key]: value });
const skirt = (key: keyof SkirtingSettings, value: number) => setSkirting({ [key]: value });
return (
<>
@ -43,18 +44,8 @@ export function MaterialsEditor({
Use the length and width printed on your flooring pack.
</p>
<div className="field-grid">
<NumberField
label="Board length"
value={f.length}
min={1}
onChange={(n) => floor('length', n)}
/>
<NumberField
label="Board width"
value={f.width}
min={1}
onChange={(n) => floor('width', n)}
/>
<NumberField label="Board length" value={f.length} onChange={(n) => floor('length', n)} />
<NumberField label="Board width" value={f.width} onChange={(n) => floor('width', n)} />
</div>
<div className="field">
<span>Laying pattern</span>
@ -158,13 +149,11 @@ export function MaterialsEditor({
<NumberField
label="Shortest end piece"
value={f.minLength}
min={1}
onChange={(n) => floor('minLength', n)}
/>
<NumberField
label="Narrowest edge row"
value={f.minWidth}
min={1}
onChange={(n) => floor('minWidth', n)}
/>
</div>
@ -181,7 +170,6 @@ export function MaterialsEditor({
<NumberField
label="Skirting board length"
value={s.stockLength}
min={1}
onChange={(n) => skirt('stockLength', n)}
/>
<NumberField
@ -196,7 +184,6 @@ export function MaterialsEditor({
<NumberField
label="Minimum piece"
value={s.minLength}
min={1}
onChange={(n) => skirt('minLength', n)}
/>
<NumberField

View file

@ -9,7 +9,7 @@ import {
ArrowUp,
ArrowDown,
} from 'lucide-react';
import type { Geometry, Project, Room, Wall } from '../lib/types';
import type { Geometry, Room, Wall } from '../lib/types';
import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format';
@ -22,7 +22,7 @@ interface Props {
geometry: Geometry;
tolerance: number;
changeRoom: (fn: (room: Room) => Room) => void;
changeProject: (fn: (project: Project) => Project) => void;
setTolerance: (value: number) => void;
remove: () => void;
canRemove: boolean;
}
@ -31,7 +31,7 @@ export function OutlineEditor({
geometry,
tolerance,
changeRoom,
changeProject,
setTolerance,
remove,
canRemove,
}: Props) {
@ -182,7 +182,6 @@ export function OutlineEditor({
<NumberField
label="Room width"
value={room.walls[0].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
@ -193,7 +192,6 @@ export function OutlineEditor({
<NumberField
label="Room length"
value={room.walls[1].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
@ -244,16 +242,12 @@ export function OutlineEditor({
<span className="wall-code">W{i + 1}</span>
<NumericInput
aria-label={`Wall ${i + 1} length`}
min={1}
step={1}
value={wall.length}
onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
/>
<NumericInput
aria-label={`Wall ${i + 1} turn`}
min={-179}
max={179}
step={0.1}
signed
value={
geometry.closed && i === room.walls.length - 1
? Math.round(normalizeAngle(-(lastEdge?.heading ?? 0)) * 10) / 10
@ -408,7 +402,11 @@ export function OutlineEditor({
}
>
{room.walls.map((w, i) => (
<option key={w.id} value={w.id}>
<option
key={w.id}
value={w.id}
disabled={w.id !== wall.id && w.doors.length >= PROJECT_LIMITS.doorsPerWall}
>
W{i + 1}
</option>
))}
@ -447,7 +445,6 @@ export function OutlineEditor({
/>
<NumberField
label="Door width"
min={1}
value={door.width}
onChange={(n) =>
changeWall(wall.id, (w) => ({
@ -473,8 +470,7 @@ export function OutlineEditor({
<NumberField
label="Measurement tolerance"
value={tolerance}
max={PROJECT_LIMITS.maxTolerance}
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
onChange={setTolerance}
hint="Largest closing gap accepted for the cut plan."
/>
{canRemove && (

View file

@ -1,13 +1,7 @@
import type { CutData } from '../lib/cut-list';
import type { FlooringPlan, Geometry, Project, SkirtingPlan } from '../lib/types';
import { area, mm } from '../lib/format';
import {
groupFlooringCuts,
groupSkirtingCuts,
readyOffcutInstructions,
skirtingInstructions,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
import { flooringInstructions } from '../lib/cut-instructions';
import { FloorPlan } from './FloorPlan';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
@ -15,18 +9,22 @@ import { FlooringPreview, SkirtingPreview } from './CutPreview';
/** All rooms first, then cutting sheets in purchased-board order, including shared offcuts. */
export function PrintPlan({
project,
cuts,
roomNames,
geometries,
flooring,
skirting,
}: {
project: Project;
cuts: CutData;
roomNames: Map<string, string>;
geometries: Geometry[];
flooring: FlooringPlan;
skirting: SkirtingPlan;
}) {
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const wholeFloor = uncutFlooring(flooring, project.flooring).wholeBoards;
const wholeSkirt = uncutSkirting(skirting).reduce((sum, group) => sum + group.count, 0);
const roomName = (id: string) => roomNames.get(id) ?? id;
const wholeFloor = cuts.wholeFloor.wholeBoards;
const wholeSkirt = cuts.wholeSkirt;
return (
<section className="print-plan" aria-label="Printable plan">
<header className="print-title">
@ -59,7 +57,7 @@ export function PrintPlan({
))}
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
<h2>Flooring cuts</h2>
{groupFlooringCuts(flooring).map((group) => (
{[...cuts.floorGroups.values()].map((group) => (
<section
className={`print-stock ${project.completedBoards.includes(`floor:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
@ -90,11 +88,11 @@ export function PrintPlan({
</div>
</section>
))}
{!groupFlooringCuts(flooring).length && <p>No flooring cuts needed.</p>}
{!cuts.floorGroups.size && <p>No flooring cuts needed.</p>}
</section>
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
<h2>Skirting cuts</h2>
{groupSkirtingCuts(skirting).map((group) => (
{[...cuts.skirtGroups.values()].map((group) => (
<section
className={`print-stock ${project.completedBoards.includes(`skirting:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
@ -105,9 +103,7 @@ export function PrintPlan({
{project.completedBoards.includes(`skirting:${group[0].stockId}`)
? ' · Done ✓'
: ''}{' '}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
<span>{mm(project.skirting.stockLength)} mm</span>
</h3>
<div className="print-cut-grid">
{group.map((piece) => (
@ -125,7 +121,7 @@ export function PrintPlan({
</div>
</section>
))}
{!groupSkirtingCuts(skirting).length && <p>No skirting cuts needed.</p>}
{!cuts.skirtGroups.size && <p>No skirting cuts needed.</p>}
</section>
</section>
);

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 };
}

View file

@ -59,15 +59,9 @@ select {
border-radius: 2px;
font-size: 16px;
}
input[type='number'] {
input[inputmode] {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
appearance: textfield;
}
input::-webkit-inner-spin-button,
input::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
input:disabled {
color: #617085;
@ -149,9 +143,10 @@ small {
font-weight: 600;
}
.app-main {
--planner-bottom-space: 40px;
max-width: 1720px;
margin: auto;
padding: 0 32px 40px;
padding: 0 32px var(--planner-bottom-space);
}
.copy-status {
display: block;
@ -853,7 +848,8 @@ summary span {
}
@media (max-width: 1199px) {
.app-main {
padding: 0 24px 30px;
--planner-bottom-space: 30px;
padding: 0 24px var(--planner-bottom-space);
}
.app-header {
padding: 20px 24px;
@ -898,7 +894,8 @@ summary span {
display: none;
}
.app-main {
padding: 0 14px 25px;
--planner-bottom-space: 25px;
padding: 0 14px var(--planner-bottom-space);
}
.project-bar {
margin: 16px 0;
@ -1410,6 +1407,7 @@ summary span {
}
}
.workspace {
--drawing-height: min(900px, calc(100dvh - 16px - var(--planner-bottom-space)));
display: grid;
grid-template-columns: minmax(370px, 0.85fr) minmax(0, 1.15fr);
gap: 28px;
@ -1540,17 +1538,24 @@ summary span {
margin-top: 20px;
}
.drawing-column {
display: flex;
flex-direction: column;
height: var(--drawing-height);
position: sticky;
top: 16px;
min-width: 0;
align-self: start;
background: #fffdf7;
}
.drawing-column .drawing-viewport {
height: clamp(180px, calc(100dvh - 316px), 780px);
.drawing-column .drawing-card {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.drawing-column:has(.plan-search-status) .drawing-viewport {
height: clamp(160px, calc(100dvh - 391px), 720px);
.drawing-column .drawing-viewport {
flex: 1;
min-height: 0;
}
.drawing-column .room-svg {
width: 100%;
@ -1592,11 +1597,13 @@ summary span {
}
.matching-rooms {
display: flex;
flex-wrap: wrap;
overflow-x: auto;
gap: 6px;
margin-top: 7px;
}
.matching-rooms button {
flex-shrink: 0;
white-space: nowrap;
padding: 6px 10px;
font-size: 12px;
}
@ -1662,6 +1669,7 @@ summary span {
padding-inline: 16px;
}
.workspace {
--drawing-height: clamp(300px, 45dvh, 420px);
display: flex;
flex-direction: column;
gap: 20px;
@ -1675,17 +1683,13 @@ summary span {
top: 0;
z-index: 5;
}
.drawing-column .drawing-viewport,
.drawing-column:has(.plan-search-status) .drawing-viewport {
height: clamp(150px, 25dvh, 240px);
}
.panel-toggle {
font-size: 18px;
min-height: 50px;
scroll-margin-top: 390px;
scroll-margin-top: calc(var(--drawing-height) + 16px);
}
.planner-panel {
scroll-margin-top: 390px;
scroll-margin-top: calc(var(--drawing-height) + 16px);
}
.board-cuts {
padding-inline: 12px;
@ -1697,7 +1701,7 @@ summary span {
select,
button,
summary {
scroll-margin-top: 410px;
scroll-margin-top: calc(var(--drawing-height, 0px) + 16px);
scroll-margin-bottom: 24px;
}
}