|
|
@@ -1,6 +1,6 @@
|
|
|
-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 { Suspense, useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
|
+import { useThree, useFrame } from "@react-three/fiber";
|
|
|
+import { OrbitControls, useGLTF, Bounds, useFBX, useAnimations } from "@react-three/drei";
|
|
|
import * as THREE from "three";
|
|
|
|
|
|
/**
|
|
|
@@ -82,80 +82,14 @@ export function useFetchLoader(url) {
|
|
|
return state;
|
|
|
}
|
|
|
|
|
|
-function Loader({ customState, color }) {
|
|
|
- // 只负责显示手动下载的进度
|
|
|
- // 如果没有在下载,直接返回 null
|
|
|
- if (!customState || !customState.loading) return null;
|
|
|
-
|
|
|
- return <LoaderUI progress={customState.progress} loaded={customState.loaded} total={customState.total} status="downloading" color={color} />;
|
|
|
-}
|
|
|
-
|
|
|
-// 专门用于 Suspense fallback 的解析状态提示
|
|
|
-function ParsingLoader({ color }) {
|
|
|
- return <LoaderUI progress={100} loaded={0} total={0} status="parsing" color={color} />;
|
|
|
-}
|
|
|
-
|
|
|
-function LoaderUI({ progress, loaded, total, status, color }) {
|
|
|
- // 判断是否无法计算总大小
|
|
|
- const isIndeterminate = status === "downloading" && total === 0 && loaded > 0;
|
|
|
-
|
|
|
- return (
|
|
|
- <Html fullscreen style={{ zIndex: 9999, pointerEvents: "none" }}>
|
|
|
- <div
|
|
|
- style={{
|
|
|
- width: "100%",
|
|
|
- height: "100%",
|
|
|
- background: "#ffffff", // 统一使用白色背景,防止阶段切换时的闪烁
|
|
|
- display: "flex",
|
|
|
- alignItems: "center",
|
|
|
- justifyContent: "center",
|
|
|
- }}
|
|
|
- >
|
|
|
- <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={{
|
|
|
- width: isIndeterminate ? "100%" : `${progress}%`,
|
|
|
- height: "100%",
|
|
|
- background: color || "#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>
|
|
|
- </div>
|
|
|
- <style>{`
|
|
|
- @keyframes progress-bar-stripes {
|
|
|
- from { background-position: 20px 0; }
|
|
|
- to { background-position: 0 0; }
|
|
|
- }
|
|
|
- `}</style>
|
|
|
- </Html>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
export function OverlayLoader({ fetchState, isModelReady, color, active }) {
|
|
|
// active: 是否应该处于激活状态(只要有 src 就应该激活)
|
|
|
-
|
|
|
+
|
|
|
// 计算当前状态
|
|
|
const isDownloading = fetchState && fetchState.loading;
|
|
|
// 只要有 blobUrl 但场景没好,就是在解析或自适应
|
|
|
const isProcessing = fetchState && fetchState.blobUrl && !isModelReady;
|
|
|
-
|
|
|
+
|
|
|
// 只要 active 为 true,且模型未就绪,就应该显示
|
|
|
// 如果 active 为 false (无 src),则隐藏
|
|
|
// 如果模型已就绪 (isModelReady),则隐藏
|
|
|
@@ -288,7 +222,7 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
|
|
|
const lastState = useRef({ pos: new THREE.Vector3(), target: new THREE.Vector3() });
|
|
|
const stableFrames = useRef(0);
|
|
|
const isFitted = useRef(false);
|
|
|
- const startTime = useRef(Date.now()); // 记录组件挂载时间
|
|
|
+ const startTime = useRef(Infinity); // 记录组件挂载时间,初始设为 Infinity 确保在 Effect 执行前不触发逻辑
|
|
|
|
|
|
// 当 URL 变化时重置检测状态,重新开始监听
|
|
|
useEffect(() => {
|
|
|
@@ -326,39 +260,16 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
|
|
|
if (stableFrames.current > 20) {
|
|
|
isFitted.current = true;
|
|
|
const distance = currentPos.distanceTo(currentTarget);
|
|
|
-
|
|
|
+
|
|
|
// 核心修复:如果设置了 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
|
|
|
- });
|
|
|
- }
|
|
|
+
|
|
|
+ onFitReady({
|
|
|
+ position: currentPos.clone(),
|
|
|
+ target: currentTarget.clone(),
|
|
|
+ distance: distance,
|
|
|
+ baseDistance: distance,
|
|
|
+ });
|
|
|
}
|
|
|
});
|
|
|
|
|
|
@@ -414,48 +325,6 @@ function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRot
|
|
|
* 组装模型、灯光、交互,并管理全局状态
|
|
|
*/
|
|
|
|
|
|
-function LoadingOverlay({ visible, color }) {
|
|
|
- // 修改:即使 visible 为 false,也不返回 null,而是通过 opacity 控制显示/隐藏
|
|
|
- // 这样 CSS transition 才能生效,实现淡出效果
|
|
|
- return (
|
|
|
- <Html fullscreen style={{ pointerEvents: "none", zIndex: 9998 }}>
|
|
|
- <div
|
|
|
- style={{
|
|
|
- width: "100%",
|
|
|
- height: "100%",
|
|
|
- background: "#ffffff",
|
|
|
- display: "flex",
|
|
|
- alignItems: "center",
|
|
|
- justifyContent: "center",
|
|
|
- opacity: visible ? 1 : 0, // 通过透明度控制显隐
|
|
|
- transition: "opacity 0.8s ease-out", // 增加淡出时长,让过程更平滑
|
|
|
- pointerEvents: visible ? "auto" : "none", // 隐藏后不阻挡点击
|
|
|
- }}
|
|
|
- >
|
|
|
- <div
|
|
|
- style={{
|
|
|
- width: 600,
|
|
|
- height: 10,
|
|
|
- background: "#fff",
|
|
|
- borderRadius: 5,
|
|
|
- overflow: "hidden",
|
|
|
- position: "relative",
|
|
|
- boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
|
|
- }}
|
|
|
- >
|
|
|
- <div
|
|
|
- style={{
|
|
|
- width: "100%",
|
|
|
- height: "100%",
|
|
|
- background: color || "#4C7F7A",
|
|
|
- }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Html>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
|
|
|
const controlsRef = useRef();
|
|
|
const { camera, gl } = useThree();
|
|
|
@@ -474,32 +343,31 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
|
|
|
// isReady: 标记是否完成初始飞入动画,完成后才解锁交互
|
|
|
// 修改:默认 false,既控制交互也控制场景的可见性(避免跳变被用户看到)
|
|
|
const [isReady, setIsReady] = useState(false);
|
|
|
-
|
|
|
- // 新增:Fit 状态标记,只有当 FitWatcher 完成调整后才设为 true
|
|
|
- // 我们用它来控制 LoadingOverlay 的消失
|
|
|
- // const [fitAdjusted, setFitAdjusted] = useState(false); // 已被 UnifiedLoader 接管
|
|
|
|
|
|
// 记录初始 fit 后的完美状态(位置 + 目标点),用于双击复位
|
|
|
const initialState = useRef(null);
|
|
|
const timerRef = useRef(null);
|
|
|
|
|
|
// 当 FitWatcher 告诉我们相机稳定时调用
|
|
|
- const handleFitReady = useCallback((state) => {
|
|
|
- initialState.current = {
|
|
|
- position: state.position,
|
|
|
- target: state.target,
|
|
|
- };
|
|
|
- // 如果 state 中有 baseDistance (原始1倍距离),则使用它,否则使用当前距离
|
|
|
- // 这样做的目的是:CameraController 需要知道 "1倍缩放" 对应的距离是多少,
|
|
|
- // 以便正确计算 maxDistance (baseDistance / zoomMin)
|
|
|
- setBaseDistance(state.baseDistance || state.distance);
|
|
|
- setIsReady(true); // 解锁交互
|
|
|
-
|
|
|
- // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
|
|
|
- if (onReady) {
|
|
|
- setTimeout(() => onReady(), 50);
|
|
|
- }
|
|
|
- }, [onReady]);
|
|
|
+ const handleFitReady = useCallback(
|
|
|
+ (state) => {
|
|
|
+ initialState.current = {
|
|
|
+ position: state.position,
|
|
|
+ target: state.target,
|
|
|
+ };
|
|
|
+ // 如果 state 中有 baseDistance (原始1倍距离),则使用它,否则使用当前距离
|
|
|
+ // 这样做的目的是:CameraController 需要知道 "1倍缩放" 对应的距离是多少,
|
|
|
+ // 以便正确计算 maxDistance (baseDistance / zoomMin)
|
|
|
+ setBaseDistance(state.baseDistance || state.distance);
|
|
|
+ setIsReady(true); // 解锁交互
|
|
|
+
|
|
|
+ // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
|
|
|
+ if (onReady) {
|
|
|
+ setTimeout(() => onReady(), 50);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [onReady],
|
|
|
+ );
|
|
|
|
|
|
// 平滑复位函数:使用插值动画让相机回到初始状态
|
|
|
// 支持 onComplete 回调,用于在复位完成后执行操作(如开始自动旋转)
|
|
|
@@ -733,6 +601,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
|
|
|
}
|
|
|
},
|
|
|
};
|
|
|
+ console.log("已提出方法:", window.sceneFc);
|
|
|
return () => {
|
|
|
// 组件卸载时清理全局对象
|
|
|
if (window.sceneFc) {
|
|
|
@@ -761,23 +630,12 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
|
|
|
|
|
|
return (
|
|
|
<>
|
|
|
- {/* Bounds: 自动计算模型包围盒并调整相机视角 */}
|
|
|
- {/* 不渲染任何 UI,仅负责逻辑检测 */}
|
|
|
- {/*
|
|
|
- 注意:Bounds 需要渲染其中的 children 才能计算。
|
|
|
- */}
|
|
|
-
|
|
|
{/* 恢复 Bounds 的渲染,不被 visible 控制,改用 CSS 遮挡或 LoadingUI 延长显示 */}
|
|
|
<Bounds fit clip margin={1.5}>
|
|
|
- <ModelLoader url={url} fileType={fileType} />
|
|
|
+ <ModelLoader url={url} fileType={fileType} />
|
|
|
</Bounds>
|
|
|
|
|
|
- {/* 遮挡层:在 Fit 调整完成前一直显示白色背景,遮住 "先1倍再2倍" 的跳变过程 */}
|
|
|
- {/* <LoadingOverlay visible={!fitAdjusted} color={progressColor} /> */}
|
|
|
-
|
|
|
- <CameraController
|
|
|
- controlsRef={controlsRef}
|
|
|
- zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} autoRotateSpeed={effectiveSpeed} enabled={isReady} onStart={handleInteractionStart} onEnd={handleInteractionEnd} />
|
|
|
+ <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} />
|
|
|
@@ -785,35 +643,23 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-export default function Scene({ blobUrl, zoomMin, zoomMax, progressColor, onModelReady }) {
|
|
|
+export default function Scene({ blobUrl, zoomMin, zoomMax, onModelReady }) {
|
|
|
// 推断文件类型,传递给 ModelLoader
|
|
|
const fileType = useMemo(() => {
|
|
|
if (!blobUrl) return null;
|
|
|
- // 由于 blobUrl 没有后缀,这里需要外部传入 fileType 或者我们假设它是 gltf
|
|
|
- // 之前是根据 url 推断,现在 url 可能是 blob:http://...
|
|
|
- // 但 fetchState.blobUrl 是 blob,原来的 url 是真实的 url
|
|
|
- // 我们需要原来的 url 来推断类型,或者让 App 传进来
|
|
|
- // 简单起见,我们假设外部会处理好,或者我们这里只负责渲染
|
|
|
- // 实际上 SceneContent 需要 fileType
|
|
|
return "gltf"; // 默认
|
|
|
}, [blobUrl]);
|
|
|
|
|
|
// 为了保持兼容,我们还是需要真实的 URL 来推断类型,或者修改 App 传 fileType
|
|
|
// 这里暂时简化,假设都是 gltf,如果需要支持 fbx,App 需要传 fileType
|
|
|
-
|
|
|
+
|
|
|
return (
|
|
|
<>
|
|
|
<ambientLight intensity={0.8} />
|
|
|
<directionalLight position={[5, 5, 5]} intensity={1.0} />
|
|
|
{blobUrl && (
|
|
|
<Suspense fallback={null}>
|
|
|
- <SceneContent
|
|
|
- url={blobUrl}
|
|
|
- zoomMin={zoomMin}
|
|
|
- zoomMax={zoomMax}
|
|
|
- fileType={fileType}
|
|
|
- onReady={onModelReady}
|
|
|
- />
|
|
|
+ <SceneContent url={blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} fileType={fileType} onReady={onModelReady} />
|
|
|
</Suspense>
|
|
|
)}
|
|
|
</>
|