import { useId } from 'react'; import type { FlooringPlan, Geometry, PlanMaterial, Point, Room, SkirtingPlan, WallEdge, } from '../lib/types'; import { area, cutMm, mm } from '../lib/format'; 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 { PRECISION } from '../lib/limits'; import type { DimensionLabel, LabelBox } from '../lib/dimension-labels'; const points = (path: Point[]) => path.map((p) => `${p.x},${p.y}`).join(' '); const midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); function Wall({ edge, span, label }: { edge: WallEdge; span: number; label: DimensionLabel }) { const { normal: n, offset } = label; const dx = edge.end.x - edge.start.x, dy = edge.end.y - edge.start.y; const length = Math.hypot(dx, dy) || 1; return ( {[edge.start, edge.end].map((p, i) => ( ))} {label.text} {edge.doors .filter( (d) => d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + PRECISION.clipping, ) .map((door) => { const a = { x: edge.start.x + (dx / length) * door.offset, y: edge.start.y + (dy / length) * door.offset, }; const b = { x: a.x + (dx / length) * door.width, y: a.y + (dy / length) * door.width }; const swing = { x: a.x - n.x * door.width, y: a.y - n.y * door.width }; const clockwise = dx * -n.y - dy * -n.x > 0; return ( Door: {mm(door.width)} mm, {mm(door.offset)} mm from W{edge.index + 1} start ); })} ); } interface Props { room: Room; geometry: Geometry; flooring: FlooringPlan; skirting: SkirtingPlan; selectedPiece: string | null; select?: (id: string) => void; interactive?: boolean; completedBoards?: string[]; highlightedPieces?: string[]; searchActive?: boolean; material?: PlanMaterial; } export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, select, interactive = true, completedBoards = [], highlightedPieces = [], searchActive = false, material, }: Props) { const hatch = useId().replaceAll(':', ''); const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]); const minX = Math.min(0, ...allPoints.map((p) => p.x)), maxX = Math.max(100, ...allPoints.map((p) => p.x)); const minY = Math.min(0, ...allPoints.map((p) => p.y)), maxY = Math.max(100, ...allPoints.map((p) => p.y)); const span = Math.max(maxX - minX, maxY - minY, 1000), font = span * 0.024; const orientation = Math.sign(signedArea(geometry.vertices)) || 1; // Printing includes both materials; the interactive drawing shows the chosen view. const floorPieces = material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id); const skirtPieces = material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id); const skirtLabels = new Map(); 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 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 ( {room.name} {area(geometry.area)} m² {material !== 'skirting' && ( Flooring cuts )} {material !== 'flooring' && ( Skirting cuts )} Uncut {geometry.closed && } {floorPieces.map((piece) => { const cut = piece.cut || piece.reused; const done = completedBoards.includes(`floor:${piece.stockId}`); const highlighted = highlightedPieces.includes(piece.id); const x = (Math.min(...piece.polygon.map((p) => p.x)) + Math.max(...piece.polygon.map((p) => p.x))) / 2; const y = (Math.min(...piece.polygon.map((p) => p.y)) + Math.max(...piece.polygon.map((p) => p.y))) / 2; return ( select?.(piece.id) : undefined} onKeyDown={ cut ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select?.(piece.id); } } : undefined } > {done && !cut && ( ✓ )} {cut && ( <> {piece.id}: {cutMm(piece.length, piece.blankLength)} ×{' '} {cutMm(piece.width, piece.blankWidth)} mm > )} ); })} {geometry.edges.map((edge, i) => ( ))} {skirtPieces.map((piece) => { const cut = isSkirtingCut(piece, skirting); const done = completedBoards.includes(`skirting:${piece.stockId}`); const highlighted = highlightedPieces.includes(piece.id); // Each template uses its true wall-facing and room-facing edges, including mitres. const polygon = skirtingPolygon(piece, orientation); return ( select?.(piece.id) : undefined} onKeyDown={ cut ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select?.(piece.id); } } : undefined } > {cut && ( )} {done && !cut && ( ✓ )} {cut && ( {piece.id}: {cutMm(piece.length, piece.blankLength)} mm · {mm(piece.leftMitre)}° / {mm(piece.rightMitre)}° )} ); })} {!geometry.closed && geometry.edges.length > 1 && ( )} {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 ( select?.(piece.id) : undefined} > {label.text} ); })} {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 ( select?.(label.id) : undefined} > {label.leader && ( <> > )} {label.text} ); })} ); }