3d tooltip
@@ -21,12 +21,17 @@ export default function Home() {
|
|||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectModel = (path: string) => {
|
||||||
|
console.log('Model selected:', path)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen">
|
<div className="relative h-screen">
|
||||||
<ModelViewer
|
<ModelViewer
|
||||||
modelPath="/models/your_model_name.gltf" //пока что передаем модель через navigation page
|
modelPath="/models/your_model_name.gltf" //пока что передаем модель через navigation page
|
||||||
onModelLoaded={handleModelLoaded}
|
onModelLoaded={handleModelLoaded}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
onSelectModel={handleSelectModel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -261,7 +261,6 @@ const NavigationPage: React.FC = () => {
|
|||||||
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
<div className="absolute left-0 top-[73px] bottom-0 bg-[#161824] border-r border-gray-700 z-20 w-[500px]">
|
||||||
<div className="h-full overflow-auto p-4">
|
<div className="h-full overflow-auto p-4">
|
||||||
<Monitoring
|
<Monitoring
|
||||||
objectId={objectId || undefined}
|
|
||||||
onClose={closeMonitoring}
|
onClose={closeMonitoring}
|
||||||
onSelectModel={(path) => {
|
onSelectModel={(path) => {
|
||||||
console.log('[NavigationPage] Model selected:', path);
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
@@ -416,7 +415,14 @@ const NavigationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ModelViewer
|
<ModelViewer
|
||||||
|
key={selectedModelPath || 'no-model'}
|
||||||
modelPath={selectedModelPath}
|
modelPath={selectedModelPath}
|
||||||
|
onSelectModel={(path) => {
|
||||||
|
console.log('[NavigationPage] Model selected:', path);
|
||||||
|
setSelectedModelPath(path)
|
||||||
|
setModelError(null)
|
||||||
|
setIsModelReady(false)
|
||||||
|
}}
|
||||||
onModelLoaded={handleModelLoaded}
|
onModelLoaded={handleModelLoaded}
|
||||||
onError={handleModelError}
|
onError={handleModelError}
|
||||||
focusSensorId={selectedDetector?.serial_number ?? selectedAlert?.detector_id?.toString() ?? null}
|
focusSensorId={selectedDetector?.serial_number ?? selectedAlert?.detector_id?.toString() ?? null}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface NavigationStore {
|
|||||||
currentObject: { id: string | undefined; title: string | undefined }
|
currentObject: { id: string | undefined; title: string | undefined }
|
||||||
navigationHistory: string[]
|
navigationHistory: string[]
|
||||||
currentSubmenu: string | null
|
currentSubmenu: string | null
|
||||||
|
currentModelPath: string | null
|
||||||
|
|
||||||
showMonitoring: boolean
|
showMonitoring: boolean
|
||||||
showFloorNavigation: boolean
|
showFloorNavigation: boolean
|
||||||
@@ -72,6 +73,7 @@ export interface NavigationStore {
|
|||||||
clearCurrentObject: () => void
|
clearCurrentObject: () => void
|
||||||
addToHistory: (path: string) => void
|
addToHistory: (path: string) => void
|
||||||
goBack: () => string | null
|
goBack: () => string | null
|
||||||
|
setCurrentModelPath: (path: string) => void
|
||||||
|
|
||||||
setCurrentSubmenu: (submenu: string | null) => void
|
setCurrentSubmenu: (submenu: string | null) => void
|
||||||
clearSubmenu: () => void
|
clearSubmenu: () => void
|
||||||
@@ -86,8 +88,7 @@ export interface NavigationStore {
|
|||||||
closeListOfDetectors: () => void
|
closeListOfDetectors: () => void
|
||||||
openSensors: () => void
|
openSensors: () => void
|
||||||
closeSensors: () => void
|
closeSensors: () => void
|
||||||
|
|
||||||
// Close all menus and submenus
|
|
||||||
closeAllMenus: () => void
|
closeAllMenus: () => void
|
||||||
|
|
||||||
setSelectedDetector: (detector: DetectorType | null) => void
|
setSelectedDetector: (detector: DetectorType | null) => void
|
||||||
@@ -100,6 +101,7 @@ export interface NavigationStore {
|
|||||||
isOnNavigationPage: () => boolean
|
isOnNavigationPage: () => boolean
|
||||||
getCurrentRoute: () => string | null
|
getCurrentRoute: () => string | null
|
||||||
getActiveSidebarItem: () => number
|
getActiveSidebarItem: () => number
|
||||||
|
PREFERRED_MODEL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const useNavigationStore = create<NavigationStore>()(
|
const useNavigationStore = create<NavigationStore>()(
|
||||||
@@ -111,6 +113,7 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
},
|
},
|
||||||
navigationHistory: [],
|
navigationHistory: [],
|
||||||
currentSubmenu: null,
|
currentSubmenu: null,
|
||||||
|
currentModelPath: null,
|
||||||
|
|
||||||
showMonitoring: false,
|
showMonitoring: false,
|
||||||
showFloorNavigation: false,
|
showFloorNavigation: false,
|
||||||
@@ -124,6 +127,8 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
showNotificationDetectorInfo: false,
|
showNotificationDetectorInfo: false,
|
||||||
selectedAlert: null,
|
selectedAlert: null,
|
||||||
showAlertMenu: false,
|
showAlertMenu: false,
|
||||||
|
|
||||||
|
PREFERRED_MODEL: 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910',
|
||||||
|
|
||||||
setCurrentObject: (id: string | undefined, title: string | undefined) =>
|
setCurrentObject: (id: string | undefined, title: string | undefined) =>
|
||||||
set({ currentObject: { id, title } }),
|
set({ currentObject: { id, title } }),
|
||||||
@@ -131,6 +136,8 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
clearCurrentObject: () =>
|
clearCurrentObject: () =>
|
||||||
set({ currentObject: { id: undefined, title: undefined } }),
|
set({ currentObject: { id: undefined, title: undefined } }),
|
||||||
|
|
||||||
|
setCurrentModelPath: (path: string) => set({ currentModelPath: path }),
|
||||||
|
|
||||||
addToHistory: (path: string) => {
|
addToHistory: (path: string) => {
|
||||||
const { navigationHistory } = get()
|
const { navigationHistory } = get()
|
||||||
const newHistory = [...navigationHistory, path]
|
const newHistory = [...navigationHistory, path]
|
||||||
@@ -247,8 +254,7 @@ const useNavigationStore = create<NavigationStore>()(
|
|||||||
selectedDetector: null,
|
selectedDetector: null,
|
||||||
currentSubmenu: null
|
currentSubmenu: null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Close all menus and submenus
|
|
||||||
closeAllMenus: () => set({
|
closeAllMenus: () => set({
|
||||||
showMonitoring: false,
|
showMonitoring: false,
|
||||||
showFloorNavigation: false,
|
showFloorNavigation: false,
|
||||||
|
|||||||
@@ -17,18 +17,20 @@ import {
|
|||||||
InstancedMesh,
|
InstancedMesh,
|
||||||
Animation,
|
Animation,
|
||||||
CubicEase,
|
CubicEase,
|
||||||
EasingFunction,
|
EasingFunction,
|
||||||
Matrix,
|
|
||||||
Viewport,
|
|
||||||
ImportMeshAsync,
|
ImportMeshAsync,
|
||||||
|
PointerEventTypes,
|
||||||
|
PointerInfo,
|
||||||
} from '@babylonjs/core'
|
} from '@babylonjs/core'
|
||||||
import '@babylonjs/loaders'
|
import '@babylonjs/loaders'
|
||||||
|
|
||||||
|
import SceneToolbar from './SceneToolbar';
|
||||||
import LoadingSpinner from '../ui/LoadingSpinner'
|
import LoadingSpinner from '../ui/LoadingSpinner'
|
||||||
|
|
||||||
|
|
||||||
export interface ModelViewerProps {
|
export interface ModelViewerProps {
|
||||||
modelPath: string
|
modelPath: string
|
||||||
|
onSelectModel: (path: string) => void;
|
||||||
onModelLoaded?: (modelData: {
|
onModelLoaded?: (modelData: {
|
||||||
meshes: AbstractMesh[]
|
meshes: AbstractMesh[]
|
||||||
boundingBox: {
|
boundingBox: {
|
||||||
@@ -43,6 +45,7 @@ export interface ModelViewerProps {
|
|||||||
|
|
||||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||||
modelPath,
|
modelPath,
|
||||||
|
onSelectModel,
|
||||||
onModelLoaded,
|
onModelLoaded,
|
||||||
onError,
|
onError,
|
||||||
focusSensorId,
|
focusSensorId,
|
||||||
@@ -62,6 +65,142 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
const [overlayPos, setOverlayPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
const [overlayData, setOverlayData] = useState<{ name?: string; sensorId?: string } | null>(null)
|
||||||
const [modelReady, setModelReady] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -165,6 +304,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
setLoadingProgress(0)
|
setLoadingProgress(0)
|
||||||
setShowModel(false)
|
setShowModel(false)
|
||||||
setModelReady(false)
|
setModelReady(false)
|
||||||
|
setPanActive(false)
|
||||||
|
|
||||||
const oldMeshes = sceneRef.current.meshes.slice();
|
const oldMeshes = sceneRef.current.meshes.slice();
|
||||||
const activeCameraId = sceneRef.current.activeCamera?.uniqueId;
|
const activeCameraId = sceneRef.current.activeCamera?.uniqueId;
|
||||||
@@ -263,8 +403,7 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearInterval(progressInterval)
|
clearInterval(progressInterval)
|
||||||
// Only report error if this loading is still relevant
|
|
||||||
if (!isDisposedRef.current && modelPath === currentModelPath) {
|
if (!isDisposedRef.current && modelPath === currentModelPath) {
|
||||||
console.error('Error loading GLTF model:', error)
|
console.error('Error loading GLTF model:', error)
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
@@ -409,34 +548,6 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
}
|
}
|
||||||
}, [focusSensorId, modelReady])
|
}, [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 (
|
return (
|
||||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||||
{!modelPath ? (
|
{!modelPath ? (
|
||||||
@@ -482,6 +593,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SceneToolbar
|
||||||
|
onZoomIn={handleZoomIn}
|
||||||
|
onZoomOut={handleZoomOut}
|
||||||
|
onTopView={handleTopView}
|
||||||
|
onPan={handlePan}
|
||||||
|
onSelectModel={onSelectModel}
|
||||||
|
panActive={panActive}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{renderOverlay
|
{renderOverlay
|
||||||
|
|||||||
185
frontend/components/model/SceneToolbar.tsx
Normal 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;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import useNavigationStore from '@/app/store/navigationStore';
|
||||||
|
|
||||||
interface MonitoringProps {
|
interface MonitoringProps {
|
||||||
objectId?: string;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onSelectModel?: (modelPath: string) => void;
|
onSelectModel?: (modelPath: string) => void;
|
||||||
}
|
}
|
||||||
@@ -10,15 +10,13 @@ interface MonitoringProps {
|
|||||||
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
||||||
const [models, setModels] = useState<{ title: string; path: string }[]>([]);
|
const [models, setModels] = useState<{ title: string; path: string }[]>([]);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const PREFERRED_MODEL = useNavigationStore((state) => state.PREFERRED_MODEL);
|
||||||
|
|
||||||
const handleSelectModel = useCallback((modelPath: string) => {
|
const handleSelectModel = useCallback((modelPath: string) => {
|
||||||
console.log(`[NavigationPage] Model selected: ${modelPath}`);
|
console.log(`[NavigationPage] Model selected: ${modelPath}`);
|
||||||
onSelectModel?.(modelPath);
|
onSelectModel?.(modelPath);
|
||||||
}, [onSelectModel]);
|
}, [onSelectModel]);
|
||||||
|
|
||||||
console.log('[Monitoring] Models:', models, 'Error:', loadError);
|
|
||||||
|
|
||||||
// Загружаем список доступных моделей из assets/big-models через API
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchModels = async () => {
|
const fetchModels = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -31,14 +29,15 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : [];
|
const items: { name: string; path: string }[] = Array.isArray(data?.models) ? data.models : [];
|
||||||
|
|
||||||
// Приоритизируем указанную модель, чтобы она была первой карточкой
|
const preferredModelName = PREFERRED_MODEL.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
||||||
const preferred = 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910';
|
|
||||||
const formatted = items
|
const formatted = items
|
||||||
.map((it) => ({ title: it.name, path: it.path }))
|
.map((it) => ({ title: it.name, path: it.path }))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const ap = a.path.includes(preferred) ? -1 : 0;
|
const aName = a.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
||||||
const bp = b.path.includes(preferred) ? -1 : 0;
|
const bName = b.path.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
||||||
if (ap !== bp) return ap - bp;
|
if (aName === preferredModelName) return -1;
|
||||||
|
if (bName === preferredModelName) return 1;
|
||||||
return a.title.localeCompare(b.title);
|
return a.title.localeCompare(b.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose, onSelectModel }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchModels();
|
fetchModels();
|
||||||
}, []);
|
}, [PREFERRED_MODEL]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<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="col-span-2">
|
||||||
<div className="rounded-lg bg-gray-200 text-gray-700 text-xs px-3 py-2 border border-gray-300">
|
<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.
|
Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "next lint && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babylonjs/core": "^8.33.4",
|
"@babylonjs/core": "^8.33.4",
|
||||||
|
|||||||
BIN
frontend/public/icons/Layers.png
Normal file
|
After Width: | Height: | Size: 516 B |
BIN
frontend/public/icons/Pointer.png
Normal file
|
After Width: | Height: | Size: 607 B |
BIN
frontend/public/icons/Video.png
Normal file
|
After Width: | Height: | Size: 419 B |
BIN
frontend/public/icons/Warehouse.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
frontend/public/icons/Zoom.png
Normal file
|
After Width: | Height: | Size: 592 B |
1
frontend/public/icons/arrow-down.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
1
frontend/public/icons/arrow-left.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 270 B |
1
frontend/public/icons/arrow-right.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
1
frontend/public/icons/arrow-up.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 270 B |
3
frontend/public/icons/minus.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
4
frontend/public/icons/plus.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 275 B |