|
|
@@ -8,8 +8,8 @@ import * as THREE from "three";
|
|
|
* 负责不同格式模型的加载 (GLTF, FBX) 以及动画播放
|
|
|
*/
|
|
|
|
|
|
-// 自定义 Hook: 手动 Fetch 获取真实进度
|
|
|
-function useFetchLoader(url) {
|
|
|
+// 导出 useFetchLoader 供外部使用
|
|
|
+export function useFetchLoader(url) {
|
|
|
const [state, setState] = useState({
|
|
|
progress: 0,
|
|
|
loaded: 0,
|
|
|
@@ -100,32 +100,43 @@ function LoaderUI({ progress, loaded, total, status, color }) {
|
|
|
const isIndeterminate = status === "downloading" && total === 0 && loaded > 0;
|
|
|
|
|
|
return (
|
|
|
- <Html center style={{ zIndex: 9999, pointerEvents: "none" }}>
|
|
|
+ <Html fullscreen style={{ zIndex: 9999, pointerEvents: "none" }}>
|
|
|
<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)",
|
|
|
+ width: "100%",
|
|
|
+ height: "100%",
|
|
|
+ background: "#ffffff", // 统一使用白色背景,防止阶段切换时的闪烁
|
|
|
+ display: "flex",
|
|
|
+ alignItems: "center",
|
|
|
+ justifyContent: "center",
|
|
|
}}
|
|
|
>
|
|
|
<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",
|
|
|
+ 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 {
|
|
|
@@ -137,6 +148,70 @@ function LoaderUI({ progress, loaded, total, status, color }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+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),则隐藏
|
|
|
+ const shouldShow = active && !isModelReady;
|
|
|
+
|
|
|
+ // 进度条逻辑
|
|
|
+ let progress = 0;
|
|
|
+ if (isDownloading) progress = fetchState.progress;
|
|
|
+ else if (isProcessing) progress = 100;
|
|
|
+ else if (shouldShow) progress = 0; // 初始状态
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: "fixed",
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ width: "100%",
|
|
|
+ height: "100%",
|
|
|
+ background: "#ffffff",
|
|
|
+ display: "flex",
|
|
|
+ alignItems: "center",
|
|
|
+ justifyContent: "center",
|
|
|
+ opacity: shouldShow ? 1 : 0,
|
|
|
+ // 关键修改:显示时瞬间显示(无transition),隐藏时才淡出
|
|
|
+ transition: shouldShow ? "none" : "opacity 0.8s ease-out",
|
|
|
+ pointerEvents: shouldShow ? "auto" : "none",
|
|
|
+ zIndex: 9999,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <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%",
|
|
|
+ transform: `scaleX(${progress / 100})`,
|
|
|
+ transformOrigin: "left",
|
|
|
+ height: "100%",
|
|
|
+ background: color || "#4C7F7A",
|
|
|
+ transition: "transform 0.2s linear",
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
// GLTF 模型组件
|
|
|
function GLTFModel({ url, ...props }) {
|
|
|
const { scene, animations } = useGLTF(url, true);
|
|
|
@@ -213,17 +288,23 @@ 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()); // 记录组件挂载时间
|
|
|
|
|
|
// 当 URL 变化时重置检测状态,重新开始监听
|
|
|
useEffect(() => {
|
|
|
isFitted.current = false;
|
|
|
stableFrames.current = 0;
|
|
|
+ startTime.current = Date.now();
|
|
|
}, [onFitReady]);
|
|
|
|
|
|
// useFrame 会在每一帧渲染时执行
|
|
|
useFrame(() => {
|
|
|
if (!controlsRef.current || isFitted.current) return;
|
|
|
|
|
|
+ // 强制等待至少 1000ms,确保 Bounds 有足够时间启动动画,且避免缓存加载时 Loader 闪现
|
|
|
+ // 这段时间内,Loader 会一直遮挡屏幕
|
|
|
+ if (Date.now() - startTime.current < 1000) return;
|
|
|
+
|
|
|
const controls = controlsRef.current;
|
|
|
const currentPos = camera.position;
|
|
|
const currentTarget = controls.target;
|
|
|
@@ -241,8 +322,8 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
|
|
|
lastState.current.pos.copy(currentPos);
|
|
|
lastState.current.target.copy(currentTarget);
|
|
|
|
|
|
- // 连续 10 帧稳定(约 160ms @ 60fps)认为 fit 动画彻底结束
|
|
|
- if (stableFrames.current > 10) {
|
|
|
+ // 连续 20 帧稳定(约 330ms @ 60fps)认为 fit 动画彻底结束
|
|
|
+ if (stableFrames.current > 20) {
|
|
|
isFitted.current = true;
|
|
|
const distance = currentPos.distanceTo(currentTarget);
|
|
|
|
|
|
@@ -333,28 +414,49 @@ function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRot
|
|
|
* 组装模型、灯光、交互,并管理全局状态
|
|
|
*/
|
|
|
|
|
|
-function LoadingOverlay({ visible }) {
|
|
|
- if (!visible) return null;
|
|
|
+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", // 使用白色或背景色遮挡
|
|
|
+ background: "#ffffff",
|
|
|
display: "flex",
|
|
|
alignItems: "center",
|
|
|
justifyContent: "center",
|
|
|
- transition: "opacity 0.3s",
|
|
|
+ opacity: visible ? 1 : 0, // 通过透明度控制显隐
|
|
|
+ transition: "opacity 0.8s ease-out", // 增加淡出时长,让过程更平滑
|
|
|
+ pointerEvents: visible ? "auto" : "none", // 隐藏后不阻挡点击
|
|
|
}}
|
|
|
>
|
|
|
- {/* 这里可以放一个 spinner,但因为 Loader 已经有进度条了,这里纯遮挡即可 */}
|
|
|
+ <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 }) {
|
|
|
+function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
|
|
|
const controlsRef = useRef();
|
|
|
const { camera, gl } = useThree();
|
|
|
|
|
|
@@ -375,7 +477,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
|
|
|
// 新增:Fit 状态标记,只有当 FitWatcher 完成调整后才设为 true
|
|
|
// 我们用它来控制 LoadingOverlay 的消失
|
|
|
- const [fitAdjusted, setFitAdjusted] = useState(false);
|
|
|
+ // const [fitAdjusted, setFitAdjusted] = useState(false); // 已被 UnifiedLoader 接管
|
|
|
|
|
|
// 记录初始 fit 后的完美状态(位置 + 目标点),用于双击复位
|
|
|
const initialState = useRef(null);
|
|
|
@@ -394,8 +496,10 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
setIsReady(true); // 解锁交互
|
|
|
|
|
|
// 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
|
|
|
- setTimeout(() => setFitAdjusted(true), 50);
|
|
|
- }, []);
|
|
|
+ if (onReady) {
|
|
|
+ setTimeout(() => onReady(), 50);
|
|
|
+ }
|
|
|
+ }, [onReady]);
|
|
|
|
|
|
// 平滑复位函数:使用插值动画让相机回到初始状态
|
|
|
// 支持 onComplete 回调,用于在复位完成后执行操作(如开始自动旋转)
|
|
|
@@ -669,7 +773,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
</Bounds>
|
|
|
|
|
|
{/* 遮挡层:在 Fit 调整完成前一直显示白色背景,遮住 "先1倍再2倍" 的跳变过程 */}
|
|
|
- <LoadingOverlay visible={!fitAdjusted} />
|
|
|
+ {/* <LoadingOverlay visible={!fitAdjusted} color={progressColor} /> */}
|
|
|
|
|
|
<CameraController
|
|
|
controlsRef={controlsRef}
|
|
|
@@ -681,36 +785,36 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-export default function Scene({ url, zoomMin, zoomMax, progressColor }) {
|
|
|
- // 使用自定义 Hook 加载文件
|
|
|
- const fetchState = useFetchLoader(url);
|
|
|
-
|
|
|
+export default function Scene({ blobUrl, zoomMin, zoomMax, progressColor, onModelReady }) {
|
|
|
// 推断文件类型,传递给 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]);
|
|
|
-
|
|
|
+ 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} />
|
|
|
- {url ? (
|
|
|
- <>
|
|
|
- {fetchState.blobUrl && (
|
|
|
- <Suspense fallback={<ParsingLoader color={progressColor} />}>
|
|
|
- <SceneContent url={fetchState.blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} fileType={fileType} />
|
|
|
- </Suspense>
|
|
|
- )}
|
|
|
- <Loader customState={fetchState} color={progressColor} />
|
|
|
- </>
|
|
|
- ) : (
|
|
|
- <Html center style={{ width: "200px", color: "#222", fontSize: 14 }}>
|
|
|
- 请在地址栏提供 ?src=GLB地址
|
|
|
- </Html>
|
|
|
+ {blobUrl && (
|
|
|
+ <Suspense fallback={null}>
|
|
|
+ <SceneContent
|
|
|
+ url={blobUrl}
|
|
|
+ zoomMin={zoomMin}
|
|
|
+ zoomMax={zoomMax}
|
|
|
+ fileType={fileType}
|
|
|
+ onReady={onModelReady}
|
|
|
+ />
|
|
|
+ </Suspense>
|
|
|
)}
|
|
|
</>
|
|
|
);
|