'use client' import React, { useEffect, useRef, useState } from 'react' import { Engine, Scene, Vector3, HemisphericLight, ArcRotateCamera, Color3, Color4, AbstractMesh, Nullable, HighlightLayer, Animation, CubicEase, EasingFunction, ImportMeshAsync, PointerEventTypes, PointerInfo, Matrix, } from '@babylonjs/core' import '@babylonjs/loaders' import SceneToolbar from './SceneToolbar'; import LoadingSpinner from '../ui/LoadingSpinner' import useNavigationStore from '@/app/store/navigationStore' import { getSensorIdFromMesh, collectSensorMeshes, applyHighlightToMeshes, statusToColor3, } from './sensorHighlight' import { computeSensorOverlayCircles, hexWithAlpha, } from './sensorHighlightOverlay' export interface ModelViewerProps { modelPath: string onSelectModel: (path: string) => void; onModelLoaded?: (modelData: { meshes: AbstractMesh[] boundingBox: { min: { x: number; y: number; z: number } max: { x: number; y: number; z: number } } }) => void onError?: (error: string) => void activeMenu?: string | null focusSensorId?: string | null renderOverlay?: (params: { anchor: { left: number; top: number } | null; info?: { name?: string; sensorId?: string } | null }) => React.ReactNode isSensorSelectionEnabled?: boolean onSensorPick?: (sensorId: string | null) => void highlightAllSensors?: boolean sensorStatusMap?: Record } const ModelViewer: React.FC = ({ modelPath, onSelectModel, onModelLoaded, onError, focusSensorId, renderOverlay, isSensorSelectionEnabled, onSensorPick, highlightAllSensors, sensorStatusMap, }) => { const canvasRef = useRef(null) const engineRef = useRef>(null) const sceneRef = useRef>(null) const [isLoading, setIsLoading] = useState(false) const [loadingProgress, setLoadingProgress] = useState(0) const [showModel, setShowModel] = useState(false) const isInitializedRef = useRef(false) const isDisposedRef = useRef(false) const importedMeshesRef = useRef([]) const highlightLayerRef = useRef(null) const highlightedMeshesRef = useRef([]) const chosenMeshRef = useRef(null) const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null) const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null) const [modelReady, setModelReady] = useState(false) const [panActive, setPanActive] = useState(false); const [webglError, setWebglError] = useState(null) const [allSensorsOverlayCircles, setAllSensorsOverlayCircles] = useState< { sensorId: string; left: number; top: number; colorHex: string }[] >([]) // NEW: State for tracking hovered sensor in overlay circles const [hoveredSensorId, setHoveredSensorId] = useState(null) const handlePan = () => setPanActive(!panActive); useEffect(() => { const scene = sceneRef.current; const camera = scene?.activeCamera as ArcRotateCamera; const canvas = canvasRef.current; if (!scene || !camera || !canvas) { return; } let observer: any = null; if (panActive) { camera.detachControl(); observer = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { const evt = pointerInfo.event; if (evt.buttons === 1) { camera.inertialPanningX -= evt.movementX / camera.panningSensibility; camera.inertialPanningY += evt.movementY / camera.panningSensibility; } else if (evt.buttons === 2) { camera.inertialAlphaOffset -= evt.movementX / camera.angularSensibilityX; camera.inertialBetaOffset -= evt.movementY / camera.angularSensibilityY; } }, PointerEventTypes.POINTERMOVE); } else { camera.detachControl(); camera.attachControl(canvas, true); } return () => { if (observer) { scene.onPointerObservable.remove(observer); } if (!camera.isDisposed() && !camera.inputs.attachedToElement) { camera.attachControl(canvas, true); } }; }, [panActive, sceneRef, canvasRef]); const handleZoomIn = () => { const camera = sceneRef.current?.activeCamera as ArcRotateCamera if (camera) { sceneRef.current?.stopAnimation(camera) const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) const frameRate = 60 const durationMs = 300 const totalFrames = Math.round((durationMs / 1000) * frameRate) const currentRadius = camera.radius const targetRadius = Math.max(camera.lowerRadiusLimit ?? 0.1, currentRadius * 0.8) Animation.CreateAndStartAnimation( 'zoomIn', camera, 'radius', frameRate, totalFrames, currentRadius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease ) } } const handleZoomOut = () => { const camera = sceneRef.current?.activeCamera as ArcRotateCamera if (camera) { sceneRef.current?.stopAnimation(camera) const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT) const frameRate = 60 const durationMs = 300 const totalFrames = Math.round((durationMs / 1000) * frameRate) const currentRadius = camera.radius const targetRadius = Math.min(camera.upperRadiusLimit ?? Infinity, currentRadius * 1.2) Animation.CreateAndStartAnimation( 'zoomOut', camera, 'radius', frameRate, totalFrames, currentRadius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease ) } } const handleTopView = () => { const camera = sceneRef.current?.activeCamera as ArcRotateCamera; if (camera) { sceneRef.current?.stopAnimation(camera); const ease = new CubicEase(); ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT); const frameRate = 60; const durationMs = 500; const totalFrames = Math.round((durationMs / 1000) * frameRate); Animation.CreateAndStartAnimation( 'topViewAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, Math.PI / 2, Animation.ANIMATIONLOOPMODE_CONSTANT, ease ); Animation.CreateAndStartAnimation( 'topViewBeta', camera, 'beta', frameRate, totalFrames, camera.beta, 0, Animation.ANIMATIONLOOPMODE_CONSTANT, ease ); } }; // NEW: Function to handle overlay circle click const handleOverlayCircleClick = (sensorId: string) => { console.log('[ModelViewer] Overlay circle clicked:', sensorId) // Find the mesh for this sensor const allMeshes = importedMeshesRef.current || [] const sensorMeshes = collectSensorMeshes(allMeshes) const targetMesh = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) if (!targetMesh) { console.warn(`[ModelViewer] Mesh not found for sensor: ${sensorId}`) return } const scene = sceneRef.current const camera = scene?.activeCamera as ArcRotateCamera if (!scene || !camera) return // Calculate bounding box of the sensor mesh const bbox = (typeof targetMesh.getHierarchyBoundingVectors === 'function') ? targetMesh.getHierarchyBoundingVectors() : { min: targetMesh.getBoundingInfo().boundingBox.minimumWorld, max: targetMesh.getBoundingInfo().boundingBox.maximumWorld } const center = bbox.min.add(bbox.max).scale(0.5) const size = bbox.max.subtract(bbox.min) const maxDimension = Math.max(size.x, size.y, size.z) // Calculate optimal camera distance const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) // Stop any current animations scene.stopAnimation(camera) // Setup easing const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) const frameRate = 60 const durationMs = 600 // 0.6 seconds for smooth animation const totalFrames = Math.round((durationMs / 1000) * frameRate) // Animate camera target position Animation.CreateAndStartAnimation( 'camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease ) // Animate camera radius (zoom) Animation.CreateAndStartAnimation( 'camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease ) // Call callback to display tooltip onSensorPick?.(sensorId) console.log('[ModelViewer] Camera animation started for sensor:', sensorId) } useEffect(() => { isDisposedRef.current = false isInitializedRef.current = false return () => { isDisposedRef.current = true } }, []) useEffect(() => { if (!canvasRef.current || isInitializedRef.current) return const canvas = canvasRef.current setWebglError(null) let hasWebGL = false try { const testCanvas = document.createElement('canvas') const gl = testCanvas.getContext('webgl2') || testCanvas.getContext('webgl') || testCanvas.getContext('experimental-webgl') hasWebGL = !!gl } catch { hasWebGL = false } if (!hasWebGL) { const message = 'WebGL не поддерживается в текущем окружении' setWebglError(message) onError?.(message) setIsLoading(false) setModelReady(false) return } let engine: Engine try { // Оптимизация: используем FXAA вместо MSAA для снижения нагрузки на GPU engine = new Engine(canvas, false, { stencil: true }) // false = отключаем MSAA } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const message = `WebGL недоступен: ${errorMessage}` setWebglError(message) onError?.(message) setIsLoading(false) setModelReady(false) return } engineRef.current = engine engine.runRenderLoop(() => { if (!isDisposedRef.current && sceneRef.current) { sceneRef.current.render() } }) const scene = new Scene(engine) sceneRef.current = scene scene.clearColor = new Color4(0.1, 0.1, 0.15, 1) // Оптимизация: включаем FXAA (более легковесное сглаживание) scene.imageProcessingConfiguration.fxaaEnabled = true const camera = new ArcRotateCamera('camera', 0, Math.PI / 3, 20, Vector3.Zero(), scene) camera.attachControl(canvas, true) camera.lowerRadiusLimit = 2 camera.upperRadiusLimit = 200 camera.wheelDeltaPercentage = 0.01 camera.panningSensibility = 50 camera.angularSensibilityX = 1000 camera.angularSensibilityY = 1000 const ambientLight = new HemisphericLight('ambientLight', new Vector3(0, 1, 0), scene) ambientLight.intensity = 0.4 ambientLight.diffuse = new Color3(0.7, 0.7, 0.8) ambientLight.specular = new Color3(0.2, 0.2, 0.3) ambientLight.groundColor = new Color3(0.3, 0.3, 0.4) const keyLight = new HemisphericLight('keyLight', new Vector3(1, 1, 0), scene) keyLight.intensity = 0.6 keyLight.diffuse = new Color3(1, 1, 0.9) keyLight.specular = new Color3(1, 1, 0.9) const fillLight = new HemisphericLight('fillLight', new Vector3(-1, 0.5, -1), scene) fillLight.intensity = 0.3 fillLight.diffuse = new Color3(0.8, 0.8, 1) const hl = new HighlightLayer('highlight-layer', scene, { mainTextureRatio: 1, blurTextureSizeRatio: 1, }) hl.innerGlow = false hl.outerGlow = true hl.blurHorizontalSize = 2 hl.blurVerticalSize = 2 highlightLayerRef.current = hl const handleResize = () => { if (!isDisposedRef.current) { engine.resize() } } window.addEventListener('resize', handleResize) isInitializedRef.current = true return () => { isDisposedRef.current = true isInitializedRef.current = false window.removeEventListener('resize', handleResize) highlightLayerRef.current?.dispose() highlightLayerRef.current = null if (engineRef.current) { engineRef.current.dispose() engineRef.current = null } sceneRef.current = null } }, [onError]) useEffect(() => { if (!modelPath || !sceneRef.current || !engineRef.current) return const scene = sceneRef.current setIsLoading(true) setLoadingProgress(0) setShowModel(false) setModelReady(false) const loadModel = async () => { try { console.log('[ModelViewer] Starting to load model:', modelPath) // UI элемент загрузчика (есть эффект замедленности) const progressInterval = setInterval(() => { setLoadingProgress(prev => { if (prev >= 90) { clearInterval(progressInterval) return 90 } return prev + Math.random() * 15 }) }, 100) // Use the correct ImportMeshAsync signature: (url, scene, onProgress) const result = await ImportMeshAsync(modelPath, scene, (evt) => { if (evt.lengthComputable) { const progress = (evt.loaded / evt.total) * 100 setLoadingProgress(progress) console.log('[ModelViewer] Loading progress:', progress) } }) clearInterval(progressInterval) if (isDisposedRef.current) { console.log('[ModelViewer] Component disposed during load') return } console.log('[ModelViewer] Model loaded successfully:', { meshesCount: result.meshes.length, particleSystemsCount: result.particleSystems.length, skeletonsCount: result.skeletons.length, animationGroupsCount: result.animationGroups.length }) importedMeshesRef.current = result.meshes if (result.meshes.length > 0) { const boundingBox = result.meshes[0].getHierarchyBoundingVectors() onModelLoaded?.({ meshes: result.meshes, boundingBox: { min: { x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z }, max: { x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }, }, }) // Автоматическое кадрирование камеры для отображения всей модели const camera = scene.activeCamera as ArcRotateCamera if (camera) { const center = boundingBox.min.add(boundingBox.max).scale(0.5) const size = boundingBox.max.subtract(boundingBox.min) const maxDimension = Math.max(size.x, size.y, size.z) // Устанавливаем оптимальное расстояние камеры const targetRadius = maxDimension * 2.5 // Множитель для комфортного отступа // Плавная анимация камеры к центру модели scene.stopAnimation(camera) const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) const frameRate = 60 const durationMs = 800 // 0.8 секунды const totalFrames = Math.round((durationMs / 1000) * frameRate) // Анимация позиции камеры Animation.CreateAndStartAnimation( 'frameCameraTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease ) // Анимация зума Animation.CreateAndStartAnimation( 'frameCameraRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease ) console.log('[ModelViewer] Camera framed to model:', { center, targetRadius, maxDimension }) } } setLoadingProgress(100) setShowModel(true) setModelReady(true) setIsLoading(false) } catch (error) { if (isDisposedRef.current) return const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка' console.error('[ModelViewer] Error loading model:', errorMessage) const message = `Ошибка при загрузке модели: ${errorMessage}` onError?.(message) setIsLoading(false) setModelReady(false) } } loadModel() }, [modelPath, onModelLoaded, onError]) useEffect(() => { if (!highlightAllSensors || focusSensorId || !modelReady) { setAllSensorsOverlayCircles([]) return } const scene = sceneRef.current const engine = engineRef.current if (!scene || !engine) { setAllSensorsOverlayCircles([]) return } const allMeshes = importedMeshesRef.current || [] const sensorMeshes = collectSensorMeshes(allMeshes) if (sensorMeshes.length === 0) { setAllSensorsOverlayCircles([]) return } const engineTyped = engine as Engine const updateCircles = () => { const circles = computeSensorOverlayCircles({ scene, engine: engineTyped, meshes: sensorMeshes, sensorStatusMap, }) setAllSensorsOverlayCircles(circles) } updateCircles() const observer = scene.onBeforeRenderObservable.add(updateCircles) return () => { scene.onBeforeRenderObservable.remove(observer) setAllSensorsOverlayCircles([]) } }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) useEffect(() => { if (!highlightAllSensors || focusSensorId || !modelReady) { return } const scene = sceneRef.current if (!scene) return const allMeshes = importedMeshesRef.current || [] if (allMeshes.length === 0) { return } const sensorMeshes = collectSensorMeshes(allMeshes) console.log('[ModelViewer] Total meshes in model:', allMeshes.length) console.log('[ModelViewer] Sensor meshes found:', sensorMeshes.length) // Log first 5 sensor IDs found in meshes const sensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean).slice(0, 5) console.log('[ModelViewer] Sample sensor IDs from meshes:', sensorIds) if (sensorMeshes.length === 0) { console.warn('[ModelViewer] No sensor meshes found in 3D model!') return } applyHighlightToMeshes( highlightLayerRef.current, highlightedMeshesRef, sensorMeshes, mesh => { const sid = getSensorIdFromMesh(mesh) const status = sid ? sensorStatusMap?.[sid] : undefined return statusToColor3(status ?? null) }, ) }, [highlightAllSensors, focusSensorId, modelReady, sensorStatusMap]) useEffect(() => { if (!focusSensorId || !modelReady) { for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) setAllSensorsOverlayCircles([]) return } const sensorId = (focusSensorId ?? '').trim() if (!sensorId) { for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) return } const allMeshes = importedMeshesRef.current || [] if (allMeshes.length === 0) { for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) return } const sensorMeshes = collectSensorMeshes(allMeshes) const allSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)) const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId) console.log('[ModelViewer] Sensor focus', { requested: sensorId, totalImportedMeshes: allMeshes.length, totalSensorMeshes: sensorMeshes.length, allSensorIds: allSensorIds, chosen: chosen ? { id: chosen.id, name: chosen.name, uniqueId: chosen.uniqueId, parent: chosen.parent?.name } : null, source: 'result.meshes', }) const scene = sceneRef.current! if (chosen) { try { const camera = scene.activeCamera as ArcRotateCamera const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') ? chosen.getHierarchyBoundingVectors() : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } const center = bbox.min.add(bbox.max).scale(0.5) const size = bbox.max.subtract(bbox.min) const maxDimension = Math.max(size.x, size.y, size.z) const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) // Вычисляем оптимальные углы камеры для видимости датчика // Позиционируем камеру спереди датчика с небольшим наклоном сверху const directionToCamera = camera.position.subtract(center).normalize() // Вычисляем целевые углы alpha и beta // alpha - горизонтальный угол (вокруг оси Y) // beta - вертикальный угол (наклон) let targetAlpha = Math.atan2(directionToCamera.x, directionToCamera.z) let targetBeta = Math.acos(directionToCamera.y) // Если датчик за стеной, позиционируем камеру спереди // Используем направление от центра сцены к датчику const sceneCenter = Vector3.Zero() const directionFromSceneCenter = center.subtract(sceneCenter).normalize() targetAlpha = Math.atan2(directionFromSceneCenter.x, directionFromSceneCenter.z) + Math.PI targetBeta = Math.PI / 3 // 60 градусов - смотрим немного сверху // Нормализуем alpha в диапазон [-PI, PI] while (targetAlpha > Math.PI) targetAlpha -= 2 * Math.PI while (targetAlpha < -Math.PI) targetAlpha += 2 * Math.PI // Ограничиваем beta в разумных пределах targetBeta = Math.max(0.1, Math.min(Math.PI - 0.1, targetBeta)) scene.stopAnimation(camera) const ease = new CubicEase() ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) const frameRate = 60 const durationMs = 800 const totalFrames = Math.round((durationMs / 1000) * frameRate) Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) Animation.CreateAndStartAnimation('camAlpha', camera, 'alpha', frameRate, totalFrames, camera.alpha, targetAlpha, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) Animation.CreateAndStartAnimation('camBeta', camera, 'beta', frameRate, totalFrames, camera.beta, targetBeta, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) applyHighlightToMeshes( highlightLayerRef.current, highlightedMeshesRef, [chosen], mesh => { const sid = getSensorIdFromMesh(mesh) const status = sid ? sensorStatusMap?.[sid] : undefined return statusToColor3(status ?? null) }, ) chosenMeshRef.current = chosen setOverlayData({ name: chosen.name, sensorId }) } catch { for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) } } else { for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 } highlightedMeshesRef.current = [] highlightLayerRef.current?.removeAllMeshes() chosenMeshRef.current = null setOverlayPos(null) setOverlayData(null) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusSensorId, modelReady, highlightAllSensors]) useEffect(() => { const scene = sceneRef.current if (!scene || !modelReady || !isSensorSelectionEnabled) return const pickObserver = scene.onPointerObservable.add((pointerInfo: PointerInfo) => { if (pointerInfo.type !== PointerEventTypes.POINTERPICK) return const pick = pointerInfo.pickInfo if (!pick || !pick.hit) { onSensorPick?.(null) return } const pickedMesh = pick.pickedMesh const sensorId = getSensorIdFromMesh(pickedMesh) if (sensorId) { onSensorPick?.(sensorId) } else { onSensorPick?.(null) } }) return () => { scene.onPointerObservable.remove(pickObserver) } }, [modelReady, isSensorSelectionEnabled, onSensorPick]) const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { if (!sceneRef.current || !mesh) return null const scene = sceneRef.current try { const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') ? mesh.getHierarchyBoundingVectors() : { min: mesh.getBoundingInfo().boundingBox.minimumWorld, max: mesh.getBoundingInfo().boundingBox.maximumWorld } const center = bbox.min.add(bbox.max).scale(0.5) const viewport = scene.activeCamera?.viewport.toGlobal(engineRef.current!.getRenderWidth(), engineRef.current!.getRenderHeight()) if (!viewport) return null const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) if (!projected) return null return { left: projected.x, top: projected.y } } catch (error) { console.error('[ModelViewer] Error computing overlay position:', error) return null } }, []) useEffect(() => { if (!chosenMeshRef.current || !overlayData) return const pos = computeOverlayPosition(chosenMeshRef.current) setOverlayPos(pos) }, [overlayData, computeOverlayPosition]) useEffect(() => { if (!sceneRef.current || !chosenMeshRef.current || !overlayData) return const scene = sceneRef.current const updateOverlayPosition = () => { const pos = computeOverlayPosition(chosenMeshRef.current) setOverlayPos(pos) } scene.registerBeforeRender(updateOverlayPosition) return () => scene.unregisterBeforeRender(updateOverlayPosition) }, [overlayData, computeOverlayPosition]) return (
{!modelPath ? (
3D модель не выбрана
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
) : ( <> {webglError ? (
3D просмотр недоступен
{webglError}
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
) : isLoading ? (
) : !modelReady ? (
3D модель не загружена
Модель не готова к отображению
) : null} )} {/* UPDATED: Interactive overlay circles with hover effects */} {allSensorsOverlayCircles.map(circle => { const size = 36 const radius = size / 2 const fill = hexWithAlpha(circle.colorHex, 0.2) const isHovered = hoveredSensorId === circle.sensorId return (
handleOverlayCircleClick(circle.sensorId)} onMouseEnter={() => setHoveredSensorId(circle.sensorId)} onMouseLeave={() => setHoveredSensorId(null)} style={{ position: 'absolute', left: circle.left - radius, top: circle.top - radius, width: size, height: size, borderRadius: '9999px', border: `2px solid ${circle.colorHex}`, backgroundColor: fill, pointerEvents: 'auto', cursor: 'pointer', transition: 'all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)', transform: isHovered ? 'scale(1.4)' : 'scale(1)', boxShadow: isHovered ? `0 0 25px ${circle.colorHex}, inset 0 0 10px ${circle.colorHex}` : `0 0 8px ${circle.colorHex}`, zIndex: isHovered ? 50 : 10, }} title={`Датчик: ${circle.sensorId}`} /> ) })} {renderOverlay && overlayPos && overlayData ? renderOverlay({ anchor: overlayPos, info: overlayData }) : null }
) } export default ModelViewer