lanxin 1 ماه پیش
والد
کامیت
3a109b80fe
1فایلهای تغییر یافته به همراه109 افزوده شده و 34 حذف شده
  1. 109 34
      src/scene.jsx

+ 109 - 34
src/scene.jsx

@@ -1,9 +1,7 @@
-import  { Suspense, useEffect, useMemo, useRef, useState, useCallback } from "react";
+import  { Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
 import {  useThree, useFrame } from "@react-three/fiber";
-import { OrbitControls, useGLTF, Bounds, useFBX, useAnimations } from "@react-three/drei";
+import { OrbitControls, useGLTF, Bounds, useFBX, useAnimations, useBounds } from "@react-three/drei";
 import * as THREE from "three";
-import { useLoader } from '@react-three/fiber';
-import {TinyUSDZLoader} from '../usdzLoader/TinyUSDZLoader.js'
 /**
  * --- 1. 模型加载模块 (Model Loading) ---
  * 负责不同格式模型的加载 (GLTF, FBX) 以及动画播放
@@ -25,7 +23,55 @@ function enhanceMaterials(object) {
   });
 }
 
-// 导出 useFetchLoader 供外部使用
+const MODEL_CENTER = new THREE.Vector3(0, 0, 0);
+// 初始观察方向(归一化后从该方向看向模型中心)
+const DEFAULT_VIEW_DIR = new THREE.Vector3(0, 0.5, 1).normalize();
+
+function getDefaultCameraPosition(distance, target = MODEL_CENTER) {
+  return DEFAULT_VIEW_DIR.clone().multiplyScalar(distance).add(target);
+}
+
+function computeModelCenterOffset(object) {
+  object.updateMatrixWorld(true);
+  const box = new THREE.Box3().setFromObject(object);
+  if (box.isEmpty()) return new THREE.Vector3();
+  return box.getCenter(new THREE.Vector3()).multiplyScalar(-1);
+}
+
+function useCenteredModelGroup(object, onCentered) {
+  const groupRef = useRef();
+  const offset = useMemo(() => {
+    if (!object) return null;
+    return computeModelCenterOffset(object);
+  }, [object]);
+
+  useLayoutEffect(() => {
+    const group = groupRef.current;
+    if (!group || !offset) return;
+    group.position.copy(offset);
+    onCentered?.();
+  }, [offset, onCentered]);
+
+  return groupRef;
+}
+
+function BoundsRefitter({ trigger }) {
+  const bounds = useBounds();
+  useLayoutEffect(() => {
+    if (!trigger) return;
+    bounds.refresh().clip().fit();
+  }, [trigger, bounds]);
+  return null;
+}
+
+function syncOrbitTarget(controls, target = MODEL_CENTER) {
+  if (!controls) return;
+  controls.target.copy(target);
+  controls.update();
+}
+
+// 导出 useFetchLoader 供 App 使用
+// eslint-disable-next-line react-refresh/only-export-components
 export function useFetchLoader(url) {
   const [state, setState] = useState({
     progress: 0,
@@ -163,9 +209,10 @@ export function OverlayLoader({ fetchState, isModelReady, color, active }) {
 }
 
 // GLTF 模型组件
-function GLTFModel({ url, ...props }) {
+function GLTFModel({ url, onCentered, ...props }) {
   const { scene, animations } = useGLTF(url, true);
   const ref = useRef();
+  const groupRef = useCenteredModelGroup(scene, onCentered);
   const { actions, names } = useAnimations(animations, ref);
 
   // 自动播放第一个动画
@@ -179,13 +226,18 @@ function GLTFModel({ url, ...props }) {
     enhanceMaterials(scene);
   }, [scene]);
 
-  return <primitive ref={ref} object={scene} {...props} />;
+  return (
+    <group ref={groupRef}>
+      <primitive ref={ref} object={scene} {...props} />
+    </group>
+  );
 }
 
 // FBX 模型组件
-function FBXModel({ url, ...props }) {
+function FBXModel({ url, onCentered, ...props }) {
   const obj = useFBX(url);
   const ref = useRef();
+  const groupRef = useCenteredModelGroup(obj, onCentered);
   const { actions, names } = useAnimations(obj.animations || [], ref);
 
   // 自动播放第一个动画
@@ -199,18 +251,21 @@ function FBXModel({ url, ...props }) {
     enhanceMaterials(obj);
   }, [obj]);
 
-  return <primitive ref={ref} object={obj} {...props} />;
+  return (
+    <group ref={groupRef}>
+      <primitive ref={ref} object={obj} {...props} />
+    </group>
+  );
 }
 
 // 预留 USDZ 支持 (iOS ARQuickLook 格式)
-function USDZModel({ url, ...props }) {
-  // const usdz = useLoader(TinyUSDZLoader, url); // 返回 THREE.Group 或 Scene
-  // return <primitive object={usdz} {...props} />;
+function USDZModel() {
+  return null;
 }
 
 // 统一模型加载器
 // 根据 URL 后缀分发到对应的加载组件
-function ModelLoader({ url, fileType }) {
+function ModelLoader({ url, fileType, onCentered }) {
   const kind = useMemo(() => {
     // 如果显式传递了 fileType,则直接使用
     if (fileType) return fileType;
@@ -224,11 +279,11 @@ function ModelLoader({ url, fileType }) {
 
   switch (kind) {
     case "fbx":
-      return <FBXModel url={url} />;
+      return <FBXModel url={url} onCentered={onCentered} />;
     case "usdz":
       return <USDZModel url={url} />;
     default:
-      return <GLTFModel url={url} />;
+      return <GLTFModel url={url} onCentered={onCentered} />;
   }
 }
 
@@ -239,7 +294,7 @@ function ModelLoader({ url, fileType }) {
 
 // FitWatcher: 负责检测 Bounds 组件的自适应动画是否完成
 // 原理:监听相机位置和目标点的变化,当连续多帧保持静止时,认为自适应完成
-function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
+function FitWatcher({ controlsRef, onFitReady, resetKey }) {
   const { camera } = useThree();
   // 记录上一帧的状态,用于对比差异
   const lastState = useRef({ pos: new THREE.Vector3(), target: new THREE.Vector3() });
@@ -247,12 +302,12 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
   const isFitted = useRef(false);
   const startTime = useRef(Infinity); // 记录组件挂载时间,初始设为 Infinity 确保在 Effect 执行前不触发逻辑
 
-  // 当 URL 变化时重置检测状态,重新开始监听
+  // 当 URL 或模型居中完成后重置检测状态
   useEffect(() => {
     isFitted.current = false;
     stableFrames.current = 0;
     startTime.current = Date.now();
-  }, [onFitReady]);
+  }, [onFitReady, resetKey]);
 
   // useFrame 会在每一帧渲染时执行
   useFrame(() => {
@@ -321,6 +376,7 @@ function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRot
   return (
     <OrbitControls
       ref={controlsRef}
+      target={MODEL_CENTER}
       enabled={enabled} // 在 fit 完成前禁用交互,防止用户打断自动飞入动画
       enableDamping
       dampingFactor={0.08}
@@ -351,6 +407,8 @@ function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRot
 function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
   const controlsRef = useRef();
   const { camera, gl } = useThree();
+  const [centerVersion, setCenterVersion] = useState(0);
+  const [refitKey, setRefitKey] = useState(0);
 
   // baseDistance: 模型自适应后的基础观察距离,用于计算缩放限制
   const [baseDistance, setBaseDistance] = useState(null);
@@ -371,17 +429,35 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
   const initialState = useRef(null);
   const timerRef = useRef(null);
 
+  const resetForNewModel = useCallback(() => {
+    setCenterVersion((v) => v + 1);
+    setBaseDistance(null);
+    setIsReady(false);
+    initialState.current = null;
+    syncOrbitTarget(controlsRef.current);
+  }, []);
+
+  const handleModelCentered = useCallback(() => {
+    resetForNewModel();
+    setRefitKey((v) => v + 1);
+  }, [resetForNewModel]);
+
   // 当 FitWatcher 告诉我们相机稳定时调用
   const handleFitReady = useCallback(
     (state) => {
+      const controls = controlsRef.current;
+      syncOrbitTarget(controls);
+
+      const distance = state.baseDistance || state.distance;
+      const position = getDefaultCameraPosition(distance);
+      camera.position.copy(position);
+      controls?.update();
+
       initialState.current = {
-        position: state.position,
-        target: state.target,
+        position: position.clone(),
+        target: MODEL_CENTER.clone(),
       };
-      // 如果 state 中有 baseDistance (原始1倍距离),则使用它,否则使用当前距离
-      // 这样做的目的是:CameraController 需要知道 "1倍缩放" 对应的距离是多少,
-      // 以便正确计算 maxDistance (baseDistance / zoomMin)
-      setBaseDistance(state.baseDistance || state.distance);
+      setBaseDistance(distance);
       setIsReady(true); // 解锁交互
 
       // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
@@ -389,7 +465,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
         setTimeout(() => onReady(), 50);
       }
     },
-    [onReady],
+    [camera, onReady],
   );
 
   // 平滑复位函数:使用插值动画让相机回到初始状态
@@ -640,15 +716,14 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
     <>
       {/* 恢复 Bounds 的渲染,不被 visible 控制,改用 CSS 遮挡或 LoadingUI 延长显示 */}
       <Bounds fit clip margin={1.5}>
