"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, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Box, 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" | "fading" | "hidden"; 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; }; const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { 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 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 WorldEmptyState({ icon: Icon, title, description, previewImageUrl, }: { icon: React.ComponentType<{ className?: string }>; title: string; description: string; previewImageUrl?: string | null; }) { return (
{previewImageUrl ? (
{title}
) : (
)}

{title}

{description}

); } const SPLAT_LOADING_PARTICLES = [ { x: 8, y: 18, size: 5, dx: 18, dy: -22, delay: 0.1, duration: 4.6 }, { x: 18, y: 64, size: 7, dx: 26, dy: -18, delay: 0.4, duration: 5.2 }, { x: 26, y: 34, size: 4, dx: -12, dy: -28, delay: 0.7, duration: 4.8 }, { x: 34, y: 78, size: 6, dx: 14, dy: -24, delay: 1.1, duration: 5.6 }, { x: 44, y: 16, size: 8, dx: -18, dy: 18, delay: 0.3, duration: 5.4 }, { x: 52, y: 56, size: 5, dx: 20, dy: -26, delay: 0.9, duration: 4.9 }, { x: 61, y: 28, size: 6, dx: -22, dy: -16, delay: 1.4, duration: 5.8 }, { x: 68, y: 72, size: 4, dx: 16, dy: -20, delay: 0.2, duration: 4.7 }, { x: 76, y: 42, size: 8, dx: -14, dy: 22, delay: 1.2, duration: 5.1 }, { x: 84, y: 62, size: 5, dx: 22, dy: -14, delay: 0.6, duration: 5.7 }, { x: 91, y: 24, size: 6, dx: -16, dy: 18, delay: 1.5, duration: 4.5 }, { x: 12, y: 44, size: 4, dx: 12, dy: 14, delay: 1.8, duration: 5.3 }, ] as const; const SPLAT_ASSEMBLY_POINT_COUNT = 220; const SPLAT_ASSEMBLY_COLORS = ["#67e8f9", "#93c5fd", "#f8fafc", "#22d3ee"] as const; function seededUnit(index: number, salt: number) { const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453; return value - Math.floor(value); } function SplatAssemblyCloud({ phase, }: { phase: Exclude; }) { const pointsRef = useRef(null); const materialRef = useRef(null); const geometryRef = useRef(null); const phaseRef = useRef(phase); const phaseStartedAtRef = useRef(0); const seeds = useMemo( () => Array.from({ length: SPLAT_ASSEMBLY_POINT_COUNT }, (_, index) => { const spread = seededUnit(index, 1); const tilt = seededUnit(index, 2); const orbit = seededUnit(index, 3); const colorIndex = Math.floor( seededUnit(index, 4) * SPLAT_ASSEMBLY_COLORS.length ); return { target: new THREE.Vector3( (spread - 0.5) * 4.8, 0.45 + tilt * 2.6, (orbit - 0.5) * 4.4 ), orbitRadius: 0.4 + seededUnit(index, 5) * 2.2, orbitSpeed: 0.2 + seededUnit(index, 6) * 0.8, angle: seededUnit(index, 7) * Math.PI * 2, verticalAmplitude: 0.08 + seededUnit(index, 8) * 0.32, drift: 0.65 + seededUnit(index, 9) * 0.75, color: new THREE.Color( SPLAT_ASSEMBLY_COLORS[colorIndex] || SPLAT_ASSEMBLY_COLORS[0] ), }; }), [] ); const positions = useMemo( () => new Float32Array(SPLAT_ASSEMBLY_POINT_COUNT * 3), [] ); const colors = useMemo(() => { const result = new Float32Array(SPLAT_ASSEMBLY_POINT_COUNT * 3); seeds.forEach((seed, index) => { result[index * 3] = seed.color.r; result[index * 3 + 1] = seed.color.g; result[index * 3 + 2] = seed.color.b; }); return result; }, [seeds]); 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 elapsed = state.clock.getElapsedTime(); const fadeElapsed = phaseRef.current === "fading" ? Math.min((performance.now() - phaseStartedAtRef.current) / 850, 1) : 0; const baseOpacity = phaseRef.current === "loading" ? 0.78 : THREE.MathUtils.lerp(0.78, 0, fadeElapsed); material.opacity = baseOpacity; material.size = THREE.MathUtils.lerp(0.09, 0.02, fadeElapsed); seeds.forEach((seed, index) => { const stride = index * 3; const orbitAngle = elapsed * seed.orbitSpeed + seed.angle; const phaseMix = phaseRef.current === "loading" ? 0.22 + 0.2 * Math.sin(elapsed * 0.65 + seed.angle) : THREE.MathUtils.lerp(0.72, 1.02, fadeElapsed); const orbitRadius = seed.orbitRadius * (phaseRef.current === "loading" ? 0.95 + 0.08 * Math.sin(elapsed * 1.3 + seed.angle) : Math.max(0, 0.45 * (1 - fadeElapsed))); const verticalWave = Math.sin(elapsed * seed.drift + seed.angle * 1.4) * seed.verticalAmplitude * (phaseRef.current === "loading" ? 1 : 1 - fadeElapsed); positions[stride] = seed.target.x * phaseMix + Math.cos(orbitAngle) * orbitRadius; positions[stride + 1] = seed.target.y * phaseMix + verticalWave + Math.sin(orbitAngle * 0.7) * orbitRadius * 0.18; positions[stride + 2] = seed.target.z * phaseMix + Math.sin(orbitAngle) * orbitRadius; }); geometry.attributes.position.needsUpdate = true; }); return ( ); } function WorldSplatLoadingOverlay({ previewImageUrl, phase, }: { previewImageUrl?: string | null; phase: Exclude; }) { const isFading = phase === "fading"; return (
{previewImageUrl ? ( 世界预览 ) : null}
{SPLAT_LOADING_PARTICLES.map((particle, index) => ( ))}

{isFading ? "正在显露 3D 世界细节" : "正在构建 3D 世界体积"}

{isFading ? "Gaussian Splat 已加载完成,正在把真实细节从过渡层中释放出来。" : "SPZ 会先以粗糙体积快速占位,再逐步补齐细节。先用粒子过渡能让加载观感更顺。"}

{isFading ? "即将完成" : "初始化 Splat 渲染器"}
); } function createMovementState(): MovementState { return { ...EMPTY_MOVEMENT_STATE }; } function DirectionPad({ onCommandChange, }: { onCommandChange: (command: MovementCommand, active: boolean) => void; }) { const pressHandlers = (command: MovementCommand) => ({ onMouseDown: () => onCommandChange(command, true), onMouseUp: () => onCommandChange(command, false), onMouseLeave: () => onCommandChange(command, false), onTouchStart: () => onCommandChange(command, true), onTouchEnd: () => onCommandChange(command, false), onTouchCancel: () => onCommandChange(command, false), }); return (
方向移动
前后左右
); } function CameraMovementControls({ enabled, firstPerson, virtualMovement, orbitControlsRef, }: { enabled: boolean; firstPerson: boolean; virtualMovement: MovementState; 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) || Object.values(virtualMovement).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 || virtualMovement.forward) { moveRef.current.add(directionRef.current); } if (pressed.backward || virtualMovement.backward) { moveRef.current.addScaledVector(directionRef.current, -1); } if (pressed.left || virtualMovement.left) { moveRef.current.addScaledVector(rightRef.current, -1); } if (pressed.right || virtualMovement.right) { moveRef.current.add(rightRef.current); } if (pressed.up || virtualMovement.up) { moveRef.current.add(upRef.current); } if (pressed.down || virtualMovement.down) { moveRef.current.addScaledVector(upRef.current, -1); } if (moveRef.current.lengthSq() === 0) return; moveRef.current.normalize().multiplyScalar(distance); camera.position.add(moveRef.current); if (!firstPerson && orbitControlsRef.current?.target) { orbitControlsRef.current.target.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, assemblyPhase = "loading", firstPerson = false, hotspots = [], interactionMode = "navigate", onPick, onHotspotSelect, semanticsMetadata, onLoadStateChange, }: { splatUrl: string; assemblyPhase?: SplatLoadingPhase; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; onLoadStateChange?: (state: "ready" | "error") => void; }) { const renderer = useThree((state) => state.gl); const orbitControlsRef = useRef(null); const [virtualMovement, setVirtualMovement] = useState(createMovementState); const semanticFacts = useMemo( () => getSemanticFacts(semanticsMetadata), [semanticsMetadata] ); const sparkRenderer = useMemo( () => new SparkRenderer({ renderer, blurAmount: 0.2, maxStdDev: Math.sqrt(7), }), [renderer] ); const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]); useEffect(() => { sparkRenderer.frustumCulled = false; sparkRenderer.renderOrder = 10; return () => { sparkRenderer.removeFromParent(); }; }, [sparkRenderer]); useEffect(() => { splatMesh.frustumCulled = false; splatMesh.rotation.x = Math.PI; let cancelled = false; splatMesh.initialized .then(() => { if (!cancelled) onLoadStateChange?.("ready"); }) .catch(() => { if (!cancelled) onLoadStateChange?.("error"); }); return () => { cancelled = true; splatMesh.dispose(); }; }, [onLoadStateChange, splatMesh]); useFrame(() => { sparkRenderer.needsUpdate = true; }); const groundPlaneOffset = typeof semanticsMetadata?.ground_plane_offset === "number" ? -semanticsMetadata.ground_plane_offset : null; const handleDirectionChange = (command: MovementCommand, active: boolean) => { setVirtualMovement((prev) => { if (prev[command] === active) return prev; return { ...prev, [command]: active, }; }); }; 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} {assemblyPhase !== "hidden" ? ( ) : null} {hotspots.map((hotspot) => ( ))} {!firstPerson ? ( ) : ( )}
SPZ {semanticFacts.map((fact) => ( {fact.label} {fact.value} ))}
{interactionMode === "hotspot" ? (
点击场景放置热点
) : null}
{firstPerson ? "方向键 / WASD 前后左右 · Q/E 或 PgUp/PgDn 上下 · 鼠标观察 · Esc 退出" : "拖拽旋转 · 滚轮缩放 · 方向键 / 按钮可做六向移动"}
{interactionMode === "navigate" ? ( ) : null} {(firstPerson || interactionMode === "hotspot") && (
)}
); } function WorldSplatViewer({ splatUrl, previewImageUrl, firstPerson, hotspots, interactionMode, onPick, onHotspotSelect, semanticsMetadata, }: { splatUrl?: string | null; previewImageUrl?: string | null; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [overlayPhase, setOverlayPhase] = useState("loading"); useEffect(() => { setLoadState("loading"); setOverlayPhase("loading"); }, [splatUrl]); useEffect(() => { if (loadState === "ready") { setOverlayPhase("fading"); const timer = window.setTimeout(() => { setOverlayPhase("hidden"); }, 720); return () => window.clearTimeout(timer); } if (loadState === "loading") { setOverlayPhase("loading"); } if (loadState === "error") { setOverlayPhase("hidden"); } }, [loadState]); if (!splatUrl) { return ( ); } if (loadState === "error") { return ( ); } return (
{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, }: World3DViewerProps) { const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null; if (mode === "mesh") { return (
); } if (mode === "world") { return (
{worldUrl ? (