3d tooltip

This commit is contained in:
iv_vuytsik
2025-12-05 00:17:21 +03:00
parent 2456f21929
commit 60e8ef921d
18 changed files with 382 additions and 50 deletions

View File

@@ -17,18 +17,20 @@ import {
InstancedMesh,
Animation,
CubicEase,
EasingFunction,
Matrix,
Viewport,
EasingFunction,
ImportMeshAsync,
PointerEventTypes,
PointerInfo,
} from '@babylonjs/core'
import '@babylonjs/loaders'
import SceneToolbar from './SceneToolbar';
import LoadingSpinner from '../ui/LoadingSpinner'
export interface ModelViewerProps {
modelPath: string
onSelectModel: (path: string) => void;
onModelLoaded?: (modelData: {
meshes: AbstractMesh[]
boundingBox: {
@@ -43,6 +45,7 @@ export interface ModelViewerProps {
const ModelViewer: React.FC<ModelViewerProps> = ({
modelPath,
onSelectModel,
onModelLoaded,
onError,
focusSensorId,
@@ -62,6 +65,142 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
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 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
);
}
};
useEffect(() => {
@@ -165,6 +304,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
setLoadingProgress(0)
setShowModel(false)
setModelReady(false)
setPanActive(false)
const oldMeshes = sceneRef.current.meshes.slice();
const activeCameraId = sceneRef.current.activeCamera?.uniqueId;
@@ -263,8 +403,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
setIsLoading(false)
}
} catch (error) {
clearInterval(progressInterval)
// Only report error if this loading is still relevant
clearInterval(progressInterval)
if (!isDisposedRef.current && modelPath === currentModelPath) {
console.error('Error loading GLTF model:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
@@ -409,34 +548,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}
}, [focusSensorId, modelReady])
useEffect(() => {
const scene = sceneRef.current
if (!scene || isDisposedRef.current) return
const observer = scene.onAfterRenderObservable.add(() => {
const chosen = chosenMeshRef.current
if (!chosen) return
try {
const engine = scene.getEngine()
const cam = scene.activeCamera
if (!cam) return
const center = chosen.getBoundingInfo().boundingBox.centerWorld
const world = Matrix.IdentityReadOnly
const transform = scene.getTransformMatrix()
const viewport = new Viewport(0, 0, engine.getRenderWidth(), engine.getRenderHeight())
const projected = Vector3.Project(center, world, transform, viewport)
setOverlayPos({ left: projected.x, top: projected.y })
} catch (error) {
console.warn('[ModelViewer] Error updating overlay position:', error)
setOverlayPos(null)
}
})
return () => {
scene.onAfterRenderObservable.remove(observer)
}
}, [])
return (
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
{!modelPath ? (
@@ -482,6 +593,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
</div>
</div>
)}
<SceneToolbar
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onTopView={handleTopView}
onPan={handlePan}
onSelectModel={onSelectModel}
panActive={panActive}
/>
</>
)}
{renderOverlay

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react';
import Image from 'next/image';
import useNavigationStore from '@/app/store/navigationStore';
interface ToolbarButton {
icon: string;
label: string;
onClick: () => void;
onMouseDown?: () => void;
onMouseUp?: () => void;
active?: boolean;
children?: ToolbarButton[];
}
interface SceneToolbarProps {
onZoomIn?: () => void;
onZoomOut?: () => void;
onTopView?: () => void;
onPan?: () => void;
onSelectModel?: (modelPath: string) => void;
panActive?: boolean;
navMenuActive?: boolean;
}
const SceneToolbar: React.FC<SceneToolbarProps> = ({
onZoomIn,
onZoomOut,
onTopView,
onPan,
onSelectModel,
panActive = false,
navMenuActive = false,
}) => {
const [isZoomOpen, setIsZoomOpen] = useState(false);
const { PREFERRED_MODEL, showMonitoring, openMonitoring, closeMonitoring } = useNavigationStore();
const handleToggleNavMenu = () => {
if (showMonitoring) {
closeMonitoring();
} else {
openMonitoring();
}
};
const handleHomeClick = async () => {
if (onSelectModel) {
try {
const res = await fetch('/api/big-models/list');
if (!res.ok) {
throw new Error('Failed to fetch models list');
}
const data = await res.json();
const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : [];
const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
const preferredModel = items.find(model => (model.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '') === preferredModelName);
if (preferredModel) {
onSelectModel(preferredModel.path);
} else {
console.error('Preferred model not found in the list');
}
} catch (error) {
console.error('Error fetching models list:', error);
}
}
};
const defaultButtons: ToolbarButton[] = [
{
icon: '/icons/Zoom.png',
label: 'Zoom',
onClick: () => setIsZoomOpen(!isZoomOpen),
active: isZoomOpen,
children: [
{
icon: '/icons/plus.svg',
label: 'Zoom In',
onClick: onZoomIn || (() => {}),
},
{
icon: '/icons/minus.svg',
label: 'Zoom Out',
onClick: onZoomOut || (() => {}),
},
]
},
{
icon: '/icons/Video.png',
label: "Top View",
onClick: onTopView || (() => console.log('Top View')),
},
{
icon: '/icons/Pointer.png',
label: 'Pan',
onClick: onPan || (() => console.log('Pan')),
active: panActive,
},
{
icon: '/icons/Warehouse.png',
label: 'Home',
onClick: handleHomeClick,
},
{
icon: '/icons/Layers.png',
label: 'Levels',
onClick: handleToggleNavMenu,
active: navMenuActive,
},
];
return (
<div className="fixed right-5 top-1/2 transform -translate-y-1/2 z-50">
<div className="flex flex-col gap-0">
<div
className="flex flex-col items-center gap-2 py-4 bg-[#161824] rounded-[15px] border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)]"
style={{ minHeight: '320px' }}
>
{defaultButtons.map((button, index) => (
<div key={index} className="flex flex-col items-center gap-2">
<button
onClick={button.onClick}
className={`
relative group flex items-center justify-center w-16 h-12 rounded-lg transition-all duration-200
hover:bg-blue-600/20 hover:scale-110 hover:shadow-lg
focus:outline-none focus:ring-2 focus:ring-blue-500/50
${button.active
? 'bg-blue-600/30 text-blue-400 shadow-md'
: 'bg-transparent text-gray-300 hover:text-blue-400'
}
`}
title={button.label}
>
<Image
src={button.icon}
alt={button.label}
width={20}
height={20}
className="w-5 h-5 transition-transform duration-200 group-hover:scale-110"
/>
<div className="absolute right-full mr-3 top-1/2 transform -translate-y-1/2
opacity-0 group-hover:opacity-100 transition-opacity duration-200
pointer-events-none z-60">
<div className="bg-gray-900 text-white text-xs px-2 py-1 rounded
whitespace-nowrap shadow-lg border border-gray-700">
{button.label}
</div>
<div className="absolute left-full top-1/2 transform -translate-y-1/2
w-0 h-0 border-t-4 border-t-transparent
border-b-4 border-b-transparent
border-l-4 border-l-gray-900">
</div>
</div>
</button>
{button.active && button.children && (
<div className="flex flex-col gap-2 mt-2">
{button.children.map((childButton, childIndex) => (
<button
key={childIndex}
onClick={childButton.onClick}
onMouseDown={childButton.onMouseDown}
onMouseUp={childButton.onMouseUp}
className="relative group flex items-center justify-center w-12 h-10 bg-gray-800/50 rounded-md transition-all duration-200 hover:bg-blue-600/30"
title={childButton.label}
>
<Image
src={childButton.icon}
alt={childButton.label}
width={16}
height={16}
className="w-4 h-4"
/>
</button>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
};
export default SceneToolbar;

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react';
import Image from 'next/image';
import useNavigationStore from '@/app/store/navigationStore';
interface MonitoringProps {
objectId?: string;
onClose?: () => void;
onSelectModel?: (modelPath: string) => void;
}
@@ -10,15 +10,13 @@ interface MonitoringProps {
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
const [models, setModels] = useState<{ title: string; path: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const PREFERRED_MODEL = useNavigationStore((state) => state.PREFERRED_MODEL);
const handleSelectModel = useCallback((modelPath: string) => {
console.log(`[NavigationPage] Model selected: ${modelPath}`);
onSelectModel?.(modelPath);
}, [onSelectModel]);
console.log('[Monitoring] Models:', models, 'Error:', loadError);
// Загружаем список доступных моделей из assets/big-models через API
useEffect(() => {
const fetchModels = async () => {
try {
@@ -31,14 +29,15 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
const data = await res.json();
const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : [];
// Приоритизируем указанную модель, чтобы она была первой карточкой
const preferred = 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910';
const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
const formatted = items
.map((it) => ({ title: it.name, path: it.path }))
.sort((a, b) => {
const ap = a.path.includes(preferred) ? -1 : 0;
const bp = b.path.includes(preferred) ? -1 : 0;
if (ap !== bp) return ap - bp;
const aName = a.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
const bName = b.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
if (aName === preferredModelName) return -1;
if (bName === preferredModelName) return 1;
return a.title.localeCompare(b.title);
});
@@ -51,7 +50,7 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
};
fetchModels();
}, []);
}, [PREFERRED_MODEL]);
return (
<div className="w-full">
@@ -144,7 +143,7 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
</>
)}
{models.length === 0 && (
{models.length === 0 && !loadError && (
<div className="col-span-2">
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list.