941 lines
32 KiB
Plaintext
941 lines
32 KiB
Plaintext
'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<string, string>
|
||
}
|
||
|
||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||
modelPath,
|
||
onSelectModel,
|
||
onModelLoaded,
|
||
onError,
|
||
focusSensorId,
|
||
renderOverlay,
|
||
isSensorSelectionEnabled,
|
||
onSensorPick,
|
||
highlightAllSensors,
|
||
sensorStatusMap,
|
||
}) => {
|
||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||
const engineRef = useRef<Nullable<Engine>>(null)
|
||
const sceneRef = useRef<Nullable<Scene>>(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<AbstractMesh[]>([])
|
||
const highlightLayerRef = useRef<HighlightLayer | null>(null)
|
||
const highlightedMeshesRef = useRef<AbstractMesh[]>([])
|
||
const chosenMeshRef = useRef<AbstractMesh | null>(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<string | null>(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<string | null>(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 {
|
||
engine = new Engine(canvas, true, { stencil: true })
|
||
} 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)
|
||
|
||
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 (
|
||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||
{!modelPath ? (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||
<div className="text-amber-400 text-lg font-semibold mb-2">
|
||
3D модель не выбрана
|
||
</div>
|
||
<div className="text-gray-300 mb-4">
|
||
Выберите модель в панели «Зоны мониторинга», чтобы начать просмотр
|
||
</div>
|
||
<div className="text-sm text-gray-400">
|
||
Если список пуст, добавьте файлы в каталог assets/big-models или проверьте API
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<canvas
|
||
ref={canvasRef}
|
||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
||
}`}
|
||
/>
|
||
{webglError ? (
|
||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md shadow-xl">
|
||
<div className="text-red-400 text-lg font-semibold mb-2">
|
||
3D просмотр недоступен
|
||
</div>
|
||
<div className="text-gray-300 mb-4">
|
||
{webglError}
|
||
</div>
|
||
<div className="text-sm text-gray-400">
|
||
Включите аппаратное ускорение в браузере или откройте страницу в другом браузере/устройстве
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : isLoading ? (
|
||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
||
<LoadingSpinner
|
||
progress={loadingProgress}
|
||
size={120}
|
||
strokeWidth={8}
|
||
/>
|
||
</div>
|
||
) : !modelReady ? (
|
||
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
|
||
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
|
||
<div className="text-gray-400 text-lg font-semibold mb-4">
|
||
3D модель не загружена
|
||
</div>
|
||
<div className="text-sm text-gray-400">
|
||
Модель не готова к отображению
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<SceneToolbar
|
||
onZoomIn={handleZoomIn}
|
||
onZoomOut={handleZoomOut}
|
||
onTopView={handleTopView}
|
||
onPan={handlePan}
|
||
onSelectModel={onSelectModel}
|
||
panActive={panActive}
|
||
onToggleSensorHighlights={useNavigationStore.getState().toggleSensorHighlights}
|
||
sensorHighlightsActive={useNavigationStore.getState().showSensorHighlights}
|
||
/>
|
||
</>
|
||
)}
|
||
{/* 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 (
|
||
<div
|
||
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||
onClick={() => 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
|
||
}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ModelViewer
|