This commit is contained in:
@@ -1,218 +0,0 @@
|
||||
import React, { Suspense, useState, useEffect } from 'react';
|
||||
import { Canvas, useLoader } from '@react-three/fiber';
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader';
|
||||
import { OrbitControls, Stage, useProgress, Environment, ContactShadows } from '@react-three/drei';
|
||||
import { Spin } from 'antd';
|
||||
import JSZip from 'jszip';
|
||||
import * as THREE from 'three';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error("3D Model Viewer Error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#888',
|
||||
padding: 20,
|
||||
textAlign: 'center',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
3D 模型加载失败
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const Model = ({ objPath, mtlPath, scale = 1 }) => {
|
||||
// If mtlPath is provided, load materials first
|
||||
const materials = mtlPath ? useLoader(MTLLoader, mtlPath) : null;
|
||||
|
||||
const obj = useLoader(OBJLoader, objPath, (loader) => {
|
||||
if (materials) {
|
||||
materials.preload();
|
||||
loader.setMaterials(materials);
|
||||
}
|
||||
});
|
||||
|
||||
const clone = obj.clone();
|
||||
return <primitive object={clone} scale={scale} />;
|
||||
};
|
||||
|
||||
const LoadingOverlay = () => {
|
||||
const { progress, active } = useProgress();
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ color: '#00b96b', marginTop: 10, fontWeight: 'bold' }}>
|
||||
{progress.toFixed(0)}% Loading
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => {
|
||||
const [paths, setPaths] = useState(null);
|
||||
const [unzipping, setUnzipping] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const blobUrls = [];
|
||||
|
||||
const loadPaths = async () => {
|
||||
if (!objPath) return;
|
||||
|
||||
// 如果是 zip 文件
|
||||
if (objPath.toLowerCase().endsWith('.zip')) {
|
||||
setUnzipping(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(objPath);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
let extractedObj = null;
|
||||
let extractedMtl = null;
|
||||
const fileMap = {};
|
||||
|
||||
// 1. 提取所有文件并创建 Blob URL 映射
|
||||
for (const [filename, file] of Object.entries(zip.files)) {
|
||||
if (file.dir) continue;
|
||||
|
||||
const content = await file.async('blob');
|
||||
const url = URL.createObjectURL(content);
|
||||
blobUrls.push(url);
|
||||
|
||||
// 记录文件名到 URL 的映射,用于后续材质引用图片等情况
|
||||
const baseName = filename.split('/').pop();
|
||||
fileMap[baseName] = url;
|
||||
|
||||
if (filename.toLowerCase().endsWith('.obj')) {
|
||||
extractedObj = url;
|
||||
} else if (filename.toLowerCase().endsWith('.mtl')) {
|
||||
extractedMtl = url;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
if (extractedObj) {
|
||||
setPaths({ obj: extractedObj, mtl: extractedMtl });
|
||||
} else {
|
||||
setError('压缩包内未找到 .obj 模型文件');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error unzipping model:', err);
|
||||
if (isMounted) setError('加载压缩包失败');
|
||||
} finally {
|
||||
if (isMounted) setUnzipping(false);
|
||||
}
|
||||
} else {
|
||||
// 普通路径
|
||||
setPaths({ obj: objPath, mtl: mtlPath });
|
||||
}
|
||||
};
|
||||
|
||||
loadPaths();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// 清理 Blob URL 释放内存
|
||||
blobUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [objPath, mtlPath]);
|
||||
|
||||
if (unzipping) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
background: 'rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<div style={{ color: '#00b96b', marginTop: 15, fontWeight: '500' }}>正在解压 3D 资源...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#ff4d4f',
|
||||
padding: 20,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!paths) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<ErrorBoundary>
|
||||
<LoadingOverlay />
|
||||
<Canvas shadows dpr={[1, 2]} camera={{ fov: 45, position: [0, 0, 5] }} style={{ height: '100%', width: '100%' }}>
|
||||
<ambientLight intensity={0.7} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||
<spotLight position={[-10, 10, 10]} angle={0.15} penumbra={1} intensity={1} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Stage environment="city" intensity={0.6} adjustCamera={true}>
|
||||
<Model objPath={paths.obj} mtlPath={paths.mtl} scale={scale} />
|
||||
</Stage>
|
||||
<Environment preset="city" />
|
||||
<ContactShadows position={[0, -0.8, 0]} opacity={0.4} scale={10} blur={2} far={0.8} />
|
||||
</Suspense>
|
||||
<OrbitControls autoRotate={autoRotate} makeDefault />
|
||||
</Canvas>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelViewer;
|
||||
Reference in New Issue
Block a user