diff --git a/frontend/package.json b/frontend/package.json index 5757c39..0712903 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "antd": "^6.2.2", "axios": "^1.13.4", "framer-motion": "^12.29.2", + "jszip": "^3.10.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", diff --git a/frontend/src/components/ModelViewer.jsx b/frontend/src/components/ModelViewer.jsx index d766735..db7a4b4 100644 --- a/frontend/src/components/ModelViewer.jsx +++ b/frontend/src/components/ModelViewer.jsx @@ -1,16 +1,21 @@ -import React, { Suspense } from 'react'; +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'; 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) => { - materials.preload(); - loader.setMaterials(materials); + if (materials) { + materials.preload(); + loader.setMaterials(materials); + } }); const clone = obj.clone(); @@ -46,6 +51,111 @@ const LoadingOverlay = () => { }; 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 ( +