/* 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/. */ import { useCallback, useEffect, useState, useRef } from 'react'; import { Home, ZoomIn, ZoomOut, Layers, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useViewerStore } from '@/store'; import { goHomeFromStore } from '@/store/homeView'; import { useIfc } from '@/hooks/useIfc'; import { cn } from '@/lib/utils'; import { ViewCube, type ViewCubeRef } from './ViewCube'; import { AxisHelper, type AxisHelperRef } from './AxisHelper'; import { BasepointOverlay } from './BasepointOverlay'; import { PointCloudPanel } from './PointCloudPanel'; import { Crosshair } from 'lucide-react'; export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: boolean } = {}) { const selectedStoreys = useViewerStore((s) => s.selectedStoreys); const hiddenEntities = useViewerStore((s) => s.hiddenEntities); const isolatedEntities = useViewerStore((s) => s.isolatedEntities); const basketPresentationVisible = useViewerStore((s) => s.basketPresentationVisible); const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks); const isMobile = useViewerStore((s) => s.isMobile); const setOnCameraRotationChange = useViewerStore((s) => s.setOnCameraRotationChange); const setOnScaleChange = useViewerStore((s) => s.setOnScaleChange); const { ifcDataStore, geometryResult } = useIfc(); // Cesium state const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled); // Use refs for rotation to avoid re-renders - ViewCube updates itself directly const cameraRotationRef = useRef({ azimuth: 45, elevation: 25 }); const viewCubeRef = useRef(null); const axisHelperRef = useRef(null); // Local state for scale - updated via callback, no global re-renders const [scale, setScale] = useState(10); const lastScaleRef = useRef(10); // Register callback for real-time rotation updates - updates ViewCube directly useEffect(() => { const handleRotationChange = (rotation: { azimuth: number; elevation: number }) => { cameraRotationRef.current = rotation; // Update ViewCube directly via ref (no React re-render) const viewCubeRotationX = -rotation.elevation; const viewCubeRotationY = -rotation.azimuth; viewCubeRef.current?.updateRotation(viewCubeRotationX, viewCubeRotationY); axisHelperRef.current?.updateRotation(viewCubeRotationX, viewCubeRotationY); }; setOnCameraRotationChange(handleRotationChange); return () => setOnCameraRotationChange(null); }, [setOnCameraRotationChange]); // Register callback for real-time scale updates // Only update state if scale changed significantly (>1%) to avoid unnecessary re-renders useEffect(() => { const handleScaleChange = (newScale: number) => { const lastScale = lastScaleRef.current; // Only update if scale changed by more than 1% if (Math.abs(newScale - lastScale) / lastScale > 0.01) { lastScaleRef.current = newScale; setScale(newScale); } }; setOnScaleChange(handleScaleChange); return () => setOnScaleChange(null); }, [setOnScaleChange]); // Get names of selected storeys const storeyNames = selectedStoreys.size > 0 && ifcDataStore ? Array.from(selectedStoreys).map(id => ifcDataStore.entities.getName(id) || `Storey #${id}` ) : null; // Calculate visible count considering visibility filters const totalCount = geometryResult?.meshes?.length ?? 0; let visibleCount = totalCount; if (isolatedEntities !== null) { visibleCount = isolatedEntities.size; } else if (hiddenEntities.size > 0) { visibleCount = totalCount - hiddenEntities.size; } // Initial rotation values (ViewCube will update itself via ref) const initialRotationX = -cameraRotationRef.current.elevation; const initialRotationY = -cameraRotationRef.current.azimuth; const handleViewChange = useCallback((view: string) => { const viewMap: Record = { top: 'top', bottom: 'bottom', front: 'front', back: 'back', left: 'left', right: 'right', }; const mappedView = viewMap[view]; if (mappedView && cameraCallbacks.setPresetView) { cameraCallbacks.setPresetView(mappedView); } }, [cameraCallbacks]); const handleHome = useCallback(() => { goHomeFromStore(); }, []); const handleFitAll = useCallback(() => { cameraCallbacks.fitAll?.(); }, [cameraCallbacks]); const handleZoomIn = useCallback(() => { cameraCallbacks.zoomIn?.(); }, [cameraCallbacks]); const handleZoomOut = useCallback(() => { cameraCallbacks.zoomOut?.(); }, [cameraCallbacks]); // Format scale value for display const formatScale = (worldSize: number): string => { if (worldSize >= 1000) { return `${(worldSize / 1000).toFixed(1)}km`; } else if (worldSize >= 1) { return `${worldSize.toFixed(1)}m`; } else if (worldSize >= 0.1) { return `${(worldSize * 100).toFixed(0)}cm`; } else { return `${(worldSize * 1000).toFixed(0)}mm`; } }; return ( <> {/* Bottom-right: Navigation controls (hidden when Cesium active — Cesium is web-only) */} {!cesiumEnabled && (
Home (H) Zoom In (+) Zoom Out (-)
)} {/* Context Info — Storey names. Top-center on mobile (URL bar steals the bottom). */} {storeyNames && storeyNames.length > 0 && (
{storeyNames.length === 1 ? storeyNames[0] : `${storeyNames.length} storeys`}
)} {/* ViewCube (top-right) */} {!hideViewCube && (
cameraCallbacks.orbit?.(deltaX, deltaY)} rotationX={initialRotationX} rotationY={initialRotationY} />
)} {/* Basepoint toggle + Axis Helper + Scale Bar — desktop only; mobile keeps the viewport unobstructed */} {!isMobile && (
{formatScale(scale)}
)} {/* Per-model IFC (0,0,0) markers — toggled via BasepointToggleButton. Hidden by default; component returns null when the toggle is off. */} ); } /** * Toggle for the per-model IFC-origin overlay. Sits next to the AxisHelper so * it's discoverable in the same "scene reference" cluster. */ function BasepointToggleButton() { const showModelBasepoints = useViewerStore((s) => s.showModelBasepoints); const toggleShowModelBasepoints = useViewerStore((s) => s.toggleShowModelBasepoints); const modelCount = useViewerStore((s) => s.models.size); if (modelCount === 0) return null; return ( {showModelBasepoints ? 'Hide model basepoints' : 'Show model basepoints (IFC 0,0,0)'} ); } /** * Tiny indirection so the panel can subscribe to its own slice without * pulling extra state into the parent overlay component. */ function PointCloudPanelMount() { const count = useViewerStore((s) => s.pointCloudAssetCount); // Triangle total comes from the merged geometry result. The panel // gates the BIM↔scan deviation compute button on triangleCount > 0 // so the user can't trigger an empty-BVH compute pass. const triangleCount = useViewerStore((s) => s.geometryResult?.totalTriangles ?? 0); return ; }