This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:56 +01:00
commit 9689bfcc18
23 changed files with 1815 additions and 955 deletions

View file

@ -3,7 +3,6 @@ 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 {
@ -33,11 +32,6 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
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}
@ -71,87 +65,24 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
{floor && (
<>
<p className="cut-origin">
{room} · Row {floor.row} · Source {floor.stockId}
{floor.reused ? ' · Reused offcut' : ''}
{room} · Row {floor.row}
</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}
{room} · Wall {skirt.wallIndex + 1}
</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>
</>
)}
<p className="cut-instructions">
Measure the longest points. Mitre angles are measured from square; the end views show
the cut direction.
</p>
</>
)}
</div>

View file

@ -5,6 +5,7 @@ import { downloadCsv } from '../lib/export';
import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
uncutFlooring,
uncutSkirting,
@ -12,32 +13,6 @@ import {
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;
@ -49,29 +24,23 @@ interface Props {
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.
Each group comes from one board. Select a piece for its cutting diagram.
</p>
</div>
<button
className="button secondary small"
className="button"
onClick={() => downloadCsv(project, flooring, skirting)}
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
>
@ -79,186 +48,138 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
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
Buy {flooring.boardCount} boards · {wholeFloor.wholeBoards} 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>
) : (
{groupFlooringCuts(flooring).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Floorboard ${group[0].stockId}`}
>
<h4>
Floorboard {group[0].stockId}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
<button
className="cut-preview-button flooring-preview-button"
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 aria-hidden="true">↗</span>
</span>
</button>
<div>
<p className="cut-size">
{mm(piece.length)} × {mm(piece.width)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
</p>
{piece.reused && (
<p className="cut-note">
{piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'}
</p>
)}
</div>
</li>
))}
</ul>
</section>
))}
{!floorCuts.length && (
<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.
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} 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>
) : (
{groupSkirtingCuts(skirting).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Skirting board ${group[0].stockId}`}
>
<h4>
Skirting board {group[0].stockId}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
<button
className="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 aria-hidden="true">↗</span>
</span>
</button>
<div>
<p className="cut-size">
{mm(piece.length)} mm <span className="cut-note">long point</span>
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</p>
<p className="cut-note">
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
</p>
</div>
</li>
))}
</ul>
</section>
))}
{!skirtCuts.length && (
<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}
floor={floorCuts.find((piece) => piece.id === selectedPiece)}
skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
project={project}
close={() => select(null)}
/>

View file

@ -2,12 +2,107 @@ import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import {
exactMm,
dimensionedOutline,
flooringTemplate,
fullPreviewBox,
skirtingEndBoxes,
skirtingTemplate,
viewBox,
} from '../lib/cut-preview';
import type { EdgeDimension } from '../lib/cut-preview';
function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: number }) {
return (
<g className="cut-dimensions">
{dimensions.map((dimension) => {
const { start, end, lineStart, lineEnd, normal, label } = dimension;
const center = { x: (lineStart.x + lineEnd.x) / 2, y: (lineStart.y + lineEnd.y) / 2 };
const staggered =
Math.abs((label.x - center.x) * -normal.y + (label.y - center.y) * normal.x) > font * 0.1;
return (
<g
key={dimension.edge}
className="dimension-lines"
stroke="#637185"
strokeWidth="0.8"
fill="none"
>
{[
[start, lineStart],
[end, lineEnd],
].map(([point, projected], i) => (
<line
key={i}
className="dimension-extension"
x1={point.x + normal.x * font * 0.12}
y1={point.y + normal.y * font * 0.12}
x2={projected.x + normal.x * font * 0.12}
y2={projected.y + normal.y * font * 0.12}
vectorEffect="non-scaling-stroke"
/>
))}
<line
className="dimension-line"
x1={lineStart.x}
y1={lineStart.y}
x2={lineEnd.x}
y2={lineEnd.y}
vectorEffect="non-scaling-stroke"
/>
{[lineStart, lineEnd].map((point, i) => (
<line
key={i}
className="dimension-tick"
x1={point.x - (normal.x - normal.y) * font * 0.075}
y1={point.y - (normal.y + normal.x) * font * 0.075}
x2={point.x + (normal.x - normal.y) * font * 0.075}
y2={point.y + (normal.y + normal.x) * font * 0.075}
vectorEffect="non-scaling-stroke"
/>
))}
{staggered && (
<line
className="dimension-leader"
x1={center.x}
y1={center.y}
x2={label.x}
y2={label.y}
vectorEffect="non-scaling-stroke"
/>
)}
</g>
);
})}
{dimensions.map((dimension) => (
<g key={dimension.edge} className="dimension-caption" data-edge={dimension.edge}>
<rect {...dimension.labelBox} fill="white" />
<text
className="dimension-label"
x={dimension.label.x}
y={dimension.label.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={font}
fill="#17263f"
>
{dimension.angle === undefined ? (
dimension.text
) : (
<>
<tspan x={dimension.label.x} dy={-font * 0.6}>
{dimension.text}
</tspan>{' '}
<tspan x={dimension.label.x} dy={font * 1.2}>
{exactMm(dimension.angle)}°
</tspan>
</>
)}
</text>
</g>
))}
</g>
);
}
export function FlooringPreview({
piece,
@ -17,12 +112,15 @@ export function FlooringPreview({
compact?: boolean;
}) {
const template = flooringTemplate(piece);
const dimensions = compact
? undefined
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1]);
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))}
viewBox={viewBox(dimensions?.box ?? fullPreviewBox(piece.length, piece.width))}
preserveAspectRatio="xMidYMid meet"
aria-hidden={compact || undefined}
role={compact ? undefined : 'img'}
@ -35,24 +133,13 @@ export function FlooringPreview({
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>
))}
{dimensions && <Dimensions dimensions={dimensions.dimensions} font={dimensions.font} />}
</svg>
{!compact && (
<figcaption>
Length {exactMm(piece.length)} mm × width {exactMm(piece.width)} mm · top view, to scale
{exactMm(piece.length)} × {exactMm(piece.width)} mm
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
' · angles from horizontal'}
</figcaption>
)}
</Wrapper>
@ -74,6 +161,18 @@ export function SkirtingPreview({
const Grid = compact ? 'span' : 'div';
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
const boxes = skirtingEndBoxes(template, thickness);
const dimensions = compact
? undefined
: dimensionedOutline(
[template[0], template[1], { x: piece.length, y: thickness }, { x: 0, y: thickness }],
[0, 2],
);
// The lower dimension measures the overall blank, between its actual longest points.
const overall = dimensions?.dimensions.find((dimension) => dimension.edge === 2);
if (overall) {
overall.start = template.reduce((a, b) => (a.x > b.x ? a : b));
overall.end = template.reduce((a, b) => (a.x < b.x ? a : b));
}
const points = template.map((point) => `${point.x},${point.y}`).join(' ');
const ends = [
{ side: 'left', back: template[0], front: template[3], box: boxes.left },
@ -84,7 +183,7 @@ export function SkirtingPreview({
{!compact && (
<figure className="skirting-full-preview">
<svg
viewBox={viewBox(fullPreviewBox(piece.length, thickness))}
viewBox={viewBox(dimensions!.box)}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={`Full skirting shape ${piece.id}`}
@ -96,10 +195,10 @@ export function SkirtingPreview({
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
</svg>
<figcaption>
Full length {exactMm(piece.length)} mm × thickness {exactMm(thickness)} mm · top view,
to scale
{exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
</figcaption>
</figure>
)}
@ -142,8 +241,9 @@ export function SkirtingPreview({
</svg>
{!compact && (
<figcaption>
{index === 0 ? 'Left' : 'Right'} end ·{' '}
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°
{index === 0 ? 'Left' : 'Right'} ·{' '}
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°{' '}
{index === 0 ? piece.leftType : piece.rightType}
</figcaption>
)}
</EndWrapper>
@ -155,12 +255,6 @@ export function SkirtingPreview({
</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>
);
}

View file

@ -0,0 +1,64 @@
import type { Door, Wall } from '../lib/types';
import { mm } from '../lib/format';
/** A wall viewed from its start toward its end; only the user's offset and width are inputs. */
export function DoorPosition({
wall,
door,
wallIndex,
wallCount,
closed,
}: {
wall: Wall;
door: Door;
wallIndex: number;
wallCount: number;
closed: boolean;
}) {
const start =
wallIndex === 0 && !closed ? 'W1 start' : `W${wallIndex === 0 ? wallCount : wallIndex} corner`;
const end =
wallIndex === wallCount - 1 && !closed
? `W${wallCount} end`
: `W${((wallIndex + 1) % wallCount) + 1} corner`;
const ratio = (n: number) => 12 + Math.max(0, Math.min(1, n / Math.max(wall.length, 1))) * 216;
const a = ratio(door.offset),
b = ratio(door.offset + door.width);
return (
<div className="door-position">
<p>
W{wallIndex + 1}: measure from {start} toward {end}.
</p>
<svg
viewBox="0 0 240 65"
role="img"
aria-label={`Door on W${wallIndex + 1}: ${mm(door.offset)} mm from ${start}, then ${mm(door.width)} mm opening toward ${end}`}
>
<line x1="12" y1="22" x2="228" y2="22" stroke="#17263f" strokeWidth="3" />
<line x1={a} y1="22" x2={b} y2="22" stroke="#fffdf7" strokeWidth="5" />
<line
x1={a}
y1="22"
x2={b}
y2="22"
stroke="#1647ab"
strokeWidth="2"
strokeDasharray="3 3"
/>
<circle cx="12" cy="22" r="4" fill="#17263f" />
<path
d="M 12 10 L 46 10 M 40 6 L 46 10 L 40 14"
stroke="#1647ab"
fill="none"
strokeWidth="1.5"
/>
<text x="12" y="45" textAnchor="start">
{start}
</text>
<text x="228" y="59" textAnchor="end">
{end}
</text>
</svg>
</div>
);
}

View file

@ -111,10 +111,19 @@ interface Props {
flooring: FlooringPlan;
skirting: SkirtingPlan;
selectedPiece: string | null;
select: (id: string) => void;
select?: (id: string) => void;
interactive?: boolean;
}
export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, select }: Props) {
export function FloorPlan({
room,
geometry,
flooring,
skirting,
selectedPiece,
select,
interactive = true,
}: 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)),
@ -159,13 +168,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
<div className="drawing-legend">
<span>
<i className="floor-key" />
Flooring
Flooring cuts
</span>
<span>
<i className="skirt-key" />
Skirting
Skirting cuts
</span>
<span>
<i className="uncut-key" />
Uncut
</span>
</div>
{interactive && <p className="drawing-help">Select a numbered piece for cut details.</p>}
<div className="drawing-viewport">
<svg
className="room-svg"
@ -212,18 +226,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
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}
role={cut && interactive ? 'button' : undefined}
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 ? () => select(piece.id) : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select(piece.id);
select?.(piece.id);
}
}
: undefined
@ -283,18 +297,18 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
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}
role={cut && interactive ? 'button' : undefined}
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 ? () => select(piece.id) : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select(piece.id);
select?.(piece.id);
}
}
: undefined
@ -302,8 +316,8 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
>
<polygon
points={points(polygon)}
fill={selectedPiece === piece.id ? '#17263f' : '#b74900'}
stroke="#8d3800"
fill={cut ? (selectedPiece === piece.id ? '#17263f' : '#b74900') : '#d8d5cd'}
stroke={cut ? '#8d3800' : '#79756b'}
strokeWidth={span * 0.001}
/>
{cut && (
@ -317,20 +331,20 @@ export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, s
/>
)}
{label && (
<g transform={`translate(${label.x} ${label.y})`} pointerEvents="none">
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
<rect
x={-font * 1.1}
y={-font * 0.58}
width={font * 2.2}
height={font * 1.16}
fill="#fffdf7"
stroke="#b74900"
fill="#b74900"
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#8d3800"
fill="#fff"
fontSize={font * 0.8}
fontWeight="700"
>

View file

@ -24,7 +24,7 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
<>
<div className="editor-heading">
<h2>Materials</h2>
<span>Shared across rooms · mm</span>
<span>Shared across rooms</span>
</div>
<section className="material-section flooring" aria-labelledby="flooring-materials">
<h3 id="flooring-materials" className="material-heading">

View file

@ -4,6 +4,7 @@ import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format';
import { NumberField, Issues } from './Fields';
import { DoorPosition } from './DoorPosition';
interface Props {
room: Room;
geometry: Geometry;
@ -87,7 +88,6 @@ export function OutlineEditor({
<>
<div className="editor-heading">
<h2>Room measurements</h2>
<span>Millimetres</span>
</div>
<label className="field">
<span>Room name</span>
@ -267,9 +267,7 @@ export function OutlineEditor({
</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>
<p className="helper">Door width includes trim where skirting stops.</p>
{room.walls.flatMap((wall, wallIndex) =>
wall.doors.map((door) => (
<div className="door-card" key={door.id}>
@ -312,9 +310,16 @@ export function OutlineEditor({
<Trash2 size={14} />
</button>
</div>
<DoorPosition
wall={wall}
door={door}
wallIndex={wallIndex}
wallCount={room.walls.length}
closed={geometry.closed}
/>
<div className="field-grid">
<NumberField
label="Door offset"
label={`From ${wallIndex === 0 && !geometry.closed ? 'W1 start' : `W${wallIndex === 0 ? room.walls.length : wallIndex} corner`}`}
value={door.offset}
onChange={(n) =>
changeWall(wall.id, (w) => ({

View file

@ -0,0 +1,114 @@
import type { FlooringPlan, Geometry, Project, SkirtingPlan } from '../lib/types';
import { area, mm } from '../lib/format';
import {
groupFlooringCuts,
groupSkirtingCuts,
readyOffcutInstructions,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { flooringInstructions } from '../lib/cut-instructions';
import { FloorPlan } from './FloorPlan';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
/** All rooms first, then cutting sheets in purchased-board order, including shared offcuts. */
export function PrintPlan({
project,
geometries,
flooring,
skirting,
}: {
project: Project;
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);
return (
<section className="print-plan" aria-label="Printable plan">
<header className="print-title">
<h1>Flooring installation plan</h1>
<p>
{project.rooms.length} rooms · {mm(project.flooring.length)} ×{' '}
{mm(project.flooring.width)} mm floorboards · {mm(project.flooring.gap)} mm wall spacers
</p>
<p>
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
</p>
</header>
{project.rooms.map((room, index) => (
<section className="print-room" key={room.id} data-room-id={room.id}>
<h2>
{index + 1}. {room.name} <span>{area(geometries[index].area)} m²</span>
</h2>
<FloorPlan
room={room}
geometry={geometries[index]}
flooring={flooring}
skirting={skirting}
selectedPiece={null}
interactive={false}
/>
</section>
))}
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
<h2>Flooring cuts</h2>
{groupFlooringCuts(flooring).map((group) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<h3>
Floorboard {group[0].stockId}{' '}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
</h3>
<div className="print-cut-grid">
{group.map((piece) => (
<article className="print-cut" data-cut-id={piece.id} key={piece.id}>
<h4>
{piece.id}{' '}
<span>
{roomName(piece.roomId)} · Row {piece.row}
</span>
</h4>
<FlooringPreview piece={piece} />
<p>{piece.cut ? flooringInstructions(piece) : readyOffcutInstructions(piece)}</p>
</article>
))}
</div>
</section>
))}
{!groupFlooringCuts(flooring).length && <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) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<h3>
Skirting board {group[0].stockId}{' '}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
</h3>
<div className="print-cut-grid">
{group.map((piece) => (
<article className="print-cut" data-cut-id={piece.id} key={piece.id}>
<h4>
{piece.id}{' '}
<span>
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</span>
</h4>
<SkirtingPreview piece={piece} thickness={project.skirting.thickness} />
</article>
))}
</div>
</section>
))}
{!groupSkirtingCuts(skirting).length && <p>No skirting cuts needed.</p>}
</section>
</section>
);
}