221 lines
6.0 KiB
TypeScript
221 lines
6.0 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
Engine,
|
|
Scene,
|
|
Vector3,
|
|
HemisphericLight,
|
|
ArcRotateCamera,
|
|
Color3,
|
|
Color4,
|
|
AbstractMesh,
|
|
Nullable,
|
|
SceneLoader
|
|
} from '@babylonjs/core'
|
|
import '@babylonjs/loaders'
|
|
import LoadingSpinner from '../ui/LoadingSpinner'
|
|
|
|
|
|
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 [loadingProgress, setLoadingProgress] = useState(0)
|
|
const [showModel, setShowModel] = 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)
|
|
setLoadingProgress(0)
|
|
setShowModel(false)
|
|
console.log('Loading GLTF model:', modelPath)
|
|
|
|
// UI элемент загрузчика (есть эффект замедленности)
|
|
const progressInterval = setInterval(() => {
|
|
setLoadingProgress(prev => {
|
|
if (prev >= 90) {
|
|
clearInterval(progressInterval)
|
|
return 90
|
|
}
|
|
return prev + Math.random() * 15
|
|
})
|
|
}, 100)
|
|
|
|
try {
|
|
const result = await SceneLoader.ImportMeshAsync(
|
|
'',
|
|
modelPath,
|
|
'',
|
|
sceneRef.current
|
|
)
|
|
|
|
clearInterval(progressInterval)
|
|
setLoadingProgress(100)
|
|
|
|
console.log('GLTF Model loaded successfully!')
|
|
|
|
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
|
|
|
|
|
|
onModelLoaded?.({
|
|
meshes: result.meshes,
|
|
boundingBox: {
|
|
min: boundingBox.min,
|
|
max: boundingBox.max
|
|
}
|
|
})
|
|
|
|
// Плавное появление модели
|
|
setTimeout(() => {
|
|
if (!isDisposedRef.current) {
|
|
setShowModel(true)
|
|
setIsLoading(false)
|
|
}
|
|
}, 500)
|
|
} else {
|
|
console.warn('No meshes found in model')
|
|
onError?.('No geometry found in model')
|
|
setIsLoading(false)
|
|
}
|
|
} catch (error) {
|
|
clearInterval(progressInterval)
|
|
console.error('Error loading GLTF model:', error)
|
|
onError?.(`Failed to load model: ${error}`)
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
loadModel()
|
|
}, [modelPath, onError, onModelLoaded])
|
|
|
|
return (
|
|
<div className="w-full h-screen relative bg-gray-900 overflow-hidden">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={`w-full h-full outline-none block transition-opacity duration-500 ${
|
|
showModel ? 'opacity-100' : 'opacity-0'
|
|
}`}
|
|
/>
|
|
{isLoading && (
|
|
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
|
|
<LoadingSpinner
|
|
progress={loadingProgress}
|
|
size={120}
|
|
strokeWidth={8}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ModelViewer |