/* 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 */}
{/* Distance label at midpoint - brutalist style */}
{formatDistance(m.distance)}
))}
{/* Active measurement (live preview while dragging) */}
{activeMeasurement && (
{/* 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 (
);
})()}
{/* 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 (
);
})()}
{/* Plane indicator - subtle grid/cross for face snaps */}
{snapVisualization?.planeIndicator && (
)}
{/* 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 (
);
}