wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
160
src/components/CutDialog.tsx
Normal file
160
src/components/CutDialog.tsx
Normal 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
267
src/components/CutList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/CutPreview.tsx
Normal file
166
src/components/CutPreview.tsx
Normal 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
96
src/components/Fields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
365
src/components/FloorPlan.tsx
Normal file
365
src/components/FloorPlan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/MaterialsEditor.tsx
Normal file
178
src/components/MaterialsEditor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
364
src/components/OutlineEditor.tsx
Normal file
364
src/components/OutlineEditor.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue