lanxin 2 هفته پیش
والد
کامیت
d1248de311
2فایلهای تغییر یافته به همراه641 افزوده شده و 246 حذف شده
  1. 636 242
      src/scene.jsx
  2. 5 4
      vite.config.js

+ 636 - 242
src/scene.jsx

@@ -1,46 +1,332 @@
-import React, { Suspense, useEffect, useMemo, useRef ,useState} from 'react'
-import { Canvas, useThree } from '@react-three/fiber'
-import { OrbitControls, Html, useGLTF, Bounds, useFBX, useAnimations } from '@react-three/drei'
-import * as THREE from 'three'
-
-// 轨道控制器,限制缩放比和距离
-function ControlsWithLimits({ controlsRef, zoomMin, zoomMax, baseDistance }) {
-  const { camera } = useThree()
-  // autoRotateSpeed!==0即自动旋转
-  const [autoRotateSpeed,setAutoRotateSpeed] = useState(0)
+import React, { Suspense, useEffect, useMemo, useRef, useState, useCallback } from "react";
+import { Canvas, useThree, useFrame } from "@react-three/fiber";
+import { OrbitControls, Html, useGLTF, Bounds, useFBX, useAnimations, useProgress } from "@react-three/drei";
+import * as THREE from "three";
+
+/**
+ * --- 1. 模型加载模块 (Model Loading) ---
+ * 负责不同格式模型的加载 (GLTF, FBX) 以及动画播放
+ */
+
+// 自定义 Hook: 手动 Fetch 获取真实进度
+function useFetchLoader(url) {
+  const [state, setState] = useState({
+    progress: 0,
+    loaded: 0,
+    total: 0,
+    blobUrl: null,
+    error: null,
+    loading: false
+  });
+
   useEffect(() => {
-    const controls = controlsRef.current
-    if (!controls || baseDistance == null) return
-    controls.maxDistance = baseDistance * Math.max(1, zoomMin)
-  }, [camera, controlsRef, baseDistance, zoomMin, zoomMax])
-  useEffect(()=>{
-    window.sceneFc = {
-      resetView: () => {
-        if (controlsRef.current) {
-          controlsRef.current.reset()
+    if (!url) return;
+
+    const controller = new AbortController();
+    let objectUrl = null;
+
+    const load = async () => {
+      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 total = contentLength ? parseInt(contentLength, 10) : 0;
+        
+        const reader = response.body.getReader();
+        let receivedLength = 0;
+        const chunks = [];
+        
+        while(true) {
+          const { done, value } = await reader.read();
+          if (done) break;
+          
+          chunks.push(value);
+          receivedLength += value.length;
+          
+          setState(prev => ({
+            ...prev,
+            loaded: receivedLength,
+            total: total,
+            progress: total ? (receivedLength / total) * 100 : 0
+          }));
+        }
+        
+        const blob = new Blob(chunks);
+        objectUrl = URL.createObjectURL(blob);
+        
+        setState(prev => ({
+          ...prev,
+          blobUrl: objectUrl,
+          loading: false,
+          progress: 100
+        }));
+        
+      } catch (e) {
+        if (e.name !== 'AbortError') {
+          console.error("Fetch Loader error:", e);
+          setState(prev => ({ ...prev, error: e.message, loading: false }));
         }
-      },
-      setAutoRotate: (speed = 1.0) => {
-        const clampedSpeed = Math.max(0.1, Math.min(8, Number(speed) || 1))
-        setAutoRotateSpeed(clampedSpeed)
       }
+    };
+    
+    load();
+    
+    return () => {
+      controller.abort();
+      if (objectUrl) URL.revokeObjectURL(objectUrl);
+    };
+  }, [url]);
+  
+  return state;
+}
+
+function Loader({ customState }) {
+  // 优先使用传入的 customState,否则回退到 useProgress (用于纹理加载等后续过程)
+  const { active, progress: dreiProgress } = useProgress();
+  
+  // 合并状态:如果是下载阶段(customState.loading),用 customState;否则用 drei
+  const isLoading = customState?.loading || active;
+  if (!isLoading) return null;
+
+  const loaded = customState?.loading ? customState.loaded : 0;
+  const total = customState?.loading ? customState.total : 0;
+  const progress = customState?.loading ? customState.progress : (dreiProgress || 0);
+  
+  // 格式化文件大小
+  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 = customState?.loading && total === 0 && loaded > 0;
+  
+  const progressText = isIndeterminate 
+    ? `已下载 ${formatBytes(loaded)}` 
+    : `${progress.toFixed(0)}%`;
+
+  const statusText = customState?.loading 
+    ? (total > 0 ? `下载中 ${formatBytes(loaded)} / ${formatBytes(total)}` : '下载中...')
+    : '解析模型中...';
+
+  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)"
+        }}
+      >
+        <div style={{ marginBottom: 8, fontWeight: 500 }}>{statusText}</div>
+        <div
+          style={{
+            width: "100%",
+            height: 6,
+            background: "#eee",
+            borderRadius: 3,
+            overflow: "hidden",
+            position: "relative"
+          }}
+        >
+          <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; }
+          to { background-position: 0 0; }
+        }
+      `}</style>
+    </Html>
+  );
+}
+
+// GLTF 模型组件
+function GLTFModel({ url, ...props }) {
+  const { scene, animations } = useGLTF(url, true);
+  const ref = useRef();
+  const { actions, names } = useAnimations(animations, ref);
+
+  // 自动播放第一个动画
+  useEffect(() => {
+    if (actions && names.length > 0) {
+      actions[names[0]].reset().fadeIn(0.5).play();
     }
-  },[])
-  // 添加双击归位
+  }, [actions, names]);
+
+  return <primitive ref={ref} object={scene} {...props} />;
+}
+
+// FBX 模型组件
+function FBXModel({ url, ...props }) {
+  const obj = useFBX(url);
+  const ref = useRef();
+  const { actions, names } = useAnimations(obj.animations || [], ref);
+
+  // 自动播放第一个动画
   useEffect(() => {
-    const onDoubleClick = () => {
-      if (controlsRef.current) {
-        console.log(123)
-        controlsRef.current.reset()
-      }
+    if (actions && names.length > 0) {
+      actions[names[0]].reset().fadeIn(0.5).play();
+    }
+  }, [actions, names]);
+
+  return <primitive ref={ref} object={obj} {...props} />;
+}
+
+// 预留 USDZ 支持 (iOS ARQuickLook 格式)
+function USDZModel({ url }) {
+  // 注意:useGLTF 默认不支持 .usdz,需配合特定 loader 或其他库
+  // 这里仅作占位,方便后续扩展
+  return null;
+}
+
+// 统一模型加载器
+// 根据 URL 后缀分发到对应的加载组件
+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";
+    if (u.endsWith(".usdz")) return "usdz";
+    return "gltf";
+  }, [url, fileType]);
+
+  switch (kind) {
+    case "fbx":
+      return <FBXModel url={url} />;
+    case "usdz":
+      return <USDZModel url={url} />;
+    default:
+      return <GLTFModel url={url} />;
+  }
+}
+
+/**
+ * --- 2. 交互逻辑模块 (Interaction Logic) ---
+ * 包含:自动适应检测、相机控制、交互锁定
+ */
+
+// FitWatcher: 负责检测 Bounds 组件的自适应动画是否完成
+// 原理:监听相机位置和目标点的变化,当连续多帧保持静止时,认为自适应完成
+function FitWatcher({ controlsRef, onFitReady }) {
+  const { camera } = useThree();
+  // 记录上一帧的状态,用于对比差异
+  const lastState = useRef({ pos: new THREE.Vector3(), target: new THREE.Vector3() });
+  const stableFrames = useRef(0);
+  const isFitted = useRef(false);
+
+  // 当 URL 变化时重置检测状态,重新开始监听
+  useEffect(() => {
+    isFitted.current = false;
+    stableFrames.current = 0;
+  }, [onFitReady]);
+
+  // useFrame 会在每一帧渲染时执行
+  useFrame(() => {
+    if (!controlsRef.current || isFitted.current) return;
+
+    const controls = controlsRef.current;
+    const currentPos = camera.position;
+    const currentTarget = controls.target;
+
+    const distDiff = currentPos.distanceTo(lastState.current.pos);
+    const targetDiff = currentTarget.distanceTo(lastState.current.target);
+
+    // 只有当相机几乎静止时(位移 < 0.001)才开始计数
+    if (distDiff < 0.001 && targetDiff < 0.001) {
+      stableFrames.current++;
+    } else {
+      stableFrames.current = 0;
     }
-    window.addEventListener('dblclick', onDoubleClick)
-    return () => window.removeEventListener('dblclick', onDoubleClick)
-  }, [controlsRef])
+
+    lastState.current.pos.copy(currentPos);
+    lastState.current.target.copy(currentTarget);
+
+    // 连续 10 帧稳定(约 160ms @ 60fps)认为 fit 动画彻底结束
+    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,
+      });
+    }
+  });
+
+  return null;
+}
+
+// CameraController: 封装 OrbitControls 并处理缩放限制
+function CameraController({
+  controlsRef,
+  zoomMin,
+  zoomMax,
+  baseDistance,
+  autoRotateSpeed,
+  enabled = true,
+  onStart,
+  onEnd,
+}) {
+  // 动态计算 maxDistance 和 minDistance
+  useEffect(() => {
+    const controls = controlsRef.current;
+    if (!controls || baseDistance == null) return;
+
+    // zoomMin (default 1) -> 最小放大倍数 -> 意味着相机拉得最远 -> 对应 maxDistance
+    // zoomMax (default 4) -> 最大放大倍数 -> 意味着相机拉得最近 -> 对应 minDistance
+    // 公式:实际距离 = 基础距离(baseDistance) / 缩放倍率(zoom)
+
+    // 防止除以 0
+    const minZ = Math.max(0.001, zoomMin || 1);
+    const maxZ = Math.max(minZ, zoomMax || 4);
+
+    controls.maxDistance = baseDistance / minZ;
+    controls.minDistance = baseDistance / maxZ;
+  }, [baseDistance, zoomMin, zoomMax, controlsRef]);
 
   return (
     <OrbitControls
       ref={controlsRef}
+      enabled={enabled} // 在 fit 完成前禁用交互,防止用户打断自动飞入动画
       enableDamping
       dampingFactor={0.08}
       rotateSpeed={0.8}
@@ -51,251 +337,359 @@ function ControlsWithLimits({ controlsRef, zoomMin, zoomMax, baseDistance }) {
       screenSpacePanning
       touches={{
         ONE: THREE.TOUCH.ROTATE,
-        TWO: THREE.TOUCH.DOLLY_PAN
+        TWO: THREE.TOUCH.DOLLY_PAN,
       }}
       makeDefault
       autoRotate={!!autoRotateSpeed}
       autoRotateSpeed={autoRotateSpeed}
+      onStart={onStart}
+      onEnd={onEnd}
     />
-  )
+  );
 }
 
-// glb/gltf文件加载
-function Model({ url }) {
-  const { scene, animations } = useGLTF(url, true)
-  const ref = useRef()
-  const { actions, names } = useAnimations(animations, ref)
-  useEffect(() => {
-    if (!actions || !names || names.length === 0) return
-    const a = actions[names[0]]
-    if (a) {
-      a.reset()
-      a.play()
+/**
+ * --- 3. 场景容器 (Scene Container) ---
+ * 组装模型、灯光、交互,并管理全局状态
+ */
+
+function SceneContent({ url, zoomMin, zoomMax, fileType }) {
+  const controlsRef = useRef();
+  const { camera, gl } = useThree();
+
+  // baseDistance: 模型自适应后的基础观察距离,用于计算缩放限制
+  const [baseDistance, setBaseDistance] = useState(null);
+
+  // 自动旋转配置
+  const [autoRotateConfig, setAutoRotateConfig] = useState({
+    active: false, // 是否开启了自动旋转模式(总开关)
+    speed: 1.0, // 旋转速度
+    delay: 5000, // 无操作后的恢复延迟 (ms)
+    isInteracting: false, // 用户是否正在交互
+  });
+
+  // isReady: 标记是否完成初始飞入动画,完成后才解锁交互
+  const [isReady, setIsReady] = useState(false);
+
+  // 记录初始 fit 后的完美状态(位置 + 目标点),用于双击复位
+  const initialState = useRef(null);
+  const timerRef = useRef(null);
+
+  // 当 FitWatcher 告诉我们相机稳定时调用
+  const handleFitReady = useCallback((state) => {
+    initialState.current = {
+      position: state.position,
+      target: state.target,
+    };
+    setBaseDistance(state.distance);
+    setIsReady(true); // 解锁交互
+  }, []);
+
+  // 平滑复位函数:使用插值动画让相机回到初始状态
+  // 支持 onComplete 回调,用于在复位完成后执行操作(如开始自动旋转)
+  const resetToInitial = useCallback(
+    (onComplete) => {
+      if (!controlsRef.current || !initialState.current) return;
+
+      const controls = controlsRef.current;
+      const startPos = camera.position.clone();
+      const startTarget = controls.target.clone();
+      const endPos = initialState.current.position;
+      const endTarget = initialState.current.target;
+
+      const duration = 800; // 动画时长 ms
+      const startTime = performance.now();
+
+      const animate = (time) => {
+        const elapsed = time - startTime;
+        const t = Math.min(elapsed / duration, 1);
+        const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out 缓动效果
+
+        camera.position.lerpVectors(startPos, endPos, ease);
+        controls.target.lerpVectors(startTarget, endTarget, ease);
+        controls.update();
+
+        if (t < 1) {
+          requestAnimationFrame(animate);
+        } else {
+          if (typeof onComplete === "function") {
+            onComplete();
+          }
+        }
+      };
+      requestAnimationFrame(animate);
+    },
+    [camera]
+  );
+
+  // 处理用户交互开始
+  const handleInteractionStart = useCallback(() => {
+    setAutoRotateConfig((prev) => ({ ...prev, isInteracting: true }));
+    if (timerRef.current) {
+      clearTimeout(timerRef.current);
+      timerRef.current = null;
     }
-  }, [actions, names])
-  return <primitive ref={ref} object={scene} />
-}
-
-// fbx文件加载
-function FBXModel({ url }) {
-  const obj = useFBX(url)
-  const ref = useRef()
-  const { actions, names } = useAnimations(obj.animations || [], ref)
-  useEffect(() => {
-    if (!actions || !names || names.length === 0) return
-    const a = actions[names[0]]
-    if (a) {
-      a.reset()
-      a.play()
+  }, []);
+
+  // 处理用户交互结束
+  const handleInteractionEnd = useCallback(() => {
+    setAutoRotateConfig((prev) => ({ ...prev, isInteracting: false }));
+
+    // 如果开启了自动旋转模式,则启动定时器恢复旋转
+    if (autoRotateConfig.active) {
+      setAutoRotateConfig((prev) => ({ ...prev, isWaiting: true }));
+      
+      timerRef.current = setTimeout(() => {
+        // 核心修改:等待结束后,先执行复位,复位完成后再恢复旋转
+        console.log("无操作超时,开始复位并恢复旋转...");
+        resetToInitial(() => {
+           // 复位动画完成的回调
+           setAutoRotateConfig((prev) => ({ ...prev, isWaiting: false }));
+        });
+      }, autoRotateConfig.delay);
     }
-  }, [actions, names])
-  return <primitive ref={ref} object={obj} />
-}
-
-// 方便模型类型
-function pickModel(url) {
-  const u = (url || '').toLowerCase()
-  const clean = u.split('?')[0].split('#')[0]
-  if (clean.endsWith('.fbx')) return 'fbx'
-  return 'gltf'
-}
-
-// 场景
-function Scene({ url, zoomMin, zoomMax }) {
-  const controlsRef = useRef()
-  const { camera ,gl} = useThree()
-  const [baseDistance, setBaseDistance] = useState(null)
-  const [initialState, setInitialState] = useState(null)  // 保存 fit 后的初始相机 + target
-
-  const kind = pickModel(url)
-
-  // 平滑复位函数
-  const resetToInitial = () => {
-    if (!controlsRef.current || !initialState) {
-      console.warn('无法复位:controls 或 initialState 未就绪')
-      return
-    }
-
-    const controls = controlsRef.current
+  }, [autoRotateConfig.active, autoRotateConfig.delay, resetToInitial]);
 
-    const startPos = camera.position.clone()
-    const startTarget = controls.target.clone()
-    const endPos = initialState.position
-    const endTarget = initialState.target
+  // 计算最终传给 Controls 的旋转速度
+  // 只有当:总开关开启(active) 且 没有在交互(isInteracting) 且 没有在等待延迟(isWaiting) 时,才有速度
+  const effectiveSpeed = autoRotateConfig.active && !autoRotateConfig.isInteracting && !autoRotateConfig.isWaiting ? autoRotateConfig.speed : 0;
 
-    let t = 0
-    const duration = 800  // ms,可调
-    const startTime = performance.now()
+  // 通用相机位移动画函数
+  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 animate = (time) => {
-      const elapsed = time - startTime
-      t = Math.min(elapsed / duration, 1)
-
-      camera.position.lerpVectors(startPos, endPos, t)
-      controls.target.lerpVectors(startTarget, endTarget, t)
-      controls.update()
-
+      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)
+        requestAnimationFrame(animate);
+      } else {
+        if (typeof onComplete === "function") {
+          onComplete();
+        }
       }
+    };
+    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));
 
-    requestAnimationFrame(animate)
-  }
-
-  // 监听模型适应大小后,回调确认位置
-function FitWatcher({ controlsRef, onBase }) {
-  const { camera } = useThree()
+    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(() => {
-    if (!controlsRef.current) return
-
-    let rafId
-    let attempts = 0
-    const maxAttempts = 30  // 防死循环,大约 0.5 秒
-
-    const tryCapture = () => {
-      const controls = controlsRef.current
-      const d = camera.position.distanceTo(controls.target)
-
-      // 如果连续几帧距离几乎不变,就认为 fit 完成了
-      if (Math.abs(d - (window.lastD || d)) < 0.001 && attempts > 5) {
-        onBase(d)
-        setTimeout(() => {
-      if (!initialState && controlsRef.current) {
-        console.log(controlsRef.current.target.clone())
-        setInitialState({
-          position: camera.position.clone(),
-          target: controlsRef.current.target.clone(),
-        })
-        console.log('稳定后保存初始状态:', camera.position)
-      }
-    }, 300)
-        return
-      }
-
-      window.lastD = d
-      attempts++
-      if (attempts < maxAttempts) {
-        rafId = requestAnimationFrame(tryCapture)
+    window.sceneFc = {
+      resetView: () => {
+        // 手动重置时,如果当前正在自动旋转,是否应该关闭自动旋转?
+        // 通常手动重置意味着用户想回到初始状态,可能希望停止自动旋转。
+        // 这里我们假设手动重置会关闭自动旋转,除非用户再次调用 setAutoRotate
+        setAutoRotateConfig((prev) => ({ ...prev, active: false }));
+        resetToInitial();
+        console.log("已调用重置方法!");
+      },
+      setAutoRotate: (speed = 1.0, delay = 5000) => {
+        const s = Math.max(0.1, Math.min(8, Number(speed) || 1));
+        const d = Math.max(0, Number(delay) || 5000);
+
+        console.log(`设置自动旋转: 速度=${s}, 延迟=${d}ms`);
+
+        // 1. 先关闭当前的旋转(如果正在转)
+        setAutoRotateConfig((prev) => ({ ...prev, active: false }));
+
+        // 2. 调用重置
+        resetToInitial(() => {
+          // 3. 重置完成后,开启自动旋转
+          setAutoRotateConfig({
+            active: true,
+            speed: s,
+            delay: d,
+            isInteracting: false,
+            isWaiting: false,
+          });
+        });
+      },
+      stopAutoRotate: () => {
+        console.log("停止自动旋转");
+        setAutoRotateConfig((prev) => ({ ...prev, active: false }));
+      },
+      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(); 
+           }
+        };
+
+        if (autoRotateConfig.active) {
+          // 流程:暂停 -> 复位 -> 缩放 -> 等待(handleInteractionEnd)
+          handleInteractionStart(); // 1. 暂停
+          resetToInitial(() => {    // 2. 复位到(旧)初始位置
+            doZoom(() => {          // 3. 缩放并更新初始位置
+              handleInteractionEnd(); // 4. 进入等待恢复流程 (恢复后旋转将基于新位置)
+            });
+          });
+        } else {
+          doZoom(); // 直接缩放
+        }
+      },
+      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();
+           }
+        };
+
+        if (autoRotateConfig.active) {
+          handleInteractionStart();
+          resetToInitial(() => {
+            doZoom(() => {
+              handleInteractionEnd();
+            });
+          });
+        } else {
+          doZoom();
+        }
       }
-    }
-
-    rafId = requestAnimationFrame(tryCapture)
-
+    };
     return () => {
-      cancelAnimationFrame(rafId)
-      delete window.lastD
-    }
-  }, [onBase, camera, controlsRef])
-
-  return null
-}
-
-  // 在 fit 完成后保存初始状态
-  useEffect(() => {
-     setTimeout(() => {
-     setInitialState({
-        position: camera.position.clone(),
-        target: controlsRef.current.target.clone(),
-      })
-     }, 200);
-    if (baseDistance && !initialState && controlsRef.current) {
-      console.log(camera.position.clone(),controlsRef.current.target.clone())
-      setInitialState({
-        position: camera.position.clone(),
-        target: controlsRef.current.target.clone(),
-      })
-    }
-  }, [baseDistance, camera, initialState])
+      // 组件卸载时清理全局对象
+      if (window.sceneFc) {
+        delete window.sceneFc;
+      }
+    };
+  }, [resetToInitial, baseDistance, camera, autoRotateConfig.active, handleInteractionStart, handleInteractionEnd, animateCamera, calculateZoomPos]);
 
-  // 暴露到 window(只暴露一次,组件卸载时清理)
+  // 清理定时器
   useEffect(() => {
-    // 创建或覆盖 window.sceneFc
-    window.sceneFc = window.sceneFc || {}
-    window.sceneFc.reset = resetToInitial
-
-    console.log('window.sceneFc.reset 已暴露,可外部调用')
-
     return () => {
-      // 清理(可选,但推荐)
-      if (window.sceneFc && window.sceneFc.reset === resetToInitial) {
-        delete window.sceneFc.reset
-      }
-    }
-  }, [resetToInitial])  // 依赖 resetToInitial,确保函数稳定
+      if (timerRef.current) clearTimeout(timerRef.current);
+    };
+  }, []);
 
-  // 双击复位监听(使用 click 事件判断双击,更可靠)
+  // 双击复位监听 (Native DOM Event)
   useEffect(() => {
-    let lastClickTime = 0
-    let clickCount = 0
-    let timeoutId
-
-    const handleClick = (e) => {
-      const now = Date.now()
-
-      // 如果点击在 OrbitControls 控制区域内(避免误触 UI)
-      if (e.target !== gl.domElement) return
-
-      clickCount++
-
-      if (clickCount === 1) {
-        timeoutId = setTimeout(() => {
-          clickCount = 0
-        }, 300)
-      }
-
-      if (clickCount === 2) {
-        clearTimeout(timeoutId)
-        clickCount = 0
-
-        // 阻止默认行为(如果有)
-        e.preventDefault()
-        e.stopPropagation()
-
-        resetToInitial()
-      }
+    const dom = gl.domElement;
+    const handleDblClick = (e) => {
+      e.preventDefault();
+      resetToInitial();
+    };
+    dom.addEventListener("dblclick", handleDblClick);
+    return () => dom.removeEventListener("dblclick", handleDblClick);
+  }, [gl, resetToInitial]);
 
-      lastClickTime = now
-    }
+  return (
+    <>
+      {/* Bounds: 自动计算模型包围盒并调整相机视角 */}
+      <Bounds fit clip observe margin={1.5}>
+        <ModelLoader url={url} fileType={fileType} />
+      </Bounds>
+
+      <CameraController
+        controlsRef={controlsRef}
+        zoomMin={zoomMin}
+        zoomMax={zoomMax}
+        baseDistance={baseDistance}
+        autoRotateSpeed={effectiveSpeed}
+        enabled={isReady}
+        onStart={handleInteractionStart}
+        onEnd={handleInteractionEnd}
+      />
+
+      {/* 不渲染任何 UI,仅负责逻辑检测 */}
+      <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} />
+    </>
+  );
+}
 
-    const dom = gl.domElement
-    dom.addEventListener('click', handleClick)
-
-    return () => {
-      dom.removeEventListener('click', handleClick)
-    }
-  }, [gl, resetToInitial])
+export default function Scene({ url, zoomMin, zoomMax }) {
+  // 使用自定义 Hook 加载文件
+  const fetchState = useFetchLoader(url);
+  
+  // 推断文件类型,传递给 ModelLoader
+  const fileType = useMemo(() => {
+    if (!url) return null;
+    const u = url.toLowerCase().split("?")[0].split("#")[0];
+    if (u.endsWith(".fbx")) return "fbx";
+    if (u.endsWith(".usdz")) return "usdz";
+    return "gltf";
+  }, [url]);
 
   return (
     <>
       <ambientLight intensity={0.8} />
       <directionalLight position={[5, 5, 5]} intensity={1.0} />
       {url ? (
-        <Suspense
-          fallback={
-            <Html center style={{ color: '#222', fontSize: 14 }}>
-              模型加载中…
-            </Html>
-          }
-        >
-          <Bounds fit clip observe margin={1.2}>
-            {kind === 'fbx' ? <FBXModel url={url} /> : kind === 'usdz' ? <USDZModel url={url} /> : <Model url={url} />}
-            <ControlsWithLimits controlsRef={controlsRef} zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} />
-            <FitWatcher controlsRef={controlsRef} onBase={setBaseDistance} />
-          </Bounds>
-        </Suspense>
-      ) : (
         <>
-          <ControlsWithLimits controlsRef={controlsRef} zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} />
-          <FitWatcher controlsRef={controlsRef} onBase={setBaseDistance} />
-          <Html center style={{ color: '#222', fontSize: 14 }}>
-            请在地址栏提供 ?src=GLB地址
-          </Html>
+          {fetchState.blobUrl && (
+            <Suspense fallback={null}>
+              <SceneContent url={fetchState.blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} fileType={fileType} />
+            </Suspense>
+          )}
+          <Loader customState={fetchState} />
         </>
+      ) : (
+        <Html center style={{ color: "#222", fontSize: 14 }}>
+          请在地址栏提供 ?src=GLB地址
+        </Html>
       )}
     </>
-  )
+  );
 }
-
-export default Scene

+ 5 - 4
vite.config.js

@@ -1,6 +1,7 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
 
 export default defineConfig({
-  plugins: [react()]
-})
+  plugins: [react()],
+  base: "./",
+});