This commit is contained in:
Andras Schmelczer 2026-09-11 16:09:28 +01:00
commit d8f2407ddf
44 changed files with 10377 additions and 0 deletions

View file

@ -0,0 +1,160 @@
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types';
import { flooringInstructions } from '../lib/cut-instructions';
import { readyOffcutInstructions } from '../lib/cut-list';
import { exactMm, flooringTemplate, skirtingTemplate } from '../lib/cut-preview';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
interface Props {
floor?: FlooringPiece;
skirt?: SkirtingPiece;
project: Project;
close: () => void;
}
export function CutDialog({ floor, skirt, project, close }: Props) {
const dialog = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLElement | null>(null);
const selected = floor ?? skirt;
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (selected && !element.open) {
opener.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
element.showModal();
} else if (!selected && element.open) {
element.close();
if (opener.current?.isConnected) opener.current.focus({ preventScroll: true });
}
}, [selected?.id]);
const room =
selected &&
(project.rooms.find((candidate) => candidate.id === selected.roomId)?.name ?? selected.roomId);
const template = floor
? flooringTemplate(floor)
: skirt
? skirtingTemplate(skirt, project.skirting.thickness)
: [];
return (
<dialog
ref={dialog}
className="cut-dialog"
aria-labelledby="cut-dialog-title"
onCancel={(event) => {
event.preventDefault();
close();
}}
onClick={(event) => {
if (event.target !== event.currentTarget) return;
const box = event.currentTarget.getBoundingClientRect();
if (
event.clientX < box.left ||
event.clientX > box.right ||
event.clientY < box.top ||
event.clientY > box.bottom
)
close();
}}
>
<header className="cut-dialog-header">
<h2 id="cut-dialog-title">
{floor ? `Flooring cut ${floor.id}` : skirt ? `Skirting cut ${skirt.id}` : 'Cut preview'}
</h2>
<button className="icon-button" aria-label="Close cut preview" onClick={close} autoFocus>
<X size={20} />
</button>
</header>
<div className="cut-dialog-body">
{floor && (
<>
<p className="cut-origin">
{room} · Row {floor.row} · Source {floor.stockId}
{floor.reused ? ' · Reused offcut' : ''}
</p>
<FlooringPreview piece={floor} />
<p className="cut-instructions">
{floor.cut ? flooringInstructions(floor) : readyOffcutInstructions(floor)}
</p>
<p className="helper">
Left and right run along the board; top and bottom are its long edges.
</p>
{floor.shaped && (
<>
<p>
Mark these coordinates from the blank’s top-left corner: x along the board, y
across it.
</p>
<div className="coordinates">
{template.map((point, index) => (
<span key={index}>
{index + 1}: ({exactMm(point.x)}, {exactMm(point.y)}) mm
</span>
))}
</div>
<p className="cut-edge-lengths">
{template
.map((point, index) => {
const next = template[(index + 1) % template.length];
return `${index + 1}–${((index + 1) % template.length) + 1}: ${exactMm(Math.hypot(next.x - point.x, next.y - point.y))} mm`;
})
.join(' · ')}
</p>
</>
)}
</>
)}
{skirt && (
<>
<p className="cut-origin">
{room} · Wall {skirt.wallIndex + 1} · Source {skirt.stockId}
</p>
<SkirtingPreview piece={skirt} thickness={project.skirting.thickness} />
<dl className="cut-measurements">
<div>
<dt>Long point</dt>
<dd>{exactMm(skirt.length)} mm</dd>
</div>
<div>
<dt>Wall-facing length</dt>
<dd>{exactMm(skirt.backLength)} mm</dd>
</div>
<div>
<dt>Left mitre</dt>
<dd>
{exactMm(Math.abs(skirt.leftMitre))}° · {skirt.leftType}
</dd>
</div>
<div>
<dt>Right mitre</dt>
<dd>
{exactMm(Math.abs(skirt.rightMitre))}° · {skirt.rightType}
</dd>
</div>
</dl>
{template.length === 4 && (
<>
<p>
Cut the square blank first. Mark these distances from its left end, then join the
front and back marks at each end:
</p>
<div className="coordinates">
<span>Back left: {exactMm(template[0].x)} mm</span>
<span>Back right: {exactMm(template[1].x)} mm</span>
<span>Front left: {exactMm(template[3].x)} mm</span>
<span>Front right: {exactMm(template[2].x)} mm</span>
</div>
<p className="helper">
Left and right follow the wall direction. Mitre angles are measured from a square
cut; the marked points show which way to lean each cut.
</p>
</>
)}
</>
)}
</div>
</dialog>
);
}

267
src/components/CutList.tsx Normal file
View file

