"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, 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 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, }; 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}

); } function FirstPersonMovement({ enabled }: { enabled: boolean }) { const { camera } = useThree(); const pressedKeysRef = useRef>({}); const directionRef = useRef(new THREE.Vector3()); const rightRef = useRef(new THREE.Vector3()); const upRef = useRef(new THREE.Vector3()); useEffect(() => { if (!enabled) { pressedKeysRef.current = {}; return; } const handleKeyDown = (event: KeyboardEvent) => { pressedKeysRef.current[event.code] = true; }; const handleKeyUp = (event: KeyboardEvent) => { pressedKeysRef.current[event.code] = false; }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, [enabled]); useFrame((_, delta) => { if (!enabled) return; const pressed = pressedKeysRef.current; const moveSpeed = pressed.ShiftLeft || pressed.ShiftRight ? 6.5 : 3.4; const distance = moveSpeed * delta; camera.getWorldDirection(directionRef.current); directionRef.current.normalize(); rightRef.current.crossVectors(directionRef.current, camera.up).normalize(); upRef.current.copy(camera.up).normalize(); if (pressed.KeyW) camera.position.addScaledVector(directionRef.current, distance); if (pressed.KeyS) camera.position.addScaledVector(directionRef.current, -distance); if (pressed.KeyA) camera.position.addScaledVector(rightRef.current, -distance); if (pressed.KeyD) camera.position.addScaledVector(rightRef.current, distance); if (pressed.KeyQ) camera.position.addScaledVector(upRef.current, distance); if (pressed.KeyE) camera.position.addScaledVector(upRef.current, -distance); }); if (!enabled) return null; return ; } function HotspotBillboard({ hotspot, onSelect, }: { hotspot: World3DHotspot; onSelect?: (hotspotId: string) => void; }) { return ( { event.stopPropagation(); onSelect?.(hotspot.id); }} >
{hotspot.label}
); } function WorldSplatScene({ splatUrl, firstPerson = false, hotspots = [], interactionMode = "navigate", onPick, onHotspotSelect, semanticsMetadata, onLoadStateChange, }: { splatUrl: string; 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 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; 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 handlePointerDown = (event: ThreeEvent) => { if (interactionMode !== "hotspot") return; event.stopPropagation(); onPick?.({ point: [event.point.x, event.point.y, event.point.z], }); }; return ( <> {groundPlaneOffset !== null ? ( ) : null} {hotspots.map((hotspot) => ( ))} {!firstPerson ? ( ) : ( )}
SPZ {semanticFacts.map((fact) => ( {fact.label} {fact.value} ))}
{interactionMode === "hotspot" ? (
点击场景放置热点
) : null}
{firstPerson ? "WASD 移动 · 鼠标观察 · Shift 加速 · Esc 退出" : "拖拽旋转 · 滚轮缩放 · 原生 SPZ 渲染"}
{(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"); useEffect(() => { setLoadState("loading"); }, [splatUrl]); if (!splatUrl) { return ( ); } if (loadState === "error") { return ( ); } return (
{loadState === "loading" ? (
) : 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 ? (