ai-chatbot/components/canvas/world3d/World3DViewer.tsx
Your Name 240b367acd
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: center world orbit rotation on view
2026-04-10 13:30:16 +08:00

1160 lines
35 KiB
TypeScript

"use client";
/* eslint-disable @next/next/no-img-element */
import { Html, OrbitControls, PointerLockControls } from "@react-three/drei";
import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber";
import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark";
import {
AlertCircle,
Crosshair,
Globe,
ImageIcon,
Loader2,
Sparkles,
} from "lucide-react";
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import {
type Model3DLayer,
type Model3DStats,
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { World3DHotspot, World3DViewMode } from "@/types/canvas";
type World3DPickResult = {
point: [number, number, number];
};
type MovementCommand =
| "forward"
| "backward"
| "left"
| "right"
| "up"
| "down";
type MovementState = Record<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 World3DViewerProps = {
mode: World3DViewMode;
worldUrl?: string | null;
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
previewImageUrl?: string | null;
emptyStateTitle?: string;
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;
};
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 = 2600;
const SPLAT_PARTICLE_SAMPLE_LIMIT = 1800;
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;
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 positionCameraInsideWorld({
camera,
controls,
splatMesh,
groundPlaneOffset,
}: {
camera: THREE.Camera;
controls: { target?: THREE.Vector3; update?: () => void } | null;
splatMesh: SparkSplatMesh;
groundPlaneOffset: number | null;
}) {
if (!(camera instanceof THREE.PerspectiveCamera)) return;
splatMesh.updateMatrixWorld(true);
const worldBounds = splatMesh.getBoundingBox(true).clone().applyMatrix4(splatMesh.matrixWorld);
if (worldBounds.isEmpty()) return;
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 marginX = Math.max(size.x * 0.12, 0.35);
const marginZ = Math.max(size.z * 0.12, 0.35);
const eyeY =
groundPlaneOffset !== null
? groundPlaneOffset + 1.58
: center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9);
const anchor = new THREE.Vector3(center.x, eyeY, center.z);
const cameraPosition = anchor
.clone()
.addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65));
cameraPosition.x = THREE.MathUtils.clamp(
cameraPosition.x,
worldBounds.min.x + marginX,
worldBounds.max.x - marginX
);
cameraPosition.z = THREE.MathUtils.clamp(
cameraPosition.z,
worldBounds.min.z + marginZ,
worldBounds.max.z - marginZ
);
const lookDirection = anchor.clone().sub(cameraPosition).normalize();
const anchorDistance = cameraPosition.distanceTo(anchor);
const targetPosition = cameraPosition
.clone()
.addScaledVector(lookDirection, THREE.MathUtils.clamp(anchorDistance * 0.8, 0.55, 1.2));
camera.position.copy(cameraPosition);
camera.lookAt(targetPosition);
camera.updateProjectionMatrix();
if (controls?.target) {
controls.target.copy(targetPosition);
controls.update?.();
}
}
function WorldEmptyState({
icon: Icon,
title,
description,
previewImageUrl,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
previewImageUrl?: string | null;
}) {
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 (
<group rotation={[Math.PI, 0, 0]}>
<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>
</group>
);
}
function WorldSplatLoadingOverlay({
previewImageUrl,
phase,
}: {
previewImageUrl?: string | null;
phase: Exclude<SplatLoadingPhase, "hidden">;
}) {
const isRevealing = phase === "revealing";
return (
<>
{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]"
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}
<div
className={cn(
"pointer-events-none absolute bottom-5 left-5 transition-all duration-300 opacity-100"
)}
>
<div className="rounded-2xl border border-white/10 bg-black/45 px-3 py-2 shadow-[0_20px_60px_rgba(0,0,0,0.38)] backdrop-blur-xl">
<div className="flex items-center gap-2 text-[11px] text-zinc-100">
<Sparkles className="h-3.5 w-3.5 text-cyan-200" />
<span>{isRevealing ? "世界数据正在聚合" : "正在载入世界数据"}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{isRevealing ? "当前世界正在从颗粒态恢复细节" : "空间数据准备中"}</span>
</div>
</div>
</div>
</>
);
}
function createMovementState(): MovementState {
return { ...EMPTY_MOVEMENT_STATE };
}
function CameraMovementControls({
enabled,
firstPerson,
orbitControlsRef,
}: {
enabled: boolean;
firstPerson: boolean;
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();
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditableKeyboardTarget(event.target)) {
pressedKeysRef.current = createMovementState();
return;
}
pressedKeysRef.current[movement] = true;
};
const handleKeyUp = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
if (isEditableKeyboardTarget(event.target)) {
pressedKeysRef.current = createMovementState();
return;
}
pressedKeysRef.current[movement] = false;
};
const handleFocusIn = (event: FocusEvent) => {
if (isEditableKeyboardTarget(event.target)) {
pressedKeysRef.current = createMovementState();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("focusin", handleFocusIn);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("focusin", handleFocusIn);
};
}, [enabled]);
useFrame((_, delta) => {
if (!enabled) return;
const pressed = pressedKeysRef.current;
const hasMovement = Object.values(pressed).some(Boolean);
if (!hasMovement) return;
const moveSpeed = 3.8;
const distance = moveSpeed * delta;
camera.getWorldDirection(directionRef.current);
directionRef.current.setY(0).normalize();
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
upRef.current.copy(camera.up).normalize();
moveRef.current.set(0, 0, 0);
if (pressed.forward) {
moveRef.current.add(directionRef.current);
}
if (pressed.backward) {
moveRef.current.addScaledVector(directionRef.current, -1);
}
if (pressed.left) {
moveRef.current.addScaledVector(rightRef.current, -1);
}
if (pressed.right) {
moveRef.current.add(rightRef.current);
}
if (pressed.up) {
moveRef.current.add(upRef.current);
}
if (pressed.down) {
moveRef.current.addScaledVector(upRef.current, -1);
}
if (moveRef.current.lengthSq() === 0) return;
moveRef.current.normalize().multiplyScalar(distance);
camera.position.add(moveRef.current);
orbitControlsRef.current?.update?.();
});
if (!enabled || !firstPerson) return null;
return <PointerLockControls makeDefault />;
}
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,
hotspots = [],
interactionMode = "navigate",
onPick,
onHotspotSelect,
semanticsMetadata,
onLoadStateChange,
onRevealComplete,
resetViewToken,
}: {
splatUrl: string;
revealPhase?: SplatLoadingPhase;
firstPerson?: 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;
}) {
const camera = useThree((state) => state.camera);
const renderer = useThree((state) => state.gl);
const orbitControlsRef = useRef<any>(null);
const hasAppliedInitialViewRef = useRef(false);
const revealCompletedRef = useRef(false);
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),
originDistance: 0.85,
view: {
sortRadial: true,
sortDistance: 0.04,
},
}),
[renderer]
);
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]);
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
? -semanticsMetadata.ground_plane_offset
: null;
useEffect(() => {
sparkRenderer.frustumCulled = false;
sparkRenderer.renderOrder = 10;
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_NORMAL_PROFILE);
return () => {
sparkRenderer.removeFromParent();
};
}, [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;
revealCompletedRef.current = false;
setParticleCloudData(null);
splatMesh.frustumCulled = false;
splatMesh.rotation.x = Math.PI;
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;
positionCameraInsideWorld({
camera,
controls: orbitControlsRef.current,
groundPlaneOffset,
splatMesh,
});
hasAppliedInitialViewRef.current = true;
onLoadStateChange?.("ready");
const buildParticleCloud = () => {
if (cancelled) return;
setParticleCloudData(buildSplatParticleCloudData(splatMesh));
};
if ("requestIdleCallback" in window) {
idleCallbackId = window.requestIdleCallback(buildParticleCloud, {
timeout: 900,
});
} else {
timeoutId = globalThis.setTimeout(buildParticleCloud, 120);
}
});
})
.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, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
useEffect(() => {
if (!hasAppliedInitialViewRef.current) return;
positionCameraInsideWorld({
camera,
controls: orbitControlsRef.current,
groundPlaneOffset,
splatMesh,
});
}, [camera, groundPlaneOffset, resetViewToken, splatMesh]);
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.needsUpdate = 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={interactionMode === "navigate" && firstPerson}
firstPerson={firstPerson}
orbitControlsRef={orbitControlsRef}
/>
{!firstPerson ? (
<OrbitControls
autoRotate={false}
dampingFactor={0.08}
enableDamping
enablePan={false}
enableRotate
enableZoom
makeDefault
maxDistance={16}
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE,
}}
minDistance={0.5}
ref={orbitControlsRef}
/>
) : (
<OrbitControls
enablePan={false}
enableRotate={false}
enableZoom={false}
ref={orbitControlsRef}
/>
)}
<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">
<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>
{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-end">
<div className="flex h-12 w-12 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 WorldSplatViewer({
splatUrl,
previewImageUrl,
firstPerson,
hotspots,
interactionMode,
onPick,
onHotspotSelect,
semanticsMetadata,
resetViewToken,
}: {
splatUrl?: string | null;
previewImageUrl?: string | null;
firstPerson?: boolean;
hotspots?: World3DHotspot[];
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
semanticsMetadata?: Record<string, unknown> | null;
resetViewToken?: number;
}) {
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
useEffect(() => {
setLoadState("loading");
setOverlayPhase("loading");
}, [splatUrl]);
useEffect(() => {
if (loadState === "ready") {
setOverlayPhase("revealing");
}
if (loadState === "loading") {
setOverlayPhase("loading");
}
if (loadState === "error") {
setOverlayPhase("hidden");
}
}, [loadState]);
if (!splatUrl) {
return (
<WorldEmptyState
description="当前结果没有返回可用的世界数据。"
icon={Sparkles}
previewImageUrl={previewImageUrl}
title="准备加载 World 视图"
/>
);
}
if (loadState === "error") {
return (
<WorldEmptyState
description="当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
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%)]"
frameloop="always"
gl={{ antialias: false }}
>
<WorldSplatScene
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
onHotspotSelect={onHotspotSelect}
onLoadStateChange={setLoadState}
onPick={onPick}
onRevealComplete={() => setOverlayPhase("hidden")}
revealPhase={overlayPhase}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}
/>
</Canvas>
{overlayPhase !== "hidden" ? (
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
) : null}
</div>
);
}
export function World3DViewer({
mode,
worldUrl,
imageUrl,
meshUrl,
splatUrl,
previewImageUrl,
emptyStateTitle = "准备生成 3D 世界",
meshSettings,
meshLayerVisibility,
onMeshLayersChange,
onMeshStatsChange,
firstPerson = false,
hotspots = [],
interactionMode = "navigate",
onPick,
onHotspotSelect,
semanticsMetadata,
className,
resetViewToken,
}: World3DViewerProps) {
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
if (mode === "mesh") {
return (
<div className={cn("h-full w-full", className)}>
<Model3DViewer
emptyStateTitle={emptyStateTitle}
layerVisibility={meshLayerVisibility}
modelUrl={meshUrl}
onLayersChange={onMeshLayersChange}
onStatsChange={onMeshStatsChange}
previewImageUrl={previewImageUrl}
resetViewToken={resetViewToken}
settings={meshSettings || DEFAULT_MESH_SETTINGS}
/>
</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 视图。"
icon={Globe}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
/>
)}
</div>
);
}
if (mode === "image") {
return (
<div
className={cn(
"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="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 视图。"
icon={ImageIcon}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
/>
)}
</div>
);
}
return (
<div className={cn("h-full w-full", className)}>
<WorldSplatViewer
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
onHotspotSelect={onHotspotSelect}
onPick={onPick}
previewImageUrl={previewImageUrl}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}
/>
</div>
);
}