ai-chatbot/components/canvas/world3d/World3DViewer.tsx
Your Name 61da591a5c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: improve canvas local edit output
2026-05-21 10:25:19 +08:00

1732 lines
53 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,
Crosshair,
Globe,
ImageIcon,
Loader2,
Sparkles,
} from "lucide-react";
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import {
type Model3DLayer,
type Model3DStats,
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import { Badge } from "@/components/ui/badge";
import { 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 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;
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;
};
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
cameraPreset: "iso",
envPreset: "city",
lightIntensity: 1,
autoRotate: false,
showGrid: true,
wireframe: false,
};
const EMPTY_MOVEMENT_STATE: MovementState = {
forward: false,
backward: false,
left: false,
right: false,
up: false,
down: false,
};
const 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.12,
preBlurAmount: 0.02,
minAlpha: 0.015,
maxStdDev: Math.sqrt(6.5),
focalAdjustment: 1.18,
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 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) {
splatMesh.updateMatrixWorld(true);
return splatMesh
.getBoundingBox(true)
.clone()
.applyMatrix4(splatMesh.matrixWorld);
}
function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
const packedSplats = splatMesh.packedSplats;
if (!packedSplats) {
return getSplatWorldBounds(splatMesh);
}
const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
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 = packedSplats.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 horizontalAxis =
size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0);
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 estimatedFloorY = semanticGroundY ?? worldBounds.min.y + size.y * 0.06;
const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.18, 0.9, 1.48);
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 = 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 = 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));
nearTargetPosition.y = THREE.MathUtils.clamp(
eyeY - THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18),
minEyeY <= maxEyeY ? minEyeY : worldBounds.min.y,
minEyeY <= maxEyeY ? maxEyeY : worldBounds.max.y
);
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,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
fillFrame?: boolean;
previewImageUrl?: string | null;
}) {
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">
<Loader2 className="h-7 w-7 animate-spin text-white/55 drop-shadow" />
</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]">
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
</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,
}: {
previewImageUrl?: string | null;
phase: Exclude<SplatLoadingPhase, "hidden">;
}) {
const isRevealing = phase === "revealing";
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}
</>
);
}
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 ? 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<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,
revealPhase = "loading",
firstPerson = false,
minimalFrame = false,
hotspots = [],
interactionMode = "navigate",
onPick,
onHotspotSelect,
semanticsMetadata,
onLoadStateChange,
onRevealComplete,
resetViewToken,
showInfoOverlay = true,
movementStateRef,
movementEnabled = false,
}: {
splatUrl: string;
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") => void;
onRevealComplete?: () => void;
resetViewToken?: number;
showInfoOverlay?: boolean;
movementStateRef?: React.MutableRefObject<MovementState>;
movementEnabled?: boolean;
}) {
const camera = useThree((state) => state.camera);
const renderer = useThree((state) => state.gl);
const orbitControlsRef = useRef<any>(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 [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,
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,
}),
[minimalFrame, splatUrl]
);
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
? semanticsMetadata.ground_plane_offset
: null;
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(0, 0, 0);
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
let cancelled = false;
let idleCallbackId: number | null = null;
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
splatMesh.initialized
.then(() => {
if (cancelled) return;
window.requestAnimationFrame(() => {
if (cancelled || hasAppliedInitialViewRef.current) return;
lastCameraPoseRef.current = positionCameraInsideWorld({
camera,
controls: orbitControlsRef.current,
firstPerson,
groundPlaneOffset,
splatMesh,
});
hasAppliedInitialViewRef.current = true;
onLoadStateChange?.("ready");
const buildParticleCloud = () => {
if (cancelled || revealPhaseRef.current === "hidden") return;
setParticleCloudData(buildSplatParticleCloudData(splatMesh));
};
if ("requestIdleCallback" in window) {
idleCallbackId = window.requestIdleCallback(buildParticleCloud, {
timeout: 250,
});
} else {
timeoutId = globalThis.setTimeout(buildParticleCloud, 160);
}
});
})
.catch(() => {
if (!cancelled) onLoadStateChange?.("error");
});
return () => {
cancelled = true;
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;
});
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
if (interactionMode !== "hotspot") return;
event.stopPropagation();
onPick?.({
point: [event.point.x, event.point.y, event.point.z],
});
};
return (
<>
<color args={["#020617"]} attach="background" />
<fog attach="fog" args={["#020617", 10, 45]} />
<ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} />
{groundPlaneOffset !== null ? (
<gridHelper
args={[18, 18, "#334155", "#0f172a"]}
position={[0, groundPlaneOffset, 0]}
/>
) : null}
<Suspense fallback={null}>
<primitive object={sparkRenderer}>
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
</primitive>
</Suspense>
{particleCloudData ? <SplatParticleCloud data={particleCloudData} phase={revealPhase} /> : null}
{hotspots.map((hotspot) => (
<HotspotBillboard
hotspot={hotspot}
key={hotspot.id}
onSelect={onHotspotSelect}
/>
))}
<CameraMovementControls
enabled={movementEnabled && interactionMode === "navigate"}
firstPerson={firstPerson}
movementStateRef={movementStateRef}
orbitControlsRef={orbitControlsRef}
/>
<FirstPersonLookControls
enabled={interactionMode === "navigate" && firstPerson}
resetToken={resetViewToken}
/>
{!firstPerson ? (
<OrbitControls
autoRotate={false}
dampingFactor={0.08}
enableDamping
enablePan={false}
enableRotate
enableZoom
maxDistance={16}
key="world-splat-orbit"
makeDefault
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE,
}}
minDistance={0.5}
ref={handleOrbitControlsRef}
/>
) : 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>
{(firstPerson || interactionMode === "hotspot") && (
<div className="flex items-end justify-between gap-3">
{firstPerson && interactionMode === "navigate" ? (
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[10px] text-zinc-300 shadow-xl backdrop-blur-sm">
WASD / 方向键移动,拖动画面转向,Q/R 上升,E/F 下降
</div>
) : (
<div />
)}
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
<Crosshair className="h-5 w-5" />
</div>
</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,
worldUrl,
previewImageUrl,
firstPerson,
hotspots,
interactionMode,
onPick,
onHotspotSelect,
semanticsMetadata,
resetViewToken,
minimalFrame = false,
onCaptureReady,
showInfoOverlay = true,
showMovementControls = false,
}: {
splatUrl?: 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 resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
const loadStartedAtRef = useRef(0);
const movementStateRef = useRef<MovementState>(createMovementState());
useEffect(() => {
loadStartedAtRef.current = performance.now();
setLoadState("loading");
setOverlayPhase("loading");
}, [resolvedSplatUrl]);
useEffect(() => {
if (!showMovementControls) {
movementStateRef.current = createMovementState();
}
}, [showMovementControls]);
useEffect(() => {
if (loadState === "ready") {
const elapsed = performance.now() - loadStartedAtRef.current;
setOverlayPhase(
elapsed <= SPLAT_FAST_LOAD_SKIP_REVEAL_MS ? "hidden" : "revealing"
);
}
if (loadState === "loading") {
setOverlayPhase("loading");
}
if (loadState === "error") {
setOverlayPhase("hidden");
}
}, [loadState]);
if (!resolvedSplatUrl) {
return (
<WorldEmptyState
description={
worldUrl
? "官方在线预览页不能稳定嵌入画布,正在等待可本地渲染的 World 资产。"
: "当前结果没有返回可用的世界数据。"
}
fillFrame={minimalFrame}
icon={Sparkles}
previewImageUrl={previewImageUrl}
title="准备加载 World 视图"
/>
);
}
if (loadState === "error") {
return (
<WorldEmptyState
description={
worldUrl
? "World 资产加载失败,已避免退回不稳定的官方 iframe。可以切换到图像或 Mesh 视图继续查看。"
: "当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
}
fillFrame={minimalFrame}
icon={AlertCircle}
previewImageUrl={previewImageUrl}
title="World 视图加载失败"
/>
);
}
return (
<div className="relative h-full w-full">
<Canvas
camera={{ position: [0, 1.6, 4], fov: 55 }}
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
dpr={minimalFrame ? [1, 1.5] : [1, 2]}
frameloop="always"
gl={{
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: Boolean(onCaptureReady),
}}
>
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
<WorldSplatScene
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
minimalFrame={minimalFrame}
onHotspotSelect={onHotspotSelect}
onLoadStateChange={setLoadState}
onPick={onPick}
onRevealComplete={() => setOverlayPhase("hidden")}
revealPhase={overlayPhase}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
showInfoOverlay={showInfoOverlay}
splatUrl={resolvedSplatUrl}
movementStateRef={movementStateRef}
movementEnabled={showMovementControls}
/>
</Canvas>
{showMovementControls && loadState === "ready" ? (
<WorldMovementPad movementStateRef={movementStateRef} />
) : null}
{overlayPhase !== "hidden" ? (
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
) : null}
</div>
);
}
export function World3DViewer({
mode,
worldUrl,
imageUrl,
meshUrl,
splatUrl,
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 (
<div className={cn("h-full w-full", className)}>
<Model3DViewer
emptyStateTitle={emptyStateTitle}
layerVisibility={meshLayerVisibility}
modelRotation={[Math.PI, 0, 0]}
modelUrl={meshUrl}
onLayersChange={onMeshLayersChange}
onStatsChange={onMeshStatsChange}
onCaptureReady={onCaptureReady}
previewImageUrl={previewImageUrl}
resetViewToken={resetViewToken}
settings={meshSettings || DEFAULT_MESH_SETTINGS}
showMovementControls={showMovementControls}
/>
</div>
);
}
if (mode === "world") {
return (
<div className={cn("h-full w-full 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}
/>
)}
</div>
);
}
if (mode === "image") {
return (
<div
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}
/>
)}
</div>
);
}
return (
<div className={cn("h-full w-full", className)}>
<WorldSplatViewer
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
onCaptureReady={onCaptureReady}
onHotspotSelect={onHotspotSelect}
onPick={onPick}
previewImageUrl={previewImageUrl}
minimalFrame={minimalFrame}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
showInfoOverlay={!minimalFrame}
showMovementControls={showMovementControls}
splatUrl={splatUrl}
worldUrl={worldUrl}
/>
</div>
);
}