This commit is contained in:
Andras Schmelczer 2026-09-11 17:57:03 +01:00
commit 7959fe85cc
29 changed files with 1679 additions and 498 deletions

View file

@ -2,7 +2,7 @@ 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 { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
interface Props {
@ -79,10 +79,7 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
{room} · Wall {skirt.wallIndex + 1}
</p>
<SkirtingPreview piece={skirt} thickness={project.skirting.thickness} />
<p className="cut-instructions">
Measure the longest points. Mitre angles are measured from square; the end views show
the cut direction.
</p>
<p className="cut-instructions">{skirtingInstructions(skirt)}</p>
</>
)}
</div>

View file

@ -1,11 +1,12 @@
import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import { cutMm, degree, mm } from '../lib/format';
import { cutMm, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
skirtingEndLabel,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
@ -198,7 +199,8 @@ export function CutList({
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} × {cutMm(piece.width)} mm
{cutMm(piece.length, piece.blankLength)} ×{' '}
{cutMm(piece.width, piece.blankWidth)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
@ -302,13 +304,16 @@ export function CutList({
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} mm <span className="cut-note">long point</span>
{cutMm(piece.length, piece.blankLength)} 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)}
Left: {skirtingEndLabel(piece, 'left')}
<br />
Right: {skirtingEndLabel(piece, 'right')}
</p>
<button
className="button cut-locate"

View file

@ -1,6 +1,7 @@
import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import { cutMm, degree } from '../lib/format';
import { skirtingEndLabel } from '../lib/cut-list';
import {
dimensionedOutline,
flooringTemplate,
@ -114,7 +115,10 @@ export function FlooringPreview({
const template = flooringTemplate(piece);
const dimensions = compact
? undefined
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1]);
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1], {
length: piece.blankLength,
width: piece.blankWidth,
});
const Wrapper = compact ? 'span' : 'figure';
return (
<Wrapper className={`flooring-preview-figure ${compact ? 'compact-preview' : 'exact-preview'}`}>
@ -137,7 +141,7 @@ export function FlooringPreview({
</svg>
{!compact && (
<figcaption>
{cutMm(piece.length)} × {cutMm(piece.width)} mm
{cutMm(piece.length, piece.blankLength)} × {cutMm(piece.width, piece.blankWidth)} mm
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
' · angles from horizontal'}
</figcaption>
@ -160,12 +164,13 @@ export function SkirtingPreview({
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 boxes = skirtingEndBoxes(template, thickness, piece.installedTemplate);
const dimensions = compact
? undefined
: dimensionedOutline(
[template[0], template[1], { x: piece.length, y: thickness }, { x: 0, y: thickness }],
[0, 2],
{ length: piece.blankLength, width: thickness },
);
// The lower dimension measures the overall blank, between its actual longest points.
const overall = dimensions?.dimensions.find((dimension) => dimension.edge === 2);
@ -175,8 +180,24 @@ export function SkirtingPreview({
}
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 },
{
side: 'left',
back: template[0],
front: template[3],
box: boxes.left,
type: piece.leftType,
installedBack: piece.installedTemplate?.[0],
installedFront: piece.installedTemplate?.[3],
},
{
side: 'right',
back: template[1],
front: template[2],
box: boxes.right,
type: piece.rightType,
installedBack: piece.installedTemplate?.[1],
installedFront: piece.installedTemplate?.[2],
},
] as const;
return (
<Wrapper className={`skirting-preview ${compact ? 'compact-preview' : 'exact-preview'}`}>
@ -198,7 +219,10 @@ export function SkirtingPreview({
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
</svg>
<figcaption>
{cutMm(piece.length)} × {cutMm(thickness)} mm · wall side above
{cutMm(piece.length, piece.blankLength)} × {cutMm(thickness, thickness)} mm · wall side
above
{(piece.leftType === 'cope' || piece.rightType === 'cope') &&
' · preparation before coping'}
</figcaption>
</figure>
)}
@ -217,7 +241,7 @@ export function SkirtingPreview({
aria-label={
compact
? undefined
: `${end.side === 'left' ? 'Left' : 'Right'} mitre close-up ${piece.id}`
: `${end.side === 'left' ? 'Left' : 'Right'} ${end.type === 'cope' ? 'cope preparation' : 'mitre'} close-up ${piece.id}`
}
>
<polygon
@ -238,12 +262,24 @@ export function SkirtingPreview({
strokeWidth={compact ? 1.5 : 2.5}
vectorEffect="non-scaling-stroke"
/>
{end.type === 'cope' && end.installedBack && end.installedFront && (
<line
className="skirting-cope-body"
data-end={end.side}
x1={end.installedBack.x}
y1={end.installedBack.y}
x2={end.installedFront.x}
y2={end.installedFront.y}
stroke="#9a3412"
strokeWidth={1.5}
strokeDasharray="3 2"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
{!compact && (
<figcaption>
{index === 0 ? 'Left' : 'Right'} ·{' '}
{degree(index === 0 ? piece.leftMitre : piece.rightMitre)}{' '}
{index === 0 ? piece.leftType : piece.rightType}
{index === 0 ? 'Left' : 'Right'} · {skirtingEndLabel(piece, end.side)}
</figcaption>
)}
</EndWrapper>

View file

@ -1,7 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react';
import type { InputHTMLAttributes } from 'react';
import type { Issue } from '../lib/types';
import { AlertCircle, Info, Check } from 'lucide-react';
import { AlertCircle, Info } from 'lucide-react';
const numberFromDraft = (text: string): number | null => {
if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null;
@ -139,11 +139,3 @@ export function Issues({ issues, compact = false }: { issues: Issue[]; compact?:
</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

@ -5,8 +5,8 @@ import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import { floorPieceLabels } from '../lib/piece-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 });
@ -141,57 +141,82 @@ export function FloorPlan({
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 skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
if (!isSkirtingCut(piece, skirting)) continue;
const center = midpoint(piece.start, piece.end);
const length = Math.hypot(piece.end.x - piece.start.x, piece.end.y - piece.start.y) || 1;
const 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.4,
maxX: x + font * 1.4,
minY: y - font * 0.7,
maxY: y + font * 0.7,
});
const text = piece.id + (completedBoards.includes(`skirting:${piece.stockId}`) ? ' ✓' : '');
const width = Math.max(font * 1.8, text.length * font * 0.8 * 0.64 + font * 0.55);
let box!: LabelBox;
for (let lane = 0; lane < 24; lane++) {
const offset = span * (0.032 + lane * 0.035);
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * offset;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * offset;
box = {
minX: x - width / 2,
maxX: x + width / 2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
};
skirtLabels.set(piece.id, { x, y, width, text });
if (
!reserved.some(
(other) =>
box.minX < other.maxX &&
box.maxX > other.minX &&
box.minY < other.maxY &&
box.maxY > other.minY,
)
)
break;
}
reserved.push(box);
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const floorLabels = floorPieceLabels(
floorPieces,
geometry.vertices,
room.direction,
font,
[...reserved, ...labels.map((label) => label.box)],
completedBoards,
);
const boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
...floorLabels.map((label) => label.box),
];
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top;
return (
<section className="drawing-card" aria-label="Floor plan preview">
<section
className="drawing-card"
aria-label="Floor plan preview"
data-search-active={searchActive}
>
<div className="drawing-header">
<h2>
Floor plan{' '}
<small>
{room.name} · {area(geometry.area)} m²
</small>
{room.name} <small>{area(geometry.area)} m²</small>
</h2>
<ClosedBadge closed={geometry.closed && geometry.valid} />
<div className="drawing-legend">
<span>
<i className="floor-key" />
Flooring cuts
</span>
<span>
<i className="skirt-key" />
Skirting cuts
</span>
<span>
<i className="uncut-key" />
Uncut
</span>
</div>
</div>
<div className="drawing-legend">
<span>
<i className="floor-key" />
Flooring cuts
</span>
<span>
<i className="skirt-key" />
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"
@ -289,30 +314,9 @@ export function FloorPlan({
)}
{cut && (
<>
<g
transform={`translate(${x} ${y})${room.direction === 90 ? ' rotate(90)' : ''}`}
pointerEvents="none"
>
<rect
x={-font * 1.3}
y={-font * 0.55}
width={font * 2.6}
height={font * 1.1}
fill={done ? '#27655d' : '#1647ab'}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.76}
fontWeight="600"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
<title>
{piece.id}: {cutMm(piece.length)} × {cutMm(piece.width)} mm
{piece.id}: {cutMm(piece.length, piece.blankLength)} ×{' '}
{cutMm(piece.width, piece.blankWidth)} mm
</title>
</>
)}
@ -326,7 +330,6 @@ export function FloorPlan({
const cut = isSkirtingCut(piece, skirting);
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
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);
@ -393,33 +396,10 @@ export function FloorPlan({
✓
</text>
)}
{label && (
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
<rect
x={-font * 1.3}
y={-font * 0.58}
width={font * 2.6}
height={font * 1.16}
fill={done ? '#27655d' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.8}
fontWeight="700"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
)}
{cut && (
<title>
{piece.id}: {cutMm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{mm(piece.rightMitre)}°
{piece.id}: {cutMm(piece.length, piece.blankLength)} mm · {mm(piece.leftMitre)}°
/ {mm(piece.rightMitre)}°
</title>
)}
</g>
@ -436,6 +416,97 @@ export function FloorPlan({
strokeDasharray={`${span * 0.015} ${span * 0.008}`}
/>
)}
<g className="skirting-labels" aria-hidden="true">
{skirtPieces.map((piece) => {
const label = skirtLabels.get(piece.id);
if (!label) return null;
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
return (
<g
key={piece.id}
className={`skirting-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={piece.id}
transform={`translate(${label.x} ${label.y})`}
onClick={interactive ? () => select?.(piece.id) : undefined}
>
<rect
x={-label.width / 2}
y={-font * 0.58}
width={label.width}
height={font * 1.16}
fill={done ? '#27655d' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.8}
fontWeight="700"
>
{label.text}
</text>
</g>
);
})}
</g>
<g className="floor-labels" aria-hidden="true">
{floorLabels.map((label) => {
const piece = floorPieces.find((piece) => piece.id === label.id)!;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(label.id);
return (
<g
key={label.id}
className={`floor-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={label.id}
onClick={interactive ? () => select?.(label.id) : undefined}
>
{label.leader && (
<>
<line
className="piece-label-leader"
x1={label.anchor.x}
y1={label.anchor.y}
x2={label.x}
y2={label.y}
stroke="#1647ab"
strokeWidth={span * 0.0015}
pointerEvents="none"
/>
<circle
cx={label.anchor.x}
cy={label.anchor.y}
r={font * 0.09}
fill="#1647ab"
pointerEvents="none"
/>
</>
)}
<g transform={`translate(${label.x} ${label.y}) rotate(${label.angle})`}>
<rect
x={-label.width / 2}
y={-label.height / 2}
width={label.width}
height={label.height}
fill={done ? '#27655d' : '#1647ab'}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={label.font}
fontWeight="600"
>
{label.text}
</text>
</g>
</g>
);
})}
</g>
</svg>
</div>
</section>

View file

@ -1,4 +1,4 @@
import { Plus, Trash2, ArrowRight, ArrowDown } from 'lucide-react';
import { ArrowRight, ArrowDown } from 'lucide-react';
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
import { NumberField } from './Fields';
@ -17,7 +17,7 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
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[]) =>
const skirt = (key: keyof SkirtingSettings, value: number) =>
changeProject((p) => ({ ...p, skirting: { ...p.skirting, [key]: value } }));
return (
@ -118,42 +118,13 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
<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 board length"
value={s.stockLength}
min={1}
onChange={(n) => skirt('stockLength', n)}
/>
<NumberField
label="Skirting thickness"
value={s.thickness}

View file

@ -4,6 +4,7 @@ import {
groupFlooringCuts,
groupSkirtingCuts,
readyOffcutInstructions,
skirtingInstructions,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
@ -118,6 +119,7 @@ export function PrintPlan({
</span>
</h4>
<SkirtingPreview piece={piece} thickness={project.skirting.thickness} />
<p>{skirtingInstructions(piece)}</p>
</article>
))}
</div>