From c6bc5d19f298180d1f3ae9c4e2bf42b9fb76ca8b Mon Sep 17 00:00:00 2001 From: sysadminix Date: Tue, 10 Feb 2026 00:26:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=82=D1=83=D0=BB=D1=82=D0=B8=D0=BF=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=87=D0=B8=D0=BA=D0=B0,=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B0=20(=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=B3=D0=BE=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=82=D0=B5=D0=BC=D0=BD=D0=BE=D0=BC=20?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=D0=B5,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BE=D1=82=D1=81=D1=82=D1=83=D0=BF=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(protected)/objects/page.tsx | 2 +- .../objects/page.tsx — копия 3 | 176 ++++ frontend/components/model/ModelViewer.tsx | 10 +- .../model/ModelViewer.tsx — копия 4 | 983 ++++++++++++++++++ frontend/components/objects/ObjectCard.tsx | 26 +- 5 files changed, 1172 insertions(+), 25 deletions(-) create mode 100644 frontend/app/(protected)/objects/page.tsx — копия 3 create mode 100644 frontend/components/model/ModelViewer.tsx — копия 4 diff --git a/frontend/app/(protected)/objects/page.tsx b/frontend/app/(protected)/objects/page.tsx index 1649f97..daa4ae8 100644 --- a/frontend/app/(protected)/objects/page.tsx +++ b/frontend/app/(protected)/objects/page.tsx @@ -156,7 +156,7 @@ const ObjectsPage: React.FC = () => {
{/* Заголовок */} -

+

Выберите объект для работы

diff --git a/frontend/app/(protected)/objects/page.tsx — копия 3 b/frontend/app/(protected)/objects/page.tsx — копия 3 new file mode 100644 index 0000000..1649f97 --- /dev/null +++ b/frontend/app/(protected)/objects/page.tsx — копия 3 @@ -0,0 +1,176 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import ObjectGallery from '../../../components/objects/ObjectGallery' +import { ObjectData } from '../../../components/objects/ObjectCard' +import AnimatedBackground from '../../../components/ui/AnimatedBackground' +import { useRouter } from 'next/navigation' +import Image from 'next/image' + +// Универсальная функция для преобразования объекта из бэкенда в ObjectData +const transformRawToObjectData = (raw: any): ObjectData => { + const rawId = raw?.id ?? raw?.object_id ?? raw?.uuid ?? raw?.name + const object_id = typeof rawId === 'number' ? `object_${rawId}` : String(rawId ?? '') + + const deriveTitle = (): string => { + const t = (raw?.title || '').toString().trim() + if (t) return t + const idStr = String(rawId ?? '').toString() + const numMatch = typeof rawId === 'number' + ? rawId + : (() => { const m = idStr.match(/\d+/); return m ? Number(m[0]) : undefined })() + if (typeof numMatch === 'number' && !Number.isNaN(numMatch)) { + return `Объект ${numMatch}` + } + return idStr ? `Объект ${idStr}` : `Объект ${object_id}` + } + + return { + object_id, + title: deriveTitle(), + description: raw?.description ?? `Описание объекта ${raw?.title ?? object_id}`, + image: raw?.image ?? null, + location: raw?.location ?? raw?.address ?? 'Не указано', + floors: Number(raw?.floors ?? 0), + area: String(raw?.area ?? ''), + type: raw?.type ?? 'object', + status: raw?.status ?? 'active', + } +} + +const ObjectsPage: React.FC = () => { + const [objects, setObjects] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedObjectId, setSelectedObjectId] = useState(null) + const router = useRouter() + + useEffect(() => { + const loadData = async () => { + setLoading(true) + setError(null) + try { + const url = '/api/get-objects' + const res = await fetch(url, { cache: 'no-store' }) + const payloadText = await res.text() + let payload: any + try { payload = JSON.parse(payloadText) } catch { payload = payloadText } + console.log('[ObjectsPage] GET /api/get-objects', { status: res.status, payload }) + + if (!res.ok) { + const errorMessage = typeof payload === 'string' ? payload : (payload?.error || 'Не удалось получить данные объектов') + + if (errorMessage.includes('Authentication required') || res.status === 401) { + console.log('[ObjectsPage] Authentication required, redirecting to login') + router.push('/login') + return + } + + throw new Error(errorMessage) + } + + const data = (payload?.data ?? payload) as any + let rawObjectsArray: any[] = [] + if (Array.isArray(data)) { + rawObjectsArray = data + } else if (Array.isArray(data?.objects)) { + rawObjectsArray = data.objects + } else if (data && typeof data === 'object') { + rawObjectsArray = Object.values(data) + } + + const transformedObjects = rawObjectsArray.map(transformRawToObjectData) + setObjects(transformedObjects) + } catch (err: any) { + console.error('Ошибка при загрузке данных объектов:', err) + setError(err?.message || 'Произошла неизвестная ошибка') + } finally { + setLoading(false) + } + } + + loadData() + }, [router]) + + const handleObjectSelect = (objectId: string) => { + console.log('Object selected:', objectId) + setSelectedObjectId(objectId) + } + + if (loading) { + return ( +
+ +
+
+

Загрузка объектов...

+
+
+ ) + } + + if (error) { + return ( +
+ +
+
+ + + +
+

Ошибка загрузки данных

+

{error}

+

Если проблема повторяется, обратитесь к администратору

+
+
+ ) + } + + return ( +
+ + + {/* Header */} +
+
+
+ AerBIM Logo +
+
+ + Версия: 3.0.0 + +
+
+
+ + {/* Main Content */} +
+
+ + {/* Заголовок */} +

+ Выберите объект для работы +

+ + {/* Галерея объектов */} + +
+
+
+ ) +} + +export default ObjectsPage diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index 2dfa720..1508a94 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -827,7 +827,7 @@ const ModelViewer: React.FC = ({ }, [modelReady, isSensorSelectionEnabled, onSensorPick]) const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => { - if (!sceneRef.current || !mesh) return null + if (!sceneRef.current || !mesh || !canvasRef.current) return null const scene = sceneRef.current try { const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function') @@ -841,7 +841,13 @@ const ModelViewer: React.FC = ({ const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport) if (!projected) return null - return { left: projected.x, top: projected.y } + // Позиционируем тултип слева от датчика + // Учитываем ширину тултипа (max-w-[400px]) + отступ 50px от датчика + const tooltipWidth = 400 // Максимальная ширина тултипа из DetectorMenu + const gapFromSensor = 50 // Отступ между правым краем тултипа и датчиком + const leftOffset = -(tooltipWidth + gapFromSensor) // Смещение влево от датчика + + return { left: projected.x + leftOffset, top: projected.y } } catch (error) { console.error('[ModelViewer] Error computing overlay position:', error) return null diff --git a/frontend/components/model/ModelViewer.tsx — копия 4 b/frontend/components/model/ModelViewer.tsx — копия 4 new file mode 100644 index 0000000..2dfa720 --- /dev/null +++ b/frontend/components/model/ModelViewer.tsx — копия 4 @@ -0,0 +1,983 @@ +'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, + Ray, +} 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, + showStats = false, + onToggleStats, +}) => { + 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, sensorStatusMap) + 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, sensorStatusMap) + 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 + } + + // Сначала найдём ВСЕ датчики в 3D модели (без фильтра) + const allSensorMeshesInModel = collectSensorMeshes(allMeshes, null) + const allSensorIdsInModel = allSensorMeshesInModel.map(m => getSensorIdFromMesh(m)).filter(Boolean) + + // Теперь применим фильтр по sensorStatusMap + const sensorMeshes = collectSensorMeshes(allMeshes, sensorStatusMap) + const filteredSensorIds = sensorMeshes.map(m => getSensorIdFromMesh(m)).filter(Boolean) + + console.log('[ModelViewer] Total meshes in model:', allMeshes.length) + console.log('[ModelViewer] ALL sensor meshes in 3D model (unfiltered):', allSensorIdsInModel.length, allSensorIdsInModel) + console.log('[ModelViewer] sensorStatusMap keys count:', sensorStatusMap ? Object.keys(sensorStatusMap).length : 0) + console.log('[ModelViewer] Sensor meshes found (filtered by sensorStatusMap):', sensorMeshes.length, filteredSensorIds) + + // Найдём датчики которые есть в sensorStatusMap но НЕТ в 3D модели + if (sensorStatusMap) { + const missingInModel = Object.keys(sensorStatusMap).filter(id => !allSensorIdsInModel.includes(id)) + if (missingInModel.length > 0) { + console.warn('[ModelViewer] Sensors in sensorStatusMap but MISSING in 3D model:', missingInModel.length, missingInModel.slice(0, 10)) + } + } + + 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, sensorStatusMap) + 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) + + // Простое позиционирование камеры - всегда поворачиваемся к датчику + console.log('[ModelViewer] Calculating camera direction to sensor') + + // Вычисляем направление от текущей позиции камеры к датчику + const directionToSensor = center.subtract(camera.position).normalize() + + // Преобразуем в сферические координаты + // alpha - горизонтальный угол (вокруг оси Y) + let targetAlpha = Math.atan2(directionToSensor.x, directionToSensor.z) + + // beta - вертикальный угол (от вертикали) + // Используем оптимальный угол 60° для обзора + let targetBeta = Math.PI / 3 // 60° + + console.log('[ModelViewer] Calculated camera direction:', { + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°', + sensorPosition: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + cameraPosition: { x: camera.position.x.toFixed(2), y: camera.position.y.toFixed(2), z: camera.position.z.toFixed(2) } + }) + + // Нормализуем 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) + + // Логирование перед анимацией + console.log('[ModelViewer] Starting camera animation:', { + sensorId, + from: { + target: { x: camera.target.x.toFixed(2), y: camera.target.y.toFixed(2), z: camera.target.z.toFixed(2) }, + radius: camera.radius.toFixed(2), + alpha: (camera.alpha * 180 / Math.PI).toFixed(1) + '°', + beta: (camera.beta * 180 / Math.PI).toFixed(1) + '°' + }, + to: { + target: { x: center.x.toFixed(2), y: center.y.toFixed(2), z: center.z.toFixed(2) }, + radius: targetRadius.toFixed(2), + alpha: (targetAlpha * 180 / Math.PI).toFixed(1) + '°', + beta: (targetBeta * 180 / Math.PI).toFixed(1) + '°' + }, + alphaChange: ((targetAlpha - camera.alpha) * 180 / Math.PI).toFixed(1) + '°', + betaChange: ((targetBeta - camera.beta) * 180 / Math.PI).toFixed(1) + '°' + }) + + 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 React.memo(ModelViewer) diff --git a/frontend/components/objects/ObjectCard.tsx b/frontend/components/objects/ObjectCard.tsx index 8c2026e..27b74af 100644 --- a/frontend/components/objects/ObjectCard.tsx +++ b/frontend/components/objects/ObjectCard.tsx @@ -21,12 +21,7 @@ interface ObjectCardProps { isSelected?: boolean } -// Иконка редактирования -const EditIcon = ({ className }: { className?: string }) => ( - - - -) + const ObjectCard: React.FC = ({ object, onSelect, isSelected = false }) => { const navigationService = useNavigationService() @@ -39,11 +34,7 @@ const ObjectCard: React.FC = ({ object, onSelect, isSelected = navigationService.selectObjectAndGoToDashboard(object.object_id, object.title) } - const handleEditClick = (e: React.MouseEvent) => { - e.stopPropagation() - console.log('Edit object:', object.object_id) - // Логика редактирования объекта - } + // Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей const resolveImageSrc = (src?: string | null): string => { @@ -82,7 +73,7 @@ const ObjectCard: React.FC = ({ object, onSelect, isSelected = return (
= ({ object, onSelect, isSelected = {object.description}

- + {/* Изображение объекта */}