diff --git a/frontend/app/(protected)/model/page.tsx b/frontend/app/(protected)/model/page.tsx index 43ee2fe..76f01b0 100644 --- a/frontend/app/(protected)/model/page.tsx +++ b/frontend/app/(protected)/model/page.tsx @@ -21,12 +21,17 @@ export default function Home() { setError(errorMessage) } + const handleSelectModel = (path: string) => { + console.log('Model selected:', path) + } + return (
{error && ( diff --git a/frontend/app/(protected)/navigation/page.tsx b/frontend/app/(protected)/navigation/page.tsx index 2bb17ed..a190aeb 100644 --- a/frontend/app/(protected)/navigation/page.tsx +++ b/frontend/app/(protected)/navigation/page.tsx @@ -261,7 +261,6 @@ const NavigationPage: React.FC = () => {
{ console.log('[NavigationPage] Model selected:', path); @@ -416,7 +415,14 @@ const NavigationPage: React.FC = () => {
) : ( { + console.log('[NavigationPage] Model selected:', path); + setSelectedModelPath(path) + setModelError(null) + setIsModelReady(false) + }} onModelLoaded={handleModelLoaded} onError={handleModelError} focusSensorId={selectedDetector?.serial_number ?? selectedAlert?.detector_id?.toString() ?? null} diff --git a/frontend/app/store/navigationStore.ts b/frontend/app/store/navigationStore.ts index ea8dc30..703488e 100644 --- a/frontend/app/store/navigationStore.ts +++ b/frontend/app/store/navigationStore.ts @@ -54,6 +54,7 @@ export interface NavigationStore { currentObject: { id: string | undefined; title: string | undefined } navigationHistory: string[] currentSubmenu: string | null + currentModelPath: string | null showMonitoring: boolean showFloorNavigation: boolean @@ -72,6 +73,7 @@ export interface NavigationStore { clearCurrentObject: () => void addToHistory: (path: string) => void goBack: () => string | null + setCurrentModelPath: (path: string) => void setCurrentSubmenu: (submenu: string | null) => void clearSubmenu: () => void @@ -86,8 +88,7 @@ export interface NavigationStore { closeListOfDetectors: () => void openSensors: () => void closeSensors: () => void - - // Close all menus and submenus + closeAllMenus: () => void setSelectedDetector: (detector: DetectorType | null) => void @@ -100,6 +101,7 @@ export interface NavigationStore { isOnNavigationPage: () => boolean getCurrentRoute: () => string | null getActiveSidebarItem: () => number + PREFERRED_MODEL: string } const useNavigationStore = create()( @@ -111,6 +113,7 @@ const useNavigationStore = create()( }, navigationHistory: [], currentSubmenu: null, + currentModelPath: null, showMonitoring: false, showFloorNavigation: false, @@ -124,6 +127,8 @@ const useNavigationStore = create()( showNotificationDetectorInfo: false, selectedAlert: null, showAlertMenu: false, + + PREFERRED_MODEL: 'AerBIM-Monitor_ASM-HT-Viewer_Expo2017Astana_20250910', setCurrentObject: (id: string | undefined, title: string | undefined) => set({ currentObject: { id, title } }), @@ -131,6 +136,8 @@ const useNavigationStore = create()( clearCurrentObject: () => set({ currentObject: { id: undefined, title: undefined } }), + setCurrentModelPath: (path: string) => set({ currentModelPath: path }), + addToHistory: (path: string) => { const { navigationHistory } = get() const newHistory = [...navigationHistory, path] @@ -247,8 +254,7 @@ const useNavigationStore = create()( selectedDetector: null, currentSubmenu: null }), - - // Close all menus and submenus + closeAllMenus: () => set({ showMonitoring: false, showFloorNavigation: false, diff --git a/frontend/components/model/ModelViewer.tsx b/frontend/components/model/ModelViewer.tsx index adca0c8..96c8e25 100644 --- a/frontend/components/model/ModelViewer.tsx +++ b/frontend/components/model/ModelViewer.tsx @@ -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 = ({ modelPath, + onSelectModel, onModelLoaded, onError, focusSensorId, @@ -62,6 +65,142 @@ const ModelViewer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ } }, [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 (
{!modelPath ? ( @@ -482,6 +593,14 @@ const ModelViewer: React.FC = ({
)} + )} {renderOverlay diff --git a/frontend/components/model/SceneToolbar.tsx b/frontend/components/model/SceneToolbar.tsx new file mode 100644 index 0000000..458f265 --- /dev/null +++ b/frontend/components/model/SceneToolbar.tsx @@ -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 = ({ + 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 ( +
+
+
+ {defaultButtons.map((button, index) => ( +
+ + {button.active && button.children && ( +
+ {button.children.map((childButton, childIndex) => ( + + ))} +
+ )} +
+ ))} +
+
+
+ ); +}; + +export default SceneToolbar; \ No newline at end of file diff --git a/frontend/components/navigation/Monitoring.tsx b/frontend/components/navigation/Monitoring.tsx index 590d348..9410d18 100644 --- a/frontend/components/navigation/Monitoring.tsx +++ b/frontend/components/navigation/Monitoring.tsx @@ -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 = ({ onClose, onSelectModel }) => { const [models, setModels] = useState<{ title: string; path: string }[]>([]); const [loadError, setLoadError] = useState(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 = ({ 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 = ({ onClose, onSelectModel }) => { }; fetchModels(); - }, []); + }, [PREFERRED_MODEL]); return (
@@ -144,7 +143,7 @@ const Monitoring: React.FC = ({ onClose, onSelectModel }) => { )} - {models.length === 0 && ( + {models.length === 0 && !loadError && (
Список моделей пуст. Добавьте файлы в assets/big-models или проверьте API /api/big-models/list. diff --git a/frontend/package.json b/frontend/package.json index a10ce7d..d98325e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "next lint && tsc --noEmit" }, "dependencies": { "@babylonjs/core": "^8.33.4", diff --git a/frontend/public/icons/Layers.png b/frontend/public/icons/Layers.png new file mode 100644 index 0000000..077db4b Binary files /dev/null and b/frontend/public/icons/Layers.png differ diff --git a/frontend/public/icons/Pointer.png b/frontend/public/icons/Pointer.png new file mode 100644 index 0000000..f53f228 Binary files /dev/null and b/frontend/public/icons/Pointer.png differ diff --git a/frontend/public/icons/Video.png b/frontend/public/icons/Video.png new file mode 100644 index 0000000..e5d048f Binary files /dev/null and b/frontend/public/icons/Video.png differ diff --git a/frontend/public/icons/Warehouse.png b/frontend/public/icons/Warehouse.png new file mode 100644 index 0000000..6ff846f Binary files /dev/null and b/frontend/public/icons/Warehouse.png differ diff --git a/frontend/public/icons/Zoom.png b/frontend/public/icons/Zoom.png new file mode 100644 index 0000000..d283f7c Binary files /dev/null and b/frontend/public/icons/Zoom.png differ diff --git a/frontend/public/icons/arrow-down.svg b/frontend/public/icons/arrow-down.svg new file mode 100644 index 0000000..a53ccfe --- /dev/null +++ b/frontend/public/icons/arrow-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons/arrow-left.svg b/frontend/public/icons/arrow-left.svg new file mode 100644 index 0000000..798ad2e --- /dev/null +++ b/frontend/public/icons/arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons/arrow-right.svg b/frontend/public/icons/arrow-right.svg new file mode 100644 index 0000000..a40fd6f --- /dev/null +++ b/frontend/public/icons/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons/arrow-up.svg b/frontend/public/icons/arrow-up.svg new file mode 100644 index 0000000..64ef56e --- /dev/null +++ b/frontend/public/icons/arrow-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons/minus.svg b/frontend/public/icons/minus.svg new file mode 100644 index 0000000..367f133 --- /dev/null +++ b/frontend/public/icons/minus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/public/icons/plus.svg b/frontend/public/icons/plus.svg new file mode 100644 index 0000000..6cda55c --- /dev/null +++ b/frontend/public/icons/plus.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file