import type { ReactNode } from 'react'; import { Group, Rect, Line, Text } from 'react-konva'; import { useCanvasColors } from '../utils/canvasThemeColors'; import type { ProjectedOpening, ProjectedElectrical, ProjectedFurniture, } from '../utils/projectionMapping'; import { projectionToPixel } from '../utils/projectionMapping'; import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared'; import { getOutletInvertCoordX, getOutletInvertCoordY } from '../symbols/electrical'; interface ProjectionMeasurementsProps { readonly projectedOpenings: readonly ProjectedOpening[]; readonly projectedElectrical: readonly ProjectedElectrical[]; readonly projectedFurniture?: readonly ProjectedFurniture[]; readonly wallLength: number; readonly wallHeight: number; readonly scale: number; readonly padding: number; readonly outletWidth?: number; readonly outletHeight?: number; /** When false, the wall-level dimensions/labels are skipped (useful when the * measurements layer is toggled off but per-item overlays should still draw). */ readonly showWallDimensions?: boolean; } /** Dimension line with arrows and text. */ function DimensionLine({ x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor, }: { readonly x1: number; readonly y1: number; readonly x2: number; readonly y2: number; readonly label: string; readonly offset: number; readonly horizontal: boolean; readonly lineColor: string; readonly textColor: string; }) { const arrowSize = 4; if (horizontal) { const lineY = y1 + offset; const midX = (x1 + x2) / 2; return ( {/* Extension lines */} {/* Main line */} {/* Arrows */} {/* Label */} ); } // Vertical dimension const lineX = x1 + offset; const midY = (y1 + y2) / 2; return ( ); } function formatM(meters: number): string { return `${meters.toFixed(2)}m`; } /** Render measurement annotations on a wall projection view. */ export function ProjectionMeasurements({ projectedOpenings, projectedElectrical, projectedFurniture = [], wallLength: wallLen, wallHeight, scale, padding, outletWidth = DEFAULT_OUTLET_WIDTH, outletHeight = DEFAULT_OUTLET_HEIGHT, showWallDimensions = true, }: ProjectionMeasurementsProps) { const colors = useCanvasColors(); const elements: ReactNode[] = []; if (showWallDimensions) { // Wall width dimension (along bottom) const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding); const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); elements.push( , ); // Wall height dimension (along right side) const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding); const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); elements.push( , ); } // Opening dimensions: sill height for windows, door height for doors for (const po of projectedOpenings) { const { rect, opening } = po; const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding); const bottomLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding); // Height annotation (vertical, left side of opening) elements.push( , ); // Sill height for windows if (opening.type === 'WINDOW' && rect.y > 0.01) { const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding); elements.push( , ); } // Width annotation (horizontal, above opening) const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding); elements.push( , ); } // Electrical item coordinate labels: (X; Y) near each item. // For OUTLET groups with count > 1, the symbol bounding box extends to // either side of the anchor by `count * outletWidth / 2`. Push the label // past the right edge of the box (plus a small gap) so it doesn't overlap // the outlet face plates. A semi-opaque background pill keeps it readable // even when it sits over a wall stripe or other UI. for (const pe of projectedElectrical) { const center = projectionToPixel( pe.position.alongWall, pe.position.fromFloor, wallHeight, scale, padding, ); // Half-width of the visible symbol along the wall axis, in pixels. let halfWidthPx = 8; // default for non-outlet symbols (~SYMBOL_SIZE/2 + margin) if (pe.item.type === 'OUTLET') { const safeCount = Math.max(1, Math.round(pe.item.count)); halfWidthPx = (safeCount * outletWidth * scale) / 2; } // Outlets can opt into inverted coordinate read-outs per axis — useful // when the user measures distances from the opposite wall edge or from // the ceiling. Display-only: the stored position is untouched. const invertX = pe.item.type === 'OUTLET' && getOutletInvertCoordX(pe.item.metadata); const invertY = pe.item.type === 'OUTLET' && getOutletInvertCoordY(pe.item.metadata); const displayX = invertX ? wallLen - pe.position.alongWall : pe.position.alongWall; const displayY = invertY ? wallHeight - pe.elevation : pe.elevation; const coordLabel = `(${displayX.toFixed(2)}; ${displayY.toFixed(2)})`; const labelX = center.x + halfWidthPx + 6; const labelY = center.y - 6; // Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9. const labelWidth = coordLabel.length * 5.5 + 4; elements.push( , ); } // Opening horizontal position labels for (const po of projectedOpenings) { const { rect, opening } = po; const openingCenter = projectionToPixel( rect.x + rect.width / 2, rect.y + rect.height, wallHeight, scale, padding, ); elements.push( , ); } // Per-furniture dimension overlay (only when toggled on). // Coordinates are only projected onto an axis when the item is *near* that // axis — otherwise the extension lines would sprawl across the whole view // and add more noise than information. const NEAR_AXIS = 0.1; // meters for (const pf of projectedFurniture) { if (!pf.item.showProjection) continue; const { rect, item } = pf; { const isNearFloor = rect.y <= NEAR_AXIS; const isNearLeft = rect.x <= NEAR_AXIS; // ── Horizontal axis (along-wall) projection ── // If the item touches the floor we extend the dimension all the way to // the bottom ruler; otherwise we draw the width dimension just below // the item itself. const wLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding); const wRight = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding); if (isNearFloor) { const wLeftFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding); const wRightFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding); elements.push( , ); if (rect.x > 0.001) { const oLeft = projectionToPixel(0, 0, wallHeight, scale, padding); elements.push( , ); } } else { // Inline width dimension drawn just below the bottom edge of the item elements.push( , ); // Inline start-offset (distance from wall start to the left edge), // shown as a thin extension line + label so the user still sees the // horizontal position when the item is not on the floor. if (rect.x > 0.001) { const oLeft = projectionToPixel(0, rect.y, wallHeight, scale, padding); elements.push( , ); } } // ── Vertical axis (height) projection ── // If the item touches the left edge of the wall we extend to the left // ruler; otherwise we draw the height dimension just to the left of // the item itself. const hBottom = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding); const hTop = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding); if (isNearLeft) { elements.push( , ); if (rect.y > 0.001) { const eFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding); elements.push( , ); } } else { // Inline height dimension drawn just to the left of the item elements.push( , ); // Inline elevation (distance from floor to the bottom of the item), // so wall-mounted items still show their vertical position. if (rect.y > 0.001) { const eFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding); const eBottom = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding); elements.push( , ); } } } } return {elements}; }