|
|
@@ -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>
|
|
|
)}
|
|
|
</>
|