"use client"; /* eslint-disable @next/next/no-img-element */ import { Html, OrbitControls } from "@react-three/drei"; import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber"; import { SparkRenderer, SplatFileType, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark"; import { AlertCircle, 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 { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; 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 PrefetchedSplatAsset = { bytes: Uint8Array; fileName: string; url: string; }; type SplatSampleSource = { getNumSplats?: () => number; getSplat: (index: number) => { center: THREE.Vector3; opacity: number; }; numSplats?: number; }; type SparkRevealProfile = { blurAmount: number; preBlurAmount: number; minAlpha: number; maxStdDev: number; focalAdjustment: number; falloff: number; opacity: number; }; type WorldCameraPose = { cameraPosition: THREE.Vector3; targetPosition: THREE.Vector3; }; type World3DViewerProps = { mode: World3DViewMode; worldUrl?: string | null; imageUrl?: string | null; meshUrl?: string | null; splatUrl?: string | null; splatUrls?: string[] | null; previewImageUrl?: string | null; emptyStateTitle?: string; minimalFrame?: boolean; 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; onCaptureReady?: (capture: (() => Promise) | null) => void; showMovementControls?: boolean; }; const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { cameraPreset: "iso", envPreset: "city", lightIntensity: 1, autoRotate: false, showGrid: true, wireframe: false, }; const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0]; const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1); const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08; const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22; 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.045, preBlurAmount: 0, minAlpha: 0.015, maxStdDev: Math.sqrt(4.2), focalAdjustment: 1.36, 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 = 900; const SPLAT_PARTICLE_SAMPLE_LIMIT = 720; const SPLAT_WORLD_BOUNDS_SAMPLE_LIMIT = 1200; const SPLAT_FAST_LOAD_SKIP_REVEAL_MS = 1200; const SPARK_INLINE_LOD_BUILD_THRESHOLD = 180_000; const SPARK_INLINE_LOD_SPLAT_SCALE = 0.78; const SPARK_INLINE_LOD_RENDER_SCALE = 1.8; const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.82; const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 1.6; const SPARK_MIN_SORT_INTERVAL_MS = 24; const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40; const SPARK_SPLAT_FETCH_TIMEOUT_MS = 150_000; const SPARK_SPLAT_LOAD_TIMEOUT_MS = 45_000; 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; if (!packedSplats) return null; 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 getPercentileValue(values: number[], percentile: number) { if (!values.length) return 0; const index = THREE.MathUtils.clamp( Math.floor((values.length - 1) * percentile), 0, values.length - 1 ); return values[index] ?? 0; } function getSplatWorldBounds(splatMesh: SparkSplatMesh) { try { splatMesh.updateMatrixWorld(true); return splatMesh .getBoundingBox(true) .clone() .applyMatrix4(splatMesh.matrixWorld); } catch { return new THREE.Box3(); } } function getWorldLabsDisplayGroundPlaneOffset(value: unknown) { if (typeof value !== "number" || !Number.isFinite(value)) return null; // World Labs semantics are authored before the SPZ/GLB is rotated into our // Three.js display coordinates. The X-axis 180deg rotation flips vertical Y. return -value; } function getWorldLabsDisplayGroundHelperOffset(value: number | null) { return value === null ? null : value - WORLDLABS_DISPLAY_GROUND_HELPER_BIAS; } function getLoadedSplatCount(splatMesh: SparkSplatMesh) { try { return Math.max( 0, splatMesh.splats?.getNumSplats?.() ?? splatMesh.packedSplats?.getNumSplats?.() ?? splatMesh.extSplats?.getNumSplats?.() ?? splatMesh.numSplats ?? 0 ); } catch { return 0; } } function getSplatSampleSource(splatMesh: SparkSplatMesh): SplatSampleSource | null { return splatMesh.packedSplats || splatMesh.extSplats || null; } function getSplatSampleSourceCount(source: SplatSampleSource) { try { return Math.max(0, source.getNumSplats?.() ?? source.numSplats ?? 0); } catch { return 0; } } function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) { const splats = getSplatSampleSource(splatMesh); if (!splats) { return getSplatWorldBounds(splatMesh); } const totalSplats = getSplatSampleSourceCount(splats); if (totalSplats < 32) { return getSplatWorldBounds(splatMesh); } splatMesh.updateMatrixWorld(true); const targetCount = Math.min(totalSplats, SPLAT_WORLD_BOUNDS_SAMPLE_LIMIT); const sampleStep = Math.max(Math.floor(totalSplats / targetCount), 1); const point = new THREE.Vector3(); const xs: number[] = []; const ys: number[] = []; const zs: number[] = []; for (let index = 0; index < totalSplats; index += sampleStep) { const splat = splats.getSplat(index); if (splat.opacity < 0.025) continue; point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld); xs.push(point.x); ys.push(point.y); zs.push(point.z); } if (xs.length < 32) { return getSplatWorldBounds(splatMesh); } xs.sort((a, b) => a - b); ys.sort((a, b) => a - b); zs.sort((a, b) => a - b); const robustBounds = new THREE.Box3( new THREE.Vector3( getPercentileValue(xs, 0.02), getPercentileValue(ys, 0.02), getPercentileValue(zs, 0.02) ), new THREE.Vector3( getPercentileValue(xs, 0.98), getPercentileValue(ys, 0.98), getPercentileValue(zs, 0.98) ) ); return robustBounds.isEmpty() ? getSplatWorldBounds(splatMesh) : robustBounds; } function positionCameraInsideWorld({ camera, controls, splatMesh, groundPlaneOffset, firstPerson = false, }: { camera: THREE.Camera; controls: { target?: THREE.Vector3; update?: () => void } | null; splatMesh: SparkSplatMesh; groundPlaneOffset: number | null; firstPerson?: boolean; }): WorldCameraPose | null { if (!(camera instanceof THREE.PerspectiveCamera)) return null; const worldBounds = getRobustSplatWorldBounds(splatMesh); if (worldBounds.isEmpty()) return null; const center = worldBounds.getCenter(new THREE.Vector3()); const size = worldBounds.getSize(new THREE.Vector3()); const longestHorizontalAxis = size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); const horizontalAxis = firstPerson && size.z >= 0.8 ? WORLDLABS_REFERENCE_VIEW_AXIS.clone() : longestHorizontalAxis; const majorSpan = Math.max(horizontalAxis.z === 1 ? size.z : size.x, 1.4); const clampInsideAxis = ( value: number, min: number, max: number, margin: number, fallback: number ) => { const span = Math.max(max - min, 0); const safeMargin = Math.min(margin, span * 0.42); const safeMin = min + safeMargin; const safeMax = max - safeMargin; if (safeMin > safeMax) return fallback; return THREE.MathUtils.clamp(value, safeMin, safeMax); }; const semanticGroundY = groundPlaneOffset !== null && Number.isFinite(groundPlaneOffset) && groundPlaneOffset >= worldBounds.min.y - size.y * 0.25 && groundPlaneOffset <= worldBounds.max.y + size.y * 0.25 ? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y) : null; const displayFloorY = semanticGroundY !== null ? THREE.MathUtils.clamp( semanticGroundY - size.y * WORLDLABS_DISPLAY_GROUND_BIAS_RATIO, worldBounds.min.y, worldBounds.max.y ) : worldBounds.min.y + size.y * 0.04; const estimatedFloorY = displayFloorY; const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.24, 1.05, 1.7); const rawEyeY = estimatedFloorY + humanEyeOffset; const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42); const maxEyeY = Math.min( worldBounds.max.y - Math.max(size.y * 0.22, 0.42), center.y + size.y * 0.16 ); const eyeY = minEyeY <= maxEyeY ? THREE.MathUtils.clamp(rawEyeY, minEyeY, maxEyeY) : center.y - size.y * 0.08; const anchor = new THREE.Vector3(center.x, eyeY, center.z); const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9); const cameraPosition = firstPerson ? anchor.clone() : anchor.clone().addScaledVector(horizontalAxis, cameraDistance); cameraPosition.x = clampInsideAxis( cameraPosition.x, worldBounds.min.x, worldBounds.max.x, Math.max(size.x * 0.1, 0.24), center.x ); cameraPosition.z = clampInsideAxis( cameraPosition.z, worldBounds.min.z, worldBounds.max.z, Math.max(size.z * 0.1, 0.24), center.z ); const lookDirection = firstPerson ? horizontalAxis.clone() : anchor.clone().sub(cameraPosition); if (lookDirection.lengthSq() < 0.0001) { lookDirection.copy(horizontalAxis).multiplyScalar(-1); } lookDirection.normalize(); const nearTargetPosition = cameraPosition .clone() .addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4)); const targetDrop = firstPerson ? THREE.MathUtils.clamp(humanEyeOffset * 0.18 + size.y * 0.018, 0.16, 0.46) : THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18); const minLookTargetY = Math.max( worldBounds.min.y + Math.max(size.y * 0.04, 0.08), estimatedFloorY + 0.05 ); const maxLookTargetY = minEyeY <= maxEyeY ? maxEyeY : worldBounds.max.y - Math.max(size.y * 0.08, 0.16); nearTargetPosition.y = minLookTargetY <= maxLookTargetY ? THREE.MathUtils.clamp(eyeY - targetDrop, minLookTargetY, maxLookTargetY) : eyeY - targetDrop; nearTargetPosition.x = clampInsideAxis( nearTargetPosition.x, worldBounds.min.x, worldBounds.max.x, Math.max(size.x * 0.08, 0.18), center.x ); nearTargetPosition.z = clampInsideAxis( nearTargetPosition.z, worldBounds.min.z, worldBounds.max.z, Math.max(size.z * 0.08, 0.18), center.z ); const orbitTargetPosition = center.clone(); const orbitRadius = Math.max(size.length() * 0.5, majorSpan * 0.55, 1.2); const orbitDistance = THREE.MathUtils.clamp(orbitRadius * 1.85, 2.2, 14); const targetPosition = firstPerson ? nearTargetPosition : orbitTargetPosition; if (!firstPerson) { cameraPosition .copy(targetPosition) .addScaledVector(horizontalAxis, orbitDistance) .add( new THREE.Vector3( 0, THREE.MathUtils.clamp(size.y * 0.08, 0.18, 1.2), 0 ) ); } camera.position.copy(cameraPosition); camera.lookAt(targetPosition); camera.updateProjectionMatrix(); if (controls?.target) { controls.target.copy(targetPosition); controls.update?.(); } return { cameraPosition: cameraPosition.clone(), targetPosition: targetPosition.clone(), }; } function WorldEmptyState({ icon: Icon, title, description, fillFrame = false, previewImageUrl, loading = true, }: { icon: React.ComponentType<{ className?: string }>; title: string; description: string; fillFrame?: boolean; previewImageUrl?: string | null; loading?: boolean; }) { if (previewImageUrl && fillFrame) { return (
{title}
{loading ? ( ) : (
)}
); } if (fillFrame) { return (
); } return (
{previewImageUrl ? (
{title}
{loading ? ( ) : (
)}
) : (
)}

{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, progress, }: { previewImageUrl?: string | null; phase: Exclude; progress?: number | null; }) { const isLoading = phase === "loading"; return ( <> {previewImageUrl ? (
世界预览
) : phase === "loading" ? (
{previewImageUrl ? ( 世界预览 ) : null}
) : null} {isLoading ? (
{typeof progress === "number" ? {progress}% : null}
) : null} ); } function stopViewerSurfaceWheel(event: React.SyntheticEvent) { event.stopPropagation(); } function stopViewerSurfaceContextMenu(event: React.SyntheticEvent) { event.preventDefault(); event.stopPropagation(); } function WorldViewerInteractionSurface({ className, children, }: { className?: string; children: React.ReactNode; }) { return (
{children}
); } async function readSplatFetchError(response: Response) { const fallback = `SPZ 请求失败(HTTP ${response.status})`; const contentType = response.headers.get("content-type") || ""; if (!contentType.includes("json") && !contentType.includes("text")) { return fallback; } const bodyText = await response.text().catch(() => ""); if (!bodyText) return fallback; try { const parsed = JSON.parse(bodyText) as { error?: unknown; message?: unknown }; const message = parsed.error || parsed.message; return typeof message === "string" && message.trim() ? `${fallback}: ${message.trim()}` : fallback; } catch { return `${fallback}: ${bodyText.slice(0, 180)}`; } } async function fetchSplatAsset( url: string, signal: AbortSignal, onProgress?: (progress: number) => void ): Promise { const response = await fetch(url, { cache: "force-cache", credentials: "same-origin", signal, }); if (!response.ok) { throw new Error(await readSplatFetchError(response)); } const contentLength = Number(response.headers.get("content-length") || 0); const totalBytes = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : null; if (!response.body) { const bytes = new Uint8Array(await response.arrayBuffer()); if (!bytes.byteLength) { throw new Error("SPZ 请求成功但内容为空"); } onProgress?.(96); return { bytes, fileName: url, url, }; } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let loadedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; chunks.push(value); loadedBytes += value.byteLength; if (totalBytes) { const progress = Math.round((loadedBytes / totalBytes) * 94); onProgress?.(THREE.MathUtils.clamp(progress, 1, 96)); } } if (!loadedBytes) { throw new Error("SPZ 请求成功但内容为空"); } const bytes = new Uint8Array(loadedBytes); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } onProgress?.(96); return { bytes, fileName: url, url, }; } function createMovementState(): MovementState { return { ...EMPTY_MOVEMENT_STATE }; } function CameraMovementControls({ enabled, firstPerson, movementStateRef, orbitControlsRef, }: { enabled: boolean; firstPerson: boolean; movementStateRef?: React.MutableRefObject; 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(); if (movementStateRef) { movementStateRef.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; } event.preventDefault(); 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; } event.preventDefault(); 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, movementStateRef]); useFrame((_, delta) => { if (!enabled) return; const pressed = pressedKeysRef.current; const padPressed = movementStateRef?.current; const hasMovement = Object.values(pressed).some(Boolean) || Boolean(padPressed && Object.values(padPressed).some(Boolean)); if (!hasMovement) return; const moveSpeed = firstPerson ? 3.8 : 2.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 || padPressed?.forward) { moveRef.current.add(directionRef.current); } if (pressed.backward || padPressed?.backward) { moveRef.current.addScaledVector(directionRef.current, -1); } if (pressed.left || padPressed?.left) { moveRef.current.addScaledVector(rightRef.current, -1); } if (pressed.right || padPressed?.right) { moveRef.current.add(rightRef.current); } if (pressed.up || padPressed?.up) { moveRef.current.add(upRef.current); } if (pressed.down || padPressed?.down) { moveRef.current.addScaledVector(upRef.current, -1); } if (moveRef.current.lengthSq() === 0) return; moveRef.current.normalize().multiplyScalar(distance); camera.position.add(moveRef.current); if (orbitControlsRef.current?.target) { orbitControlsRef.current.target.add(moveRef.current); } orbitControlsRef.current?.update?.(); }); return null; } function WorldMovementPad({ movementStateRef, }: { movementStateRef: React.MutableRefObject; }) { const setPressed = React.useCallback( (command: MovementCommand, pressed: boolean) => { movementStateRef.current[command] = pressed; }, [movementStateRef] ); const clearMovement = React.useCallback(() => { movementStateRef.current = createMovementState(); }, [movementStateRef]); const buttonClass = "flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95"; const buttonProps = (command: MovementCommand) => ({ "aria-label": command, className: buttonClass, onPointerDown: (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture?.(event.pointerId); setPressed(command, true); }, onPointerUp: (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); setPressed(command, false); }, onPointerCancel: (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); clearMovement(); }, onPointerLeave: (event: React.PointerEvent) => { event.stopPropagation(); setPressed(command, false); }, type: "button" as const, }); return (
event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} >
); } function FirstPersonLookControls({ enabled, resetToken, }: { enabled: boolean; resetToken?: number; }) { const { camera, gl } = useThree(); const yawRef = useRef(0); const pitchRef = useRef(0); const draggingPointerIdRef = useRef(null); const directionRef = useRef(new THREE.Vector3()); const syncAnglesFromCamera = React.useCallback(() => { camera.getWorldDirection(directionRef.current).normalize(); yawRef.current = Math.atan2(directionRef.current.x, directionRef.current.z); pitchRef.current = Math.asin( THREE.MathUtils.clamp(directionRef.current.y, -0.98, 0.98) ); }, [camera]); const applyLookDirection = React.useCallback(() => { const pitch = THREE.MathUtils.clamp( pitchRef.current, -Math.PI / 2 + 0.08, Math.PI / 2 - 0.08 ); pitchRef.current = pitch; const cosPitch = Math.cos(pitch); directionRef.current.set( Math.sin(yawRef.current) * cosPitch, Math.sin(pitch), Math.cos(yawRef.current) * cosPitch ); camera.lookAt(camera.position.clone().add(directionRef.current)); }, [camera]); useEffect(() => { if (!enabled) return; syncAnglesFromCamera(); applyLookDirection(); }, [applyLookDirection, enabled, resetToken, syncAnglesFromCamera]); useEffect(() => { if (!enabled) return; const canvas = gl.domElement; const sensitivity = 0.0032; const handlePointerDown = (event: PointerEvent) => { if (event.button !== 0) return; if (isEditableKeyboardTarget(event.target)) return; syncAnglesFromCamera(); draggingPointerIdRef.current = event.pointerId; canvas.setPointerCapture?.(event.pointerId); event.preventDefault(); }; const handlePointerMove = (event: PointerEvent) => { if (draggingPointerIdRef.current !== event.pointerId) return; yawRef.current -= event.movementX * sensitivity; pitchRef.current -= event.movementY * sensitivity; applyLookDirection(); event.preventDefault(); }; const stopDragging = (event: PointerEvent) => { if (draggingPointerIdRef.current !== event.pointerId) return; draggingPointerIdRef.current = null; canvas.releasePointerCapture?.(event.pointerId); event.preventDefault(); }; canvas.addEventListener("pointerdown", handlePointerDown); canvas.addEventListener("pointermove", handlePointerMove); canvas.addEventListener("pointerup", stopDragging); canvas.addEventListener("pointercancel", stopDragging); return () => { draggingPointerIdRef.current = null; canvas.removeEventListener("pointerdown", handlePointerDown); canvas.removeEventListener("pointermove", handlePointerMove); canvas.removeEventListener("pointerup", stopDragging); canvas.removeEventListener("pointercancel", stopDragging); }; }, [applyLookDirection, enabled, gl.domElement, syncAnglesFromCamera]); return null; } function HotspotBillboard({ hotspot, onSelect, }: { hotspot: World3DHotspot; onSelect?: (hotspotId: string) => void; }) { return ( { event.stopPropagation(); onSelect?.(hotspot.id); }} >
{hotspot.label}
); } function WorldSplatScene({ splatUrl, splatBytes, splatFileName, revealPhase = "loading", firstPerson = false, minimalFrame = false, hotspots = [], interactionMode = "navigate", onPick, onHotspotSelect, semanticsMetadata, onLoadStateChange, onLoadProgress, onRevealComplete, resetViewToken, showInfoOverlay = true, movementStateRef, movementEnabled = false, interactionReady = true, }: { splatUrl: string; splatBytes?: Uint8Array | null; splatFileName?: string | null; revealPhase?: SplatLoadingPhase; firstPerson?: boolean; minimalFrame?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; onLoadStateChange?: (state: "ready" | "error", detail?: string) => void; onLoadProgress?: (event: ProgressEvent) => void; onRevealComplete?: () => void; resetViewToken?: number; showInfoOverlay?: boolean; movementStateRef?: React.MutableRefObject; movementEnabled?: boolean; interactionReady?: boolean; }) { const camera = useThree((state) => state.camera); const renderer = useThree((state) => state.gl); const orbitControlsRef = useRef(null); const hotspotPointerDownRef = useRef<{ x: number; y: number } | null>(null); const hasAppliedInitialViewRef = useRef(false); const lastCameraPoseRef = useRef(null); const lastResetViewTokenRef = useRef(resetViewToken); const lastFirstPersonRef = useRef(firstPerson); const revealCompletedRef = useRef(false); const splatOnLoadRef = useRef<(() => void) | null>(null); const [controlsMountVersion, setControlsMountVersion] = useState(0); 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), sortRadial: true, minSortIntervalMs: minimalFrame ? SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS : SPARK_MIN_SORT_INTERVAL_MS, enableLod: true, enableLodFetching: false, lodSplatScale: minimalFrame ? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE : SPARK_INLINE_LOD_SPLAT_SCALE, lodRenderScale: minimalFrame ? SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE : SPARK_INLINE_LOD_RENDER_SCALE, numLodFetchers: 2, }), [minimalFrame, renderer] ); const splatMesh = useMemo( () => new SparkSplatMesh({ fileType: SplatFileType.SPZ, ...(splatBytes ? { fileBytes: splatBytes, fileName: splatFileName || splatUrl, } : { url: splatUrl, }), lod: true, nonLod: true, lodAbove: SPARK_INLINE_LOD_BUILD_THRESHOLD, enableLod: true, lodScale: minimalFrame ? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE : SPARK_INLINE_LOD_SPLAT_SCALE, onLoad: () => { splatOnLoadRef.current?.(); }, onProgress: onLoadProgress, }), [minimalFrame, onLoadProgress, splatBytes, splatFileName, splatUrl] ); const groundPlaneOffset = getWorldLabsDisplayGroundPlaneOffset( semanticsMetadata?.ground_plane_offset ); const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset); const handleOrbitControlsRef = React.useCallback((controls: any | null) => { orbitControlsRef.current = controls; setControlsMountVersion((version) => version + 1); }, []); useEffect(() => { sparkRenderer.frustumCulled = false; sparkRenderer.renderOrder = 10; applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE); return () => { sparkRenderer.removeFromParent(); sparkRenderer.dispose(); }; }, [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; lastCameraPoseRef.current = null; lastResetViewTokenRef.current = resetViewToken; revealCompletedRef.current = false; setParticleCloudData(null); splatMesh.frustumCulled = false; splatMesh.rotation.set(...WORLDLABS_ASSET_ROTATION); applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE); let cancelled = false; let idleCallbackId: number | null = null; let readyPollId: ReturnType | null = null; let readyFrameId: number | null = null; let timeoutId: ReturnType | null = null; let loadTimeoutId: ReturnType | null = null; const clearReadyTimers = () => { if (loadTimeoutId !== null) { globalThis.clearTimeout(loadTimeoutId); loadTimeoutId = null; } if (readyPollId !== null) { globalThis.clearInterval(readyPollId); readyPollId = null; } if (readyFrameId !== null) { window.cancelAnimationFrame(readyFrameId); readyFrameId = null; } }; const isSplatRenderable = () => splatMesh.isInitialized || getLoadedSplatCount(splatMesh) > 0; const markReady = () => { if (cancelled || hasAppliedInitialViewRef.current) return; if (!isSplatRenderable()) return; let nextCameraPose: WorldCameraPose | null = null; try { nextCameraPose = positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, firstPerson, groundPlaneOffset, splatMesh, }); } catch { nextCameraPose = null; } if (!nextCameraPose) return; clearReadyTimers(); lastCameraPoseRef.current = nextCameraPose; hasAppliedInitialViewRef.current = true; onLoadStateChange?.("ready"); const buildParticleCloud = () => { if (cancelled || revealPhaseRef.current === "hidden") return; try { setParticleCloudData(buildSplatParticleCloudData(splatMesh)); } catch (error) { console.warn("[WorldSplatScene] Failed to build SPZ particle cloud", error); } }; if ("requestIdleCallback" in window) { idleCallbackId = window.requestIdleCallback(buildParticleCloud, { timeout: 250, }); } else { timeoutId = globalThis.setTimeout(buildParticleCloud, 160); } }; const scheduleMarkReady = () => { if (cancelled || hasAppliedInitialViewRef.current) return; if (readyFrameId !== null) return; readyFrameId = window.requestAnimationFrame(() => { readyFrameId = null; markReady(); }); }; loadTimeoutId = globalThis.setTimeout(() => { if (cancelled) return; if (isSplatRenderable()) { scheduleMarkReady(); return; } onLoadStateChange?.( "error", `SPZ 加载超时(${SPARK_SPLAT_LOAD_TIMEOUT_MS}ms)` ); }, SPARK_SPLAT_LOAD_TIMEOUT_MS); splatOnLoadRef.current = scheduleMarkReady; readyPollId = globalThis.setInterval(() => { if (isSplatRenderable()) { scheduleMarkReady(); } }, 250); splatMesh.initialized .then(() => { if (cancelled) return; scheduleMarkReady(); }) .catch((error) => { clearReadyTimers(); if (!cancelled) { const message = error instanceof Error ? error.message : String(error); console.warn("[WorldSplatScene] Failed to load SPZ", { error: message, splatUrl, }); onLoadStateChange?.("error", message); } }); return () => { cancelled = true; clearReadyTimers(); if (splatOnLoadRef.current === scheduleMarkReady) { splatOnLoadRef.current = null; } if (idleCallbackId !== null && "cancelIdleCallback" in window) { window.cancelIdleCallback(idleCallbackId); } if (timeoutId !== null) { globalThis.clearTimeout(timeoutId); } splatMesh.dispose(); }; }, [camera, firstPerson, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]); useEffect(() => { if (!hasAppliedInitialViewRef.current) return; if (lastResetViewTokenRef.current === resetViewToken) return; lastResetViewTokenRef.current = resetViewToken; lastCameraPoseRef.current = positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, firstPerson, groundPlaneOffset, splatMesh, }); }, [camera, firstPerson, groundPlaneOffset, resetViewToken, splatMesh]); useEffect(() => { if (!hasAppliedInitialViewRef.current) { lastFirstPersonRef.current = firstPerson; return; } if (lastFirstPersonRef.current !== firstPerson) { lastCameraPoseRef.current = positionCameraInsideWorld({ camera, controls: orbitControlsRef.current, firstPerson, groundPlaneOffset, splatMesh, }); } lastFirstPersonRef.current = firstPerson; }, [camera, firstPerson, groundPlaneOffset, splatMesh]); useEffect(() => { if (!orbitControlsRef.current || !lastCameraPoseRef.current) return; camera.position.copy(lastCameraPoseRef.current.cameraPosition); camera.lookAt(lastCameraPoseRef.current.targetPosition); if (camera instanceof THREE.PerspectiveCamera) { camera.updateProjectionMatrix(); } orbitControlsRef.current.target?.copy(lastCameraPoseRef.current.targetPosition); orbitControlsRef.current.update?.(); }, [camera, controlsMountVersion]); 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.dirty = true; }); useEffect(() => { hotspotPointerDownRef.current = null; }, [interactionMode, splatUrl]); const handleHotspotPointerDown = (event: ThreeEvent) => { if (interactionMode !== "hotspot") return; hotspotPointerDownRef.current = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY, }; }; const handleHotspotPointerMove = (event: ThreeEvent) => { if (interactionMode !== "hotspot" || !hotspotPointerDownRef.current) return; const deltaX = event.nativeEvent.clientX - hotspotPointerDownRef.current.x; const deltaY = event.nativeEvent.clientY - hotspotPointerDownRef.current.y; if (Math.hypot(deltaX, deltaY) > 6) { hotspotPointerDownRef.current = null; } }; const handleHotspotClick = (event: ThreeEvent) => { if (interactionMode !== "hotspot") return; const pointerDown = hotspotPointerDownRef.current; hotspotPointerDownRef.current = null; if (!pointerDown) return; event.stopPropagation(); onPick?.({ point: [event.point.x, event.point.y, event.point.z], }); }; return ( <> {groundHelperOffset !== null ? ( ) : null} {particleCloudData ? : null} {hotspots.map((hotspot) => ( ))} {!firstPerson ? ( ) : null}
{showInfoOverlay ? (
SPZ {semanticFacts.map((fact) => ( {fact.label} {fact.value} ))}
) : (
)} {interactionMode === "hotspot" ? (
点击场景放置热点
) : null}
); } function WorldSplatCaptureBridge({ onCaptureReady, }: { onCaptureReady?: (capture: (() => Promise) | null) => void; }) { const { camera, gl, scene } = useThree(); useEffect(() => { if (!onCaptureReady) return; onCaptureReady(async () => { const canvas = gl.domElement; gl.render(scene, camera); const blob = await new Promise((resolve) => { if (typeof canvas.toBlob === "function") { canvas.toBlob(resolve, "image/png"); return; } try { const dataUrl = canvas.toDataURL("image/png"); fetch(dataUrl) .then((response) => response.blob()) .then((result) => resolve(result)) .catch(() => resolve(null)); } catch { resolve(null); } }); return blob; }); return () => onCaptureReady(null); }, [camera, gl, onCaptureReady, scene]); return null; } function WorldSplatViewer({ splatUrl, splatUrls, fallbackMeshUrl, fallbackMeshSettings, fallbackMeshLayerVisibility, worldUrl, previewImageUrl, firstPerson, hotspots, interactionMode, onPick, onHotspotSelect, onFallbackMeshLayersChange, onFallbackMeshStatsChange, semanticsMetadata, resetViewToken, minimalFrame = false, onCaptureReady, showInfoOverlay = true, showMovementControls = false, }: { splatUrl?: string | null; splatUrls?: string[] | null; fallbackMeshUrl?: string | null; fallbackMeshSettings?: Model3DViewerSettings; fallbackMeshLayerVisibility?: Record; worldUrl?: string | null; previewImageUrl?: string | null; minimalFrame?: boolean; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; onFallbackMeshLayersChange?: (layers: Model3DLayer[]) => void; onFallbackMeshStatsChange?: (stats: Model3DStats) => void; semanticsMetadata?: Record | null; resetViewToken?: number; onCaptureReady?: (capture: (() => Promise) | null) => void; showInfoOverlay?: boolean; showMovementControls?: boolean; }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [overlayPhase, setOverlayPhase] = useState("loading"); const [activeSplatIndex, setActiveSplatIndex] = useState(0); const [failedSplatLoads, setFailedSplatLoads] = useState< Array<{ url: string; error: string }> >([]); const [loadProgress, setLoadProgress] = useState(null); const [prefetchedSplat, setPrefetchedSplat] = useState( null ); const [useMeshFallback, setUseMeshFallback] = useState(false); const resolvedSplatUrls = useMemo(() => { const rawUrls = [ splatUrl, ...(Array.isArray(splatUrls) ? splatUrls : []), ]; const seen = new Set(); return rawUrls .map((url) => String(buildThreeAssetUrl(url) || "").trim()) .filter((url) => { if (!url || seen.has(url)) return false; seen.add(url); return true; }); }, [splatUrl, splatUrls]); const resolvedSplatSignature = resolvedSplatUrls.join("|"); const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || ""; const resolvedFallbackMeshUrl = String(fallbackMeshUrl || "").trim() || null; const hasMeshFallback = Boolean(resolvedFallbackMeshUrl); const loadStartedAtRef = useRef(0); const movementStateRef = useRef(createMovementState()); useEffect(() => { setActiveSplatIndex(0); setFailedSplatLoads([]); setUseMeshFallback(false); }, [resolvedFallbackMeshUrl, resolvedSplatSignature]); useEffect(() => { if (!resolvedSplatUrl) return; loadStartedAtRef.current = performance.now(); setPrefetchedSplat(null); setLoadProgress(null); setLoadState("loading"); setOverlayPhase("loading"); }, [resolvedSplatUrl]); useEffect(() => { if (!showMovementControls) { movementStateRef.current = createMovementState(); } }, [showMovementControls]); useEffect(() => { if (loadState === "ready") { const elapsed = performance.now() - loadStartedAtRef.current; setLoadProgress(100); setOverlayPhase( elapsed <= SPLAT_FAST_LOAD_SKIP_REVEAL_MS ? "hidden" : "revealing" ); } if (loadState === "loading") { setOverlayPhase("loading"); } if (loadState === "error") { setLoadProgress(null); setOverlayPhase("hidden"); } }, [loadState]); const handleSplatProgress = React.useCallback((event: ProgressEvent) => { if (!event.lengthComputable || event.total <= 0) return; const progress = Math.round((event.loaded / event.total) * 100); setLoadProgress(THREE.MathUtils.clamp(progress, 1, 99)); }, []); const handleSplatLoadStateChange = React.useCallback( (state: "ready" | "error", detail?: string) => { if (state === "ready") { setLoadState("ready"); return; } const message = detail || "SPZ 加载失败"; const failedUrl = resolvedSplatUrl; const nextIndex = activeSplatIndex + 1; const nextUrl = resolvedSplatUrls[nextIndex] || ""; const shouldUseMeshFallback = hasMeshFallback && (message.includes("SPZ 加载超时") || !nextUrl); if (failedUrl) { setFailedSplatLoads((items) => [ ...items, { url: failedUrl, error: message }, ]); } if (shouldUseMeshFallback) { console.warn("[WorldSplatViewer] Switching to mesh fallback", { error: message, failedUrl, attemptedUrls: resolvedSplatUrls.slice(0, nextIndex), fallbackMeshUrl: resolvedFallbackMeshUrl, }); setPrefetchedSplat(null); setLoadProgress(null); setOverlayPhase("hidden"); setUseMeshFallback(true); return; } if (nextUrl) { console.warn("[WorldSplatViewer] Falling back to next SPZ asset", { error: message, failedUrl, nextUrl, }); setPrefetchedSplat(null); setActiveSplatIndex(nextIndex); setLoadProgress(null); setLoadState("loading"); setOverlayPhase("loading"); return; } console.error("[WorldSplatViewer] All SPZ assets failed", { error: message, failedUrl, attemptedUrls: resolvedSplatUrls, }); setPrefetchedSplat(null); setLoadState("error"); }, [ activeSplatIndex, hasMeshFallback, resolvedFallbackMeshUrl, resolvedSplatUrl, resolvedSplatUrls, ] ); useEffect(() => { if (!resolvedSplatUrl || useMeshFallback) return; const abortController = new AbortController(); let cancelled = false; const timeoutId = globalThis.setTimeout(() => { abortController.abort(); }, SPARK_SPLAT_FETCH_TIMEOUT_MS); fetchSplatAsset(resolvedSplatUrl, abortController.signal, (progress) => { if (cancelled) return; setLoadProgress(progress); }) .then((asset) => { if (cancelled) return; globalThis.clearTimeout(timeoutId); setPrefetchedSplat(asset); setLoadProgress((progress) => typeof progress === "number" ? Math.max(progress, 96) : 96 ); }) .catch((error) => { if (cancelled) return; globalThis.clearTimeout(timeoutId); const message = error instanceof DOMException && error.name === "AbortError" ? `SPZ 请求超时(${SPARK_SPLAT_FETCH_TIMEOUT_MS}ms)` : error instanceof Error ? error.message : String(error); handleSplatLoadStateChange("error", message); }); return () => { cancelled = true; globalThis.clearTimeout(timeoutId); abortController.abort(); }; }, [handleSplatLoadStateChange, resolvedSplatUrl, useMeshFallback]); if ((useMeshFallback || !resolvedSplatUrl) && resolvedFallbackMeshUrl) { return ( ); } if (!resolvedSplatUrl) { return ( ); } if (loadState === "error") { const attemptedCount = Math.max(failedSplatLoads.length, resolvedSplatUrls.length); return ( ); } return (
{previewImageUrl ? ( ) : null} {prefetchedSplat?.url === resolvedSplatUrl ? ( setOverlayPhase("hidden")} revealPhase={overlayPhase} resetViewToken={resetViewToken} semanticsMetadata={semanticsMetadata} showInfoOverlay={showInfoOverlay} splatBytes={prefetchedSplat.bytes} splatFileName={prefetchedSplat.fileName} splatUrl={resolvedSplatUrl} /> ) : null} {showMovementControls && loadState === "ready" ? ( ) : null} {overlayPhase !== "hidden" ? ( ) : null}
); } export function World3DViewer({ mode, worldUrl, imageUrl, meshUrl, splatUrl, splatUrls, previewImageUrl, emptyStateTitle = "准备生成 3D 世界", minimalFrame = false, meshSettings, meshLayerVisibility, onMeshLayersChange, onMeshStatsChange, firstPerson = false, hotspots = [], interactionMode = "navigate", onPick, onHotspotSelect, semanticsMetadata, className, resetViewToken, onCaptureReady, showMovementControls = false, }: World3DViewerProps) { const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null; useEffect(() => { if (mode === "splat" || mode === "mesh") return; onCaptureReady?.(null); }, [mode, onCaptureReady]); if (mode === "mesh") { return ( ); } if (mode === "world") { return ( {worldUrl ? (