"use client"; /* eslint-disable @next/next/no-img-element */ import { Html, OrbitControls, PointerLockControls } from "@react-three/drei"; import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber"; import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark"; import { AlertCircle, Crosshair, Globe, ImageIcon, Loader2, Sparkles, } from "lucide-react"; import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; import { type Model3DLayer, type Model3DStats, Model3DViewer, type Model3DViewerSettings, } from "@/components/canvas/model3d/Model3DViewer"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import type { World3DHotspot, World3DViewMode } from "@/types/canvas"; type World3DPickResult = { point: [number, number, number]; }; type MovementCommand = | "forward" | "backward" | "left" | "right" | "up" | "down"; type MovementState = Record; type SplatLoadingPhase = "loading" | "revealing" | "hidden"; type SplatParticleCloudData = { count: number; colors: Float32Array; positions: Float32Array; seeds: Float32Array; }; type SparkRevealProfile = { blurAmount: number; preBlurAmount: number; minAlpha: number; maxStdDev: number; focalAdjustment: number; falloff: number; opacity: number; }; type World3DViewerProps = { mode: World3DViewMode; worldUrl?: string | null; imageUrl?: string | null; meshUrl?: string | null; splatUrl?: string | null; previewImageUrl?: string | null; emptyStateTitle?: string; meshSettings?: Model3DViewerSettings; meshLayerVisibility?: Record; onMeshLayersChange?: (layers: Model3DLayer[]) => void; onMeshStatsChange?: (stats: Model3DStats) => void; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; className?: string; resetViewToken?: number; }; const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { cameraPreset: "iso", envPreset: "city", lightIntensity: 1, autoRotate: false, showGrid: true, wireframe: false, }; const EMPTY_MOVEMENT_STATE: MovementState = { forward: false, backward: false, left: false, right: false, up: false, down: false, }; const SPLAT_LOADING_PROFILE: SparkRevealProfile = { blurAmount: 1.15, preBlurAmount: 0.34, minAlpha: 0.1, maxStdDev: Math.sqrt(10.5), focalAdjustment: 0.82, falloff: 0.72, opacity: 0, }; const SPLAT_PARTICLE_PROFILE: SparkRevealProfile = { blurAmount: 0.01, preBlurAmount: 0, minAlpha: 0.28, maxStdDev: Math.sqrt(1.45), focalAdjustment: 3.05, falloff: 0.08, opacity: 1, }; const SPLAT_NORMAL_PROFILE: SparkRevealProfile = { blurAmount: 0.12, preBlurAmount: 0.02, minAlpha: 0.015, maxStdDev: Math.sqrt(6.5), focalAdjustment: 1.18, falloff: 1, opacity: 1, }; function applySparkRevealProfile( sparkRenderer: SparkRenderer, splatMesh: SparkSplatMesh, profile: SparkRevealProfile ) { splatMesh.opacity = profile.opacity; sparkRenderer.blurAmount = profile.blurAmount; sparkRenderer.preBlurAmount = profile.preBlurAmount; sparkRenderer.minAlpha = profile.minAlpha; sparkRenderer.maxStdDev = profile.maxStdDev; sparkRenderer.focalAdjustment = profile.focalAdjustment; sparkRenderer.falloff = profile.falloff; } function lerpSparkRevealProfile( from: SparkRevealProfile, to: SparkRevealProfile, progress: number ): SparkRevealProfile { return { blurAmount: THREE.MathUtils.lerp(from.blurAmount, to.blurAmount, progress), preBlurAmount: THREE.MathUtils.lerp(from.preBlurAmount, to.preBlurAmount, progress), minAlpha: THREE.MathUtils.lerp(from.minAlpha, to.minAlpha, progress), maxStdDev: THREE.MathUtils.lerp(from.maxStdDev, to.maxStdDev, progress), focalAdjustment: THREE.MathUtils.lerp( from.focalAdjustment, to.focalAdjustment, progress ), falloff: THREE.MathUtils.lerp(from.falloff, to.falloff, progress), opacity: THREE.MathUtils.lerp(from.opacity, to.opacity, progress), }; } const SPLAT_REVEAL_DURATION_MS = 2600; const SPLAT_PARTICLE_SAMPLE_LIMIT = 1800; const KEY_TO_MOVEMENT: Partial> = { KeyW: "forward", ArrowUp: "forward", KeyS: "backward", ArrowDown: "backward", KeyA: "left", ArrowLeft: "left", KeyD: "right", ArrowRight: "right", KeyQ: "up", KeyR: "up", PageUp: "up", KeyE: "down", KeyF: "down", PageDown: "down", }; function isEditableKeyboardTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true; const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']"; if (target.matches(editableSelector)) return true; return Boolean(target.closest(editableSelector)); } function getSemanticFacts(metadata?: Record | null) { if (!metadata) return []; const facts: Array<{ label: string; value: string }> = []; const scaleFactor = metadata.metric_scale_factor; if (typeof scaleFactor === "number" && Number.isFinite(scaleFactor)) { facts.push({ label: "比例尺", value: `${scaleFactor.toFixed(3)}x`, }); } const groundOffset = metadata.ground_plane_offset; if (typeof groundOffset === "number" && Number.isFinite(groundOffset)) { facts.push({ label: "地平面偏移", value: `${groundOffset.toFixed(2)}m`, }); } return facts; } function seededUnit(index: number, salt: number) { const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453; return value - Math.floor(value); } function buildSplatParticleCloudData( splatMesh: SparkSplatMesh, sampleLimit = SPLAT_PARTICLE_SAMPLE_LIMIT ): SplatParticleCloudData | null { const packedSplats = splatMesh.packedSplats; const totalSplats = Math.max(0, packedSplats?.numSplats || 0); if (!totalSplats) return null; const targetCount = Math.min(sampleLimit, totalSplats); const sampleStep = Math.max(totalSplats / targetCount, 1); const points: Array<{ center: THREE.Vector3; color: THREE.Color; opacity: number; }> = []; const warmWhite = new THREE.Color("#fff1dc"); for (let sampleIndex = 0; sampleIndex < targetCount; sampleIndex += 1) { const index = Math.min(Math.floor(sampleIndex * sampleStep), totalSplats - 1); const { center, opacity, color } = packedSplats.getSplat(index); if (opacity < 0.035) continue; points.push({ center, color, opacity, }); } if (!points.length) return null; const colors = new Float32Array(points.length * 3); const positions = new Float32Array(points.length * 3); const seeds = new Float32Array(points.length); points.forEach((point, index) => { const stride = index * 3; const displayColor = point.color .clone() .lerp(warmWhite, 0.28) .multiplyScalar(0.86 + Math.min(point.opacity, 1) * 0.32); positions[stride] = point.center.x; positions[stride + 1] = point.center.y; positions[stride + 2] = point.center.z; colors[stride] = displayColor.r; colors[stride + 1] = displayColor.g; colors[stride + 2] = displayColor.b; seeds[index] = seededUnit(index, 31) * Math.PI * 2; }); return { count: points.length, colors, positions, seeds, }; } function positionCameraInsideWorld({ camera, controls, splatMesh, groundPlaneOffset, }: { camera: THREE.Camera; controls: { target?: THREE.Vector3; update?: () => void } | null; splatMesh: SparkSplatMesh; groundPlaneOffset: number | null; }) { if (!(camera instanceof THREE.PerspectiveCamera)) return; splatMesh.updateMatrixWorld(true); const worldBounds = splatMesh.getBoundingBox(true).clone().applyMatrix4(splatMesh.matrixWorld); if (worldBounds.isEmpty()) return; const center = worldBounds.getCenter(new THREE.Vector3()); const size = worldBounds.getSize(new THREE.Vector3()); const horizontalAxis = size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); const majorSpan = Math.max(horizontalAxis.z === 1 ? size.z : size.x, 1.4); const marginX = Math.max(size.x * 0.12, 0.35); const marginZ = Math.max(size.z * 0.12, 0.35); const eyeY = groundPlaneOffset !== null ? groundPlaneOffset + 1.58 : center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9); const anchor = new THREE.Vector3(center.x, eyeY, center.z); const cameraPosition = anchor .clone() .addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65)); cameraPosition.x = THREE.MathUtils.clamp( cameraPosition.x, worldBounds.min.x + marginX, worldBounds.max.x - marginX ); cameraPosition.z = THREE.MathUtils.clamp( cameraPosition.z, worldBounds.min.z + marginZ, worldBounds.max.z - marginZ ); const lookDirection = anchor.clone().sub(cameraPosition).normalize(); const anchorDistance = cameraPosition.distanceTo(anchor); const targetPosition = cameraPosition .clone() .addScaledVector(lookDirection, THREE.MathUtils.clamp(anchorDistance * 0.8, 0.55, 1.2)); camera.position.copy(cameraPosition); camera.lookAt(targetPosition); camera.updateProjectionMatrix(); if (controls?.target) { controls.target.copy(targetPosition); controls.update?.(); } } function WorldEmptyState({ icon: Icon, title, description, previewImageUrl, }: { icon: React.ComponentType<{ className?: string }>; title: string; description: string; previewImageUrl?: string | null; }) { return (
{previewImageUrl ? (
{title}
) : (
)}

