ai-chatbot/components/canvas/world3d/World3DViewer.tsx

2344 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<MovementCommand, boolean>;
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 WorldNavigationFrame = {
bounds: THREE.Box3;
center: THREE.Vector3;
floorY: number;
majorSpan: number;
viewAxis: 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<string, boolean>;
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<string, unknown> | null;
className?: string;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
showMovementControls?: boolean;
showInfoOverlay?: 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.45;
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 WORLD_FIRST_PERSON_MOVE_SPEED = 1.35;
const WORLD_ORBIT_MOVE_SPEED = 1.05;
const WORLD_MAX_FRAME_MOVE_DISTANCE = 0.04;
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
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<string, unknown> | 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 getSplatNavigationFrame(
splatMesh: SparkSplatMesh,
worldBounds: THREE.Box3,
floorY: number
): WorldNavigationFrame {
const worldCenter = worldBounds.getCenter(new THREE.Vector3());
const worldSize = worldBounds.getSize(new THREE.Vector3());
const fallbackAxis =
worldSize.z >= worldSize.x
? WORLDLABS_REFERENCE_VIEW_AXIS.clone()
: new THREE.Vector3(1, 0, 0);
const fallbackFrame = () => ({
bounds: worldBounds.clone(),
center: new THREE.Vector3(worldCenter.x, floorY, worldCenter.z),
floorY,
majorSpan: Math.max(worldSize.z >= worldSize.x ? worldSize.z : worldSize.x, 1.4),
viewAxis: fallbackAxis,
});
const splats = getSplatSampleSource(splatMesh);
if (!splats) return fallbackFrame();
const totalSplats = getSplatSampleSourceCount(splats);
if (totalSplats < 64) return fallbackFrame();
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 zs: number[] = [];
const minSampleY = floorY - Math.max(worldSize.y * 0.04, 0.12);
const maxSampleY = floorY + THREE.MathUtils.clamp(worldSize.y * 0.42, 0.9, 2.4);
for (let index = 0; index < totalSplats; index += sampleStep) {
const splat = splats.getSplat(index);
if (splat.opacity < 0.035) continue;
point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld);
if (point.y < minSampleY || point.y > maxSampleY) continue;
xs.push(point.x);
zs.push(point.z);
}
if (xs.length < 48) return fallbackFrame();
xs.sort((a, b) => a - b);
zs.sort((a, b) => a - b);
const minX = getPercentileValue(xs, 0.08);
const maxX = getPercentileValue(xs, 0.92);
const minZ = getPercentileValue(zs, 0.08);
const maxZ = getPercentileValue(zs, 0.92);
const navigationBounds = new THREE.Box3(
new THREE.Vector3(minX, floorY, minZ),
new THREE.Vector3(maxX, floorY, maxZ)
);
if (navigationBounds.isEmpty()) return fallbackFrame();
const spanX = Math.max(maxX - minX, 0);
const spanZ = Math.max(maxZ - minZ, 0);
const viewAxis =
spanZ >= Math.max(spanX * 0.55, 0.7)
? WORLDLABS_REFERENCE_VIEW_AXIS.clone()
: new THREE.Vector3(1, 0, 0);
return {
bounds: navigationBounds,
center: new THREE.Vector3((minX + maxX) / 2, floorY, (minZ + maxZ) / 2),
floorY,
majorSpan: Math.max(spanZ >= spanX ? spanZ : spanX, 1.4),
viewAxis,
};
}
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 navigationFrame = getSplatNavigationFrame(
splatMesh,
worldBounds,
displayFloorY
);
const navigationBounds = navigationFrame.bounds;
const navigationCenter = navigationFrame.center;
const navigationSize = navigationBounds.getSize(new THREE.Vector3());
const estimatedFloorY = navigationFrame.floorY;
const activeHorizontalAxis =
firstPerson && navigationFrame.viewAxis.lengthSq() > 0
? navigationFrame.viewAxis.clone()
: horizontalAxis.clone();
const activeMajorSpan = navigationFrame.majorSpan;
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(navigationCenter.x, eyeY, navigationCenter.z);
const cameraDistance = THREE.MathUtils.clamp(activeMajorSpan * 0.16, 0.7, 1.9);
const cameraPosition = firstPerson
? anchor.clone()
: anchor.clone().addScaledVector(activeHorizontalAxis, cameraDistance);
cameraPosition.x = clampInsideAxis(
cameraPosition.x,
navigationBounds.min.x,
navigationBounds.max.x,
Math.max(navigationSize.x * 0.12, 0.18),
navigationCenter.x
);
cameraPosition.z = clampInsideAxis(
cameraPosition.z,
navigationBounds.min.z,
navigationBounds.max.z,
Math.max(navigationSize.z * 0.12, 0.18),
navigationCenter.z
);
const lookDirection = firstPerson
? activeHorizontalAxis.clone()
: anchor.clone().sub(cameraPosition);
if (lookDirection.lengthSq() < 0.0001) {
lookDirection.copy(activeHorizontalAxis).multiplyScalar(-1);
}
lookDirection.normalize();
const nearTargetPosition = cameraPosition
.clone()
.addScaledVector(lookDirection, THREE.MathUtils.clamp(activeMajorSpan * 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,
navigationBounds.min.x,
navigationBounds.max.x,
Math.max(navigationSize.x * 0.1, 0.14),
navigationCenter.x
);
nearTargetPosition.z = clampInsideAxis(
nearTargetPosition.z,
navigationBounds.min.z,
navigationBounds.max.z,
Math.max(navigationSize.z * 0.1, 0.14),
navigationCenter.z
);
const orbitTargetPosition = new THREE.Vector3(
navigationCenter.x,
Math.max(eyeY - THREE.MathUtils.clamp(humanEyeOffset * 0.22, 0.22, 0.5), estimatedFloorY + 0.08),
navigationCenter.z
);
const orbitRadius = Math.max(
navigationSize.length() * 0.5,
activeMajorSpan * 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(activeHorizontalAxis, 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 (
<div className="relative h-full w-full overflow-hidden bg-zinc-950">
<img
alt={title}
className="h-full w-full object-cover"
src={previewImageUrl}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
{loading ? (
<Loader2 className="h-7 w-7 animate-spin text-white/55 drop-shadow" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-black/55 text-white/80 shadow-lg backdrop-blur-sm">
<Icon className="h-4 w-4" />
</div>
)}
</div>
</div>
);
}
if (fillFrame) {
return (
<div className="flex h-full w-full items-center justify-center bg-zinc-950/70">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/60 shadow-inner">
<Icon className="h-10 w-10 text-zinc-700" />
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/70 p-6 text-center">
{previewImageUrl ? (
<div className="relative aspect-video w-full max-w-xl overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-2xl">
<img
alt={title}
className="h-full w-full object-cover opacity-50"
src={previewImageUrl}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30 backdrop-blur-[1px]">
{loading ? (
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-black/55 text-white/80 shadow-xl">
<Icon className="h-5 w-5" />
</div>
)}
</div>
</div>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/60 shadow-inner">
<Icon className="h-10 w-10 text-zinc-700" />
</div>
)}
<div className="space-y-2">
<p className="font-medium text-lg text-zinc-200">{title}</p>
<p className="max-w-md text-sm text-zinc-500">{description}</p>
</div>
</div>
);
}
function SplatParticleCloud({
data,
phase,
}: {
data: SplatParticleCloudData;
phase: SplatLoadingPhase;
}) {
const geometryRef = useRef<THREE.BufferGeometry>(null);
const materialRef = useRef<THREE.PointsMaterial>(null);
const pointsRef = useRef<THREE.Points>(null);
const phaseRef = useRef<SplatLoadingPhase>(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 (
<points
frustumCulled={false}
ref={pointsRef}
renderOrder={11}
>
<bufferGeometry ref={geometryRef}>
<bufferAttribute attach="attributes-position" args={[positionBuffer, 3]} />
<bufferAttribute attach="attributes-color" args={[data.colors, 3]} />
</bufferGeometry>
<pointsMaterial
blending={THREE.AdditiveBlending}
depthTest
depthWrite={false}
ref={materialRef}
size={0.02}
sizeAttenuation
toneMapped={false}
transparent
vertexColors
/>
</points>
);
}
function WorldSplatLoadingOverlay({
previewImageUrl,
phase,
progress,
}: {
previewImageUrl?: string | null;
phase: Exclude<SplatLoadingPhase, "hidden">;
progress?: number | null;
}) {
const isLoading = phase === "loading";
return (
<>
{previewImageUrl ? (
<div
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-500",
isLoading ? "opacity-100" : "opacity-30"
)}
>
<img
alt="世界预览"
className={cn(
"absolute inset-0 h-full w-full object-cover transition-transform duration-700",
isLoading ? "scale-[1.01]" : "scale-100"
)}
decoding="async"
fetchPriority="high"
src={previewImageUrl}
/>
<div
className={cn(
"absolute inset-0 transition-opacity duration-500",
isLoading
? "bg-gradient-to-b from-black/10 via-black/12 to-black/30"
: "bg-gradient-to-b from-black/0 via-black/5 to-black/18"
)}
/>
</div>
) : phase === "loading" ? (
<div className="pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-300">
{previewImageUrl ? (
<img
alt="世界预览"
className="absolute inset-0 h-full w-full object-cover opacity-[0.1] blur-[10px] saturate-[0.72]"
decoding="async"
fetchPriority="high"
src={previewImageUrl}
/>
) : null}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(255,245,220,0.05)_0%,_rgba(11,15,25,0.08)_24%,_rgba(1,2,6,0.38)_100%)]" />
</div>
) : null}
{isLoading ? (
<div className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-white/12 bg-black/36 px-3 py-2 text-xs font-medium text-white/78 shadow-[0_12px_32px_rgba(0,0,0,0.24)] backdrop-blur-md">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{typeof progress === "number" ? <span>{progress}%</span> : null}
</div>
) : 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 (
<div
className={cn("h-full w-full touch-none overscroll-contain", className)}
data-canvas-no-drag="true"
onContextMenu={stopViewerSurfaceContextMenu}
onWheel={stopViewerSurfaceWheel}
>
{children}
</div>
);
}
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<PrefetchedSplatAsset> {
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<MovementState>;
orbitControlsRef: React.MutableRefObject<any>;
}) {
const { camera } = useThree();
const pressedKeysRef = useRef<MovementState>(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
? WORLD_FIRST_PERSON_MOVE_SPEED
: WORLD_ORBIT_MOVE_SPEED;
const distance = Math.min(
moveSpeed * delta,
WORLD_MAX_FRAME_MOVE_DISTANCE
);
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<MovementState>;
}) {
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<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
setPressed(command, true);
},
onPointerUp: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setPressed(command, false);
},
onPointerCancel: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
clearMovement();
},
onPointerLeave: (event: React.PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
setPressed(command, false);
},
type: "button" as const,
});
return (
<div
className="nodrag nopan nowheel pointer-events-auto absolute bottom-3 left-3 z-30 flex items-end gap-2"
data-canvas-no-drag="true"
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<div className="grid grid-cols-3 gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
<span />
<button {...buttonProps("forward")}>W</button>
<span />
<button {...buttonProps("left")}>A</button>
<button {...buttonProps("backward")}>S</button>
<button {...buttonProps("right")}>D</button>
</div>
<div className="grid gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
<button {...buttonProps("up")}>Q</button>
<button {...buttonProps("down")}>E</button>
</div>
</div>
);
}
function FirstPersonLookControls({
enabled,
resetToken,
}: {
enabled: boolean;
resetToken?: number;
}) {
const { camera, gl } = useThree();
const yawRef = useRef(0);
const pitchRef = useRef(0);
const draggingPointerIdRef = useRef<number | null>(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 (
<group position={[hotspot.position.x, hotspot.position.y, hotspot.position.z]}>
<mesh
onClick={(event) => {
event.stopPropagation();
onSelect?.(hotspot.id);
}}
>
<sphereGeometry args={[0.07, 24, 24]} />
<meshStandardMaterial color="#facc15" emissive="#ca8a04" emissiveIntensity={0.8} />
</mesh>
<Html center distanceFactor={8} position={[0, 0.14, 0]}>
<div className="rounded-full border border-yellow-400/30 bg-black/70 px-2 py-1 text-[10px] font-medium text-yellow-200 shadow-lg backdrop-blur-sm">
{hotspot.label}
</div>
</Html>
</group>
);
}
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<string, unknown> | null;
onLoadStateChange?: (state: "ready" | "error", detail?: string) => void;
onLoadProgress?: (event: ProgressEvent) => void;
onRevealComplete?: () => void;
resetViewToken?: number;
showInfoOverlay?: boolean;
movementStateRef?: React.MutableRefObject<MovementState>;
movementEnabled?: boolean;
interactionReady?: boolean;
}) {
const camera = useThree((state) => state.camera);
const renderer = useThree((state) => state.gl);
const orbitControlsRef = useRef<any>(null);
const hotspotPointerDownRef = useRef<{ x: number; y: number } | null>(null);
const hasAppliedInitialViewRef = useRef(false);
const lastCameraPoseRef = useRef<WorldCameraPose | null>(null);
const lastResetViewTokenRef = useRef<number | undefined>(resetViewToken);
const lastFirstPersonRef = useRef(firstPerson);
const revealCompletedRef = useRef(false);
const splatOnLoadRef = useRef<(() => void) | null>(null);
const [controlsMountVersion, setControlsMountVersion] = useState(0);
const [particleCloudData, setParticleCloudData] = useState<SplatParticleCloudData | null>(null);
const semanticFacts = useMemo(
() => getSemanticFacts(semanticsMetadata),
[semanticsMetadata]
);
const revealPhaseRef = useRef<SplatLoadingPhase>(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 shouldShowGroundHelper = groundHelperOffset !== null && !firstPerson;
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<typeof globalThis.setInterval> | null = null;
let readyFrameId: number | null = null;
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
let loadTimeoutId: ReturnType<typeof globalThis.setTimeout> | 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<PointerEvent>) => {
if (interactionMode !== "hotspot") return;
hotspotPointerDownRef.current = {
x: event.nativeEvent.clientX,
y: event.nativeEvent.clientY,
};
};
const handleHotspotPointerMove = (event: ThreeEvent<PointerEvent>) => {
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<MouseEvent>) => {
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 (
<>
<ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} />
{shouldShowGroundHelper ? (
<gridHelper
args={[18, 18, "#334155", "#0f172a"]}
position={[0, groundHelperOffset, 0]}
/>
) : null}
<Suspense fallback={null}>
<primitive object={sparkRenderer}>
<primitive
object={splatMesh}
onClick={handleHotspotClick}
onPointerDown={handleHotspotPointerDown}
onPointerMove={handleHotspotPointerMove}
/>
</primitive>
</Suspense>
{particleCloudData ? <SplatParticleCloud data={particleCloudData} phase={revealPhase} /> : null}
{hotspots.map((hotspot) => (
<HotspotBillboard
hotspot={hotspot}
key={hotspot.id}
onSelect={onHotspotSelect}
/>
))}
<CameraMovementControls
enabled={interactionReady && movementEnabled && interactionMode === "navigate"}
firstPerson={firstPerson}
movementStateRef={movementStateRef}
orbitControlsRef={orbitControlsRef}
/>
<FirstPersonLookControls
enabled={interactionReady && interactionMode === "navigate" && firstPerson}
resetToken={resetViewToken}
/>
{!firstPerson ? (
<OrbitControls
autoRotate={false}
dampingFactor={0.08}
enableDamping
enablePan
enableRotate
enableZoom
maxDistance={16}
key="world-splat-orbit"
makeDefault
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
}}
panSpeed={0.8}
screenSpacePanning
minDistance={0.5}
ref={handleOrbitControlsRef}
touches={{
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
}}
/>
) : null}
<Html className="pointer-events-none" fullscreen>
<div className="flex h-full w-full flex-col justify-between p-4">
<div className="flex items-start justify-between">
{showInfoOverlay ? (
<div className="flex gap-2">
<Badge className="border-cyan-500/30 bg-cyan-500/10 text-cyan-200" variant="outline">
SPZ
</Badge>
{semanticFacts.map((fact) => (
<Badge
className="border-white/10 bg-black/50 text-zinc-200"
key={fact.label}
variant="outline"
>
{fact.label} {fact.value}
</Badge>
))}
</div>
) : (
<div />
)}
{interactionMode === "hotspot" ? (
<div className="rounded-full border border-yellow-400/30 bg-black/65 px-3 py-1 text-[11px] text-yellow-100">
点击场景放置热点
</div>
) : null}
</div>
</div>
</Html>
</>
);
}
function WorldSplatCaptureBridge({
onCaptureReady,
}: {
onCaptureReady?: (capture: (() => Promise<Blob | null>) | 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<Blob | null>((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,
worldUrl,
previewImageUrl,
firstPerson,
hotspots,
interactionMode,
onPick,
onHotspotSelect,
semanticsMetadata,
resetViewToken,
minimalFrame = false,
onCaptureReady,
showInfoOverlay = true,
showMovementControls = false,
}: {
splatUrl?: string | null;
splatUrls?: string[] | null;
worldUrl?: string | null;
previewImageUrl?: string | null;
minimalFrame?: boolean;
firstPerson?: boolean;
hotspots?: World3DHotspot[];
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
semanticsMetadata?: Record<string, unknown> | null;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
showInfoOverlay?: boolean;
showMovementControls?: boolean;
}) {
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
const [activeSplatIndex, setActiveSplatIndex] = useState(0);
const [failedSplatLoads, setFailedSplatLoads] = useState<
Array<{ url: string; error: string }>
>([]);
const [loadProgress, setLoadProgress] = useState<number | null>(null);
const [prefetchedSplat, setPrefetchedSplat] = useState<PrefetchedSplatAsset | null>(
null
);
const resolvedSplatUrls = useMemo(() => {
const rawUrls = [
splatUrl,
...(Array.isArray(splatUrls) ? splatUrls : []),
];
const seen = new Set<string>();
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 loadStartedAtRef = useRef(0);
const movementStateRef = useRef<MovementState>(createMovementState());
useEffect(() => {
setActiveSplatIndex(0);
setFailedSplatLoads([]);
}, [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] || "";
if (failedUrl) {
setFailedSplatLoads((items) => [
...items,
{ url: failedUrl, error: message },
]);
}
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,
resolvedSplatUrl,
resolvedSplatUrls,
]
);
useEffect(() => {
if (!resolvedSplatUrl) 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]);
if (!resolvedSplatUrl) {
return (
<WorldEmptyState
description={
worldUrl
? "官方在线预览页不能稳定嵌入画布,正在等待可本地渲染的 World 资产。"
: "当前结果没有返回可用的世界数据。"
}
fillFrame={minimalFrame}
icon={Sparkles}
previewImageUrl={previewImageUrl}
title="准备加载 World 视图"
/>
);
}
if (loadState === "error") {
const attemptedCount = Math.max(failedSplatLoads.length, resolvedSplatUrls.length);
return (
<WorldEmptyState
description={
worldUrl
? `World 资产加载失败,已自动尝试 ${attemptedCount} 个 SPZ 档位。可以切换到图像或 Mesh 视图继续查看。`
: `当前世界数据暂时无法加载,已自动尝试 ${attemptedCount} 个 SPZ 档位。可以切换到图像或 Mesh 视图继续查看。`
}
fillFrame={minimalFrame}
icon={AlertCircle}
loading={false}
previewImageUrl={previewImageUrl}
title="World 视图加载失败"
/>
);
}
return (
<div className="relative h-full w-full overflow-hidden bg-zinc-100">
{previewImageUrl ? (
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-35 blur-md saturate-90"
decoding="async"
src={previewImageUrl}
/>
) : null}
<Canvas
camera={{ position: [0, 1.6, 4], fov: 55 }}
className="relative h-full w-full bg-transparent"
dpr={minimalFrame ? [1, 1.5] : [1, 2]}
frameloop="always"
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: Boolean(onCaptureReady),
}}
>
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
{prefetchedSplat?.url === resolvedSplatUrl ? (
<WorldSplatScene
key={resolvedSplatUrl}
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
interactionReady={loadState === "ready"}
minimalFrame={minimalFrame}
movementEnabled={showMovementControls && loadState === "ready"}
movementStateRef={movementStateRef}
onHotspotSelect={onHotspotSelect}
onLoadProgress={handleSplatProgress}
onLoadStateChange={handleSplatLoadStateChange}
onPick={onPick}
onRevealComplete={() => setOverlayPhase("hidden")}
revealPhase={overlayPhase}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
showInfoOverlay={showInfoOverlay}
splatBytes={prefetchedSplat.bytes}
splatFileName={prefetchedSplat.fileName}
splatUrl={resolvedSplatUrl}
/>
) : null}
</Canvas>
{showMovementControls && loadState === "ready" ? (
<WorldMovementPad movementStateRef={movementStateRef} />
) : null}
{overlayPhase !== "hidden" ? (
<WorldSplatLoadingOverlay
phase={overlayPhase}
progress={loadProgress}
previewImageUrl={previewImageUrl}
/>
) : null}
</div>
);
}
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,
showInfoOverlay = !minimalFrame,
}: World3DViewerProps) {
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
useEffect(() => {
if (mode === "splat" || mode === "mesh") return;
onCaptureReady?.(null);
}, [mode, onCaptureReady]);
if (mode === "mesh") {
return (
<WorldViewerInteractionSurface className={className}>
<Model3DViewer
emptyStateTitle={emptyStateTitle}
layerVisibility={meshLayerVisibility}
modelRotation={WORLDLABS_ASSET_ROTATION}
modelUrl={meshUrl}
onLayersChange={onMeshLayersChange}
onStatsChange={onMeshStatsChange}
onCaptureReady={onCaptureReady}
previewImageUrl={previewImageUrl}
resetViewToken={resetViewToken}
settings={meshSettings || DEFAULT_MESH_SETTINGS}
showMovementControls={showMovementControls}
/>
</WorldViewerInteractionSurface>
);
}
if (mode === "world") {
return (
<WorldViewerInteractionSurface
className={cn("overflow-hidden bg-zinc-950", className)}
>
{worldUrl ? (
<iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
allowFullScreen
className="h-full w-full border-0"
referrerPolicy="no-referrer-when-downgrade"
src={worldUrl}
title="3D 世界在线预览"
/>
) : (
<WorldEmptyState
description="当前结果还没有可嵌入的在线预览入口,可以先切换到 World、图像或 Mesh 视图。"
fillFrame={minimalFrame}
icon={Globe}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
/>
)}
</WorldViewerInteractionSurface>
);
}
if (mode === "image") {
return (
<WorldViewerInteractionSurface
className={cn(
minimalFrame
? "flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950"
: "flex h-full w-full items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(39,39,42,0.95),_rgba(2,6,23,1)_68%)] p-6",
className
)}
>
{resolvedImageUrl ? (
<img
alt="世界图像预览"
className={
minimalFrame
? "h-full w-full object-cover"
: "max-h-full w-full rounded-2xl object-contain shadow-[0_30px_80px_rgba(0,0,0,0.45)]"
}
src={resolvedImageUrl}
/>
) : (
<WorldEmptyState
description="当前结果还没有可用的图像资产,可以先切换到 World 或 Mesh 视图。"
fillFrame={minimalFrame}
icon={ImageIcon}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
/>
)}
</WorldViewerInteractionSurface>
);
}
return (
<WorldViewerInteractionSurface className={className}>
<WorldSplatViewer
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
onCaptureReady={onCaptureReady}
onHotspotSelect={onHotspotSelect}
onPick={onPick}
previewImageUrl={previewImageUrl}
minimalFrame={minimalFrame}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
showInfoOverlay={showInfoOverlay}
showMovementControls={showMovementControls}
splatUrl={splatUrl}
splatUrls={splatUrls}
worldUrl={worldUrl}
/>
</WorldViewerInteractionSurface>
);
}