Overhaul of the highlight system
This commit is contained in:
@@ -12,9 +12,7 @@ import {
|
||||
Color4,
|
||||
AbstractMesh,
|
||||
Nullable,
|
||||
HighlightLayer,
|
||||
Mesh,
|
||||
InstancedMesh,
|
||||
HighlightLayer,
|
||||
Animation,
|
||||
CubicEase,
|
||||
EasingFunction,
|
||||
@@ -24,9 +22,19 @@ import {
|
||||
Matrix,
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
|
||||
|
||||
import SceneToolbar from './SceneToolbar';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||
import {
|
||||
getSensorIdFromMesh,
|
||||
collectSensorMeshes,
|
||||
applyHighlightToMeshes,
|
||||
statusToColor3,
|
||||
} from './sensorHighlight'
|
||||
import {
|
||||
computeSensorOverlayCircles,
|
||||
hexWithAlpha,
|
||||
} from './sensorHighlightOverlay'
|
||||
|
||||
export interface ModelViewerProps {
|
||||
modelPath: string
|
||||
@@ -45,6 +53,7 @@ export interface ModelViewerProps {
|
||||
isSensorSelectionEnabled?: boolean
|
||||
onSensorPick?: (sensorId: string | null) => void
|
||||
highlightAllSensors?: boolean
|
||||
sensorStatusMap?: Record<string, string>
|
||||
}
|
||||
|
||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
@@ -57,6 +66,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
isSensorSelectionEnabled,
|
||||
onSensorPick,
|
||||
highlightAllSensors,
|
||||
sensorStatusMap,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
@@ -74,6 +84,10 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
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 }[]
|
||||
>([])
|
||||
|
||||
const handlePan = () => setPanActive(!panActive);
|
||||
|
||||
@@ -222,7 +236,41 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
if (!canvasRef.current || isInitializedRef.current) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const engine = new Engine(canvas, true, { stencil: true })
|
||||
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(() => {
|
||||
@@ -260,7 +308,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
fillLight.intensity = 0.3
|
||||
fillLight.diffuse = new Color3(0.8, 0.8, 1)
|
||||
|
||||
const hl = new HighlightLayer('highlight-layer', scene)
|
||||
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 = () => {
|
||||
@@ -285,7 +340,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
}
|
||||
sceneRef.current = null
|
||||
}
|
||||
}, [])
|
||||
}, [onError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || isDisposedRef.current) {
|
||||
@@ -427,19 +482,30 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
}, [modelPath, onError, onModelLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[ModelViewer] highlightAllSensors effect triggered:', { highlightAllSensors, modelReady, sceneReady: !!sceneRef.current })
|
||||
if (!sceneRef.current || isDisposedRef.current || !modelReady) return
|
||||
|
||||
// Если включено выделение всех сенсоров - выделяем их все
|
||||
if (highlightAllSensors) {
|
||||
console.log('[ModelViewer] Calling highlightAllSensorMeshes()')
|
||||
highlightAllSensorMeshes()
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
applyHighlightToMeshes(
|
||||
highlightLayerRef.current,
|
||||
highlightedMeshesRef,
|
||||
sensorMeshes,
|
||||
mesh => {
|
||||
const sid = getSensorIdFromMesh(mesh)
|
||||
const status = sid ? sensorStatusMap?.[sid] : undefined
|
||||
return statusToColor3(status ?? null)
|
||||
},
|
||||
)
|
||||
chosenMeshRef.current = null
|
||||
setOverlayPos(null)
|
||||
setOverlayData(null)
|
||||
setAllSensorsOverlayCircles([])
|
||||
return
|
||||
}
|
||||
|
||||
const sensorId = (focusSensorId ?? '').trim()
|
||||
if (!sensorId) {
|
||||
console.log('[ModelViewer] Focus cleared (no Sensor_ID provided)')
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
@@ -452,7 +518,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
|
||||
if (allMeshes.length === 0) {
|
||||
console.warn('[ModelViewer] No meshes available for sensor matching')
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
@@ -462,44 +527,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const sensorMeshes = allMeshes.filter((m: any) => {
|
||||
try {
|
||||
return ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor'))
|
||||
} catch (error) {
|
||||
console.warn('[ModelViewer] Error filtering sensor mesh:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const chosen = sensorMeshes.find((m: any) => {
|
||||
try {
|
||||
const meta: any = (m as any)?.metadata
|
||||
const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras
|
||||
|
||||
const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number ?? extras?.detector_id
|
||||
if (sid != null) {
|
||||
return String(sid).trim() === sensorId
|
||||
}
|
||||
|
||||
const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance
|
||||
if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') {
|
||||
try {
|
||||
const parsedInstance = JSON.parse(monitoringSensorInstance)
|
||||
const instanceSensorId = parsedInstance?.Sensor_ID
|
||||
if (instanceSensorId != null) {
|
||||
return String(instanceSensorId).trim() === sensorId
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('[ModelViewer] Error parsing MonitoringSensor_Instance JSON:', parseError)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('[ModelViewer] Error matching sensor mesh:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
const sensorMeshes = collectSensorMeshes(allMeshes)
|
||||
const chosen = sensorMeshes.find(m => getSensorIdFromMesh(m) === sensorId)
|
||||
|
||||
console.log('[ModelViewer] Sensor focus', {
|
||||
requested: sensorId,
|
||||
@@ -533,37 +562,19 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
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)
|
||||
|
||||
const hl = highlightLayerRef.current
|
||||
if (hl) {
|
||||
// Переключаем группу рендеринга для предыдущего выделенного меша
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
|
||||
hl.removeAllMeshes()
|
||||
if (chosen instanceof Mesh) {
|
||||
chosen.renderingGroupId = 1
|
||||
highlightedMeshesRef.current.push(chosen)
|
||||
hl.addMesh(chosen, new Color3(1, 1, 0))
|
||||
} else if (chosen instanceof InstancedMesh) {
|
||||
// Сохраняем исходный меш для инстанса
|
||||
chosen.sourceMesh.renderingGroupId = 1
|
||||
highlightedMeshesRef.current.push(chosen.sourceMesh)
|
||||
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
|
||||
} else {
|
||||
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
|
||||
for (const cm of children) {
|
||||
if (cm instanceof Mesh) {
|
||||
cm.renderingGroupId = 1
|
||||
highlightedMeshesRef.current.push(cm)
|
||||
hl.addMesh(cm, new Color3(1, 1, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (error) {
|
||||
console.error('[ModelViewer] Error focusing on sensor mesh:', error)
|
||||
} catch {
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
@@ -582,99 +593,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusSensorId, modelReady, highlightAllSensors])
|
||||
|
||||
// Помощь: извлечь Sensor_ID из метаданных меша (совпадающая логика с фокусом)
|
||||
const getSensorIdFromMesh = React.useCallback((m: AbstractMesh | null): string | null => {
|
||||
if (!m) return null
|
||||
try {
|
||||
const meta: any = (m as any)?.metadata
|
||||
const extras: any = meta?.gltf?.extras ?? meta?.extras ?? (m as any)?.extras
|
||||
const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number ?? extras?.detector_id
|
||||
if (sid != null) return String(sid).trim()
|
||||
const monitoringSensorInstance = extras?.bonsaiPset_ARBM_PSet_MonitoringSensor_Instance
|
||||
if (monitoringSensorInstance && typeof monitoringSensorInstance === 'string') {
|
||||
try {
|
||||
const parsedInstance = JSON.parse(monitoringSensorInstance)
|
||||
const instanceSensorId = parsedInstance?.Sensor_ID
|
||||
if (instanceSensorId != null) return String(instanceSensorId).trim()
|
||||
} catch (parseError) {
|
||||
console.warn('[ModelViewer] Error parsing MonitoringSensor_Instance JSON in pick:', parseError)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
|
||||
// Функция для выделения всех сенсоров на модели
|
||||
const highlightAllSensorMeshes = React.useCallback(() => {
|
||||
console.log('[ModelViewer] highlightAllSensorMeshes called')
|
||||
const scene = sceneRef.current
|
||||
if (!scene || !highlightLayerRef.current) {
|
||||
console.log('[ModelViewer] Cannot highlight - scene or highlightLayer not ready:', {
|
||||
sceneReady: !!scene,
|
||||
highlightLayerReady: !!highlightLayerRef.current
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const allMeshes = importedMeshesRef.current || []
|
||||
|
||||
// Use the same logic as getSensorIdFromMesh to identify sensor meshes
|
||||
const sensorMeshes = allMeshes.filter((m: any) => {
|
||||
try {
|
||||
const sensorId = getSensorIdFromMesh(m)
|
||||
if (sensorId) {
|
||||
console.log(`[ModelViewer] Found sensor mesh: ${m.name} (id: ${m.id}, sensorId: ${sensorId})`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('[ModelViewer] Error filtering sensor mesh:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`[ModelViewer] Found ${sensorMeshes.length} sensor meshes out of ${allMeshes.length} total meshes`)
|
||||
|
||||
if (sensorMeshes.length === 0) {
|
||||
console.log('[ModelViewer] No sensor meshes found to highlight')
|
||||
return
|
||||
}
|
||||
|
||||
// Clear previous highlights
|
||||
for (const m of highlightedMeshesRef.current) { m.renderingGroupId = 0 }
|
||||
highlightedMeshesRef.current = []
|
||||
highlightLayerRef.current?.removeAllMeshes()
|
||||
|
||||
// Highlight all sensor meshes
|
||||
sensorMeshes.forEach((mesh: any) => {
|
||||
try {
|
||||
if (mesh instanceof Mesh) {
|
||||
mesh.renderingGroupId = 1
|
||||
highlightedMeshesRef.current.push(mesh)
|
||||
highlightLayerRef.current?.addMesh(mesh, new Color3(1, 1, 0))
|
||||
} else if (mesh instanceof InstancedMesh) {
|
||||
mesh.sourceMesh.renderingGroupId = 1
|
||||
highlightedMeshesRef.current.push(mesh.sourceMesh)
|
||||
highlightLayerRef.current?.addMesh(mesh.sourceMesh, new Color3(1, 1, 0))
|
||||
} else {
|
||||
const children = typeof mesh.getChildMeshes === 'function' ? mesh.getChildMeshes() : []
|
||||
for (const cm of children) {
|
||||
if (cm instanceof Mesh) {
|
||||
cm.renderingGroupId = 1
|
||||
highlightedMeshesRef.current.push(cm)
|
||||
highlightLayerRef.current?.addMesh(cm, new Color3(1, 1, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ModelViewer] Error highlighting sensor mesh:', error)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`[ModelViewer] Successfully highlighted ${highlightedMeshesRef.current.length} sensor meshes`)
|
||||
}, [getSensorIdFromMesh])
|
||||
|
||||
// Включение выбора на основе взаимодействия с моделью только при готовности модели и включении выбора сенсоров
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current
|
||||
@@ -701,7 +619,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
return () => {
|
||||
scene.onPointerObservable.remove(pickObserver)
|
||||
}
|
||||
}, [modelReady, isSensorSelectionEnabled, onSensorPick, getSensorIdFromMesh])
|
||||
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
||||
|
||||
// Расчет позиции оверлея
|
||||
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
||||
@@ -733,6 +651,41 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
setOverlayPos(pos)
|
||||
}, [overlayData, computeOverlayPosition])
|
||||
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current
|
||||
const engine = engineRef.current
|
||||
if (!scene || !engine || !modelReady) {
|
||||
setAllSensorsOverlayCircles([])
|
||||
return
|
||||
}
|
||||
if (!highlightAllSensors || focusSensorId || !sensorStatusMap) {
|
||||
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 (!sceneRef.current || !chosenMeshRef.current || !overlayData) return
|
||||
@@ -767,10 +720,24 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
||||
showModel ? 'opacity-100' : 'opacity-0'
|
||||
showModel && !webglError ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{isLoading && (
|
||||
{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}
|
||||
@@ -778,8 +745,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
strokeWidth={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!modelReady && !isLoading && (
|
||||
) : !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">
|
||||
@@ -790,7 +756,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<SceneToolbar
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
@@ -801,6 +767,27 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{allSensorsOverlayCircles.map(circle => {
|
||||
const size = 36
|
||||
const radius = size / 2
|
||||
const fill = hexWithAlpha(circle.colorHex, 0.2)
|
||||
return (
|
||||
<div
|
||||
key={`${circle.sensorId}-${Math.round(circle.left)}-${Math.round(circle.top)}`}
|
||||
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: 'none',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{renderOverlay && overlayPos && overlayData
|
||||
? renderOverlay({ anchor: overlayPos, info: overlayData })
|
||||
: null
|
||||
@@ -809,4 +796,4 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelViewer
|
||||
export default ModelViewer
|
||||
|
||||
Reference in New Issue
Block a user