reviewed
This commit is contained in:
parent
62a385e4c9
commit
7959fe85cc
29 changed files with 1679 additions and 498 deletions
57
src/App.tsx
57
src/App.tsx
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Copy, Plus, Printer } from 'lucide-react';
|
||||
import { Copy, Plus, Printer, Search, X } from 'lucide-react';
|
||||
import { useProject } from './lib/use-project';
|
||||
import { rectangle, nextId } from './lib/defaults';
|
||||
import { buildGeometry } from './lib/geometry';
|
||||
|
|
@ -314,14 +314,8 @@ export default function App() {
|
|||
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
|
||||
</strong>
|
||||
<p>
|
||||
{[...new Set(skirting.stocks.map((s) => s.length))]
|
||||
.sort((a, b) => a - b)
|
||||
.map(
|
||||
(l) =>
|
||||
`${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
|
||||
)
|
||||
.join(' + ') || 'Check settings'}{' '}
|
||||
· {wholeSkirting} used whole
|
||||
{skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '}
|
||||
{wholeSkirting} used whole
|
||||
</p>
|
||||
</div>
|
||||
<span className="waste-stat">
|
||||
|
|
@ -353,24 +347,26 @@ export default function App() {
|
|||
</PlannerPanel>
|
||||
</div>
|
||||
<aside className="drawing-column" id="drawing" aria-label="Room drawing">
|
||||
<FloorPlan
|
||||
room={room}
|
||||
geometry={geometry}
|
||||
flooring={flooring}
|
||||
skirting={skirting}
|
||||
selectedPiece={view.selectedPiece}
|
||||
select={(id) => select(id)}
|
||||
completedBoards={project.completedBoards}
|
||||
highlightedPieces={matches.pieceIds}
|
||||
searchActive={!!view.search.trim()}
|
||||
/>
|
||||
{view.search.trim() && (
|
||||
<div className="plan-search-status" role="status">
|
||||
<p>
|
||||
{matches.pieceIds.length
|
||||
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'} found for “${searchLabel}”`
|
||||
: `No boards or cuts match “${searchLabel}”`}
|
||||
</p>
|
||||
<div className="search-mode-heading">
|
||||
<div className="search-mode-text">
|
||||
<strong>
|
||||
<Search size={15} aria-hidden="true" />
|
||||
Search active
|
||||
</strong>
|
||||
<p title={searchLabel}>
|
||||
{searchLabel} ·{' '}
|
||||
{matches.pieceIds.length
|
||||
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'}`
|
||||
: 'No matching pieces'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="button clear-plan-search" onClick={() => setSearch('')}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
Show all pieces
|
||||
</button>
|
||||
</div>
|
||||
{matches.roomIds.length > 1 && (
|
||||
<div className="matching-rooms">
|
||||
{project.rooms
|
||||
|
|
@ -393,6 +389,17 @@ export default function App() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<FloorPlan
|
||||
room={room}
|
||||
geometry={geometry}
|
||||
flooring={flooring}
|
||||
skirting={skirting}
|
||||
selectedPiece={view.selectedPiece}
|
||||
select={(id) => select(id)}
|
||||
completedBoards={project.completedBoards}
|
||||
highlightedPieces={matches.pieceIds}
|
||||
searchActive={!!view.search.trim()}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type {
|
|||
SkirtingPlan,
|
||||
} from './types';
|
||||
|
||||
import { cutMm, degree } from './format';
|
||||
|
||||
const sameLength = (a: number, b: number) => Math.abs(a - b) < 0.01;
|
||||
|
||||
/** A ready offcut still needs a placement code; only fresh, uncut whole boards are omitted. */
|
||||
|
|
@ -15,6 +17,8 @@ export const flooringCuts = (plan: FlooringPlan) =>
|
|||
export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean {
|
||||
const stock = plan.stocks.find((candidate) => candidate.id === piece.stockId);
|
||||
return (
|
||||
piece.leftType === 'cope' ||
|
||||
piece.rightType === 'cope' ||
|
||||
!stock ||
|
||||
!sameLength(piece.length, stock.length) ||
|
||||
Math.abs(piece.leftMitre) > 0.001 ||
|
||||
|
|
@ -69,3 +73,32 @@ export const groupFlooringCuts = (plan: FlooringPlan) =>
|
|||
groupByStock(flooringCuts(plan), plan.stocks);
|
||||
export const groupSkirtingCuts = (plan: SkirtingPlan) =>
|
||||
groupByStock(skirtingCuts(plan), plan.stocks);
|
||||
|
||||
export function skirtingEndLabel(piece: SkirtingPiece, side: 'left' | 'right'): string {
|
||||
const type = side === 'left' ? piece.leftType : piece.rightType;
|
||||
const angle = side === 'left' ? piece.leftMitre : piece.rightMitre;
|
||||
if (type === 'cope') return `Cope · prepare ${degree(angle)}`;
|
||||
if (type === 'internal')
|
||||
return `Receive cope · ${Math.abs(angle) < 0.001 ? 'square' : `wall-fit ${degree(angle)}`}`;
|
||||
return type === 'external' ? `Mitre ${degree(angle)}` : 'Square';
|
||||
}
|
||||
|
||||
/** The profile is supplied by the user's real moulding, not by its thickness. */
|
||||
export function skirtingInstructions(piece: SkirtingPiece): string {
|
||||
const copes = [
|
||||
piece.leftType === 'cope' ? 'left' : '',
|
||||
piece.rightType === 'cope' ? 'right' : '',
|
||||
].filter(Boolean);
|
||||
const receiving = piece.leftType === 'internal' || piece.rightType === 'internal';
|
||||
const messages = [
|
||||
'Measure the longest points of the preparation blank. Angles are measured from square.',
|
||||
];
|
||||
if (receiving) messages.push('Fit the receiving end before the adjoining coped piece.');
|
||||
if (copes.length)
|
||||
messages.push(
|
||||
`Prepare the ${copes.join(' and ')} inside mitre, mark your moulding’s exposed profile, then cope and back-cut to fit the receiving face. Keep the front profile line; the dashed end shows the full-thickness body after coping, not the decorative profile.`,
|
||||
);
|
||||
if (piece.visibleLength !== undefined)
|
||||
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
|
||||
return messages.join(' ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) =>
|
|||
a.y + a.height + gap > b.y;
|
||||
|
||||
/** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */
|
||||
export function dimensionedOutline(points: Point[], edges?: number[]) {
|
||||
export function dimensionedOutline(
|
||||
points: Point[],
|
||||
edges?: number[],
|
||||
blank: { length?: number; width?: number } = {},
|
||||
) {
|
||||
if (points.length < 3)
|
||||
return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) };
|
||||
const span = Math.max(
|
||||
|
|
@ -72,7 +76,13 @@ export function dimensionedOutline(points: Point[], edges?: number[]) {
|
|||
};
|
||||
const normal = { x: orientation * direction.y, y: -orientation * direction.x };
|
||||
const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 };
|
||||
const text = cutMm(edge.length);
|
||||
const limit =
|
||||
Math.abs(direction.y) < 1e-6
|
||||
? blank.length
|
||||
: Math.abs(direction.x) < 1e-6
|
||||
? blank.width
|
||||
: undefined;
|
||||
const text = cutMm(edge.length, limit);
|
||||
const angle =
|
||||
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
|
||||
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
|
||||
|
|
@ -178,18 +188,28 @@ export function fullPreviewBox(length: number, width: number): PreviewBox {
|
|||
export function skirtingEndBoxes(
|
||||
template: Point[],
|
||||
thickness: number,
|
||||
installed?: Point[],
|
||||
): { left: PreviewBox; right: PreviewBox } {
|
||||
const [backLeft, backRight, frontRight, frontLeft] = template;
|
||||
const left = [
|
||||
template[0].x,
|
||||
template[3].x,
|
||||
...(installed ? [installed[0].x, installed[3].x] : []),
|
||||
];
|
||||
const right = [
|
||||
template[1].x,
|
||||
template[2].x,
|
||||
...(installed ? [installed[1].x, installed[2].x] : []),
|
||||
];
|
||||
const side = Math.max(
|
||||
1,
|
||||
thickness * 3,
|
||||
Math.abs(backLeft.x - frontLeft.x) / 0.76,
|
||||
Math.abs(backRight.x - frontRight.x) / 0.76,
|
||||
(Math.max(...left) - Math.min(...left)) / 0.76,
|
||||
(Math.max(...right) - Math.min(...right)) / 0.76,
|
||||
);
|
||||
const padding = side * 0.12;
|
||||
const common = { y: (thickness - side) / 2, width: side, height: side };
|
||||
return {
|
||||
left: { ...common, x: Math.min(backLeft.x, frontLeft.x) - padding },
|
||||
right: { ...common, x: Math.max(backRight.x, frontRight.x) - side + padding },
|
||||
left: { ...common, x: Math.min(...left) - padding },
|
||||
right: { ...common, x: Math.max(...right) - side + padding },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function defaultProject(): Project {
|
|||
minWidth: 60,
|
||||
kerf: 3,
|
||||
},
|
||||
skirting: { stockLengths: [2400], thickness: 18, minLength: 200, kerf: 3 },
|
||||
skirting: { stockLength: 2400, thickness: 18, minLength: 200, kerf: 3 },
|
||||
};
|
||||
}
|
||||
export function defaultState(): UrlState {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import ClipperLib from 'clipper-lib';
|
||||
import { buildGeometry, insetPolygon } from './geometry';
|
||||
import { cutLength } from './format';
|
||||
import type {
|
||||
FlooringPiece,
|
||||
FlooringPlan,
|
||||
|
|
@ -50,6 +51,10 @@ interface RowShape {
|
|||
const round = (n: number) => Math.round(n * SCALE) / SCALE;
|
||||
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
|
||||
const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
|
||||
// A complete board or an exact-fit remnant needs no crosscut, even for stock
|
||||
// supplied in fractions of a millimetre. New blanks reserve the displayed saw mark.
|
||||
const blankLength = (length: number, available: number) =>
|
||||
close(length, available) ? available : cutLength(length);
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const area = (points: Point[]) =>
|
||||
Math.abs(
|
||||
|
|
@ -112,7 +117,11 @@ function intersect(paths: Point[][], rectangle: Bounds): Point[][] {
|
|||
}
|
||||
|
||||
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
|
||||
function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: number[] = []): number[][] {
|
||||
function rowWidthOptions(
|
||||
height: number,
|
||||
s: FlooringSettings,
|
||||
cornerOffsets: number[] = [],
|
||||
): number[][] {
|
||||
const count = Math.ceil((height - EPSILON) / s.width);
|
||||
if (count <= 0 || count > 500) return [];
|
||||
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
||||
|
|
@ -149,11 +158,16 @@ function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: num
|
|||
}
|
||||
|
||||
function inwardCorners(paths: Point[][]): Point[] {
|
||||
return paths.flatMap(path => path.filter((point, index) => {
|
||||
const previous = path[(index + path.length - 1) % path.length];
|
||||
const next = path[(index + 1) % path.length];
|
||||
return (point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) < -EPSILON;
|
||||
}));
|
||||
return paths.flatMap((path) =>
|
||||
path.filter((point, index) => {
|
||||
const previous = path[(index + path.length - 1) % path.length];
|
||||
const next = path[(index + 1) % path.length];
|
||||
return (
|
||||
(point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) <
|
||||
-EPSILON
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function hasVerticalEdge(path: Point[], x: number): boolean {
|
||||
|
|
@ -189,6 +203,7 @@ function cutRow(
|
|||
const length = b.maxX - b.minX;
|
||||
const width = b.maxY - b.minY;
|
||||
if (length < s.minLength - EPSILON || width < s.minWidth - EPSILON) return null;
|
||||
if (blankLength(round(length), s.length) > s.length + EPSILON) return null;
|
||||
// An edge on a board-grid line needs its factory end, unless it is the room boundary.
|
||||
const needsLeft = left > shape.bounds.minX + EPSILON && hasVerticalEdge(polygon, left);
|
||||
const needsRight = right < shape.bounds.maxX - EPSILON && hasVerticalEdge(polygon, right);
|
||||
|
|
@ -210,12 +225,12 @@ function allocate(
|
|||
piece: CutPiece,
|
||||
pieceId: string,
|
||||
s: FlooringSettings,
|
||||
): { stockId: string; reused: boolean; crosscut: boolean } {
|
||||
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
|
||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||
let stock: AvailableStock | undefined;
|
||||
for (const candidate of layout.stocks) {
|
||||
if (
|
||||
candidate.remaining < length - EPSILON ||
|
||||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
|
||||
(piece.needsLeft && !candidate.left) ||
|
||||
(piece.needsRight && !candidate.right)
|
||||
)
|
||||
|
|
@ -235,6 +250,7 @@ function allocate(
|
|||
layout.stocks.push(stock);
|
||||
}
|
||||
const reused = stock.pieceIds.length > 0;
|
||||
const reserved = blankLength(length, stock.remaining);
|
||||
const crosscut = stock.remaining - length > EPSILON;
|
||||
stock.pieceIds.push(pieceId);
|
||||
if (!crosscut) {
|
||||
|
|
@ -242,13 +258,13 @@ function allocate(
|
|||
stock.left = false;
|
||||
stock.right = false;
|
||||
} else {
|
||||
stock.remaining = round(Math.max(0, stock.remaining - length - s.kerf));
|
||||
stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf));
|
||||
// Take starters from the right; take finishers from the left. The remainder has one new cut end.
|
||||
if (piece.needsRight) stock.right = false;
|
||||
else stock.left = false;
|
||||
}
|
||||
if (reused) layout.reusedCount++;
|
||||
return { stockId: stock.id, reused, crosscut };
|
||||
return { stockId: stock.id, reused, crosscut, blankLength: reserved };
|
||||
}
|
||||
|
||||
function appendRow(
|
||||
|
|
@ -301,6 +317,8 @@ function appendRow(
|
|||
polygon: piece.polygon.map((p) => worldPoint(p, room.direction)),
|
||||
length,
|
||||
width,
|
||||
blankLength: allocation.blankLength,
|
||||
blankWidth: Math.min(cutLength(width), s.width),
|
||||
stockId: allocation.stockId,
|
||||
reused: allocation.reused,
|
||||
cut: passes > 0,
|
||||
|
|
@ -511,8 +529,11 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
continue;
|
||||
}
|
||||
const roomBounds = bounds(paths.flat());
|
||||
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s,
|
||||
inwardCorners(paths).map(point => point.y - roomBounds.minY));
|
||||
const widthOptions = rowWidthOptions(
|
||||
roomBounds.maxY - roomBounds.minY,
|
||||
s,
|
||||
inwardCorners(paths).map((point) => point.y - roomBounds.minY),
|
||||
);
|
||||
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
|
||||
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
|
||||
issues.push({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ export const mm = (value: number) =>
|
|||
: '—';
|
||||
/** Round cutting marks up without letting floating-point noise add a half millimetre. */
|
||||
export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
|
||||
export const cutMm = (value: number) => mm(cutLength(value));
|
||||
/** An exact-fit stock edge or remnant needs no rounded-up preparation cut. */
|
||||
export const cutMm = (value: number, available = Infinity) =>
|
||||
mm(Math.min(cutLength(value), available));
|
||||
export const area = (value: number) =>
|
||||
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||
value / 1e6,
|
||||
|
|
|
|||
163
src/lib/piece-labels.ts
Normal file
163
src/lib/piece-labels.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import type { FlooringPiece, Point } from './types';
|
||||
import type { LabelBox } from './dimension-labels';
|
||||
|
||||
export interface PieceLabel {
|
||||
id: string;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
angle: number;
|
||||
width: number;
|
||||
height: number;
|
||||
font: number;
|
||||
box: LabelBox;
|
||||
anchor: Point;
|
||||
leader: boolean;
|
||||
}
|
||||
|
||||
const bounds = (points: Point[]): LabelBox => ({
|
||||
minX: Math.min(...points.map((point) => point.x)),
|
||||
maxX: Math.max(...points.map((point) => point.x)),
|
||||
minY: Math.min(...points.map((point) => point.y)),
|
||||
maxY: Math.max(...points.map((point) => point.y)),
|
||||
});
|
||||
const overlaps = (a: LabelBox, b: LabelBox, gap: number) =>
|
||||
a.minX < b.maxX + gap && a.maxX > b.minX - gap && a.minY < b.maxY + gap && a.maxY > b.minY - gap;
|
||||
const corners = (box: LabelBox) => [
|
||||
{ x: box.minX, y: box.minY },
|
||||
{ x: box.maxX, y: box.minY },
|
||||
{ x: box.maxX, y: box.maxY },
|
||||
{ x: box.minX, y: box.maxY },
|
||||
];
|
||||
const inside = (point: Point, polygon: Point[]) => {
|
||||
let result = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const a = polygon[i],
|
||||
b = polygon[j];
|
||||
if (
|
||||
a.y > point.y !== b.y > point.y &&
|
||||
point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x
|
||||
)
|
||||
result = !result;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** A midpoint of an interior scanline stays on the piece, including concave cut shapes. */
|
||||
function anchorPoint(polygon: Point[], box: LabelBox): Point {
|
||||
const y = (box.minY + box.maxY) / 2;
|
||||
const xs: number[] = [];
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
const a = polygon[i],
|
||||
b = polygon[(i + 1) % polygon.length];
|
||||
if (a.y > y !== b.y > y) xs.push(a.x + ((y - a.y) * (b.x - a.x)) / (b.y - a.y));
|
||||
}
|
||||
xs.sort((a, b) => a - b);
|
||||
let widest = -1,
|
||||
x = (box.minX + box.maxX) / 2;
|
||||
for (let i = 0; i + 1 < xs.length; i += 2) {
|
||||
if (xs[i + 1] - xs[i] > widest) {
|
||||
widest = xs[i + 1] - xs[i];
|
||||
x = (xs[i] + xs[i + 1]) / 2;
|
||||
}
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Short pieces borrow nearby empty label space; a pointer keeps their exact location clear. */
|
||||
export function floorPieceLabels(
|
||||
pieces: FlooringPiece[],
|
||||
room: Point[],
|
||||
direction: 0 | 90,
|
||||
font: number,
|
||||
reserved: LabelBox[] = [],
|
||||
completedBoards: string[] = [],
|
||||
): PieceLabel[] {
|
||||
const occupied = [...reserved];
|
||||
const roomBox = bounds(room.length ? room : [{ x: 0, y: 0 }]);
|
||||
const result: PieceLabel[] = [];
|
||||
const cuts = pieces.filter((piece) => (piece.cut || piece.reused) && piece.polygon.length >= 3);
|
||||
for (const piece of cuts) {
|
||||
const box = bounds(piece.polygon);
|
||||
const anchor = anchorPoint(piece.polygon, box);
|
||||
const text = piece.id + (completedBoards.includes(`floor:${piece.stockId}`) ? ' ✓' : '');
|
||||
const textSize = font * 0.76;
|
||||
const width = Math.max(font * 1.8, text.length * textSize * 0.64 + font * 0.55);
|
||||
const height = font * 1.15;
|
||||
const halfX = (direction === 90 ? height : width) / 2;
|
||||
const halfY = (direction === 90 ? width : height) / 2;
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
const preferred = {
|
||||
x: clamp(anchor.x, roomBox.minX + halfX + font * 0.15, roomBox.maxX - halfX - font * 0.15),
|
||||
y: clamp(anchor.y, roomBox.minY + halfY + font * 0.15, roomBox.maxY - halfY - font * 0.15),
|
||||
};
|
||||
const candidate = (x: number, y: number) => {
|
||||
const labelBox = { minX: x - halfX, maxX: x + halfX, minY: y - halfY, maxY: y + halfY };
|
||||
const inPiece = corners(labelBox).every((point) => inside(point, piece.polygon));
|
||||
const inRoom = corners(labelBox).every((point) => inside(point, room));
|
||||
const coversAnchor =
|
||||
anchor.x >= labelBox.minX &&
|
||||
anchor.x <= labelBox.maxX &&
|
||||
anchor.y >= labelBox.minY &&
|
||||
anchor.y <= labelBox.maxY;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
box: labelBox,
|
||||
inPiece,
|
||||
score:
|
||||
Math.hypot(x - anchor.x, y - anchor.y) +
|
||||
(inRoom ? 0 : font * 30) +
|
||||
(!inPiece && coversAnchor ? font * 40 : 0),
|
||||
};
|
||||
};
|
||||
const candidates = [candidate(anchor.x, anchor.y), candidate(preferred.x, preferred.y)];
|
||||
for (const sign of [-1, 1]) {
|
||||
candidates.push(
|
||||
candidate(
|
||||
direction === 0 ? anchor.x + sign * (halfX + font * 0.4) : preferred.x,
|
||||
direction === 90 ? anchor.y + sign * (halfY + font * 0.4) : preferred.y,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (let across = -3; across <= 3; across++) {
|
||||
for (let along = -4; along <= 4; along++) {
|
||||
const alongStep = width + font * 0.2;
|
||||
const acrossStep = Math.max(
|
||||
height + font * 0.2,
|
||||
direction === 90 ? box.maxX - box.minX : box.maxY - box.minY,
|
||||
);
|
||||
candidates.push(
|
||||
candidate(
|
||||
preferred.x + (direction === 90 ? across * acrossStep : along * alongStep),
|
||||
preferred.y + (direction === 90 ? along * alongStep : across * acrossStep),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
let placed = candidates.find(
|
||||
(item) => !occupied.some((other) => overlaps(item.box, other, font * 0.12)),
|
||||
);
|
||||
// Very crowded outlines get a clear outside label; its bounds expand the SVG viewport.
|
||||
for (let lane = 1; !placed; lane++) {
|
||||
const item = candidate(roomBox.maxX + halfX + lane * (width + font), anchor.y);
|
||||
if (!occupied.some((other) => overlaps(item.box, other, font * 0.12))) placed = item;
|
||||
}
|
||||
occupied.push(placed.box);
|
||||
result.push({
|
||||
id: piece.id,
|
||||
text,
|
||||
x: placed.x,
|
||||
y: placed.y,
|
||||
angle: direction,
|
||||
width,
|
||||
height,
|
||||
font: textSize,
|
||||
box: placed.box,
|
||||
anchor,
|
||||
leader: !placed.inPiece || Math.hypot(placed.x - anchor.x, placed.y - anchor.y) > font * 0.1,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Point, SkirtingPiece } from './types';
|
||||
|
||||
/** Place the cutting template against its wall, preserving mitres and butt setbacks. */
|
||||
/** Place the cutting template against its wall, preserving mitres and the coped body setbacks. */
|
||||
export function skirtingPolygon(
|
||||
piece: SkirtingPiece,
|
||||
thickness: number,
|
||||
|
|
@ -21,7 +21,8 @@ export function skirtingPolygon(
|
|||
{ x: backLength, y: thickness },
|
||||
{ x: 0, y: thickness },
|
||||
];
|
||||
return template.map((point) => ({
|
||||
const installed = piece.installedTemplate?.length ? piece.installedTemplate : template;
|
||||
return installed.map((point) => ({
|
||||
x: piece.start.x + tangent.x * (point.x - template[0].x) + inward.x * point.y,
|
||||
y: piece.start.y + tangent.y * (point.x - template[0].x) + inward.y * point.y,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ type End = {
|
|||
bevel: number;
|
||||
back: number;
|
||||
front: number;
|
||||
installedBack: number;
|
||||
installedFront: number;
|
||||
visible: number;
|
||||
};
|
||||
type Run = {
|
||||
roomId: string;
|
||||
|
|
@ -31,7 +34,18 @@ type Run = {
|
|||
right: End;
|
||||
thickness: number;
|
||||
};
|
||||
const square = (): End => ({ type: 'square', angle: 0, bevel: 0, back: 0, front: 0 });
|
||||
const square = (): End => ({
|
||||
type: 'square',
|
||||
angle: 0,
|
||||
bevel: 0,
|
||||
back: 0,
|
||||
front: 0,
|
||||
installedBack: 0,
|
||||
installedFront: 0,
|
||||
visible: 0,
|
||||
});
|
||||
const firstAllowance = (end: End) => Math.max(end.installedBack, end.installedFront, end.visible);
|
||||
const lastAllowance = (end: End) => Math.min(end.installedBack, end.installedFront, end.visible);
|
||||
|
||||
function roomPrefix(index: number): string {
|
||||
let value = index + 1,
|
||||
|
|
@ -52,39 +66,64 @@ function pointAt(edge: WallEdge, offset: number): Point {
|
|||
};
|
||||
}
|
||||
|
||||
/** At internal corners the previous wall runs through; the next butts against its face. */
|
||||
function corner(turn: number, thickness: number, side: 'left' | 'right'): End {
|
||||
if (Math.abs(turn) < EPS) return square();
|
||||
/**
|
||||
* Copes retain the inside-mitre long point at the wall corner. The moulding's
|
||||
* actual profile is then scribed and back-cut to fit the receiving piece.
|
||||
* https://www.finehomebuilding.com/pdf/021174063.pdf (Gary Katz): inside cuts use the back
|
||||
* long point and fit the receiving end first. For a turn t, intersecting faces
|
||||
* meet thickness*tan(t/2) from the corner; the full-depth body starts at thickness*csc(t).
|
||||
*/
|
||||
function corner(turn: number, thickness: number, side: 'left' | 'right', cope: boolean): End {
|
||||
if (Math.abs(turn) < EPS || thickness < EPS) return square();
|
||||
const sign = side === 'left' ? 1 : -1;
|
||||
if (turn < 0) {
|
||||
const extension = thickness * Math.tan(rad(-turn / 2));
|
||||
const front = sign * thickness * Math.tan(rad(turn / 2));
|
||||
return {
|
||||
type: 'external',
|
||||
angle: -turn / 2,
|
||||
bevel: side === 'left' ? turn / 2 : -turn / 2,
|
||||
bevel: (sign * turn) / 2,
|
||||
back: 0,
|
||||
front: side === 'left' ? -extension : extension,
|
||||
front,
|
||||
installedBack: 0,
|
||||
installedFront: front,
|
||||
visible: front,
|
||||
};
|
||||
}
|
||||
const angle = Math.abs(90 - turn);
|
||||
return side === 'left'
|
||||
? {
|
||||
type: 'internal',
|
||||
angle,
|
||||
bevel: turn - 90,
|
||||
back: thickness / Math.sin(rad(turn)),
|
||||
front: thickness * Math.tan(rad(turn / 2)),
|
||||
}
|
||||
: {
|
||||
type: 'internal',
|
||||
angle,
|
||||
bevel: 90 - turn,
|
||||
back: 0,
|
||||
front: thickness / Math.tan(rad(turn)),
|
||||
};
|
||||
const faceJoint = sign * thickness * Math.tan(rad(turn / 2));
|
||||
if (cope) {
|
||||
return {
|
||||
type: 'cope',
|
||||
angle: turn / 2,
|
||||
bevel: (sign * turn) / 2,
|
||||
back: 0,
|
||||
front: faceJoint,
|
||||
installedBack: (sign * thickness) / Math.sin(rad(turn)),
|
||||
installedFront: faceJoint,
|
||||
visible: faceJoint,
|
||||
};
|
||||
}
|
||||
// A square receiving end fits a right-angle wall; an oblique wall needs a
|
||||
// wall-fitting bevel. Its face extends past the visible joint under the cope.
|
||||
const front = (-sign * thickness) / Math.tan(rad(turn));
|
||||
return {
|
||||
type: 'internal',
|
||||
angle: Math.abs(90 - turn),
|
||||
bevel: sign * (turn - 90),
|
||||
back: 0,
|
||||
front,
|
||||
installedBack: 0,
|
||||
installedFront: front,
|
||||
visible: faceJoint,
|
||||
};
|
||||
}
|
||||
|
||||
function endCuts(piece: SkirtingPiece) {
|
||||
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
||||
return (
|
||||
Number(Math.abs(piece.leftMitre) > EPS) +
|
||||
Number(Math.abs(piece.rightMitre) > EPS) +
|
||||
Number(piece.leftType === 'cope') +
|
||||
Number(piece.rightType === 'cope')
|
||||
);
|
||||
}
|
||||
|
||||
function blankLength(piece: SkirtingPiece, available: number) {
|
||||
|
|
@ -92,7 +131,7 @@ function blankLength(piece: SkirtingPiece, available: number) {
|
|||
}
|
||||
|
||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||
return endCuts(piece) + Number(Math.abs(available - blankLength(piece, available)) > EPS);
|
||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||
}
|
||||
|
||||
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
||||
|
|
@ -130,6 +169,15 @@ function makePiece(
|
|||
leftType: left.type,
|
||||
rightType: right.type,
|
||||
stockId: '',
|
||||
visibleLength: to + right.visible - from - left.visible,
|
||||
leftOverlap: Math.abs(left.visible - (left.type === 'cope' ? left.back : left.front)),
|
||||
rightOverlap: Math.abs(right.visible - (right.type === 'cope' ? right.back : right.front)),
|
||||
installedTemplate: [
|
||||
{ x: from + left.installedBack - origin, y: 0 },
|
||||
{ x: to + right.installedBack - origin, y: 0 },
|
||||
{ x: to + right.installedFront - origin, y: run.thickness },
|
||||
{ x: from + left.installedFront - origin, y: run.thickness },
|
||||
],
|
||||
leftBevel: left.bevel,
|
||||
rightBevel: right.bevel,
|
||||
template: [
|
||||
|
|
@ -142,7 +190,12 @@ function makePiece(
|
|||
}
|
||||
|
||||
function shortestEdge(piece: SkirtingPiece) {
|
||||
return Math.min(piece.backLength, piece.template![2].x - piece.template![3].x);
|
||||
const installed = piece.installedTemplate!;
|
||||
return Math.min(
|
||||
installed[1].x - installed[0].x,
|
||||
installed[2].x - installed[3].x,
|
||||
piece.visibleLength!,
|
||||
);
|
||||
}
|
||||
|
||||
function fits(piece: SkirtingPiece, available: number) {
|
||||
|
|
@ -156,15 +209,12 @@ function splitCount(run: Run, target: number, minimum: number): number {
|
|||
if (shortestEdge(full) < minimum - EPS) return 0;
|
||||
if (fits(full, target)) return 1;
|
||||
const count = Math.max(2, Math.ceil((full.length - EPS) / target));
|
||||
const minimumSpan =
|
||||
count * minimum +
|
||||
Math.max(run.left.back, run.left.front) -
|
||||
Math.min(run.right.back, run.right.front);
|
||||
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
|
||||
if (
|
||||
count > 10000 ||
|
||||
run.to - run.from < minimumSpan - EPS ||
|
||||
Math.abs(run.left.back - run.left.front) > target - minimum + EPS ||
|
||||
Math.abs(run.right.back - run.right.front) > target - minimum + EPS
|
||||
firstAllowance(run.left) - Math.min(run.left.back, run.left.front) > target - minimum + EPS ||
|
||||
Math.max(run.right.back, run.right.front) - lastAllowance(run.right) > target - minimum + EPS
|
||||
)
|
||||
return 0;
|
||||
return count;
|
||||
|
|
@ -183,13 +233,13 @@ function splitRun(
|
|||
|
||||
const span = run.to - run.from;
|
||||
// A piece's shortest edge sets its minimum span. Its longest point sets
|
||||
// its maximum span. These differ at angled corners, even for butt joints.
|
||||
// its maximum span. These differ at angled corners, at receiving and coped corners.
|
||||
const minimums = Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
settings.minLength +
|
||||
(index === 0 ? Math.max(run.left.back, run.left.front) : 0) -
|
||||
(index === count - 1 ? Math.min(run.right.back, run.right.front) : 0),
|
||||
(index === 0 ? firstAllowance(run.left) : 0) -
|
||||
(index === count - 1 ? lastAllowance(run.right) : 0),
|
||||
);
|
||||
const maximums = Array.from(
|
||||
{ length: count },
|
||||
|
|
@ -223,12 +273,17 @@ function splitRun(
|
|||
}
|
||||
|
||||
let from = run.from;
|
||||
return spans.map((span, index) => {
|
||||
const pieces = spans.map((span, index) => {
|
||||
const to = index === count - 1 ? run.to : from + span;
|
||||
const piece = makePiece(run, from, to, index === 0, index === count - 1);
|
||||
from = to;
|
||||
return piece;
|
||||
});
|
||||
return pieces.every(
|
||||
(piece) => fits(piece, target) && shortestEdge(piece) >= settings.minLength - EPS,
|
||||
)
|
||||
? pieces
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Let useful tails set the next joint instead of fixing every wall's joints first. */
|
||||
|
|
@ -250,7 +305,7 @@ function splitWithOffcuts(
|
|||
if (fits(full, available)) return { piece: full, to: run.to };
|
||||
const to = Math.min(
|
||||
from + available + Math.min(remaining.left.back, remaining.left.front),
|
||||
run.to + Math.min(run.right.back, run.right.front) - settings.minLength,
|
||||
run.to + lastAllowance(run.right) - settings.minLength,
|
||||
);
|
||||
if (to <= from + EPS) return null;
|
||||
const piece = makePiece(remaining, from, to, true, false);
|
||||
|
|
@ -294,8 +349,8 @@ interface Packed {
|
|||
waste: number;
|
||||
}
|
||||
|
||||
/** Best-fit decreasing with one-stock lookahead across all rooms and available stock sizes. */
|
||||
function pack(source: SkirtingPiece[], stockLengths: number[], kerf: number): Packed {
|
||||
/** Best-fit decreasing across all rooms, using one purchased board length. */
|
||||
function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packed {
|
||||
const pieces = source.map((piece) => ({ ...piece }));
|
||||
let pending = [...pieces].sort(
|
||||
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
|
|
@ -303,47 +358,28 @@ function pack(source: SkirtingPiece[], stockLengths: number[], kerf: number): Pa
|
|||
const stocks: SkirtingStock[] = [];
|
||||
let cuts = 0;
|
||||
while (pending.length) {
|
||||
const options = stockLengths
|
||||
.filter((length) => fits(pending[0], length))
|
||||
.map((length) => {
|
||||
let available = length,
|
||||
optionCuts = 0;
|
||||
const assigned: SkirtingPiece[] = [];
|
||||
for (const piece of pending) {
|
||||
if (fits(piece, available)) {
|
||||
const pieceCuts = requiredCuts(piece, available);
|
||||
available -= consumedLength(piece, available, kerf);
|
||||
optionCuts += pieceCuts;
|
||||
assigned.push(piece);
|
||||
}
|
||||
}
|
||||
const used = assigned.reduce((sum, piece) => sum + piece.length, 0);
|
||||
return { length, assigned, used, cuts: optionCuts, waste: length - used };
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.waste / a.used - b.waste / b.used ||
|
||||
a.waste - b.waste ||
|
||||
a.cuts - b.cuts ||
|
||||
a.length - b.length,
|
||||
);
|
||||
const best = options[0];
|
||||
// Candidates are pre-checked against a stock length, so this is an invariant.
|
||||
if (!best) throw new Error('A skirting piece does not fit the available stock.');
|
||||
let available = stockLength;
|
||||
const assigned: SkirtingPiece[] = [];
|
||||
for (const piece of pending) {
|
||||
if (!fits(piece, available)) continue;
|
||||
piece.blankLength = blankLength(piece, available);
|
||||
cuts += requiredCuts(piece, available);
|
||||
available -= consumedLength(piece, available, kerf);
|
||||
assigned.push(piece);
|
||||
}
|
||||
if (!assigned.length) throw new Error('A skirting piece does not fit the available stock.');
|
||||
const id = `S${stocks.length + 1}`;
|
||||
best.assigned.forEach((piece) => {
|
||||
piece.stockId = id;
|
||||
});
|
||||
for (const piece of assigned) piece.stockId = id;
|
||||
const used = assigned.reduce((sum, piece) => sum + piece.length, 0);
|
||||
stocks.push({
|
||||
id,
|
||||
length: best.length,
|
||||
used: best.used,
|
||||
waste: Math.max(0, best.waste),
|
||||
pieceIds: best.assigned.map((piece) => piece.id),
|
||||
length: stockLength,
|
||||
used,
|
||||
waste: Math.max(0, stockLength - used),
|
||||
pieceIds: assigned.map((piece) => piece.id),
|
||||
});
|
||||
const assignedIds = new Set(best.assigned.map((piece) => piece.id));
|
||||
const assignedIds = new Set(assigned.map((piece) => piece.id));
|
||||
pending = pending.filter((piece) => !assignedIds.has(piece.id));
|
||||
cuts += best.cuts;
|
||||
}
|
||||
return { pieces, stocks, cuts, waste: stocks.reduce((sum, stock) => sum + stock.waste, 0) };
|
||||
}
|
||||
|
|
@ -363,10 +399,9 @@ export function planSkirting(
|
|||
issues,
|
||||
});
|
||||
if (
|
||||
!settings.stockLengths.length ||
|
||||
settings.stockLengths.some(
|
||||
(length) => !Number.isFinite(length) || length < 0.1 || length > 1e6,
|
||||
) ||
|
||||
!Number.isFinite(settings.stockLength) ||
|
||||
settings.stockLength < 0.1 ||
|
||||
settings.stockLength > 1e6 ||
|
||||
!Number.isFinite(settings.thickness) ||
|
||||
settings.thickness < 0 ||
|
||||
settings.thickness > 10000 ||
|
||||
|
|
@ -384,8 +419,9 @@ export function planSkirting(
|
|||
});
|
||||
return empty();
|
||||
}
|
||||
const stockLengths = [...new Set(settings.stockLengths)].sort((a, b) => a - b);
|
||||
const stockLength = settings.stockLength;
|
||||
let runs: Run[] = [];
|
||||
const fallbackSplits = new Map<Run, SkirtingPiece[]>();
|
||||
rooms.forEach((room, roomIndex) => {
|
||||
const geometry = buildGeometry(room, tolerance);
|
||||
if (!geometry.valid) {
|
||||
|
|
@ -409,6 +445,7 @@ export function planSkirting(
|
|||
normalizeAngle(edge.heading - previous.heading) * orientation,
|
||||
settings.thickness,
|
||||
'left',
|
||||
index > previous.index,
|
||||
)
|
||||
: square();
|
||||
const right = nextHasStart
|
||||
|
|
@ -416,6 +453,7 @@ export function planSkirting(
|
|||
normalizeAngle(next.heading - edge.heading) * orientation,
|
||||
settings.thickness,
|
||||
'right',
|
||||
index > next.index,
|
||||
)
|
||||
: square();
|
||||
const doors = [...edge.doors].sort((a, b) => a.offset - b.offset);
|
||||
|
|
@ -441,12 +479,20 @@ export function planSkirting(
|
|||
roomId: room.id,
|
||||
message: `${room.name}, wall ${index + 1}: a skirting run after corner allowances is shorter than the ${settings.minLength} mm minimum. Change the minimum or opening measurements.`,
|
||||
});
|
||||
} else if (!splitCount(run, stockLengths.at(-1)!, settings.minLength)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
roomId: room.id,
|
||||
message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`,
|
||||
});
|
||||
} else {
|
||||
// Establish a feasible rounded split per run before combining rooms.
|
||||
// A fractional stock length may need offcut-led extra joints.
|
||||
const split =
|
||||
splitRun(run, stockLength, settings, false) ??
|
||||
splitRun(run, stockLength, settings, true) ??
|
||||
splitWithOffcuts([run], stockLength, settings);
|
||||
if (split) fallbackSplits.set(run, split);
|
||||
else
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
roomId: room.id,
|
||||
message: `${room.name}, wall ${index + 1}: the stock lengths cannot fit the rounded minimum skirting pieces and corner allowances. Increase stock length or reduce the minimum.`,
|
||||
});
|
||||
}
|
||||
runs.push(run);
|
||||
}
|
||||
|
|
@ -464,19 +510,30 @@ export function planSkirting(
|
|||
const candidates: Packed[] = [];
|
||||
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
|
||||
const addCandidate = (source: SkirtingPiece[]) => {
|
||||
// Raw spans can fit while their displayed half-millimetre blanks do not.
|
||||
// Reject this split without losing other feasible joint arrangements.
|
||||
if (
|
||||
source.some(
|
||||
(piece) => !fits(piece, stockLength) || shortestEdge(piece) < settings.minLength - EPS,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const counts = new Map<string, number>();
|
||||
for (const piece of source) {
|
||||
const count = (counts.get(piece.roomId) ?? 0) + 1;
|
||||
counts.set(piece.roomId, count);
|
||||
piece.id = `${prefixes.get(piece.roomId)}${count}`;
|
||||
}
|
||||
candidates.push(pack(source, stockLengths, settings.kerf));
|
||||
candidates.push(pack(source, stockLength, settings.kerf));
|
||||
};
|
||||
// Compare full-stock, balanced, and offcut-led joints for each stock length.
|
||||
for (const target of stockLengths) {
|
||||
// Compare full-stock, balanced, and offcut-led wall joints.
|
||||
{
|
||||
const target = stockLength;
|
||||
for (const balanced of [false, true]) {
|
||||
const splits = runs.map((run) => splitRun(run, target, settings, balanced));
|
||||
if (splits.every((split) => split !== null)) addCandidate(splits.flat());
|
||||
const splits = runs.map(
|
||||
(run) => splitRun(run, target, settings, balanced) ?? fallbackSplits.get(run)!,
|
||||
);
|
||||
addCandidate(splits.flat());
|
||||
}
|
||||
const reused = splitWithOffcuts(runs, target, settings);
|
||||
if (reused) addCandidate(reused);
|
||||
|
|
@ -500,12 +557,12 @@ export function planSkirting(
|
|||
issues.push({
|
||||
severity: 'info',
|
||||
message:
|
||||
'Lengths are longest-point cuts; back lengths locate pieces on walls. Minimum length applies to both front and back edges. Internal corners use butt joints: the previous wall runs through and the next is shortened by the skirting thickness (adjusted for angled walls). Coping is excluded. External corners use equal mitres. Templates show the back at y=0 and the front at y=thickness, with x following the wall direction. Positive bevel means the front cut point is farther right than the back; use the template to orient the cut for your saw setup. Door widths include any trim to omit.',
|
||||
'Lengths are longest-point preparation blanks, including the material retained for internal coping. The earlier wall receives each cope; install it first. A cope uses an inside mitre to expose the profile, then a scribed back-cut. The actual decorative profile must be marked from your moulding. Templates show preparation cuts; the plan shows the full-thickness installed body. Minimum length applies to the exposed face and installed body. External corners use equal mitres. Door widths include any trim to omit.',
|
||||
});
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
message:
|
||||
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, then prefers less waste and fewer cuts; it is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then bevel their ends. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
|
||||
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, then prefers less waste and fewer cuts; it is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
|
||||
});
|
||||
const plan = { ...best, stockCount: best.stocks.length, issues };
|
||||
const cutNumbers = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface FlooringSettings {
|
|||
kerf: number;
|
||||
}
|
||||
export interface SkirtingSettings {
|
||||
stockLengths: number[];
|
||||
stockLength: number;
|
||||
thickness: number;
|
||||
minLength: number;
|
||||
kerf: number;
|
||||
|
|
@ -96,6 +96,9 @@ export interface FlooringPiece {
|
|||
polygon: Point[];
|
||||
length: number;
|
||||
width: number;
|
||||
/** Preparation dimensions reserved from stock, including exact-fit remnants. */
|
||||
blankLength?: number;
|
||||
blankWidth?: number;
|
||||
stockId: string;
|
||||
reused: boolean;
|
||||
cut: boolean;
|
||||
|
|
@ -139,14 +142,23 @@ export interface SkirtingPiece {
|
|||
start: Point;
|
||||
end: Point;
|
||||
length: number;
|
||||
/** Preparation length reserved from stock; a whole fractional length stays exact. */
|
||||
blankLength?: number;
|
||||
backLength: number;
|
||||
leftMitre: number;
|
||||
rightMitre: number;
|
||||
leftType: 'square' | 'external' | 'internal';
|
||||
rightType: 'square' | 'external' | 'internal';
|
||||
leftType: 'square' | 'external' | 'internal' | 'cope';
|
||||
rightType: 'square' | 'external' | 'internal' | 'cope';
|
||||
stockId: string;
|
||||
/** Plan section: x follows installation direction, back y=0, front y=thickness. */
|
||||
template?: Point[];
|
||||
/** Full-thickness installed footprint after coping, separate from the cutting blank. */
|
||||
installedTemplate?: Point[];
|
||||
/** Exposed front run used to enforce the minimum installed piece length. */
|
||||
visibleLength?: number;
|
||||
/** Extra blank length retained at each end for a coped internal joint. */
|
||||
leftOverlap?: number;
|
||||
rightOverlap?: number;
|
||||
/** Positive means the front cut point is farther right than the back point. */
|
||||
leftBevel?: number;
|
||||
rightBevel?: number;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const URL_LIMITS = {
|
|||
rooms: 20,
|
||||
walls: 100,
|
||||
doors: 30,
|
||||
stockLengths: 30,
|
||||
legacyStockLengths: 30,
|
||||
identifierLength: 120,
|
||||
nameLength: 200,
|
||||
completedBoards: 12_000,
|
||||
|
|
@ -155,15 +155,28 @@ function flooring(value: unknown): FlooringSettings {
|
|||
};
|
||||
}
|
||||
|
||||
function skirting(value: unknown): SkirtingSettings {
|
||||
function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculations: boolean } {
|
||||
const data = object(value, 'Skirting settings');
|
||||
// Earlier links allowed several purchased lengths. The first remains the chosen size.
|
||||
const legacyLengths =
|
||||
data.stockLength === undefined
|
||||
? array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.legacyStockLengths).map(
|
||||
(value) => number(value, 'Skirting stock length'),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
stockLengths: array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.stockLengths).map(
|
||||
(value) => number(value, 'Skirting stock length'),
|
||||
),
|
||||
thickness: number(data.thickness, 'Skirting thickness'),
|
||||
minLength: number(data.minLength, 'Minimum skirting length'),
|
||||
kerf: number(data.kerf, 'Skirting saw kerf'),
|
||||
settings: {
|
||||
stockLength:
|
||||
data.stockLength === undefined
|
||||
? (legacyLengths[0] ?? 0)
|
||||
: number(data.stockLength, 'Skirting board length'),
|
||||
thickness: number(data.thickness, 'Skirting thickness'),
|
||||
minLength: number(data.minLength, 'Minimum skirting length'),
|
||||
kerf: number(data.kerf, 'Skirting saw kerf'),
|
||||
},
|
||||
// This format predates coping blanks and rounded stock allocation for both
|
||||
// materials. Even a single stock size can now assign different cuts to an ID.
|
||||
legacyCalculations: data.stockLength === undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -183,15 +196,17 @@ function parseState(value: unknown): UrlState {
|
|||
if (rooms.length === 0) throw new Error('A project needs at least one room.');
|
||||
const view = object(data.view, 'View settings');
|
||||
const roomId = string(view.roomId, 'Selected room');
|
||||
const skirt = skirting(project.skirting);
|
||||
const completed = completedBoards(project.completedBoards);
|
||||
return {
|
||||
project: {
|
||||
version: 1,
|
||||
name: string(project.name, 'Project name', URL_LIMITS.nameLength),
|
||||
rooms,
|
||||
flooring: flooring(project.flooring),
|
||||
skirting: skirting(project.skirting),
|
||||
skirting: skirt.settings,
|
||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||
completedBoards: completedBoards(project.completedBoards),
|
||||
completedBoards: skirt.legacyCalculations ? [] : completed,
|
||||
},
|
||||
view: {
|
||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||
|
|
@ -216,9 +231,7 @@ export function encodeState(state: UrlState): string {
|
|||
put('name', project.name);
|
||||
put('tolerance', project.tolerance);
|
||||
for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value);
|
||||
// An empty value explicitly represents an empty stock list during editing.
|
||||
if (!project.skirting.stockLengths.length) put('skirting.stock', '');
|
||||
for (const length of project.skirting.stockLengths) put('skirting.stock', length);
|
||||
put('skirting.length', project.skirting.stockLength);
|
||||
put('skirting.thickness', project.skirting.thickness);
|
||||
put('skirting.minLength', project.skirting.minLength);
|
||||
put('skirting.kerf', project.skirting.kerf);
|
||||
|
|
@ -325,14 +338,19 @@ function parseQuery(query: string): UrlState {
|
|||
})
|
||||
: defaults.project.rooms;
|
||||
const stockValues = params.getAll('skirting.stock');
|
||||
if (stockValues.length > URL_LIMITS.stockLengths)
|
||||
throw new Error(`Skirting stock lengths supports at most ${URL_LIMITS.stockLengths} entries.`);
|
||||
const stockLengths =
|
||||
stockValues.length === 1 && stockValues[0] === ''
|
||||
? []
|
||||
: stockValues.length
|
||||
? stockValues.map((value) => measurement(value, 'skirting.stock'))
|
||||
: defaults.project.skirting.stockLengths;
|
||||
if (stockValues.length > URL_LIMITS.legacyStockLengths)
|
||||
throw new Error(
|
||||
`Skirting stock lengths supports at most ${URL_LIMITS.legacyStockLengths} entries.`,
|
||||
);
|
||||
const stock = params.has('skirting.length')
|
||||
? { stockLength: numeric('skirting.length') }
|
||||
: {
|
||||
stockLengths: !stockValues.length
|
||||
? [defaults.project.skirting.stockLength]
|
||||
: stockValues.length === 1 && stockValues[0] === ''
|
||||
? []
|
||||
: stockValues.map((value) => measurement(value, 'skirting.stock')),
|
||||
};
|
||||
const floor = defaults.project.flooring;
|
||||
return parseState({
|
||||
project: {
|
||||
|
|
@ -350,7 +368,7 @@ function parseQuery(query: string): UrlState {
|
|||
]),
|
||||
),
|
||||
skirting: {
|
||||
stockLengths,
|
||||
...stock,
|
||||
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
|
||||
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
|
||||
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),
|
||||
|
|
|
|||
164
src/styles.css
164
src/styles.css
|
|
@ -557,41 +557,46 @@ summary span {
|
|||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stock-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
.stock-input .field {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.stock-input + .stock-input {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.stock-input + .text-button {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.drawing-card {
|
||||
border: 1px solid var(--ink);
|
||||
background: white;
|
||||
}
|
||||
.drawing-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 17px 20px 12px;
|
||||
border-top: 4px solid var(--ink);
|
||||
gap: 8px 16px;
|
||||
padding: 10px 14px;
|
||||
border-top: 3px solid var(--ink);
|
||||
}
|
||||
.drawing-header h2 {
|
||||
font-size: 17px;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drawing-header h2 small {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
font: 11px var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.drawing-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.floor-code {
|
||||
cursor: pointer;
|
||||
}
|
||||
.floor-code:hover rect {
|
||||
fill: #17263f;
|
||||
}
|
||||
.floor-code.is-highlighted rect {
|
||||
stroke: #17263f;
|
||||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
|
|
@ -603,12 +608,6 @@ summary span {
|
|||
.badge.pending {
|
||||
color: #a13e00;
|
||||
}
|
||||
.drawing-legend {
|
||||
display: flex;
|
||||
gap: 25px;
|
||||
padding: 0 20px 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.drawing-legend > span {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
|
|
@ -953,23 +952,9 @@ summary span {
|
|||
.tooltip-content {
|
||||
right: 0;
|
||||
}
|
||||
.drawing-header {
|
||||
padding: 14px 13px 12px;
|
||||
}
|
||||
.drawing-header h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.drawing-header h2 small {
|
||||
font-size: 10px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
}
|
||||
.drawing-legend {
|
||||
padding: 0 13px 13px;
|
||||
gap: 23px;
|
||||
}
|
||||
.purchase-summary h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
|
@ -1246,11 +1231,6 @@ summary span {
|
|||
.cut-preview-code > span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.drawing-legend {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.uncut-key {
|
||||
width: 17px;
|
||||
height: 10px;
|
||||
|
|
@ -1258,11 +1238,6 @@ summary span {
|
|||
background: #deddd8;
|
||||
border: 1px solid #79756b;
|
||||
}
|
||||
.drawing-help {
|
||||
font-size: 11px;
|
||||
padding: 0 20px 12px;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.svg-cut:hover > polygon,
|
||||
.svg-cut:focus-visible > polygon {
|
||||
stroke: #17263f;
|
||||
|
|
@ -1272,6 +1247,9 @@ summary span {
|
|||
.skirting-code {
|
||||
cursor: pointer;
|
||||
}
|
||||
.skirting-code:hover rect {
|
||||
fill: #17263f;
|
||||
}
|
||||
.skirt-cut:hover .skirting-code rect,
|
||||
.skirt-cut:focus-visible .skirting-code rect {
|
||||
fill: #17263f;
|
||||
|
|
@ -1524,26 +1502,49 @@ summary span {
|
|||
background: #fffdf7;
|
||||
}
|
||||
.drawing-column .drawing-viewport {
|
||||
height: clamp(180px, calc(100dvh - 330px), 780px);
|
||||
height: clamp(180px, calc(100dvh - 260px), 780px);
|
||||
}
|
||||
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||
height: clamp(160px, calc(100dvh - 390px), 720px);
|
||||
height: clamp(160px, calc(100dvh - 335px), 720px);
|
||||
}
|
||||
.drawing-column .room-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.drawing-column .drawing-header h2 small {
|
||||
display: block;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.plan-search-status {
|
||||
padding: 10px 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--ink);
|
||||
border-top: 0;
|
||||
background: white;
|
||||
border-bottom: 0;
|
||||
background: #ffe6a3;
|
||||
color: #17263f;
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-mode-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.search-mode-text {
|
||||
min-width: 0;
|
||||
}
|
||||
.search-mode-text strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-mode-text p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.clear-plan-search {
|
||||
flex-shrink: 0;
|
||||
border-color: #17263f;
|
||||
font-size: 12px;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
.matching-rooms {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1581,6 +1582,26 @@ summary span {
|
|||
color: #345c55;
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.drawing-header {
|
||||
padding: 8px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
.drawing-header h2 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.drawing-legend {
|
||||
font-size: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
.plan-search-status {
|
||||
padding: 6px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.clear-plan-search {
|
||||
font-size: 11px;
|
||||
padding-inline: 7px;
|
||||
gap: 3px;
|
||||
}
|
||||
.app-main {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
|
@ -1602,29 +1623,6 @@ summary span {
|
|||
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||
height: clamp(150px, 25dvh, 240px);
|
||||
}
|
||||
.drawing-column .drawing-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.drawing-column .drawing-header h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.drawing-column .drawing-header h2 small {
|
||||
display: inline;
|
||||
font-size: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.drawing-column .drawing-legend {
|
||||
padding: 0 12px 7px;
|
||||
font-size: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
.drawing-column .drawing-help {
|
||||
display: none;
|
||||
}
|
||||
.plan-search-status {
|
||||
padding: 7px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.panel-toggle {
|
||||
font-size: 18px;
|
||||
min-height: 50px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue