diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index d21bee3cc..ecee79eb5 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -65,7 +65,6 @@ const VIEW_META: Array<{ icon: LucideIcon; toolbarKey: CanvasToolbarItemKey; }> = [ - { id: "splat", label: "W", icon: Sparkles, toolbarKey: "world3dViewSplat" }, { id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" }, { id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" }, ]; @@ -77,18 +76,27 @@ const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360; function resolveAvailableViews(data: World3DNodeData) { const views: World3DViewMode[] = []; - if (isArchivedWorldLabsAssetUrl(data.splatUrl)) views.push("splat"); if (data.imageUrl || data.previewImage) views.push("image"); if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh"); + if (views.length === 0 && isArchivedWorldLabsAssetUrl(data.splatUrl)) { + views.push("splat"); + } return views; } function resolveActiveView(data: World3DNodeData) { const availableViews = resolveAvailableViews(data); - if (data.activeView && availableViews.includes(data.activeView)) { + if ( + data.activeView && + data.activeView !== "splat" && + availableViews.includes(data.activeView) + ) { return data.activeView; } - return availableViews[0] || "splat"; + if (availableViews.includes("mesh")) { + return "mesh"; + } + return availableViews[0] || "image"; } function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) { @@ -153,6 +161,11 @@ function clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function readFiniteNumber(value: unknown) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +} + export function World3DNode({ node, isSelected, @@ -165,6 +178,7 @@ export function World3DNode({ null ); const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false); + const [generationTick, setGenerationTick] = useState(0); const hydrationAttemptRef = useRef(0); const hydrationTaskRef = useRef(null); const generationRecoveryAttemptRef = useRef(0); @@ -286,6 +300,33 @@ export function World3DNode({ }), [placeholderViewportScale] ); + const displayedGenerationProgress = useMemo(() => { + if (data.status !== "generating") return 100; + + const explicitProgress = readFiniteNumber(metadata.progress); + if (explicitProgress !== null) { + return Math.round(clampNumber(explicitProgress, 1, 99)); + } + + const startedAtRaw = + readStringValue(metadata.generationStartedAt) || + readStringValue(metadata.createdAt) || + readStringValue(metadata.startedAt); + const startedAt = startedAtRaw ? Date.parse(startedAtRaw) : NaN; + const elapsedMs = Number.isFinite(startedAt) + ? Math.max(0, Date.now() - startedAt) + : generationTick * 1000; + const simulatedProgress = + 8 + Math.floor((1 - Math.exp(-elapsedMs / 180000)) * 91); + return Math.round(clampNumber(simulatedProgress, 8, 99)); + }, [ + data.status, + generationTick, + metadata.createdAt, + metadata.generationStartedAt, + metadata.progress, + metadata.startedAt, + ]); const topLeftControlsStyle = useMemo( () => ({ transform: `scale(${overlayChromeScale})`, @@ -303,6 +344,19 @@ export function World3DNode({ const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh"); const showTopLeftControls = data.status === "completed"; + useEffect(() => { + if (data.status !== "generating") { + setGenerationTick(0); + return; + } + + setGenerationTick(0); + const timer = window.setInterval(() => { + setGenerationTick((tick) => tick + 1); + }, 1000); + return () => window.clearInterval(timer); + }, [data.status, hydrationTaskId]); + const persistCanvasStateSnapshot = useCallback(() => { if (!projectId || typeof window === "undefined") return; window.setTimeout(() => { @@ -471,7 +525,7 @@ export function World3DNode({ prompt: promptText, modelVersion, status: "completed", - activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : "image", + activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image", firstPerson: data.firstPerson ?? false, hotspots: data.hotspots || [], semanticsMetadata: @@ -616,7 +670,7 @@ export function World3DNode({ splatUrl: splatUrl || renderableSplatUrl, previewImage: previewImageUrl || imageUrl || data.previewImage, status: "completed", - activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView, + activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : data.activeView, firstPerson: data.firstPerson ?? false, semanticsMetadata: assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object" @@ -840,10 +894,18 @@ export function World3DNode({ - 3D 世界生成中... + 3D 世界生成中... {displayedGenerationProgress}% + + + - {worldModelLabel || "世界资产正在构建中"} + {displayedGenerationProgress >= 95 + ? "已接近完成,正在同步空间数据" + : worldModelLabel || "世界资产正在构建中"} diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 41339f96e..7dd2d2e8b 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -656,29 +656,27 @@ function SplatParticleCloud({ }); return ( - - - - - - - - - + + + + + + + ); } @@ -735,28 +733,6 @@ function WorldSplatLoadingOverlay({ ) : null} -
-
-
- - {isRevealing ? "高清世界正在接管预览" : "正在载入世界数据"} -
-
- - - {isRevealing - ? "预览图已显示,世界细节正在补齐" - : previewImageUrl - ? "先展示世界预览图,空间数据后台继续加载" - : "空间数据准备中"} - -
-
-
); } @@ -1077,7 +1053,7 @@ function WorldSplatScene({ ); const groundPlaneOffset = typeof semanticsMetadata?.ground_plane_offset === "number" - ? -semanticsMetadata.ground_plane_offset + ? semanticsMetadata.ground_plane_offset : null; const handleOrbitControlsRef = React.useCallback((controls: any | null) => { orbitControlsRef.current = controls; @@ -1111,7 +1087,7 @@ function WorldSplatScene({ revealCompletedRef.current = false; setParticleCloudData(null); splatMesh.frustumCulled = false; - splatMesh.rotation.x = Math.PI; + splatMesh.rotation.set(0, 0, 0); applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); let cancelled = false;