@ -0,0 +1,267 @@
import { Download } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import { degree, mm } from '../lib/format';
import { downloadCsv } from '../lib/export';
import {
flooringCuts,
groupFlooringCuts,
skirtingCuts,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
import { CutDialog } from './CutDialog';
function StockAllocations({
title,
items,
}: {
title: string;
items: { id: string; pieces: string[]; size: string; remainder: string }[];
}) {
if (!items.length) return null;
return (
<details className="stock-allocations">
<summary>{title}</summary>
<ul className="allocation-list">
{items.map((item) => (
<li key={item.id} className="stock-card">
<strong>
{item.id} <span>· {item.size}</span>
</strong>
<span>{item.pieces.join(' + ')}</span>
<small>{item.remainder}</small>
</li>
))}
</ul>
</details>
);
}
interface Props {
project: Project;
flooring: FlooringPlan;
skirting: SkirtingPlan;
selectedPiece: string | null;
select: (id: string | null, roomId?: string) => void;
}
export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
const floorGroups = groupFlooringCuts(flooring);
const floorIds = new Set(floorCuts.map((piece) => piece.id));
const skirtIds = new Set(skirtCuts.map((piece) => piece.id));
const wholeFloor = uncutFlooring(flooring, project.flooring);
const wholeSkirt = uncutSkirting(skirting);
const selectedFloor = floorCuts.find((piece) => piece.id === selectedPiece);
const selectedSkirt = skirtCuts.find((piece) => piece.id === selectedPiece);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const errors = [...flooring.issues, ...skirting.issues].some(
(issue) => issue.severity === 'error',
);
return (
<section className="cut-list-card" aria-labelledby="cut-plan-title">
<div className="cut-list-header">
<div>
<h2 id="cut-plan-title">Cut plan</h2>
<p className="helper">
Cut pieces and offcut placements. All measurements are in millimetres.
</p>
</div>
<button
className="button secondary small"
onClick={() => downloadCsv(project, flooring, skirting)}
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
>
<Download size={16} />
Export CSV
</button>
</div>
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
<h3 id="flooring-cuts-title">
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
</h3>
<p className="cut-summary">
Buy {flooring.boardCount} boards · {flooring.pieces.length} installed pieces ·{' '}
{wholeFloor.wholeBoards} full boards laid uncut
{wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
.
</p>
{floorCuts.length ? (
<div className="table-scroll">
<table className="cut-table">
<caption className="sr-only">Flooring cuts and offcut placements</caption>
<thead>
<tr>
<th scope="col">Pieces</th>
<th scope="col">Qty</th>
<th scope="col">Cut size · mm</th>
<th scope="col">Room</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{floorGroups.map((group) => {
const first = group[0];
return (
<tr
key={first.id}
className={`flooring-batch ${group.some((piece) => piece.id === selectedPiece) ? 'selected' : ''}`}
>
<td data-label="Pieces" className="batch-codes">
<div className="piece-chips">
{group.map((piece) => (
<button
key={piece.id}
className={`piece-link piece-chip cut-preview-button flooring-preview-button ${piece.id === selectedPiece ? 'selected' : ''}`}
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
</span>
<span className="cut-preview-code">{piece.id}</span>
</button>
))}
</div>
</td>
<td data-label="Qty" className="batch-qty numeric">
{group.length}
</td>
<td data-label="Cut size · mm" className="batch-size numeric">
{mm(first.length)} × {mm(first.width)}
</td>
<td data-label="Room" className="batch-room">
{roomName(first.roomId)}
</td>
<td data-label="Action" className="batch-action">
{!first.cut
? 'Use offcut · no new cut'
: first.shaped
? 'Shaped cut'
: 'Rectangular cut'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="empty-state">
{flooring.pieces.length
? 'No flooring cuts needed.'
: 'Complete the outline and flooring measurements to calculate cuts.'}
</p>
)}
<StockAllocations
title="Source boards for flooring cuts"
items={flooring.stocks
.filter((stock) => stock.pieceIds.some((id) => floorIds.has(id)))
.map((stock) => ({
id: stock.id,
pieces: stock.pieceIds.filter((id) => floorIds.has(id)),
size: `${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`,
remainder: `${mm(stock.remaining)} mm remaining`,
}))}
/>
</section>
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
<h3 id="skirting-cuts-title">
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
</h3>
<p className="cut-summary">
Buy {skirting.stockCount} lengths · {skirting.pieces.length} installed pieces ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} full lengths installed uncut.
</p>
<p className="helper">
Lengths use the longest point. Mitres are saw settings from square; left and right follow
the wall direction.
</p>
{skirtCuts.length ? (
<div className="table-scroll">
<table className="cut-table">
<caption className="sr-only">Skirting pieces requiring cuts</caption>
<thead>
<tr>
<th scope="col">Piece</th>
<th scope="col">Room / wall</th>
<th scope="col">Length · mm</th>
<th scope="col">Left / right</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{skirtCuts.map((piece) => (
<tr key={piece.id} className={selectedPiece === piece.id ? 'selected' : ''}>
<td data-label="Piece">
<button
className="piece-link cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
piece={piece}
thickness={project.skirting.thickness}
compact
/>
</span>
<span className="cut-preview-code">{piece.id}</span>
</button>
</td>
<td data-label="Room / wall">
{roomName(piece.roomId)}
<small>Wall {piece.wallIndex + 1}</small>
</td>
<td data-label="Length · mm" className="numeric">
{mm(piece.length)}
<small>{mm(piece.backLength)} at wall</small>
</td>
<td data-label="Left / right">
{degree(piece.leftMitre)} / {degree(piece.rightMitre)}
<small>
{piece.leftType} / {piece.rightType}
</small>
</td>
<td data-label="Source">{piece.stockId}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="empty-state">
{skirting.pieces.length
? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'}
</p>
)}
<StockAllocations
title="Source lengths for skirting cuts"
items={skirting.stocks
.filter((stock) => stock.pieceIds.some((id) => skirtIds.has(id)))
.map((stock) => ({
id: stock.id,
pieces: stock.pieceIds.filter((id) => skirtIds.has(id)),
size: `${mm(stock.length)} mm`,
remainder: `${mm(stock.waste)} mm waste including kerf`,
}))}
/>
</section>
<CutDialog
floor={selectedFloor}
skirt={selectedSkirt}
project={project}
close={() => select(null)}
/>
</section>
);
}

View file

@ -0,0 +1,166 @@
import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import {
exactMm,
flooringTemplate,
fullPreviewBox,
skirtingEndBoxes,
skirtingTemplate,
viewBox,
} from '../lib/cut-preview';
export function FlooringPreview({
piece,
compact = false,
}: {
piece: FlooringPiece;
compact?: boolean;
}) {
const template = flooringTemplate(piece);
const Wrapper = compact ? 'span' : 'figure';
return (
<Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}>
<svg
className="flooring-preview"
viewBox={viewBox(fullPreviewBox(piece.length, piece.width))}
preserveAspectRatio="xMidYMid meet"
aria-hidden={compact || undefined}
role={compact ? undefined : 'img'}
aria-label={compact ? undefined : `Flooring shape ${piece.id}`}
>
<polygon
points={template.map((point) => `${point.x},${point.y}`).join(' ')}
fill="#dbeafe"
stroke="#2563eb"
strokeWidth={compact ? 1 : 1.5}
vectorEffect="non-scaling-stroke"
/>
{!compact &&
piece.shaped &&
template.map((point, index) => (
<text
key={index}
x={point.x}
y={point.y - Math.max(piece.length, piece.width) * 0.015}
textAnchor="middle"
fontSize={Math.max(piece.length, piece.width) * 0.025}
fill="#1e40af"
>
{index + 1}
</text>
))}
</svg>
{!compact && (
<figcaption>
Length {exactMm(piece.length)} mm × width {exactMm(piece.width)} mm · top view, to scale
</figcaption>
)}
</Wrapper>
);
}
export function SkirtingPreview({
piece,
thickness,
compact = false,
}: {
piece: SkirtingPiece;
thickness: number;
compact?: boolean;
}) {
const template = skirtingTemplate(piece, thickness);
const Wrapper = compact ? 'span' : 'div';
const EndWrapper = compact ? 'span' : 'figure';
const Grid = compact ? 'span' : 'div';
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
const boxes = skirtingEndBoxes(template, thickness);
const points = template.map((point) => `${point.x},${point.y}`).join(' ');
const ends = [
{ side: 'left', back: template[0], front: template[3], box: boxes.left },
{ side: 'right', back: template[1], front: template[2], box: boxes.right },
] as const;
return (
<Wrapper className={`skirting-preview ${compact ? 'compact-preview' : 'exact-preview'}`}>
{!compact && (
<figure className="skirting-full-preview">
<svg
viewBox={viewBox(fullPreviewBox(piece.length, thickness))}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={`Full skirting shape ${piece.id}`}
>
<polygon
points={points}
fill="#ffedd5"
stroke="#c2410c"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
</svg>
<figcaption>
Full length {exactMm(piece.length)} mm × thickness {exactMm(thickness)} mm · top view,
to scale
</figcaption>
</figure>
)}
<Grid className="skirting-end-grid">
{ends.map((end, index) => (
<Fragment key={end.side}>
<EndWrapper className={`skirting-end ${end.side}-end`}>
<svg
className="skirting-end-preview"
data-end={end.side}
viewBox={viewBox(end.box)}
preserveAspectRatio="xMidYMid meet"
overflow="hidden"
aria-hidden={compact || undefined}
role={compact ? undefined : 'img'}
aria-label={
compact
? undefined
: `${end.side === 'left' ? 'Left' : 'Right'} mitre close-up ${piece.id}`
}
>
<polygon
points={points}
fill="#ffedd5"
stroke="#c2410c"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<line
className="skirting-cut-edge"
data-end={end.side}
x1={end.back.x}
y1={end.back.y}
x2={end.front.x}
y2={end.front.y}
stroke="#9a3412"
strokeWidth={compact ? 1.5 : 2.5}
vectorEffect="non-scaling-stroke"
/>
</svg>
{!compact && (
<figcaption>
{index === 0 ? 'Left' : 'Right'} end ·{' '}
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°
</figcaption>
)}
</EndWrapper>
{index === 0 && (
<span className="board-break" aria-hidden="true">
//
</span>
)}
</Fragment>
))}
</Grid>
{!compact && (
<p className="helper">
End close-ups; the middle is omitted. The back edge faces the wall, the front edge faces
the room. Both axes use the same scale.
</p>
)}
</Wrapper>
);
}

96
src/components/Fields.tsx Normal file
View file

@ -0,0 +1,96 @@
import { useId, useState } from 'react';
import type { Issue } from '../lib/types';
import { AlertCircle, Info, Check } from 'lucide-react';
export function NumberField({
label,
value,
onChange,
unit = 'mm',
min = 0,
max,
step = 1,
hint,
tooltip,
}: {
label: string;
value: number;
onChange: (n: number) => void;
unit?: string;
min?: number;
max?: number;
step?: number;
hint?: string;
tooltip?: string;
}) {
const id = useId();
const [showTooltip, setShowTooltip] = useState(false);
return (
<div className="field">
<span className="field-label">
<label htmlFor={id}>{label}</label>
{tooltip && (
<span className="field-tooltip">
<button
type="button"
className="tooltip-button"
aria-label={`What is ${label.toLowerCase()}?`}
aria-expanded={showTooltip}
aria-controls={`${id}-tooltip`}
aria-describedby={showTooltip ? `${id}-tooltip` : undefined}
onClick={() => setShowTooltip((shown) => !shown)}
onBlur={() => setShowTooltip(false)}
onKeyDown={(event) => {
if (event.key === 'Escape') setShowTooltip(false);
}}
>
?
</button>
{showTooltip && (
<span id={`${id}-tooltip`} role="tooltip" className="tooltip-content">
{tooltip}
</span>
)}
</span>
)}
</span>
<span className="number-input">
<input
id={id}
type="number"
inputMode="decimal"
value={value}
min={min}
max={max}
step={step}
aria-describedby={hint ? `${id}-hint` : undefined}
onChange={(e) => onChange(Number(e.target.value))}
/>
<span>{unit}</span>
</span>
{hint && <small id={`${id}-hint`}>{hint}</small>}
</div>
);
}
export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) {
const unique = issues.filter(
(issue, i) => issues.findIndex((item) => item.message === issue.message) === i,
);
return (
<div className={`issues ${compact ? 'compact' : ''}`} role="status">
{unique.map((issue, i) => (
<div className={`issue ${issue.severity}`} key={i}>
{issue.severity === 'info' ? <Info size={16} /> : <AlertCircle size={16} />}
<span>{issue.message}</span>
</div>
))}
</div>
);
}
export function ClosedBadge({ closed }: { closed: boolean }) {
return (
<span className={`badge ${closed ? 'success' : 'pending'}`}>
{closed ? <Check size={12} /> : <AlertCircle size={12} />}{' '}
{closed ? 'Outline closed' : 'Check outline'}
</span>
);
}

View file

@ -0,0 +1,365 @@
import { useId } from 'react';
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
import { area, mm } from '../lib/format';
import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels } from '../lib/dimension-labels';
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
import { ClosedBadge } from './Fields';
const points = (path: Point[]) => path.map((p) => `${p.x},${p.y}`).join(' ');
const midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
function Wall({ edge, span, label }: { edge: WallEdge; span: number; label: DimensionLabel }) {
const { normal: n, offset } = label;
const dx = edge.end.x - edge.start.x,
dy = edge.end.y - edge.start.y;
const length = Math.hypot(dx, dy) || 1;
return (
<g className="wall-drawing">
<line
x1={edge.start.x}
y1={edge.start.y}
x2={edge.end.x}
y2={edge.end.y}
stroke="#27364a"
strokeWidth={span * 0.003}
/>
<line
x1={edge.start.x + n.x * offset}
y1={edge.start.y + n.y * offset}
x2={edge.end.x + n.x * offset}
y2={edge.end.y + n.y * offset}
stroke="#8591a3"
strokeWidth={span * 0.001}
/>
{[edge.start, edge.end].map((p, i) => (
<line
key={i}
x1={p.x + n.x * (offset - span * 0.01)}
y1={p.y + n.y * (offset - span * 0.01)}
x2={p.x + n.x * (offset + span * 0.01)}
y2={p.y + n.y * (offset + span * 0.01)}
stroke="#8591a3"
strokeWidth={span * 0.001}
/>
))}
<g
className="wall-label"
transform={`translate(${label.x} ${label.y}) rotate(${label.angle})`}
>
<rect
x={-label.width / 2}
y={-label.font * 0.75}
width={label.width}
height={label.font * 1.5}
fill="#fffdf7"
/>
<text textAnchor="middle" dominantBaseline="middle" fontSize={label.font} fill="#17263f">
{label.text}
</text>
</g>
{edge.doors
.filter((d) => d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + 0.001)
.map((door) => {
const a = {
x: edge.start.x + (dx / length) * door.offset,
y: edge.start.y + (dy / length) * door.offset,
};
const b = { x: a.x + (dx / length) * door.width, y: a.y + (dy / length) * door.width };
const swing = { x: a.x - n.x * door.width, y: a.y - n.y * door.width };
const clockwise = dx * -n.y - dy * -n.x > 0;
return (
<g key={door.id}>
<line
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="#fffdf7"
strokeWidth={span * 0.012}
/>
<line
x1={a.x}
y1={a.y}
x2={swing.x}
y2={swing.y}
stroke="#53647c"
strokeWidth={span * 0.002}
/>
<path
d={`M ${b.x} ${b.y} A ${door.width} ${door.width} 0 0 ${clockwise ? 1 : 0} ${swing.x} ${swing.y}`}
fill="none"
stroke="#53647c"
strokeWidth={span * 0.0015}
strokeDasharray={`${span * 0.01} ${span * 0.008}`}
/>
<title>
Door: {mm(door.width)} mm, {mm(door.offset)} mm from W{edge.index + 1} start
</title>
</g>
);
})}
</g>
);
}
interface Props {
room: Room;
geometry: Geometry;
flooring: FlooringPlan;
skirting: SkirtingPlan;
selectedPiece: string | null;
select: (id: string) => void;
}
export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, select }: 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;
const floorPieces = flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces = skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point>();
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 x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * span * 0.032;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * span * 0.032;
skirtLabels.set(piece.id, { x, y });
reserved.push({
minX: x - font * 1.2,
maxX: x + font * 1.2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
});
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const boxes = [...reserved, ...labels.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 (
<section className="drawing-card" aria-label="Floor plan preview">
<div className="drawing-header">
<h2>
Floor plan <small>{area(geometry.area)} m²</small>
</h2>
<ClosedBadge closed={geometry.closed && geometry.valid} />
</div>
<div className="drawing-legend">
<span>
<i className="floor-key" />
Flooring
</span>
<span>
<i className="skirt-key" />
Skirting
</span>
</div>
<div className="drawing-viewport">
<svg
className="room-svg"
role="img"
aria-label={`${room.name} floor plan with ${geometry.edges.length} walls, ${floorPieces.length} floorboards and ${skirtPieces.length} skirting pieces`}
viewBox={`${left} ${top} ${width} ${height}`}
width={width}
height={height}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<pattern
id={hatch}
width={span * 0.02}
height={span * 0.02}
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line
x1="0"
y1="0"
x2="0"
y2={span * 0.02}
stroke="#1647ab"
strokeOpacity=".16"
strokeWidth={span * 0.003}
/>
</pattern>
</defs>
{geometry.closed && <polygon points={points(geometry.vertices)} fill="#fff" />}
{floorPieces.map((piece) => {
const cut = piece.cut || piece.reused;
const x =
(Math.min(...piece.polygon.map((p) => p.x)) +
Math.max(...piece.polygon.map((p) => p.x))) /
2;
const y =
(Math.min(...piece.polygon.map((p) => p.y)) +
Math.max(...piece.polygon.map((p) => p.y))) /
2;
return (
<g
key={piece.id}
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`}
data-material="flooring"
data-piece-id={piece.id}
role={cut ? 'button' : undefined}
tabIndex={cut ? 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 ? () => select(piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select(piece.id);
}
}
: undefined
}
>
<polygon
points={points(piece.polygon)}
fill={
cut ? (selectedPiece === piece.id ? '#ceddf9' : `url(#${hatch})`) : '#f5f6f8'
}
stroke={cut ? '#1647ab' : '#9baabd'}
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
/>
{cut && (
<>
<g
transform={`translate(${x} ${y})${room.direction === 90 ? ' rotate(90)' : ''}`}
pointerEvents="none"
>
<rect
x={-font * 1.1}
y={-font * 0.55}
width={font * 2.2}
height={font * 1.1}
fill="#1647ab"
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.76}
fontWeight="600"
>
{piece.id}
</text>
</g>
<title>
{piece.id}: {mm(piece.length)} × {mm(piece.width)} mm
</title>
</>
)}
</g>
);
})}
{geometry.edges.map((edge, i) => (
<Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} />
))}
{skirtPieces.map((piece) => {
const cut = isSkirtingCut(piece, skirting);
const label = skirtLabels.get(piece.id);
// Each template uses its true wall-facing and room-facing edges, including mitres.
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
const polygon = skirtingPolygon(piece, thickness, orientation);
return (
<g
key={piece.id}
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
data-material="skirting"
data-piece-id={piece.id}
role={cut ? 'button' : undefined}
tabIndex={cut ? 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 ? () => select(piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select(piece.id);
}
}
: undefined
}
>
<polygon
points={points(polygon)}
fill={selectedPiece === piece.id ? '#17263f' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.001}
/>
{cut && (
<line
x1={piece.start.x}
y1={piece.start.y}
x2={piece.end.x}
y2={piece.end.y}
stroke="transparent"
strokeWidth={span * 0.025}
/>
)}
{label && (
<g transform={`translate(${label.x} ${label.y})`} pointerEvents="none">
<rect
x={-font * 1.1}
y={-font * 0.58}
width={font * 2.2}
height={font * 1.16}
fill="#fffdf7"
stroke="#b74900"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#8d3800"
fontSize={font * 0.8}
fontWeight="700"
>
{piece.id}
</text>
</g>
)}
{cut && (
<title>
{piece.id}: {mm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{mm(piece.rightMitre)}°
</title>
)}
</g>
);
})}
{!geometry.closed && geometry.edges.length > 1 && (
<line
x1={geometry.edges.at(-1)!.end.x}
y1={geometry.edges.at(-1)!.end.y}
x2="0"
y2="0"
stroke="#b74900"
strokeWidth={span * 0.003}
strokeDasharray={`${span * 0.015} ${span * 0.008}`}
/>
)}
</svg>
</div>
</section>
);
}

View file

@ -0,0 +1,178 @@
import { Plus, Trash2, ArrowRight, ArrowDown } from 'lucide-react';
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
import { NumberField } from './Fields';
interface Props {
project: Project;
room: Room;
changeProject: (fn: (p: Project) => Project) => void;
changeRoom: (fn: (r: Room) => Room) => void;
}
const kerfHelp =
'Width of material removed by the saw blade. A 3 mm blade turns a 1,000 mm board into 600 mm + 397 mm after one cut.';
export function MaterialsEditor({ project, room, changeProject, 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 | number[]) =>
changeProject((p) => ({ ...p, skirting: { ...p.skirting, [key]: value } }));
return (
<>
<div className="editor-heading">
<h2>Materials</h2>
<span>Shared across rooms · mm</span>
</div>
<section className="material-section flooring" aria-labelledby="flooring-materials">
<h3 id="flooring-materials" className="material-heading">
Floorboards
</h3>
<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)}
/>
</div>
<div className="field">
<span>Laying pattern</span>
<div className="segmented" role="group" aria-label="Laying pattern">
<button
className={f.pattern === 'brick' ? 'active' : ''}
aria-pressed={f.pattern === 'brick'}
onClick={() => floor('pattern', 'brick')}
>
Brick
</button>
<button
className={f.pattern === 'random' ? 'active' : ''}
aria-pressed={f.pattern === 'random'}
onClick={() => floor('pattern', 'random')}
>
Random
</button>
</div>
</div>
<div className="field">
<span>Board direction · {room.name}</span>
<div className="segmented" role="group" aria-label="Board direction">
<button
className={room.direction === 0 ? 'active' : ''}
aria-pressed={room.direction === 0}
onClick={() => changeRoom((r) => ({ ...r, direction: 0 }))}
>
<ArrowRight size={16} aria-hidden="true" /> Along first wall
</button>
<button
className={room.direction === 90 ? 'active' : ''}
aria-pressed={room.direction === 90}
onClick={() => changeRoom((r) => ({ ...r, direction: 90 }))}
>
<ArrowDown size={16} aria-hidden="true" /> Across first wall
</button>
</div>
</div>
<div className="field-grid">
<NumberField label="Wall spacer" value={f.gap} onChange={(n) => floor('gap', n)} />
<NumberField
label="Saw kerf"
value={f.kerf}
onChange={(n) => floor('kerf', n)}
tooltip={kerfHelp}
/>
<NumberField
label="Minimum joint offset"
value={f.minOffset}
onChange={(n) => floor('minOffset', n)}
/>
<NumberField
label="Maximum joint offset"
value={f.maxOffset}
onChange={(n) => floor('maxOffset', n)}
/>
<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>
</section>
<section className="material-section skirting" aria-labelledby="skirting-materials">
<h3 id="skirting-materials" className="material-heading">
Skirting boards
</h3>
{s.stockLengths.map((length, i) => (
<div className="stock-input" key={i}>
<NumberField
label={`Stock length ${i + 1}`}
value={length}
min={1}
onChange={(n) =>
skirt(
'stockLengths',
s.stockLengths.map((v, j) => (i === j ? n : v)),
)
}
/>
<button
className="icon-button"
aria-label={`Remove stock length ${i + 1}`}
disabled={s.stockLengths.length === 1}
onClick={() =>
skirt(
'stockLengths',
s.stockLengths.filter((_, j) => i !== j),
)
}
>
<Trash2 size={16} />
</button>
</div>
))}
<button
className="text-button"
onClick={() => skirt('stockLengths', [...s.stockLengths, 3600])}
disabled={s.stockLengths.length >= 10}
>
<Plus size={16} /> Add a stock length
</button>
<div className="field-grid">
<NumberField
label="Skirting thickness"
value={s.thickness}
onChange={(n) => skirt('thickness', n)}
/>
<NumberField
label="Minimum piece"
value={s.minLength}
min={1}
onChange={(n) => skirt('minLength', n)}
/>
<NumberField
label="Skirting saw kerf"
value={s.kerf}
onChange={(n) => skirt('kerf', n)}
tooltip={kerfHelp}
/>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,364 @@
import { Plus, Trash2, DoorOpen, Check, MoveUpRight } from 'lucide-react';
import type { Geometry, Project, Room, Wall } from '../lib/types';
import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format';
import { NumberField, Issues } from './Fields';
interface Props {
room: Room;
geometry: Geometry;
tolerance: number;
changeRoom: (fn: (room: Room) => Room) => void;
changeProject: (fn: (project: Project) => Project) => void;
remove: () => void;
canRemove: boolean;
}
export function OutlineEditor({
room,
geometry,
tolerance,
changeRoom,
changeProject,
remove,
canRemove,
}: Props) {
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
const isRectangle =
room.walls.length === 4 &&
room.walls.every((w) => w.turn === 90) &&
room.walls[0].length === room.walls[2].length &&
room.walls[1].length === room.walls[3].length;
const lastEdge = geometry.edges.at(-1);
const finalExpectedLength = lastEdge ? Math.hypot(lastEdge.start.x, lastEdge.start.y) : 0;
const addWall = () =>
changeRoom((r) => ({
...r,
walls: [
...r.walls,
{
id: nextId(
`${r.id}-w`,
r.walls.map((w) => w.id),
),
length: 1000,
turn: 90,
doors: [],
},
],
}));
const closeOutline = () =>
changeRoom((r) => ({
...r,
walls: [
...r.walls.map((w, i) =>
i === r.walls.length - 1 ? { ...w, turn: geometry.closingTurn } : w,
),
{
id: nextId(
`${r.id}-w`,
r.walls.map((w) => w.id),
),
length: Math.round(geometry.closingLength * 10) / 10,
turn: 90,
doors: [],
},
],
}));
const addDoor = () => {
const target = room.walls[room.walls.length - 1];
if (!target) return;
changeWall(target.id, (w) => ({
...w,
doors: [
...w.doors,
{
id: nextId(
`${room.id}-door`,
room.walls.flatMap((w) => w.doors.map((d) => d.id)),
),
offset: Math.min(500, Math.max(0, w.length - 826)),
width: 826,
},
],
}));
};
return (
<>
<div className="editor-heading">
<h2>Room measurements</h2>
<span>Millimetres</span>
</div>
<label className="field">
<span>Room name</span>
<input
value={room.name}
maxLength={80}
onChange={(e) => changeRoom((r) => ({ ...r, name: e.target.value }))}
/>
</label>
<div className="template-options">
<button
className={isRectangle ? 'template active' : 'template'}
aria-pressed={isRectangle}
onClick={() => changeRoom((r) => rectangle(r.id, r.name))}
>
<span className="rectangle-icon" />
Rectangle
</button>
<button
className={room.walls.length === 8 ? 'template active' : 'template'}
aria-pressed={room.walls.length === 8}
onClick={() => changeRoom((r) => fireplace(r.id, r.name))}
>
<span className="fireplace-icon" />
With fireplace
</button>
<button
className="template"
onClick={() =>
changeRoom((r) => ({
...r,
walls: [{ id: `${r.id}-w1`, length: 4000, turn: 90, doors: [] }],
}))
}
>
<MoveUpRight size={19} />
Custom
</button>
</div>
{isRectangle && (
<div className="field-grid quick-dimensions">
<NumberField
label="Room width"
value={room.walls[0].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
walls: r.walls.map((w, i) => (i % 2 === 0 ? { ...w, length: n } : w)),
}))
}
/>
<NumberField
label="Room length"
value={room.walls[1].length}
min={1}
onChange={(n) =>
changeRoom((r) => ({
...r,
walls: r.walls.map((w, i) => (i % 2 === 1 ? { ...w, length: n } : w)),
}))
}
/>
</div>
)}
<details className="wall-details" open={!isRectangle}>
<summary>
Walls & corners <span>{room.walls.length} walls</span>
</summary>
<p className="helper">
Start to the right; work clockwise. Right turn: <b>90°</b>. Left turn: <b>−90°</b>.
</p>
<div className="wall-table">
<div className="wall-head">
<span>Wall</span>
<span>Length · mm</span>
<span>Next turn · °</span>
<span />
</div>
{room.walls.map((wall, i) => (
<div className="wall-row" key={wall.id}>
<span className="wall-code">W{i + 1}</span>
<input
aria-label={`Wall ${i + 1} length`}
type="number"
inputMode="decimal"
min={1}
step={1}
value={wall.length}
onChange={(e) =>
changeWall(wall.id, (w) => ({ ...w, length: Number(e.target.value) }))
}
/>
<input
aria-label={`Wall ${i + 1} turn`}
type="number"
inputMode="decimal"
min={-179}
max={179}
step={0.1}
value={
geometry.closed && i === room.walls.length - 1
? Math.round(normalizeAngle(-(lastEdge?.heading ?? 0)) * 10) / 10
: wall.turn
}
title={
geometry.closed && i === room.walls.length - 1
? 'The final corner is derived from the closed outline.'
: 'Turn after this wall; positive turns right.'
}
disabled={geometry.closed && i === room.walls.length - 1}
onChange={(e) =>
changeWall(wall.id, (w) => ({ ...w, turn: Number(e.target.value) }))
}
/>
<button
className="icon-button"
aria-label={`Remove wall ${i + 1}`}
disabled={room.walls.length === 1}
onClick={() =>
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
<button className="text-button" onClick={addWall} disabled={room.walls.length >= 100}>
<Plus size={15} /> Add a wall
</button>
</details>
{geometry.closed ? (
<div className="closure-check">
<Check size={17} />
<div>
<strong>Outline closed</strong>
<span>
Closing gap {mm(geometry.gap)} mm · tolerance {mm(tolerance)} mm
</span>
<span>
Final wall: {mm(finalExpectedLength)} mm expected ·{' '}
{mm(Math.abs(geometry.finalLengthError))} mm difference
</span>
</div>
</div>
) : (
room.walls.length >= 2 &&
geometry.closingLength > 0 && (
<div className="closure-suggestion">
<div>
<strong>{mm(geometry.closingLength)} mm to close</strong>
<p>Next turn {mm(geometry.closingTurn)}°. After adding, enter the measured length.</p>
<button
className="text-button"
onClick={closeOutline}
disabled={room.walls.length >= 100 || Math.abs(geometry.closingTurn) >= 179.99}
>
Add closing wall <Plus size={14} />
</button>
{room.walls.length >= 3 && (
<p>
If W{room.walls.length} is the final wall: {mm(finalExpectedLength)} mm expected;
length differs by {mm(Math.abs(geometry.finalLengthError))} mm, direction by{' '}
{mm(Math.abs(geometry.finalAngleError))}°.
</p>
)}
</div>
</div>
)
)}
<Issues issues={geometry.issues.filter((i) => i.severity !== 'info')} compact />
<div className="panel-divider" />
<div className="subheading">
<h3>
<DoorOpen size={16} /> Doorways
</h3>
<span>{room.walls.reduce((n, w) => n + w.doors.length, 0)} openings</span>
</div>
<p className="helper">
Width includes trim where skirting stops. Offset is measured from the wall’s start.
</p>
{room.walls.flatMap((wall, wallIndex) =>
wall.doors.map((door) => (
<div className="door-card" key={door.id}>
<div className="door-card-title">
<label>
On wall{' '}
<select
aria-label={`Door ${door.id} wall`}
value={wall.id}
onChange={(e) =>
changeRoom((r) => ({
...r,
walls: r.walls.map((w) =>
w.id === wall.id
? { ...w, doors: w.doors.filter((d) => d.id !== door.id) }
: w.id === e.target.value
? { ...w, doors: [...w.doors, door] }
: w,
),
}))
}
>
{room.walls.map((w, i) => (
<option key={w.id} value={w.id}>
W{i + 1}
</option>
))}
</select>
</label>
<button
className="icon-button"
aria-label={`Remove door on wall ${wallIndex + 1}`}
onClick={() =>
changeWall(wall.id, (w) => ({
...w,
doors: w.doors.filter((d) => d.id !== door.id),
}))
}
>
<Trash2 size={14} />
</button>
</div>
<div className="field-grid">
<NumberField
label="Door offset"
value={door.offset}
onChange={(n) =>
changeWall(wall.id, (w) => ({
...w,
doors: w.doors.map((d) => (d.id === door.id ? { ...d, offset: n } : d)),
}))
}
/>
<NumberField
label="Door width"
min={1}
value={door.width}
onChange={(n) =>
changeWall(wall.id, (w) => ({
...w,
doors: w.doors.map((d) => (d.id === door.id ? { ...d, width: n } : d)),
}))
}
/>
</div>
</div>
)),
)}
<button
className="text-button"
onClick={addDoor}
disabled={!room.walls.length || room.walls.at(-1)!.doors.length >= 30}
>
<Plus size={15} /> Add a doorway
</button>
<div className="panel-divider" />
<NumberField
label="Measurement tolerance"
value={tolerance}
max={50}
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
hint="Largest closing gap accepted for the cut plan."
/>
{canRemove && (
<button className="text-button danger remove-room" onClick={remove}>
<Trash2 size={14} />
Remove this room
</button>
)}
</>
);
}