|
|
@@ -16,7 +16,7 @@ function useFetchLoader(url) {
|
|
|
total: 0,
|
|
|
blobUrl: null,
|
|
|
error: null,
|
|
|
- loading: false
|
|
|
+ loading: false,
|
|
|
});
|
|
|
|
|
|
useEffect(() => {
|
|
|
@@ -26,60 +26,59 @@ function useFetchLoader(url) {
|
|
|
let objectUrl = null;
|
|
|
|
|
|
const load = async () => {
|
|
|
- setState(s => ({ ...s, loading: true, progress: 0, loaded: 0, total: 0, error: null }));
|
|
|
-
|
|
|
+ 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 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) {
|
|
|
+
|
|
|
+ while (true) {
|
|
|
const { done, value } = await reader.read();
|
|
|
if (done) break;
|
|
|
-
|
|
|
+
|
|
|
chunks.push(value);
|
|
|
receivedLength += value.length;
|
|
|
-
|
|
|
- setState(prev => ({
|
|
|
+
|
|
|
+ setState((prev) => ({
|
|
|
...prev,
|
|
|
loaded: receivedLength,
|
|
|
total: total,
|
|
|
- progress: total ? (receivedLength / total) * 100 : 0
|
|
|
+ progress: total ? (receivedLength / total) * 100 : 0,
|
|
|
}));
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
const blob = new Blob(chunks);
|
|
|
objectUrl = URL.createObjectURL(blob);
|
|
|
-
|
|
|
- setState(prev => ({
|
|
|
+
|
|
|
+ setState((prev) => ({
|
|
|
...prev,
|
|
|
blobUrl: objectUrl,
|
|
|
loading: false,
|
|
|
- progress: 100
|
|
|
+ progress: 100,
|
|
|
}));
|
|
|
-
|
|
|
} catch (e) {
|
|
|
- if (e.name !== 'AbortError') {
|
|
|
+ if (e.name !== "AbortError") {
|
|
|
console.error("Fetch Loader error:", e);
|
|
|
- setState(prev => ({ ...prev, error: e.message, loading: false }));
|
|
|
+ setState((prev) => ({ ...prev, error: e.message, loading: false }));
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
load();
|
|
|
-
|
|
|
+
|
|
|
return () => {
|
|
|
controller.abort();
|
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
|
};
|
|
|
}, [url]);
|
|
|
-
|
|
|
+
|
|
|
return state;
|
|
|
}
|
|
|
|
|
|
@@ -88,100 +87,49 @@ function Loader({ customState }) {
|
|
|
// 如果没有在下载,直接返回 null
|
|
|
if (!customState || !customState.loading) return null;
|
|
|
|
|
|
- return (
|
|
|
- <LoaderUI
|
|
|
- progress={customState.progress}
|
|
|
- loaded={customState.loaded}
|
|
|
- total={customState.total}
|
|
|
- status="downloading"
|
|
|
- />
|
|
|
- );
|
|
|
+ return <LoaderUI progress={customState.progress} loaded={customState.loaded} total={customState.total} status="downloading" />;
|
|
|
}
|
|
|
|
|
|
// 专门用于 Suspense fallback 的解析状态提示
|
|
|
function ParsingLoader() {
|
|
|
- return (
|
|
|
- <LoaderUI
|
|
|
- progress={100}
|
|
|
- loaded={0}
|
|
|
- total={0}
|
|
|
- status="parsing"
|
|
|
- />
|
|
|
- );
|
|
|
+ return <LoaderUI progress={100} loaded={0} total={0} status="parsing" />;
|
|
|
}
|
|
|
|
|
|
function LoaderUI({ progress, loaded, total, status }) {
|
|
|
- // 格式化文件大小
|
|
|
- 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 = status === 'downloading' && total === 0 && loaded > 0;
|
|
|
-
|
|
|
- const progressText = isIndeterminate
|
|
|
- ? `已下载 ${formatBytes(loaded)}`
|
|
|
- : `${progress.toFixed(0)}%`;
|
|
|
-
|
|
|
- const statusText = status === 'downloading'
|
|
|
- ? (total > 0 ? `下载中 ${formatBytes(loaded)} / ${formatBytes(total)}` : '下载中...')
|
|
|
- : '解析模型中...';
|
|
|
+ const isIndeterminate = status === "downloading" && total === 0 && loaded > 0;
|
|
|
|
|
|
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)"
|
|
|
+ <Html center 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)",
|
|
|
}}
|
|
|
>
|
|
|
- <div style={{ marginBottom: 8, fontWeight: 500 }}>{statusText}</div>
|
|
|
<div
|
|
|
style={{
|
|
|
- width: "100%",
|
|
|
- height: 6,
|
|
|
- background: "#eee",
|
|
|
- borderRadius: 3,
|
|
|
- overflow: "hidden",
|
|
|
- position: "relative"
|
|
|
+ width: isIndeterminate ? "100%" : `${progress}%`,
|
|
|
+ height: "100%",
|
|
|
+ background: "#4C7F7A", // 已加载进度颜色是 #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
|
|
|
- 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; }
|
|
|
+ from { background-position: 20px 0; }
|
|
|
to { background-position: 0 0; }
|
|
|
}
|
|
|
`}</style>
|
|
|
@@ -234,7 +182,7 @@ 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";
|
|
|
@@ -259,7 +207,7 @@ function ModelLoader({ url, fileType }) {
|
|
|
|
|
|
// FitWatcher: 负责检测 Bounds 组件的自适应动画是否完成
|
|
|
// 原理:监听相机位置和目标点的变化,当连续多帧保持静止时,认为自适应完成
|
|
|
-function FitWatcher({ controlsRef, onFitReady }) {
|
|
|
+function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
|
|
|
const { camera } = useThree();
|
|
|
// 记录上一帧的状态,用于对比差异
|
|
|
const lastState = useRef({ pos: new THREE.Vector3(), target: new THREE.Vector3() });
|
|
|
@@ -297,14 +245,39 @@ function FitWatcher({ controlsRef, onFitReady }) {
|
|
|
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,
|
|
|
- });
|
|
|
+
|
|
|
+ // 核心修复:如果设置了 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
|
|
|
+ });
|
|
|
+ }
|
|
|
}
|
|
|
});
|
|
|
|
|
|
@@ -312,16 +285,7 @@ function FitWatcher({ controlsRef, onFitReady }) {
|
|
|
}
|
|
|
|
|
|
// CameraController: 封装 OrbitControls 并处理缩放限制
|
|
|
-function CameraController({
|
|
|
- controlsRef,
|
|
|
- zoomMin,
|
|
|
- zoomMax,
|
|
|
- baseDistance,
|
|
|
- autoRotateSpeed,
|
|
|
- enabled = true,
|
|
|
- onStart,
|
|
|
- onEnd,
|
|
|
-}) {
|
|
|
+function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRotateSpeed, enabled = true, onStart, onEnd }) {
|
|
|
// 动态计算 maxDistance 和 minDistance
|
|
|
useEffect(() => {
|
|
|
const controls = controlsRef.current;
|
|
|
@@ -369,6 +333,27 @@ function CameraController({
|
|
|
* 组装模型、灯光、交互,并管理全局状态
|
|
|
*/
|
|
|
|
|
|
+function LoadingOverlay({ visible }) {
|
|
|
+ if (!visible) return null;
|
|
|
+ return (
|
|
|
+ <Html fullscreen style={{ pointerEvents: "none", zIndex: 9998 }}>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: "100%",
|
|
|
+ height: "100%",
|
|
|
+ background: "#ffffff", // 使用白色或背景色遮挡
|
|
|
+ display: "flex",
|
|
|
+ alignItems: "center",
|
|
|
+ justifyContent: "center",
|
|
|
+ transition: "opacity 0.3s",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 这里可以放一个 spinner,但因为 Loader 已经有进度条了,这里纯遮挡即可 */}
|
|
|
+ </div>
|
|
|
+ </Html>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
const controlsRef = useRef();
|
|
|
const { camera, gl } = useThree();
|
|
|
@@ -384,8 +369,16 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
isInteracting: false, // 用户是否正在交互
|
|
|
});
|
|
|
|
|
|
+ // 全局背景颜色 (默认 null,表示透明)
|
|
|
+ const [bgColor, setBgColor] = useState(null);
|
|
|
+
|
|
|
// isReady: 标记是否完成初始飞入动画,完成后才解锁交互
|
|
|
+ // 修改:默认 false,既控制交互也控制场景的可见性(避免跳变被用户看到)
|
|
|
const [isReady, setIsReady] = useState(false);
|
|
|
+
|
|
|
+ // 新增:Fit 状态标记,只有当 FitWatcher 完成调整后才设为 true
|
|
|
+ // 我们用它来控制 LoadingOverlay 的消失
|
|
|
+ const [fitAdjusted, setFitAdjusted] = useState(false);
|
|
|
|
|
|
// 记录初始 fit 后的完美状态(位置 + 目标点),用于双击复位
|
|
|
const initialState = useRef(null);
|
|
|
@@ -397,8 +390,14 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
position: state.position,
|
|
|
target: state.target,
|
|
|
};
|
|
|
- setBaseDistance(state.distance);
|
|
|
+ // 如果 state 中有 baseDistance (原始1倍距离),则使用它,否则使用当前距离
|
|
|
+ // 这样做的目的是:CameraController 需要知道 "1倍缩放" 对应的距离是多少,
|
|
|
+ // 以便正确计算 maxDistance (baseDistance / zoomMin)
|
|
|
+ setBaseDistance(state.baseDistance || state.distance);
|
|
|
setIsReady(true); // 解锁交互
|
|
|
+
|
|
|
+ // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
|
|
|
+ setTimeout(() => setFitAdjusted(true), 50);
|
|
|
}, []);
|
|
|
|
|
|
// 平滑复位函数:使用插值动画让相机回到初始状态
|
|
|
@@ -435,7 +434,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
};
|
|
|
requestAnimationFrame(animate);
|
|
|
},
|
|
|
- [camera]
|
|
|
+ [camera],
|
|
|
);
|
|
|
|
|
|
// 处理用户交互开始
|
|
|
@@ -454,13 +453,13 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
// 如果开启了自动旋转模式,则启动定时器恢复旋转
|
|
|
if (autoRotateConfig.active) {
|
|
|
setAutoRotateConfig((prev) => ({ ...prev, isWaiting: true }));
|
|
|
-
|
|
|
+
|
|
|
timerRef.current = setTimeout(() => {
|
|
|
// 核心修改:等待结束后,先执行复位,复位完成后再恢复旋转
|
|
|
console.log("无操作超时,开始复位并恢复旋转...");
|
|
|
resetToInitial(() => {
|
|
|
- // 复位动画完成的回调
|
|
|
- setAutoRotateConfig((prev) => ({ ...prev, isWaiting: false }));
|
|
|
+ // 复位动画完成的回调
|
|
|
+ setAutoRotateConfig((prev) => ({ ...prev, isWaiting: false }));
|
|
|
});
|
|
|
}, autoRotateConfig.delay);
|
|
|
}
|
|
|
@@ -471,59 +470,69 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
const effectiveSpeed = autoRotateConfig.active && !autoRotateConfig.isInteracting && !autoRotateConfig.isWaiting ? autoRotateConfig.speed : 0;
|
|
|
|
|
|
// 通用相机位移动画函数
|
|
|
- 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 animateCamera = useCallback(
|
|
|
+ (targetPos, onComplete) => {
|
|
|
+ if (!controlsRef.current) return;
|
|
|
+ const controls = controlsRef.current;
|
|
|
|
|
|
- const animate = (time) => {
|
|
|
- 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) {
|
|
|
- requestAnimationFrame(animate);
|
|
|
- } else {
|
|
|
- if (typeof onComplete === "function") {
|
|
|
- onComplete();
|
|
|
+ const startPos = camera.position.clone();
|
|
|
+ const startTime = performance.now();
|
|
|
+ const duration = 300;
|
|
|
+
|
|
|
+ const animate = (time) => {
|
|
|
+ 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) {
|
|
|
+ requestAnimationFrame(animate);
|
|
|
+ } else {
|
|
|
+ if (typeof onComplete === "function") {
|
|
|
+ onComplete();
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
- };
|
|
|
- requestAnimationFrame(animate);
|
|
|
- }, [camera]);
|
|
|
+ };
|
|
|
+ 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));
|
|
|
+ const calculateZoomPos = useCallback(
|
|
|
+ (factor, isZoomIn) => {
|
|
|
+ if (!controlsRef.current || !baseDistance) return null;
|
|
|
+ const controls = controlsRef.current;
|
|
|
|
|
|
- const direction = new THREE.Vector3().subVectors(camera.position, controls.target).normalize();
|
|
|
- return controls.target.clone().add(direction.multiplyScalar(finalDist));
|
|
|
- }, [camera, baseDistance]);
|
|
|
+ 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));
|
|
|
+
|
|
|
+ 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(() => {
|
|
|
window.sceneFc = {
|
|
|
+ setBackgroundColor: (color) => {
|
|
|
+ console.log("设置背景颜色:", color);
|
|
|
+ setBgColor(color);
|
|
|
+ },
|
|
|
resetView: () => {
|
|
|
// 手动重置时,如果当前正在自动旋转,是否应该关闭自动旋转?
|
|
|
// 通常手动重置意味着用户想回到初始状态,可能希望停止自动旋转。
|
|
|
@@ -559,32 +568,34 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
},
|
|
|
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();
|
|
|
- }
|
|
|
+ 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. 缩放并更新初始位置
|
|
|
+ resetToInitial(() => {
|
|
|
+ // 2. 复位到(旧)初始位置
|
|
|
+ doZoom(() => {
|
|
|
+ // 3. 缩放并更新初始位置
|
|
|
handleInteractionEnd(); // 4. 进入等待恢复流程 (恢复后旋转将基于新位置)
|
|
|
});
|
|
|
});
|
|
|
@@ -594,23 +605,23 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
},
|
|
|
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();
|
|
|
- }
|
|
|
+ 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) {
|
|
|
@@ -623,7 +634,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
} else {
|
|
|
doZoom();
|
|
|
}
|
|
|
- }
|
|
|
+ },
|
|
|
};
|
|
|
return () => {
|
|
|
// 组件卸载时清理全局对象
|
|
|
@@ -653,24 +664,27 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
|
|
|
return (
|
|
|
<>
|
|
|
+ {bgColor && <color attach="background" args={[bgColor]} />}
|
|
|
{/* Bounds: 自动计算模型包围盒并调整相机视角 */}
|
|
|
- <Bounds fit clip observe margin={1.5}>
|
|
|
- <ModelLoader url={url} fileType={fileType} />
|
|
|
+ {/* 不渲染任何 UI,仅负责逻辑检测 */}
|
|
|
+ {/*
|
|
|
+ 注意:Bounds 需要渲染其中的 children 才能计算。
|
|
|
+ */}
|
|
|
+
|
|
|
+ {/* 恢复 Bounds 的渲染,不被 visible 控制,改用 CSS 遮挡或 LoadingUI 延长显示 */}
|
|
|
+ <Bounds fit clip margin={1.5}>
|
|
|
+ <ModelLoader url={url} fileType={fileType} />
|
|
|
</Bounds>
|
|
|
|
|
|
+ {/* 遮挡层:在 Fit 调整完成前一直显示白色背景,遮住 "先1倍再2倍" 的跳变过程 */}
|
|
|
+ <LoadingOverlay visible={!fitAdjusted} />
|
|
|
+
|
|
|
<CameraController
|
|
|
controlsRef={controlsRef}
|
|
|
- zoomMin={zoomMin}
|
|
|
- zoomMax={zoomMax}
|
|
|
- baseDistance={baseDistance}
|
|
|
- autoRotateSpeed={effectiveSpeed}
|
|
|
- enabled={isReady}
|
|
|
- onStart={handleInteractionStart}
|
|
|
- onEnd={handleInteractionEnd}
|
|
|
- />
|
|
|
+ zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} autoRotateSpeed={effectiveSpeed} enabled={isReady} onStart={handleInteractionStart} onEnd={handleInteractionEnd} />
|
|
|
|
|
|
{/* 不渲染任何 UI,仅负责逻辑检测 */}
|
|
|
- <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} />
|
|
|
+ <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} zoomMin={zoomMin} />
|
|
|
</>
|
|
|
);
|
|
|
}
|
|
|
@@ -678,7 +692,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType }) {
|
|
|
export default function Scene({ url, zoomMin, zoomMax }) {
|
|
|
// 使用自定义 Hook 加载文件
|
|
|
const fetchState = useFetchLoader(url);
|
|
|
-
|
|
|
+
|
|
|
// 推断文件类型,传递给 ModelLoader
|
|
|
const fileType = useMemo(() => {
|
|
|
if (!url) return null;
|
|
|
@@ -702,7 +716,7 @@ export default function Scene({ url, zoomMin, zoomMax }) {
|
|
|
<Loader customState={fetchState} />
|
|
|
</>
|
|
|
) : (
|
|
|
- <Html center style={{ color: "#222", fontSize: 14 }}>
|
|
|
+ <Html center style={{ width: "200px", color: "#222", fontSize: 14 }}>
|
|
|
请在地址栏提供 ?src=GLB地址
|
|
|
</Html>
|
|
|
)}
|