{title}

{description}

); } function SplatParticleCloud({ data, phase, }: { data: SplatParticleCloudData; phase: SplatLoadingPhase; }) { const geometryRef = useRef(null); const materialRef = useRef(null); const pointsRef = useRef(null); const phaseRef = useRef(phase); const phaseStartedAtRef = useRef(performance.now()); const positionBuffer = useMemo(() => data.positions.slice(), [data.positions]); useEffect(() => { if (phaseRef.current !== phase) { phaseRef.current = phase; phaseStartedAtRef.current = performance.now(); } }, [phase]); useEffect(() => { if (pointsRef.current) { pointsRef.current.raycast = () => null; } }, []); useFrame((state) => { const geometry = geometryRef.current; const material = materialRef.current; if (!geometry || !material) return; const phaseNow = phaseRef.current; const elapsedSincePhase = performance.now() - phaseStartedAtRef.current; const revealProgress = phaseNow === "revealing" ? THREE.MathUtils.smoothstep( Math.min(elapsedSincePhase / SPLAT_REVEAL_DURATION_MS, 1), 0, 1 ) : phaseNow === "hidden" ? 1 : 0; const particleIntroEnd = 0.18; const particleHoldEnd = 0.62; let particleOpacity = 0; let particleSize = 0.01; if (phaseNow === "revealing") { if (revealProgress < particleIntroEnd) { const intro = THREE.MathUtils.smoothstep(revealProgress / particleIntroEnd, 0, 1); particleOpacity = THREE.MathUtils.lerp(0.08, 0.92, intro); particleSize = THREE.MathUtils.lerp(0.01, 0.028, intro); } else if (revealProgress < particleHoldEnd) { particleOpacity = 0.92; particleSize = 0.028; } else { const outro = THREE.MathUtils.smoothstep( (revealProgress - particleHoldEnd) / (1 - particleHoldEnd), 0, 1 ); particleOpacity = THREE.MathUtils.lerp(0.92, 0, outro); particleSize = THREE.MathUtils.lerp(0.028, 0.006, outro); } } const positionAttribute = geometry.getAttribute("position"); const positions = positionAttribute.array as Float32Array; const time = state.clock.getElapsedTime(); const jitterStrength = phaseNow === "revealing" ? (1 - revealProgress) * 0.012 : 0; for (let index = 0; index < data.count; index += 1) { const stride = index * 3; const seed = data.seeds[index] || 0; positions[stride] = data.positions[stride] + Math.sin(time * 2.8 + seed) * jitterStrength; positions[stride + 1] = data.positions[stride + 1] + Math.cos(time * 2.3 + seed * 1.7) * jitterStrength * 0.7; positions[stride + 2] = data.positions[stride + 2] + Math.sin(time * 2.1 + seed * 0.6) * jitterStrength; } positionAttribute.needsUpdate = true; material.opacity = particleOpacity; material.size = particleSize; material.visible = particleOpacity > 0.001; }); return ( ); } function WorldSplatLoadingOverlay({ previewImageUrl, phase, }: { previewImageUrl?: string | null; phase: Exclude; }) { const isRevealing = phase === "revealing"; return ( <> {phase === "loading" ? (
{previewImageUrl ? ( 世界预览 ) : null}
) : null}
{isRevealing ? "世界数据正在聚合" : "正在载入世界数据"}
{isRevealing ? "当前世界正在从颗粒态恢复细节" : "空间数据准备中"}
); } function createMovementState(): MovementState { return { ...EMPTY_MOVEMENT_STATE }; } function CameraMovementControls({ enabled, firstPerson, orbitControlsRef, }: { enabled: boolean; firstPerson: boolean; orbitControlsRef: React.MutableRefObject; }) { const { camera } = useThree(); const pressedKeysRef = useRef(createMovementState()); const directionRef = useRef(new THREE.Vector3()); const rightRef = useRef(new THREE.Vector3()); const upRef = useRef(new THREE.Vector3()); const moveRef = useRef(new THREE.Vector3()); useEffect(() => { if (!enabled) { pressedKeysRef.current = createMovementState(); return; } const handleKeyDown = (event: KeyboardEvent) => { const movement = KEY_TO_MOVEMENT[event.code]; if (!movement) return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (isEditableKeyboardTarget(event.target)) { pressedKeysRef.current = createMovementState(); return; } pressedKeysRef.current[movement] = true; }; const handleKeyUp = (event: KeyboardEvent) => { const movement = KEY_TO_MOVEMENT[event.code]; if (!movement) return; if (isEditableKeyboardTarget(event.target)) { pressedKeysRef.current = createMovementState(); return; } pressedKeysRef.current[movement] = false; }; const handleFocusIn = (event: FocusEvent) => { if (isEditableKeyboardTarget(event.target)) { pressedKeysRef.current = createMovementState(); } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); window.addEventListener("focusin", handleFocusIn); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("focusin", handleFocusIn); }; }, [enabled]); useFrame((_, delta) => { if (!enabled) return; const pressed = pressedKeysRef.current; const hasMovement = Object.values(pressed).some(Boolean); if (!hasMovement) return; const moveSpeed = 3.8; const distance = moveSpeed * delta; camera.getWorldDirection(directionRef.current); directionRef.current.setY(0).normalize(); rightRef.current.crossVectors(directionRef.current, camera.up).normalize(); upRef.current.copy(camera.up).normalize(); moveRef.current.set(0, 0, 0); if (pressed.forward) { moveRef.current.add(directionRef.current); } if (pressed.backward) { moveRef.current.addScaledVector(directionRef.current, -1); } if (pressed.left) { moveRef.current.addScaledVector(rightRef.current, -1); } if (pressed.right) { moveRef.current.add(rightRef.current); } if (pressed.up) { moveRef.current.add(upRef.current); } if (pressed.down) { moveRef.current.addScaledVector(upRef.current, -1); } if (moveRef.current.lengthSq() === 0) return; moveRef.current.normalize().multiplyScalar(distance); camera.position.add(moveRef.current); orbitControlsRef.current?.update?.(); }); if (!enabled || !firstPerson) return null; return ; } function HotspotBillboard({ hotspot, onSelect, }: { hotspot: World3DHotspot; onSelect?: (hotspotId: string) => void; }) { return ( { event.stopPropagation(); onSelect?.(hotspot.id); }} >
{hotspot.label}
); } function WorldSplatScene({ splatUrl, revealPhase = "loading", firstPerson = false, hotspots = [], interactionMode = "navigate", onPick, onHotspotSelect, semanticsMetadata, onLoadStateChange, onRevealComplete, resetViewToken, }: { splatUrl: string; revealPhase?: SplatLoadingPhase; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; onLoadStateChange?: (state: "ready" | "error") => void; onRevealComplete?: () => void; resetViewToken?: number; }) { const camera = useThree((state) => state.camera); const renderer = useThree((state) => state.gl); const orbitControlsRef = useRef(null); const hasAppliedInitialViewRef = useRef(false); const revealCompletedRef = useRef(false); const [particleCloudData, setParticleCloudData] = useState(null); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), [semanticsMetadata] ); const revealPhaseRef = useRef(revealPhase); const revealStartedAtRef = useRef(performance.now()); const sparkRenderer = useMemo( () => new SparkRenderer({ renderer, blurAmount: 0.18, maxStdDev: Math.sqrt(7), originDistance: 0.85, view: { sortRadial: true, sortDistance: 0.04, }, }), [renderer] ); const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]); const groundPlaneOffset = typeof semanticsMetadata?.ground_plane_offset === "number" ? -semanticsMetadata.ground_plane_offset : null; useEffect(() => { sparkRenderer.frustumCulled = false; sparkRenderer.renderOrder = 10; applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); return () => { sparkRenderer.removeFromParent(); }; }, [sparkRenderer, splatMesh]); useEffect(() => { if (revealPhaseRef.current !== revealPhase) { revealPhaseRef.current = revealPhase; revealStartedAtRef.current = performance.now(); if (revealPhase === "loading") { revealCompletedRef.current = false; } } }, [revealPhase]); useEffect(() => { hasAppliedInitialViewRef.current = false; revealCompletedRef.current = false; setParticleCloudData(null); splatMesh.frustumCulled = false; splatMesh.rotation.x = Math.PI; applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); let cancelled = false; let idleCallbackId: number | null = null; let timeoutId: ReturnType | null = null; splatMesh.initialized .then(() => { if (cancelled) return; window.requestAnimationFrame(() => { if (cancelled || hasAppliedInitialViewRef.current) return; positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, groundPlaneOffset, splatMesh, }); hasAppliedInitialViewRef.current = true; onLoadStateChange?.("ready"); const buildParticleCloud = () => { if (cancelled) return; setParticleCloudData(buildSplatParticleCloudData(splatMesh)); }; if ("requestIdleCallback" in window) { idleCallbackId = window.requestIdleCallback(buildParticleCloud, { timeout: 900, }); } else { timeoutId = globalThis.setTimeout(buildParticleCloud, 120); } }); }) .catch(() => { if (!cancelled) onLoadStateChange?.("error"); }); return () => { cancelled = true; if (idleCallbackId !== null && "cancelIdleCallback" in window) { window.cancelIdleCallback(idleCallbackId); } if (timeoutId !== null) { globalThis.clearTimeout(timeoutId); } splatMesh.dispose(); }; }, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]); useEffect(() => { if (!hasAppliedInitialViewRef.current) return; positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, groundPlaneOffset, splatMesh, }); }, [camera, groundPlaneOffset, resetViewToken, splatMesh]); useFrame(() => { const phase = revealPhaseRef.current; const revealElapsed = phase === "revealing" ? Math.min((performance.now() - revealStartedAtRef.current) / SPLAT_REVEAL_DURATION_MS, 1) : 0; const easedReveal = THREE.MathUtils.smoothstep(revealElapsed, 0, 1); if (phase === "loading") { applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); } else if (phase === "revealing") { const particleIntroEnd = 0.2; const particleHoldEnd = 0.6; let profile = SPLAT_PARTICLE_PROFILE; if (easedReveal < particleIntroEnd) { profile = lerpSparkRevealProfile( SPLAT_LOADING_PROFILE, SPLAT_PARTICLE_PROFILE, THREE.MathUtils.smoothstep(easedReveal / particleIntroEnd, 0, 1) ); } else if (easedReveal < particleHoldEnd) { const pulse = Math.sin((performance.now() - revealStartedAtRef.current) * 0.008) * 0.02; profile = { ...SPLAT_PARTICLE_PROFILE, minAlpha: SPLAT_PARTICLE_PROFILE.minAlpha + pulse, focalAdjustment: SPLAT_PARTICLE_PROFILE.focalAdjustment + pulse * 2.4, }; } else { const granularProgress = (easedReveal - particleHoldEnd) / (1 - particleHoldEnd); profile = lerpSparkRevealProfile( SPLAT_PARTICLE_PROFILE, SPLAT_NORMAL_PROFILE, THREE.MathUtils.smoothstep(granularProgress, 0, 1) ); } applySparkRevealProfile(sparkRenderer, splatMesh, profile); if (revealElapsed >= 1 && !revealCompletedRef.current) { revealCompletedRef.current = true; onRevealComplete?.(); } } else { applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); } sparkRenderer.needsUpdate = true; }); const handlePointerDown = (event: ThreeEvent) => { if (interactionMode !== "hotspot") return; event.stopPropagation(); onPick?.({ point: [event.point.x, event.point.y, event.point.z], }); }; return ( <> {groundPlaneOffset !== null ? ( ) : null} {particleCloudData ? : null} {hotspots.map((hotspot) => ( ))} {!firstPerson ? ( ) : ( )}
SPZ {semanticFacts.map((fact) => ( {fact.label} {fact.value} ))}
{interactionMode === "hotspot" ? (
点击场景放置热点
) : null}
{(firstPerson || interactionMode === "hotspot") && (
)}
); } function WorldSplatViewer({ splatUrl, previewImageUrl, firstPerson, hotspots, interactionMode, onPick, onHotspotSelect, semanticsMetadata, resetViewToken, }: { splatUrl?: string | null; previewImageUrl?: string | null; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; resetViewToken?: number; }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [overlayPhase, setOverlayPhase] = useState("loading"); useEffect(() => { setLoadState("loading"); setOverlayPhase("loading"); }, [splatUrl]); useEffect(() => { if (loadState === "ready") { setOverlayPhase("revealing"); } if (loadState === "loading") { setOverlayPhase("loading"); } if (loadState === "error") { setOverlayPhase("hidden"); } }, [loadState]); if (!splatUrl) { return ( ); } if (loadState === "error") { return ( ); } return (
setOverlayPhase("hidden")} revealPhase={overlayPhase} resetViewToken={resetViewToken} semanticsMetadata={semanticsMetadata} splatUrl={splatUrl} /> {overlayPhase !== "hidden" ? ( ) : null}
); } export function World3DViewer({ mode, worldUrl, imageUrl, meshUrl, splatUrl, previewImageUrl, emptyStateTitle = "准备生成 3D 世界", meshSettings, meshLayerVisibility, onMeshLayersChange, onMeshStatsChange, firstPerson = false, hotspots = [], interactionMode = "navigate", onPick, onHotspotSelect, semanticsMetadata, className, resetViewToken, }: World3DViewerProps) { const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null; if (mode === "mesh") { return (
); } if (mode === "world") { return (
{worldUrl ? (