Изменение размещения тултипа датчика, изменение страницы объекта (убрана кнопка изменить, добавлена граница карточки объекта для лучшего вида на темном фоне, добавлен отступ и изменено положение заголовка
This commit is contained in:
@@ -156,7 +156,7 @@ const ObjectsPage: React.FC = () => {
|
|||||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<h1 className="text-2xl font-bold text-white mb-6">
|
<h1 className="text-2xl font-bold text-white text-center mb-[34px]">
|
||||||
Выберите объект для работы
|
Выберите объект для работы
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
176
frontend/app/(protected)/objects/page.tsx — копия 3
Normal file
176
frontend/app/(protected)/objects/page.tsx — копия 3
Normal file
@@ -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<ObjectData[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(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 (
|
||||||
|
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-white">Загрузка объектов...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
|
<div className="text-red-500 mb-4">
|
||||||
|
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Ошибка загрузки данных</h3>
|
||||||
|
<p className="text-[#71717a] mb-4">{error}</p>
|
||||||
|
<p className="text-sm text-gray-500">Если проблема повторяется, обратитесь к администратору</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col h-screen overflow-hidden">
|
||||||
|
<AnimatedBackground />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="relative z-20 bg-[#161824]/80 backdrop-blur-sm border-b border-blue-500/20">
|
||||||
|
<div className="flex items-center justify-between px-6 py-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Image
|
||||||
|
src="/icons/logo.png"
|
||||||
|
alt="AerBIM Logo"
|
||||||
|
width={150}
|
||||||
|
height={40}
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Версия: <span className="text-cyan-400 font-semibold">3.0.0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="relative z-10 flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-6">
|
||||||
|
Выберите объект для работы
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Галерея объектов */}
|
||||||
|
<ObjectGallery
|
||||||
|
objects={objects}
|
||||||
|
title=""
|
||||||
|
onObjectSelect={handleObjectSelect}
|
||||||
|
selectedObjectId={selectedObjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ObjectsPage
|
||||||
@@ -827,7 +827,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
}, [modelReady, isSensorSelectionEnabled, onSensorPick])
|
||||||
|
|
||||||
const computeOverlayPosition = React.useCallback((mesh: AbstractMesh | null) => {
|
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
|
const scene = sceneRef.current
|
||||||
try {
|
try {
|
||||||
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
|
const bbox = (typeof mesh.getHierarchyBoundingVectors === 'function')
|
||||||
@@ -841,7 +841,13 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
|
const projected = Vector3.Project(center, Matrix.Identity(), scene.getTransformMatrix(), viewport)
|
||||||
if (!projected) return null
|
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) {
|
} catch (error) {
|
||||||
console.error('[ModelViewer] Error computing overlay position:', error)
|
console.error('[ModelViewer] Error computing overlay position:', error)
|
||||||
return null
|
return null
|
||||||
|
|||||||
983
frontend/components/model/ModelViewer.tsx — копия 4
Normal file
983
frontend/components/model/ModelViewer.tsx — копия 4
Normal file
@@ -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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||||
|
modelPath,
|
||||||
|
onSelectModel,
|
||||||
|
onModelLoaded,
|
||||||
|
onError,
|
||||||
|
focusSensorId,
|
||||||
|
renderOverlay,
|
||||||
|
isSensorSelectionEnabled,
|
||||||
|
onSensorPick,
|
||||||
|
highlightAllSensors,
|
||||||
|
sensorStatusMap,
|
||||||
|
showStats = false,
|
||||||
|
onToggleStats,
|
||||||
|
}) => {
|
||||||
|
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, 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 (
|
||||||
|
<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 React.memo(ModelViewer)
|
||||||
@@ -21,12 +21,7 @@ interface ObjectCardProps {
|
|||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Иконка редактирования
|
|
||||||
const EditIcon = ({ className }: { className?: string }) => (
|
|
||||||
<svg className={className} fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected = false }) => {
|
const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected = false }) => {
|
||||||
const navigationService = useNavigationService()
|
const navigationService = useNavigationService()
|
||||||
@@ -39,11 +34,7 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
navigationService.selectObjectAndGoToDashboard(object.object_id, object.title)
|
navigationService.selectObjectAndGoToDashboard(object.object_id, object.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
console.log('Edit object:', object.object_id)
|
|
||||||
// Логика редактирования объекта
|
|
||||||
}
|
|
||||||
|
|
||||||
// Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей
|
// Возврат к тестовому изображению, если src отсутствует/некорректен; нормализация относительных путей
|
||||||
const resolveImageSrc = (src?: string | null): string => {
|
const resolveImageSrc = (src?: string | null): string => {
|
||||||
@@ -82,7 +73,7 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`flex flex-col w-full min-h-[340px] h-[340px] sm:h-auto sm:min-h-[300px] items-start gap-3 p-3 sm:p-4 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] ${
|
className={`flex flex-col w-full min-h-[340px] h-[340px] sm:h-auto sm:min-h-[300px] items-start gap-3 p-3 sm:p-4 relative bg-[#161824] rounded-[20px] overflow-hidden cursor-pointer transition-all duration-200 hover:bg-[#1a1d2e] border border-white/20 ${
|
||||||
isSelected ? 'ring-2 ring-blue-500' : ''
|
isSelected ? 'ring-2 ring-blue-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
@@ -103,16 +94,7 @@ const ObjectCard: React.FC<ObjectCardProps> = ({ object, onSelect, isSelected =
|
|||||||
{object.description}
|
{object.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="inline-flex flex-shrink-0 bg-[#3193f5] h-10 items-center justify-center gap-2 px-3 sm:px-4 py-2 relative rounded-md transition-colors duration-200 hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 w-full sm:w-auto"
|
|
||||||
aria-label={`Изменить ${object.title}`}
|
|
||||||
onClick={handleEditClick}
|
|
||||||
>
|
|
||||||
<EditIcon className="!relative !w-4 !h-4 text-white flex-shrink-0" />
|
|
||||||
<span className="font-medium text-white text-sm leading-5 relative tracking-[0] sm:whitespace-nowrap">
|
|
||||||
Изменить
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Изображение объекта */}
|
{/* Изображение объекта */}
|
||||||
|
|||||||
Reference in New Issue
Block a user