/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * SVG measurement overlay visualizations (lines, labels, snap indicators) */ import React, { useMemo } from 'react'; import type { Measurement, SnapVisualization } from '@/store'; import type { MeasurementConstraintEdge } from '@/store/types'; import { SnapType, type SnapTarget } from '@ifc-lite/renderer'; import { formatDistance } from './formatDistance'; export interface MeasurementOverlaysProps { measurements: Measurement[]; pending: { screenX: number; screenY: number } | null; activeMeasurement: { start: { screenX: number; screenY: number; x: number; y: number; z: number }; current: { screenX: number; screenY: number }; distance: number } | null; snapTarget: SnapTarget | null; snapVisualization: SnapVisualization | null; hoverPosition?: { x: number; y: number } | null; projectToScreen?: (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null; constraintEdge?: MeasurementConstraintEdge | null; } export const MeasurementOverlays = React.memo(function MeasurementOverlays({ measurements, pending, activeMeasurement, snapTarget, snapVisualization, hoverPosition, projectToScreen, constraintEdge }: MeasurementOverlaysProps) { // Determine snap indicator position // Priority: activeMeasurement.current > snapTarget projected position > hoverPosition (fallback) const snapIndicatorPos = useMemo(() => { // During active measurement, use the measurement's current position if (activeMeasurement) { return { x: activeMeasurement.current.screenX, y: activeMeasurement.current.screenY }; } // During hover, project the snap target's world position to screen // This ensures the indicator is at the actual snap point, not the cursor if (snapTarget && projectToScreen) { const projected = projectToScreen(snapTarget.position); if (projected) { return projected; } } // Fallback to hover position (cursor position) return hoverPosition ?? null; }, [ activeMeasurement?.current?.screenX, activeMeasurement?.current?.screenY, snapTarget?.position?.x, snapTarget?.position?.y, snapTarget?.position?.z, projectToScreen, hoverPosition?.x, hoverPosition?.y, ]); return ( <> {/* SVG filter definitions for glow effect */} {/* Completed measurements */} {measurements.map((m) => (
{/* Line connecting start and end */} {/* Start point */} {/* End point */} {/* Distance label at midpoint - brutalist style */}
{formatDistance(m.distance)}
))} {/* Active measurement (live preview while dragging) */} {activeMeasurement && (
{/* Animated dashed line (marching ants effect) */} {/* Start point */} {/* Current point (slightly larger, pulsing) */} {/* Live distance label - brutalist style */}
{formatDistance(activeMeasurement.distance)}
)} {/* Orthogonal constraint axes visualization */} {activeMeasurement && constraintEdge?.activeAxis && projectToScreen && (() => { const startWorld = activeMeasurement.start; const startScreen = { x: startWorld.screenX, y: startWorld.screenY }; // Project axis endpoints to screen space const axisLength = 2.0; // 2 meters in world space const { axis1, axis2, axis3 } = constraintEdge.axes; const colors = constraintEdge.colors; // Calculate endpoints along each axis (positive and negative) const axis1End = projectToScreen({ x: startWorld.x + axis1.x * axisLength, y: startWorld.y + axis1.y * axisLength, z: startWorld.z + axis1.z * axisLength, }); const axis1Neg = projectToScreen({ x: startWorld.x - axis1.x * axisLength, y: startWorld.y - axis1.y * axisLength, z: startWorld.z - axis1.z * axisLength, }); const axis2End = projectToScreen({ x: startWorld.x + axis2.x * axisLength, y: startWorld.y + axis2.y * axisLength, z: startWorld.z + axis2.z * axisLength, }); const axis2Neg = projectToScreen({ x: startWorld.x - axis2.x * axisLength, y: startWorld.y - axis2.y * axisLength, z: startWorld.z - axis2.z * axisLength, }); const axis3End = projectToScreen({ x: startWorld.x + axis3.x * axisLength, y: startWorld.y + axis3.y * axisLength, z: startWorld.z + axis3.z * axisLength, }); const axis3Neg = projectToScreen({ x: startWorld.x - axis3.x * axisLength, y: startWorld.y - axis3.y * axisLength, z: startWorld.z - axis3.z * axisLength, }); if (!axis1End || !axis1Neg || !axis2End || !axis2Neg || !axis3End || !axis3Neg) return null; const activeAxis = constraintEdge.activeAxis; return ( {/* Axis 1 */} {/* Axis 2 */} {/* Axis 3 */} {/* Center origin dot */} ); })()} {/* Edge highlight - draw full edge in 3D-projected screen space */} {snapVisualization?.edgeLine3D && projectToScreen && (() => { const start = projectToScreen(snapVisualization.edgeLine3D.v0); const end = projectToScreen(snapVisualization.edgeLine3D.v1); if (!start || !end) return null; // Corner position (at v0 or v1) const cornerPos = snapVisualization.cornerRings ? (snapVisualization.cornerRings.atStart ? start : end) : null; return ( {/* Edge line with snap color (orange for edges) */} {/* Outer glow line for better visibility */} {/* Edge endpoints */} {/* Corner rings - shows strong attraction at corners */} {cornerPos && snapVisualization.cornerRings && ( <> {/* Outer pulsing ring */} {/* Middle ring */} {/* Inner ring */} {/* Center dot */} {/* Valence indicators (small dots around corner) */} {snapVisualization.cornerRings.valence >= 3 && ( <> )} )} ); })()} {/* Plane indicator - subtle grid/cross for face snaps */} {snapVisualization?.planeIndicator && ( {/* Cross indicator */} {/* Small circle at center */} )} {/* Snap indicator */} {snapTarget && snapIndicatorPos && ( )} {/* Pending point (legacy - keep for backward compatibility) */} {pending && !activeMeasurement && ( )} ); }, (prevProps, nextProps) => { // Custom comparison to prevent unnecessary re-renders // Return true if props are equal (skip re-render), false if different (re-render) // Compare measurements - check both IDs AND screen coordinates if (prevProps.measurements.length !== nextProps.measurements.length) return false; for (let i = 0; i < prevProps.measurements.length; i++) { const prev = prevProps.measurements[i]; const next = nextProps.measurements[i]; if (!next || prev.id !== next.id) return false; // Check screen coordinates for zoom/camera changes if (prev.start.screenX !== next.start.screenX || prev.start.screenY !== next.start.screenY) return false; if (prev.end.screenX !== next.end.screenX || prev.end.screenY !== next.end.screenY) return false; } // Compare activeMeasurement - check if it exists and if position changed if (!!prevProps.activeMeasurement !== !!nextProps.activeMeasurement) return false; if (prevProps.activeMeasurement && nextProps.activeMeasurement) { if ( prevProps.activeMeasurement.current.screenX !== nextProps.activeMeasurement.current.screenX || prevProps.activeMeasurement.current.screenY !== nextProps.activeMeasurement.current.screenY || prevProps.activeMeasurement.start.screenX !== nextProps.activeMeasurement.start.screenX || prevProps.activeMeasurement.start.screenY !== nextProps.activeMeasurement.start.screenY ) return false; } // Compare snapTarget - check type and position if (!!prevProps.snapTarget !== !!nextProps.snapTarget) return false; if (prevProps.snapTarget && nextProps.snapTarget) { if ( prevProps.snapTarget.type !== nextProps.snapTarget.type || prevProps.snapTarget.position.x !== nextProps.snapTarget.position.x || prevProps.snapTarget.position.y !== nextProps.snapTarget.position.y || prevProps.snapTarget.position.z !== nextProps.snapTarget.position.z ) return false; } // Compare snapVisualization if (!!prevProps.snapVisualization !== !!nextProps.snapVisualization) return false; if (prevProps.snapVisualization && nextProps.snapVisualization) { // Compare edgeLine3D (3D world coordinates) const prevEdge = prevProps.snapVisualization.edgeLine3D; const nextEdge = nextProps.snapVisualization.edgeLine3D; if (!!prevEdge !== !!nextEdge) return false; if (prevEdge && nextEdge) { if ( prevEdge.v0.x !== nextEdge.v0.x || prevEdge.v0.y !== nextEdge.v0.y || prevEdge.v0.z !== nextEdge.v0.z || prevEdge.v1.x !== nextEdge.v1.x || prevEdge.v1.y !== nextEdge.v1.y || prevEdge.v1.z !== nextEdge.v1.z ) return false; } // Compare slidingDot (t parameter only) const prevDot = prevProps.snapVisualization.slidingDot; const nextDot = nextProps.snapVisualization.slidingDot; if (!!prevDot !== !!nextDot) return false; if (prevDot && nextDot) { if (prevDot.t !== nextDot.t) return false; } // Compare cornerRings (atStart + valence) const prevCorner = prevProps.snapVisualization.cornerRings; const nextCorner = nextProps.snapVisualization.cornerRings; if (!!prevCorner !== !!nextCorner) return false; if (prevCorner && nextCorner) { if ( prevCorner.atStart !== nextCorner.atStart || prevCorner.valence !== nextCorner.valence ) return false; } const prevPlane = prevProps.snapVisualization.planeIndicator; const nextPlane = nextProps.snapVisualization.planeIndicator; if (!!prevPlane !== !!nextPlane) return false; if (prevPlane && nextPlane) { if ( prevPlane.x !== nextPlane.x || prevPlane.y !== nextPlane.y ) return false; } } // Compare projectToScreen (always re-render if it changes as we need it for projection) if (prevProps.projectToScreen !== nextProps.projectToScreen) return false; // Compare hoverPosition if (prevProps.hoverPosition?.x !== nextProps.hoverPosition?.x || prevProps.hoverPosition?.y !== nextProps.hoverPosition?.y) return false; // Compare pending if (prevProps.pending?.screenX !== nextProps.pending?.screenX || prevProps.pending?.screenY !== nextProps.pending?.screenY) return false; // Compare constraintEdge if (!!prevProps.constraintEdge !== !!nextProps.constraintEdge) return false; if (prevProps.constraintEdge && nextProps.constraintEdge) { if (prevProps.constraintEdge.activeAxis !== nextProps.constraintEdge.activeAxis) return false; } return true; // All props are equal, skip re-render }); interface SnapIndicatorProps { screenX: number; screenY: number; snapType: SnapType; } function SnapIndicator({ screenX, screenY, snapType }: SnapIndicatorProps) { // Distinct colors for each snap type - no labels needed, shapes are self-explanatory const snapColors = { [SnapType.VERTEX]: '#FFEB3B', // Yellow - circle = point [SnapType.EDGE]: '#FF9800', // Orange - line = edge [SnapType.FACE]: '#03A9F4', // Light Blue - square = face [SnapType.FACE_CENTER]: '#00BCD4', // Cyan - square with dot = center }; const color = snapColors[snapType]; return ( {/* Outer glow ring - subtle pulsing indicator */} {/* Vertex: filled circle (point) */} {snapType === SnapType.VERTEX && ( <> )} {/* Edge: horizontal line with center dot */} {snapType === SnapType.EDGE && ( <> )} {/* Face: square outline */} {snapType === SnapType.FACE && ( <> )} {/* Face Center: square with center dot */} {snapType === SnapType.FACE_CENTER && ( <> )} ); }