-        <group rotation={[0, 0, 0]}>
-          <ModelLoader url={url} fileType={fileType} />
-        </group>
+        <ModelLoader url={url} fileType={fileType} onCentered={handleModelCentered} />
+        <BoundsRefitter trigger={refitKey} />
       </Bounds>
 
       <CameraController controlsRef={controlsRef} zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} autoRotateSpeed={effectiveSpeed} enabled={isReady} onStart={handleInteractionStart} onEnd={handleInteractionEnd} />
 
       {/* 不渲染任何 UI,仅负责逻辑检测 */}
-      <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} zoomMin={zoomMin} />
+      <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} resetKey={`${url}-${centerVersion}`} />
     </>
   );
 }
@@ -668,13 +743,13 @@ export default function Scene({ blobUrl, zoomMin, zoomMax, onModelReady, fileTyp
     <>
       <ambientLight intensity={0.8} />
       <hemisphereLight intensity={0.75} color="#ffffff" groundColor="#e8e8e8" />
-      <directionalLight position={[6, 8, 4]} intensity={1.6} />
-      <directionalLight position={[-4, 3, -5]} intensity={0.75} />
-      <directionalLight position={[0, 2, -6]} intensity={0.45} />
-      <directionalLight position={[0, -6, 2]} intensity={0.9} />
+      <directionalLight position={[2, 0, 4]} intensity={1.6} />
+      <directionalLight position={[0, 2, -2]} intensity={1.75} />
+      <directionalLight position={[0, 6, 0]} intensity={1} />
+      <directionalLight position={[0, -10, 0]} intensity={2} />
       {blobUrl && (
         <Suspense fallback={null}>
-          <SceneContent url={blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} fileType={fileType} onReady={onModelReady} />
+          <SceneContent key={blobUrl} url={blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} fileType={fileType} onReady={onModelReady} />
         </Suspense>
       )}
     </>