feat / AEB-XX Реализован базовый 3D просмотрщик моделей с поддержкой GLTF
This commit is contained in:
206
frontend/components/ModelViewer.tsx
Normal file
206
frontend/components/ModelViewer.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import {
|
||||
Engine,
|
||||
Scene,
|
||||
Vector3,
|
||||
HemisphericLight,
|
||||
ArcRotateCamera,
|
||||
MeshBuilder,
|
||||
StandardMaterial,
|
||||
Color3,
|
||||
Color4,
|
||||
AbstractMesh,
|
||||
Mesh,
|
||||
Nullable,
|
||||
SceneLoader
|
||||
} from '@babylonjs/core'
|
||||
import '@babylonjs/loaders'
|
||||
import { getCacheFileName, loadCachedData, parseAndCacheScene, ParsedMeshData } from '../utils/meshCache'
|
||||
|
||||
interface ModelViewerProps {
|
||||
modelPath: string
|
||||
onModelLoaded?: (modelData: {
|
||||
meshes: AbstractMesh[]
|
||||
boundingBox: {
|
||||
min: { x: number; y: number; z: number }
|
||||
max: { x: number; y: number; z: number }
|
||||
}
|
||||
}) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
const ModelViewer: React.FC<ModelViewerProps> = ({
|
||||
modelPath,
|
||||
onModelLoaded,
|
||||
onError
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const engineRef = useRef<Nullable<Engine>>(null)
|
||||
const sceneRef = useRef<Nullable<Scene>>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const isInitializedRef = useRef(false)
|
||||
const isDisposedRef = useRef(false)
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
isDisposedRef.current = false
|
||||
isInitializedRef.current = false
|
||||
return () => {
|
||||
isDisposedRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || isInitializedRef.current) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const engine = new Engine(canvas, true)
|
||||
engineRef.current = engine
|
||||
|
||||
const scene = new Scene(engine)
|
||||
sceneRef.current = scene
|
||||
|
||||
scene.clearColor = new Color4(0.1, 0.1, 0.15, 1)
|
||||
|
||||
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)
|
||||
|
||||
engine.runRenderLoop(() => {
|
||||
if (!isDisposedRef.current) {
|
||||
scene.render()
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
if (engineRef.current) {
|
||||
engineRef.current.dispose()
|
||||
engineRef.current = null
|
||||
}
|
||||
sceneRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current || !modelPath || isDisposedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!sceneRef.current || isDisposedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
console.log('🚀 Loading GLTF model:', modelPath)
|
||||
|
||||
try {
|
||||
const cacheFileName = getCacheFileName(modelPath)
|
||||
const cachedData = await loadCachedData(cacheFileName)
|
||||
|
||||
if (cachedData) {
|
||||
console.log('📦 Using cached mesh data for analysis')
|
||||
} else {
|
||||
console.log('🔄 No cached data found, parsing scene...')
|
||||
}
|
||||
|
||||
const result = await SceneLoader.ImportMeshAsync(
|
||||
'',
|
||||
modelPath,
|
||||
'',
|
||||
sceneRef.current
|
||||
)
|
||||
|
||||
console.log('✅ GLTF Model loaded successfully!')
|
||||
console.log('📊 Loaded Meshes:', result.meshes.length)
|
||||
|
||||
if (result.meshes.length > 0) {
|
||||
|
||||
const boundingBox = result.meshes[0].getHierarchyBoundingVectors()
|
||||
const size = boundingBox.max.subtract(boundingBox.min)
|
||||
const maxDimension = Math.max(size.x, size.y, size.z)
|
||||
|
||||
const camera = sceneRef.current!.activeCamera as ArcRotateCamera
|
||||
camera.radius = maxDimension * 2
|
||||
camera.target = result.meshes[0].position
|
||||
|
||||
const parsedData = await parseAndCacheScene(result.meshes, cacheFileName, modelPath)
|
||||
|
||||
onModelLoaded?.({
|
||||
meshes: result.meshes,
|
||||
boundingBox: {
|
||||
min: boundingBox.min,
|
||||
max: boundingBox.max
|
||||
}
|
||||
})
|
||||
|
||||
console.log('🎉 Model ready for viewing!')
|
||||
} else {
|
||||
console.warn('⚠️ No meshes found in model')
|
||||
onError?.('No geometry found in model')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading GLTF model:', error)
|
||||
onError?.(`Failed to load model: ${error}`)
|
||||
} finally {
|
||||
if (!isDisposedRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadModel()
|
||||
}, [modelPath])
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full outline-none block"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/80 text-white px-8 py-5 rounded-xl z-50 flex items-center gap-3 text-base font-medium">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Loading Model...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelViewer
|
||||
Reference in New Issue
Block a user