lanxin недель назад: 2
Родитель
Сommit
12bd4d7708
6 измененных файлов с 6181 добавлено и 217 удалено
  1. 1 1
      index.html
  2. 1 1
      src/App.jsx
  3. 229 215
      src/scene.jsx
  4. 320 0
      usdzLoader/TinyUSDZLoader.js
  5. 5630 0
      usdzLoader/tinyusdz.js
  6. BIN
      usdzLoader/tinyusdz.wasm

+ 1 - 1
index.html

@@ -10,10 +10,10 @@
       body,
       #root {
         height: 100%;
+        background: transparent;
       }
       body {
         margin: 0;
-        background: #f5f7fa;
         overscroll-behavior: none;
       }
     </style>

+ 1 - 1
src/App.jsx

@@ -5,7 +5,7 @@ import Scene from './scene'
 // 获取 URL 参数 src:完整模型链接 max:最大缩放比 min:最小缩放比
 function readParams() {
   const params = new URLSearchParams(window.location.search)
-  const src = params.get('src') || '138.fbx'
+  const src = params.get('src') || ''
   const rawMin = Number(params.get('min'))
   const rawMax = Number(params.get('max'))
   const minZoom = Number.isFinite(rawMin) && rawMin > 0 ? rawMin : 1

+ 229 - 215
src/scene.jsx

@@ -16,7 +16,7 @@ function useFetchLoader(url) {
     total: 0,
     blobUrl: null,
     error: null,
-    loading: false
+    loading: false,
   });
 
   useEffect(() => {
@@ -26,60 +26,59 @@ function useFetchLoader(url) {
     let objectUrl = null;
 
     const load = async () => {
-      setState(s => ({ ...s, loading: true, progress: 0, loaded: 0, total: 0, error: null }));
-      
+      setState((s) => ({ ...s, loading: true, progress: 0, loaded: 0, total: 0, error: null }));
+
       try {
         const response = await fetch(url, { signal: controller.signal });
         if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
 
-        const contentLength = response.headers.get('content-length');
+        const contentLength = response.headers.get("content-length");
         const total = contentLength ? parseInt(contentLength, 10) : 0;
-        
+
         const reader = response.body.getReader();
         let receivedLength = 0;
         const chunks = [];
-        
-        while(true) {
+
+        while (true) {
           const { done, value } = await reader.read();
           if (done) break;
-          
+
           chunks.push(value);
           receivedLength += value.length;
-          
-          setState(prev => ({
+
+          setState((prev) => ({
             ...prev,
             loaded: receivedLength,
             total: total,
-            progress: total ? (receivedLength / total) * 100 : 0
+            progress: total ? (receivedLength / total) * 100 : 0,
           }));
         }
-        
+
         const blob = new Blob(chunks);
         objectUrl = URL.createObjectURL(blob);
-        
-        setState(prev => ({
+
+        setState((prev) => ({
           ...prev,
           blobUrl: objectUrl,
           loading: false,
-          progress: 100
+          progress: 100,
         }));
-        
       } catch (e) {
-        if (e.name !== 'AbortError') {
+        if (e.name !== "AbortError") {
           console.error("Fetch Loader error:", e);
-          setState(prev => ({ ...prev, error: e.message, loading: false }));
+          setState((prev) => ({ ...prev, error: e.message, loading: false }));
         }
       }
     };
-    
+
     load();
-    
+
     return () => {
       controller.abort();
       if (objectUrl) URL.revokeObjectURL(objectUrl);
     };
   }, [url]);
-  
+
   return state;
 }
 
@@ -88,100 +87,49 @@ function Loader({ customState }) {
   // 如果没有在下载,直接返回 null
   if (!customState || !customState.loading) return null;
 
-  return (
-    <LoaderUI 
-      progress={customState.progress} 
-      loaded={customState.loaded} 
-      total={customState.total} 
-      status="downloading" 
-    />
-  );
+  return <LoaderUI progress={customState.progress} loaded={customState.loaded} total={customState.total} status="downloading" />;
 }
 
 // 专门用于 Suspense fallback 的解析状态提示
 function ParsingLoader() {
-  return (
-    <LoaderUI 
-      progress={100} 
-      loaded={0} 
-      total={0} 
-      status="parsing" 
-    />
-  );
+  return <LoaderUI progress={100} loaded={0} total={0} status="parsing" />;
 }
 
 function LoaderUI({ progress, loaded, total, status }) {
-  // 格式化文件大小
-  const formatBytes = (bytes) => {
-    if (bytes === 0) return '0 B';
-    const k = 1024;
-    const sizes = ['B', 'KB', 'MB', 'GB'];
-    const i = Math.floor(Math.log(bytes) / Math.log(k));
-    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
-  };
-
   // 判断是否无法计算总大小
-  const isIndeterminate = status === 'downloading' && total === 0 && loaded > 0;
-  
-  const progressText = isIndeterminate 
-    ? `已下载 ${formatBytes(loaded)}` 
-    : `${progress.toFixed(0)}%`;
-
-  const statusText = status === 'downloading' 
-    ? (total > 0 ? `下载中 ${formatBytes(loaded)} / ${formatBytes(total)}` : '下载中...')
-    : '解析模型中...';
+  const isIndeterminate = status === "downloading" && total === 0 && loaded > 0;
 
   return (
-    <Html center style={{ zIndex: 9999, pointerEvents: 'none' }}>
-      <div 
-        style={{ 
-          color: "#333", 
-          fontSize: 14, 
-          textAlign: "center",
-          background: "rgba(255, 255, 255, 0.9)",
-          padding: "15px 25px",
-          borderRadius: "8px",
-          boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
-          minWidth: "200px",
-          backdropFilter: "blur(5px)"
+    <Html center style={{ zIndex: 9999, pointerEvents: "none" }}>
+      <div
+        style={{
+          width: 600,
+          height: 10,
+          background: "#fff", // 未加载部分是白色
+          borderRadius: 5, // 稍微圆角一点更好看,或者如果想要完全直角可以设为0
+          overflow: "hidden",
+          position: "relative",
+          boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
         }}
       >
-        <div style={{ marginBottom: 8, fontWeight: 500 }}>{statusText}</div>
         <div
           style={{
-            width: "100%",
-            height: 6,
-            background: "#eee",
-            borderRadius: 3,
-            overflow: "hidden",
-            position: "relative"
+            width: isIndeterminate ? "100%" : `${progress}%`,
+            height: "100%",
+            background: "#4C7F7A", // 已加载进度颜色是 #4C7F7A
+            transition: isIndeterminate ? "none" : "width 0.2s ease-out",
+            position: "absolute",
+            left: 0,
+            top: 0,
+            backgroundImage: isIndeterminate ? "linear-gradient(45deg,rgba(255,255,255,.3) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.3) 50%,rgba(255,255,255,.3) 75%,transparent 75%,transparent)" : "none",
+            backgroundSize: "20px 20px",
+            animation: isIndeterminate ? "progress-bar-stripes 1s linear infinite" : "none",
           }}
-        >
-          <div
-            style={{
-              width: isIndeterminate ? "100%" : `${progress}%`,
-              height: "100%",
-              background: "#666",
-              transition: isIndeterminate ? "none" : "width 0.2s ease-out",
-              position: "absolute",
-              left: 0,
-              top: 0,
-              backgroundImage: isIndeterminate 
-                ? "linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)" 
-                : "none",
-              backgroundSize: "40px 40px",
-              animation: isIndeterminate ? "progress-bar-stripes 1s linear infinite" : "none"
-            }}
-          />
-        </div>
-        <div style={{ marginTop: 8, fontSize: 12, color: "#666", display: "flex", justifyContent: "space-between" }}>
-          <span>{progressText}</span>
-          {total > 0 && <span>{formatBytes(total)}</span>}
-        </div>
+        />
       </div>
       <style>{`
         @keyframes progress-bar-stripes {
-          from { background-position: 40px 0; }
+          from { background-position: 20px 0; }
           to { background-position: 0 0; }
         }
       `}</style>
@@ -234,7 +182,7 @@ function ModelLoader({ url, fileType }) {
   const kind = useMemo(() => {
     // 如果显式传递了 fileType,则直接使用
     if (fileType) return fileType;
-    
+
     // 否则尝试从 URL 推断 (Blob URL 通常无法推断,所以 fileType 很重要)
     const u = (url || "").toLowerCase().split("?")[0].split("#")[0];
     if (u.endsWith(".fbx")) return "fbx";
@@ -259,7 +207,7 @@ function ModelLoader({ url, fileType }) {
 
 // FitWatcher: 负责检测 Bounds 组件的自适应动画是否完成
 // 原理:监听相机位置和目标点的变化,当连续多帧保持静止时,认为自适应完成
-function FitWatcher({ controlsRef, onFitReady }) {
+function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
   const { camera } = useThree();
   // 记录上一帧的状态,用于对比差异
   const lastState = useRef({ pos: new THREE.Vector3(), target: new THREE.Vector3() });
@@ -297,14 +245,39 @@ function FitWatcher({ controlsRef, onFitReady }) {
     if (stableFrames.current > 10) {
       isFitted.current = true;
       const distance = currentPos.distanceTo(currentTarget);
-
-      console.log("Fit stable, distance:", distance);
-      // 回调通知父组件:相机已就位,可以解锁交互并记录初始位置
-      onFitReady({
-        position: currentPos.clone(),
-        target: currentTarget.clone(),
-        distance,
-      });
+      
+      // 核心修复:如果设置了 zoomMin (例如 2),意味着用户希望初始视图就放大2倍
+      // Bounds 组件默认只会 fit 到正好填满屏幕 (zoom=1)
+      // 所以我们需要在这里手动应用初始缩放,防止后续 CameraController 里的 useEffect 突然跳变
+      
+      const initialZoom = Math.max(1, Number(zoomMin) || 1);
+      
+      if (initialZoom > 1) {
+          // 计算缩放后的目标位置:保持方向不变,距离缩短
+          const direction = new THREE.Vector3().subVectors(currentPos, currentTarget).normalize();
+          const newDistance = distance / initialZoom;
+          const newPos = currentTarget.clone().add(direction.multiplyScalar(newDistance));
+          
+          // 直接应用新位置,避免动画跳变
+          camera.position.copy(newPos);
+          // 更新 distance 为缩放后的距离,作为后续的基础距离
+          console.log(`Initial Fit adjusted by zoomMin=${initialZoom}: ${distance} -> ${newDistance}`);
+          
+          onFitReady({
+            position: newPos,
+            target: currentTarget.clone(),
+            distance: newDistance, // 这里的 distance 已经是被 zoomMin 除过的了
+            baseDistance: distance // 记录原始的 "1倍" 距离作为真正的 baseDistance
+          });
+      } else {
+          console.log("Fit stable, distance:", distance);
+          onFitReady({
+            position: currentPos.clone(),
+            target: currentTarget.clone(),
+            distance: distance,
+            baseDistance: distance
+          });
+      }
     }
   });
 
@@ -312,16 +285,7 @@ function FitWatcher({ controlsRef, onFitReady }) {
 }
 
 // CameraController: 封装 OrbitControls 并处理缩放限制
-function CameraController({
-  controlsRef,
-  zoomMin,
-  zoomMax,
-  baseDistance,
-  autoRotateSpeed,
-  enabled = true,
-  onStart,
-  onEnd,
-}) {
+function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRotateSpeed, enabled = true, onStart, onEnd }) {
   // 动态计算 maxDistance 和 minDistance
   useEffect(() => {
     const controls = controlsRef.current;
@@ -369,6 +333,27 @@ function CameraController({
  * 组装模型、灯光、交互,并管理全局状态
  */
 
+function LoadingOverlay({ visible }) {
+  if (!visible) return null;
+  return (
+    <Html fullscreen style={{ pointerEvents: "none", zIndex: 9998 }}>
+      <div
+        style={{
+          width: "100%",
+          height: "100%",
+          background: "#ffffff", // 使用白色或背景色遮挡
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          transition: "opacity 0.3s",
+        }}
+      >
+        {/* 这里可以放一个 spinner,但因为 Loader 已经有进度条了,这里纯遮挡即可 */}
+      </div>
+    </Html>
+  );
+}
+
 function SceneContent({ url, zoomMin, zoomMax, fileType }) {
   const controlsRef = useRef();
   const { camera, gl } = useThree();
@@ -384,8 +369,16 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
     isInteracting: false, // 用户是否正在交互
   });
 
+  // 全局背景颜色 (默认 null,表示透明)
+  const [bgColor, setBgColor] = useState(null);
+
   // isReady: 标记是否完成初始飞入动画,完成后才解锁交互
+  // 修改:默认 false,既控制交互也控制场景的可见性(避免跳变被用户看到)
   const [isReady, setIsReady] = useState(false);
+  
+  // 新增:Fit 状态标记,只有当 FitWatcher 完成调整后才设为 true
+  // 我们用它来控制 LoadingOverlay 的消失
+  const [fitAdjusted, setFitAdjusted] = useState(false);
 
   // 记录初始 fit 后的完美状态(位置 + 目标点),用于双击复位
   const initialState = useRef(null);
@@ -397,8 +390,14 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
       position: state.position,
       target: state.target,
     };
-    setBaseDistance(state.distance);
+    // 如果 state 中有 baseDistance (原始1倍距离),则使用它,否则使用当前距离
+    // 这样做的目的是:CameraController 需要知道 "1倍缩放" 对应的距离是多少,
+    // 以便正确计算 maxDistance (baseDistance / zoomMin)
+    setBaseDistance(state.baseDistance || state.distance);
     setIsReady(true); // 解锁交互
+    
+    // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
+    setTimeout(() => setFitAdjusted(true), 50);
   }, []);
 
   // 平滑复位函数:使用插值动画让相机回到初始状态
@@ -435,7 +434,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
       };
       requestAnimationFrame(animate);
     },
-    [camera]
+    [camera],
   );
 
   // 处理用户交互开始
@@ -454,13 +453,13 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
     // 如果开启了自动旋转模式,则启动定时器恢复旋转
     if (autoRotateConfig.active) {
       setAutoRotateConfig((prev) => ({ ...prev, isWaiting: true }));
-      
+
       timerRef.current = setTimeout(() => {
         // 核心修改:等待结束后,先执行复位,复位完成后再恢复旋转
         console.log("无操作超时,开始复位并恢复旋转...");
         resetToInitial(() => {
-           // 复位动画完成的回调
-           setAutoRotateConfig((prev) => ({ ...prev, isWaiting: false }));
+          // 复位动画完成的回调
+          setAutoRotateConfig((prev) => ({ ...prev, isWaiting: false }));
         });
       }, autoRotateConfig.delay);
     }
@@ -471,59 +470,69 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
   const effectiveSpeed = autoRotateConfig.active && !autoRotateConfig.isInteracting && !autoRotateConfig.isWaiting ? autoRotateConfig.speed : 0;
 
   // 通用相机位移动画函数
-  const animateCamera = useCallback((targetPos, onComplete) => {
-    if (!controlsRef.current) return;
-    const controls = controlsRef.current;
-    
-    const startPos = camera.position.clone();
-    const startTime = performance.now();
-    const duration = 300;
+  const animateCamera = useCallback(
+    (targetPos, onComplete) => {
+      if (!controlsRef.current) return;
+      const controls = controlsRef.current;
 
-    const animate = (time) => {
-      const t = Math.min((time - startTime) / duration, 1);
-      const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
-      
-      camera.position.lerpVectors(startPos, targetPos, ease);
-      controls.update();
-      
-      if (t < 1) {
-        requestAnimationFrame(animate);
-      } else {
-        if (typeof onComplete === "function") {
-          onComplete();
+      const startPos = camera.position.clone();
+      const startTime = performance.now();
+      const duration = 300;
+
+      const animate = (time) => {
+        const t = Math.min((time - startTime) / duration, 1);
+        const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
+
+        camera.position.lerpVectors(startPos, targetPos, ease);
+        controls.update();
+
+        if (t < 1) {
+          requestAnimationFrame(animate);
+        } else {
+          if (typeof onComplete === "function") {
+            onComplete();
+          }
         }
-      }
-    };
-    requestAnimationFrame(animate);
-  }, [camera]);
+      };
+      requestAnimationFrame(animate);
+    },
+    [camera],
+  );
 
   // 计算新的相机位置(不改变方向,只改变距离)
-  const calculateZoomPos = useCallback((factor, isZoomIn) => {
-    if (!controlsRef.current || !baseDistance) return null;
-    const controls = controlsRef.current;
-    
-    const currentDist = camera.position.distanceTo(controls.target);
-    // 放大是除以倍数(距离变小),缩小是乘以倍数(距离变大)
-    const targetDist = isZoomIn ? currentDist / factor : currentDist * factor;
-
-    // 检查限制
-    if (isZoomIn && targetDist < controls.minDistance) {
-      console.warn("已达到最大放大倍数");
-      return null;
-    }
-    if (!isZoomIn && targetDist > controls.maxDistance) {
-       console.warn("已达到最小缩小倍数");
-       // 允许缩放到 maxDistance,但不超过
-    }
-    const finalDist = Math.max(controls.minDistance, Math.min(targetDist, controls.maxDistance));
+  const calculateZoomPos = useCallback(
+    (factor, isZoomIn) => {
+      if (!controlsRef.current || !baseDistance) return null;
+      const controls = controlsRef.current;
 
-    const direction = new THREE.Vector3().subVectors(camera.position, controls.target).normalize();
-    return controls.target.clone().add(direction.multiplyScalar(finalDist));
-  }, [camera, baseDistance]);
+      const currentDist = camera.position.distanceTo(controls.target);
+      // 放大是除以倍数(距离变小),缩小是乘以倍数(距离变大)
+      const targetDist = isZoomIn ? currentDist / factor : currentDist * factor;
+
+      // 检查限制
+      if (isZoomIn && targetDist < controls.minDistance) {
+        console.warn("已达到最大放大倍数");
+        return null;
+      }
+      if (!isZoomIn && targetDist > controls.maxDistance) {
+        console.warn("已达到最小缩小倍数");
+        // 允许缩放到 maxDistance,但不超过
+      }
+      const finalDist = Math.max(controls.minDistance, Math.min(targetDist, controls.maxDistance));
+
+      const direction = new THREE.Vector3().subVectors(camera.position, controls.target).normalize();
+      return controls.target.clone().add(direction.multiplyScalar(finalDist));
+    },
+    [camera, baseDistance],
+  );
 
   // 暴露全局 API 给外部使用 (window.sceneFc)
   useEffect(() => {
     window.sceneFc = {
+      setBackgroundColor: (color) => {
+        console.log("设置背景颜色:", color);
+        setBgColor(color);
+      },
       resetView: () => {
         // 手动重置时,如果当前正在自动旋转,是否应该关闭自动旋转?
         // 通常手动重置意味着用户想回到初始状态,可能希望停止自动旋转。
@@ -559,32 +568,34 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
       },
       zoomIn: (factor = 1.2) => {
         const f = Math.max(1.01, Number(factor) || 1.2);
-        
+
         // 执行缩放动作的封装
         const doZoom = (onDone) => {
-           const newPos = calculateZoomPos(f, true); // true = zoomIn
-           if (newPos) {
-             animateCamera(newPos, () => {
-               // 关键修改:缩放完成后,更新 initialState,这样后续复位就会回到这个新位置
-               if (initialState.current) {
-                 initialState.current.position.copy(newPos);
-                 // target 通常不变,但为了保险也同步一下当前 target
-                 if (controlsRef.current) {
-                   initialState.current.target.copy(controlsRef.current.target);
-                 }
-               }
-               if (onDone) onDone();
-             });
-           } else if (onDone) {
-             onDone(); 
-           }
+          const newPos = calculateZoomPos(f, true); // true = zoomIn
+          if (newPos) {
+            animateCamera(newPos, () => {
+              // 关键修改:缩放完成后,更新 initialState,这样后续复位就会回到这个新位置
+              if (initialState.current) {
+                initialState.current.position.copy(newPos);
+                // target 通常不变,但为了保险也同步一下当前 target
+                if (controlsRef.current) {
+                  initialState.current.target.copy(controlsRef.current.target);
+                }
+              }
+              if (onDone) onDone();
+            });
+          } else if (onDone) {
+            onDone();
+          }
         };
 
         if (autoRotateConfig.active) {
           // 流程:暂停 -> 复位 -> 缩放 -> 等待(handleInteractionEnd)
           handleInteractionStart(); // 1. 暂停
-          resetToInitial(() => {    // 2. 复位到(旧)初始位置
-            doZoom(() => {          // 3. 缩放并更新初始位置
+          resetToInitial(() => {
+            // 2. 复位到(旧)初始位置
+            doZoom(() => {
+              // 3. 缩放并更新初始位置
               handleInteractionEnd(); // 4. 进入等待恢复流程 (恢复后旋转将基于新位置)
             });
           });
@@ -594,23 +605,23 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
       },
       zoomOut: (factor = 1.2) => {
         const f = Math.max(1.01, Number(factor) || 1.2);
-        
+
         const doZoom = (onDone) => {
-           const newPos = calculateZoomPos(f, false); // false = zoomOut
-           if (newPos) {
-             animateCamera(newPos, () => {
-               // 关键修改:更新 initialState
-               if (initialState.current) {
-                 initialState.current.position.copy(newPos);
-                 if (controlsRef.current) {
-                   initialState.current.target.copy(controlsRef.current.target);
-                 }
-               }
-               if (onDone) onDone();
-             });
-           } else if (onDone) {
-             onDone();
-           }
+          const newPos = calculateZoomPos(f, false); // false = zoomOut
+          if (newPos) {
+            animateCamera(newPos, () => {
+              // 关键修改:更新 initialState
+              if (initialState.current) {
+                initialState.current.position.copy(newPos);
+                if (controlsRef.current) {
+                  initialState.current.target.copy(controlsRef.current.target);
+                }
+              }
+              if (onDone) onDone();
+            });
+          } else if (onDone) {
+            onDone();
+          }
         };
 
         if (autoRotateConfig.active) {
@@ -623,7 +634,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
         } else {
           doZoom();
         }
-      }
+      },
     };
     return () => {
       // 组件卸载时清理全局对象
@@ -653,24 +664,27 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
 
   return (
     <>
+      {bgColor && <color attach="background" args={[bgColor]} />}
       {/* Bounds: 自动计算模型包围盒并调整相机视角 */}
-      <Bounds fit clip observe margin={1.5}>
-        <ModelLoader url={url} fileType={fileType} />
+      {/* 不渲染任何 UI,仅负责逻辑检测 */}
+      {/* 
+         注意:Bounds 需要渲染其中的 children 才能计算。
+      */}
+      
+      {/* 恢复 Bounds 的渲染,不被 visible 控制,改用 CSS 遮挡或 LoadingUI 延长显示 */}
+      <Bounds fit clip margin={1.5}>
+          <ModelLoader url={url} fileType={fileType} />
       </Bounds>
 
+      {/* 遮挡层:在 Fit 调整完成前一直显示白色背景,遮住 "先1倍再2倍" 的跳变过程 */}
+      <LoadingOverlay visible={!fitAdjusted} />
+      
       <CameraController
         controlsRef={controlsRef}
-        zoomMin={zoomMin}
-        zoomMax={zoomMax}
-        baseDistance={baseDistance}
-        autoRotateSpeed={effectiveSpeed}
-        enabled={isReady}
-        onStart={handleInteractionStart}
-        onEnd={handleInteractionEnd}
-      />
+        zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} autoRotateSpeed={effectiveSpeed} enabled={isReady} onStart={handleInteractionStart} onEnd={handleInteractionEnd} />
 
       {/* 不渲染任何 UI,仅负责逻辑检测 */}
-      <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} />
+      <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} zoomMin={zoomMin} />
     </>
   );
 }
@@ -678,7 +692,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
 export default function Scene({ url, zoomMin, zoomMax }) {
   // 使用自定义 Hook 加载文件
   const fetchState = useFetchLoader(url);
-  
+
   // 推断文件类型,传递给 ModelLoader
   const fileType = useMemo(() => {
     if (!url) return null;
@@ -702,7 +716,7 @@ export default function Scene({ url, zoomMin, zoomMax }) {
           <Loader customState={fetchState} />
         </>
       ) : (
-        <Html center style={{ color: "#222", fontSize: 14 }}>
+        <Html center style={{ width: "200px", color: "#222", fontSize: 14 }}>
           请在地址栏提供 ?src=GLB地址
         </Html>
       )}

+ 320 - 0
usdzLoader/TinyUSDZLoader.js

@@ -0,0 +1,320 @@
+import { Loader } from 'three'; // or https://cdn.jsdelivr.net/npm/three/build/three.module.js';
+
+// WASM module of TinyUSDZ.
+import initTinyUSDZNative from './tinyusdz.js';
+
+
+class FetchAssetResolver {
+    constructor() {
+        this.assetCache = new Map();
+    }
+
+    async resolveAsync(uri) {
+        try {
+            const response = await fetch(uri);
+            if (!response.ok) {
+                throw new Error(`Failed to fetch asset: ${uri}`);
+            }
+            const data = await response.arrayBuffer();
+            //console.log(`Fetched asset ${uri} successfully, size: ${data.byteLength} bytes`);
+            this.assetCache.set(uri, data);
+            return Promise.resolve([uri, data]);
+        } catch (error) {
+            console.error(`Error resolving asset ${uri}:`, error);
+            throw error;
+        }
+    }
+
+    getAsset(uri) {
+        if (this.assetCache.has(uri)) {
+            return this.assetCache.get(uri);
+        } else {
+            console.warn(`Asset not found in cache: ${uri}`);
+            return null;
+        }
+    }
+
+    hasAsset(uri) {
+        return this.assetCache.has(uri);
+    }
+
+    setAsset(uri, data) {
+        this.assetCache.set(uri, data);
+    }
+
+    clearCache() {
+        this.assetCache.clear();
+    }
+
+}
+
+// TODO
+//
+// Polish API
+//
+class TinyUSDZLoader extends Loader {
+
+    constructor(manager) {
+        super(manager);
+
+        this.native_ = null;
+
+        this.assetResolver_ = null;
+
+        // texture loader callback
+        // null = Use TinyUSDZ's builtin image loader(C++ native module)
+        //this.texLoader = null;
+
+
+        this.imageCache = {};
+        this.textureCache = {};
+
+        // Default: do NOT use zstd compressed WASM.
+        this.useZstdCompressedWasm_ = false;
+        this.compressedWasmPath_ = 'tinyusdz.wasm.zst';
+    }
+
+    // Decompress zstd compressed WASM
+    async decompressZstdWasm(compressedPath) {
+        try {
+            const fzstd = await import('fzstd');
+
+            const wasmURL = new URL(compressedPath, import.meta.url).href;
+
+            //console.log(`Loading compressed WASM from: ${wasmURL}`);
+            const response = await fetch(wasmURL);
+            //console.log(response);
+            if (!response.ok) {
+                throw new Error(`Failed to fetch compressed WASM: ${response.statusText}`);
+            }
+
+            const compressedData = await response.arrayBuffer();
+            //console.log(`Compressed WASM size: ${compressedData.byteLength} bytes`);
+
+            if (compressedData.byteLength < 1024*64) {
+                throw new Error('Compressed WASM size is unusually small, may not be valid zstd compressed data.');
+            }
+
+            // Check zstd magic number (0x28B52FFD in little-endian)
+            const magicBytes = new Uint8Array(compressedData, 0, 4);
+            const expectedMagic = [0x28, 0xB5, 0x2F, 0xFD]; // Little-endian representation
+            //console.log(magicBytes);
+            //console.log(expectedMagic);
+            
+            if (compressedData.byteLength < 4 || 
+                magicBytes[0] !== expectedMagic[0] || 
+                magicBytes[1] !== expectedMagic[1] || 
+                magicBytes[2] !== expectedMagic[2] || 
+                magicBytes[3] !== expectedMagic[3]) {
+                throw new Error('Invalid zstd file: magic number mismatch');
+            }
+
+            // Decompress using zstd
+            const decompressedData = fzstd.decompress(new Uint8Array(compressedData));
+            //console.log(`Decompressed WASM size: ${decompressedData.byteLength} bytes`);
+
+            return decompressedData;
+        } catch (error) {
+            console.error('Error decompressing zstd WASM:', error);
+            throw error;
+        }
+    }
+
+    // Initialize the native WASM module
+    // This is async but the load() method handles it internally with promises
+    async init( options = {}) {
+
+        if (Object.prototype.hasOwnProperty.call(options, 'useZstdCompressedWasm')) {
+          this.useZstdCompressedWasm_ = options.useZstdCompressedWasm;
+        }
+
+        if (!this.native_) {
+            //console.log('Initializing native module...');
+
+            let wasmBinary = null;
+            
+            if (this.useZstdCompressedWasm_) {
+                // Load and decompress zstd compressed WASM
+                wasmBinary = await this.decompressZstdWasm(this.compressedWasmPath_);
+
+            }
+
+            // Initialize with custom WASM binary if decompressed
+            const initOptions = wasmBinary ? { wasmBinary } : {};
+
+            this.native_ = await initTinyUSDZNative(initOptions);
+            if (!this.native_) {
+                throw new Error('TinyUSDZLoader: Failed to initialize native module.');
+            }
+            //console.log('Native module initialized');
+        }
+        return this;
+    }
+
+
+    // TODO: remove
+    // Set AssetResolver callback.
+    // This is used to resolve asset paths(e.g. textures, usd files) in the USD.
+    // For web app, usually we'll convert asset path to URI
+    //setAssetResolver(callback) {
+    //    this.assetResolver_ = callback;
+    //}
+
+    //
+    // Load a USDZ/USDA/USDC file from a URL as USD Stage(Freezed scene graph)
+    // NOTE: for loadAsync(), Use base Loader class's loadAsync() method
+    //
+    load(url, onLoad, onProgress, onError) {
+        //console.log('url', url);
+
+        const scope = this;
+
+        // Create a promise chain to handle initialization and loading
+        const initPromise = this.native_ ? Promise.resolve() : this.init();
+
+        initPromise
+            .then(() => {
+                return fetch(url);
+            })
+            .then((response) => {
+                return response.arrayBuffer();
+            })
+            .then((usd_data) => {
+                const usd_binary = new Uint8Array(usd_data);
+
+                //console.log('Loaded USD binary data:', usd_binary.length, 'bytes');
+
+                scope.parse(usd_binary, url, function (usd) {
+                    onLoad(usd);
+                }, onError);
+
+            })
+            .catch((error) => {
+                console.error('TinyUSDZLoader: Error initializing native module:', error);
+                if (onError) {
+                    onError(error);
+                }
+            });
+    }
+
+    //
+    // Parse a USDZ/USDA/USDC binary data
+    //
+    parse(binary /* ArrayBuffer */, filePath /* optional */, onLoad, onError) {
+
+        const _onError = function (e) {
+
+            if (onError) {
+
+                onError(e);
+
+            } else {
+
+                console.error(e);
+
+            }
+
+            //scope.manager.itemError( url );
+            //scope.manager.itemEnd( url );
+
+        };
+
+        if (!this.native_) {
+            console.error('TinyUSDZLoader: Native module is not initialized.');
+            _onError(new Error('TinyUSDZLoader: Native module is not initialized.'));
+        }
+
+        const usd = new this.native_.TinyUSDZLoaderNative();
+
+        const ok = usd.loadFromBinary(binary, filePath);
+        if (!ok) {
+            _onError(new Error('TinyUSDZLoader: Failed to load USD from binary data.', {cause: usd.error()}));
+        } else {
+            onLoad(usd);
+        }
+    }
+
+    //
+    // Load a USDZ/USDA/USDC file from a URL as USD Layer(for composition)
+    //
+    loadAsLayer(url, onLoad, onProgress, onError) {
+        //console.log('url', url);
+
+        const scope = this;
+
+        const _onError = function (e) {
+
+            if (onError) {
+
+                onError(e);
+
+            } else {
+
+                console.error(e);
+
+            }
+
+            //scope.manager.itemError( url );
+            //scope.manager.itemEnd( url );
+
+        };
+
+
+        // Create a promise chain to handle initialization and loading
+        const initPromise = this.native_ ? Promise.resolve() : this.init();
+
+        initPromise
+            .then(() => {
+                //usd_ = new this.native_.TinyUSDZLoaderNative();
+                return fetch(url);
+            })
+            .then((response) => {
+                //console.log('fetch USDZ file done:', url);
+                return response.arrayBuffer();
+            })
+            .then((usd_data) => {
+                const usd_binary = new Uint8Array(usd_data);
+
+                //console.log('Loaded USD binary data:', usd_binary.length, 'bytes');
+                //return this.parse(usd_binary);
+
+                const usd = new this.native_.TinyUSDZLoaderNative();
+
+                const ok = usd.loadAsLayerFromBinary(usd_binary, url);
+                if (!ok) {
+                    _onError(new Error('TinyUSDZLoader: Failed to load USD as Layer from binary data. url: ' + url, {cause: usd.error()}));
+                } else {
+                    onLoad(usd);
+                }
+
+            })
+            .catch((error) => {
+                console.error('TinyUSDZLoader: Error initializing native module:', error);
+                if (onError) {
+                    onError(error);
+                }
+            });
+    }
+
+    async loadAsLayerAsync(url, onProgress) {
+     	const scope = this;
+
+		return new Promise( function ( resolve, reject ) {
+
+			scope.loadAsLayer( url, resolve, onProgress, reject );
+
+		} );
+    }
+
+    ///**
+    // * Set texture callback
+    //  */
+    //setTextureLoader(texLoader) {
+    //    this.texLoader = texLoader;
+    //}
+
+
+
+}
+
+export { TinyUSDZLoader, FetchAssetResolver };

Разница между файлами не показана из-за своего большого размера
+ 5630 - 0
usdzLoader/tinyusdz.js


BIN
usdzLoader/tinyusdz.wasm