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

); } 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, 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 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} {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"); 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 ? (