|
@@ -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(() => {
|
|
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(() => {
|
|
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 (
|
|
return (
|
|
|
<OrbitControls
|
|
<OrbitControls
|
|
|
ref={controlsRef}
|
|
ref={controlsRef}
|
|
|
|
|
+ enabled={enabled} // 在 fit 完成前禁用交互,防止用户打断自动飞入动画
|
|
|
enableDamping
|
|
enableDamping
|
|
|
dampingFactor={0.08}
|
|
dampingFactor={0.08}
|
|
|
rotateSpeed={0.8}
|
|
rotateSpeed={0.8}
|
|
@@ -51,251 +337,359 @@ function ControlsWithLimits({ controlsRef, zoomMin, zoomMax, baseDistance }) {
|
|
|
screenSpacePanning
|
|
screenSpacePanning
|
|
|
touches={{
|
|
touches={{
|
|
|
ONE: THREE.TOUCH.ROTATE,
|
|
ONE: THREE.TOUCH.ROTATE,
|
|
|
- TWO: THREE.TOUCH.DOLLY_PAN
|
|
|
|
|
|
|
+ TWO: THREE.TOUCH.DOLLY_PAN,
|
|
|
}}
|
|
}}
|
|
|
makeDefault
|
|
makeDefault
|
|
|
autoRotate={!!autoRotateSpeed}
|
|
autoRotate={!!autoRotateSpeed}
|
|
|
autoRotateSpeed={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 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) {
|
|
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(() => {
|
|
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 () => {
|
|
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(() => {
|
|
useEffect(() => {
|
|
|
- // 创建或覆盖 window.sceneFc
|
|
|
|
|
- window.sceneFc = window.sceneFc || {}
|
|
|
|
|
- window.sceneFc.reset = resetToInitial
|
|
|
|
|
-
|
|
|
|
|
- console.log('window.sceneFc.reset 已暴露,可外部调用')
|
|
|
|
|
-
|
|
|
|
|
return () => {
|
|
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(() => {
|
|
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 (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
<ambientLight intensity={0.8} />
|
|
<ambientLight intensity={0.8} />
|
|
|
<directionalLight position={[5, 5, 5]} intensity={1.0} />
|
|
<directionalLight position={[5, 5, 5]} intensity={1.0} />
|
|
|
{url ? (
|
|
{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
|
|
|