This commit is contained in:
Andras Schmelczer 2026-09-11 17:57:03 +01:00
commit 7959fe85cc

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>