fix: use 3Dmodule zip
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"antd": "^6.2.2",
|
"antd": "^6.2.2",
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense, useState, useEffect } from 'react';
|
||||||
import { Canvas, useLoader } from '@react-three/fiber';
|
import { Canvas, useLoader } from '@react-three/fiber';
|
||||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
|
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
|
||||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader';
|
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader';
|
||||||
import { OrbitControls, Stage, useProgress, Environment, ContactShadows } from '@react-three/drei';
|
import { OrbitControls, Stage, useProgress, Environment, ContactShadows } from '@react-three/drei';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
const Model = ({ objPath, mtlPath, scale = 1 }) => {
|
const Model = ({ objPath, mtlPath, scale = 1 }) => {
|
||||||
const materials = useLoader(MTLLoader, mtlPath);
|
// If mtlPath is provided, load materials first
|
||||||
|
const materials = mtlPath ? useLoader(MTLLoader, mtlPath) : null;
|
||||||
|
|
||||||
const obj = useLoader(OBJLoader, objPath, (loader) => {
|
const obj = useLoader(OBJLoader, objPath, (loader) => {
|
||||||
materials.preload();
|
if (materials) {
|
||||||
loader.setMaterials(materials);
|
materials.preload();
|
||||||
|
loader.setMaterials(materials);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const clone = obj.clone();
|
const clone = obj.clone();
|
||||||
@@ -46,6 +51,111 @@ const LoadingOverlay = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => {
|
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 (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
@@ -56,7 +166,7 @@ const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => {
|
|||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Stage environment="city" intensity={0.6} adjustCamera={true}>
|
<Stage environment="city" intensity={0.6} adjustCamera={true}>
|
||||||
<Model objPath={objPath} mtlPath={mtlPath} scale={scale} />
|
<Model objPath={paths.obj} mtlPath={paths.mtl} scale={scale} />
|
||||||
</Stage>
|
</Stage>
|
||||||
<Environment preset="city" />
|
<Environment preset="city" />
|
||||||
<ContactShadows position={[0, -0.8, 0]} opacity={0.4} scale={10} blur={2} far={0.8} />
|
<ContactShadows position={[0, -0.8, 0]} opacity={0.4} scale={10} blur={2} far={0.8} />
|
||||||
|
|||||||
@@ -131,14 +131,23 @@ const Home = () => {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
color: '#444',
|
color: '#444',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.05)'
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
<motion.div
|
{product.static_image_url ? (
|
||||||
animate={{ y: [0, -10, 0] }}
|
<img
|
||||||
transition={{ repeat: Infinity, duration: 3, ease: "easeInOut" }}
|
src={product.static_image_url}
|
||||||
>
|
alt={product.name}
|
||||||
<RocketOutlined style={{ fontSize: 60, color: '#00b96b' }} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
</motion.div>
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, -10, 0] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 3, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<RocketOutlined style={{ fontSize: 60, color: '#00b96b' }} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onClick={() => navigate(`/product/${product.id}`)}
|
onClick={() => navigate(`/product/${product.id}`)}
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ const ProductDetail = () => {
|
|||||||
|
|
||||||
const getModelPaths = (p) => {
|
const getModelPaths = (p) => {
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
|
|
||||||
|
// 优先使用后台配置的 3D 模型 URL
|
||||||
|
if (p.model_3d_url) {
|
||||||
|
return { obj: p.model_3d_url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有静态图,且没有特定的 3D 模型 URL,则优先显示静态图,不进入下方的通用 3D 模板匹配
|
||||||
|
if (p.static_image_url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const text = (p.name + p.description).toLowerCase();
|
const text = (p.name + p.description).toLowerCase();
|
||||||
|
|
||||||
if (text.includes('mini')) {
|
if (text.includes('mini')) {
|
||||||
@@ -117,6 +128,8 @@ const ProductDetail = () => {
|
|||||||
}}>
|
}}>
|
||||||
{modelPaths ? (
|
{modelPaths ? (
|
||||||
<ModelViewer objPath={modelPaths.obj} mtlPath={modelPaths.mtl} />
|
<ModelViewer objPath={modelPaths.obj} mtlPath={modelPaths.mtl} />
|
||||||
|
) : product.static_image_url ? (
|
||||||
|
<img src={product.static_image_url} alt={product.name} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||||
) : (
|
) : (
|
||||||
<ThunderboltOutlined style={{ fontSize: 120, color: '#00b96b' }} />
|
<ThunderboltOutlined style={{ fontSize: 120, color: '#00b96b' }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user