1112 lines
34 KiB
TypeScript
1112 lines
34 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,
|
|
ArrowDown,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
ArrowUp,
|
|
Box,
|
|
Crosshair,
|
|
Globe,
|
|
ImageIcon,
|
|
Loader2,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
|
import * as THREE from "three";
|
|
import {
|
|
type Model3DLayer,
|
|
type Model3DStats,
|
|
Model3DViewer,
|
|
type Model3DViewerSettings,
|
|
} from "@/components/canvas/model3d/Model3DViewer";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import type { World3DHotspot, World3DViewMode } from "@/types/canvas";
|
|
|
|
type World3DPickResult = {
|
|
point: [number, number, number];
|
|
};
|
|
|
|
type MovementCommand =
|
|
| "forward"
|
|
| "backward"
|
|
| "left"
|
|
| "right"
|
|
| "up"
|
|
| "down";
|
|
|
|
type MovementState = Record<MovementCommand, boolean>;
|
|
type SplatLoadingPhase = "loading" | "revealing" | "hidden";
|
|
type SplatRevealCloudData = {
|
|
count: number;
|
|
colors: Float32Array;
|
|
startPositions: Float32Array;
|
|
targetPositions: Float32Array;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
|
envPreset: "city",
|
|
lightIntensity: 1,
|
|
autoRotate: false,
|
|
showGrid: true,
|
|
wireframe: false,
|
|
};
|
|
|
|
const EMPTY_MOVEMENT_STATE: MovementState = {
|
|
forward: false,
|
|
backward: false,
|
|
left: false,
|
|
right: false,
|
|
up: false,
|
|
down: false,
|
|
};
|
|
|
|
const KEY_TO_MOVEMENT: Partial<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 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>
|
|
);
|
|
}
|
|
|
|
const REAL_SPLAT_REVEAL_SAMPLE_LIMIT = 3600;
|
|
|
|
function seededUnit(index: number, salt: number) {
|
|
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43758.5453;
|
|
return value - Math.floor(value);
|
|
}
|
|
|
|
function buildSplatRevealCloudData(
|
|
splatMesh: SparkSplatMesh,
|
|
sampleLimit = REAL_SPLAT_REVEAL_SAMPLE_LIMIT
|
|
): SplatRevealCloudData | null {
|
|
const totalSplats = Math.max(0, splatMesh.packedSplats?.numSplats || 0);
|
|
if (!totalSplats) return null;
|
|
|
|
const sampleStep = Math.max(1, Math.floor(totalSplats / sampleLimit));
|
|
const sampled: Array<{
|
|
center: THREE.Vector3;
|
|
color: THREE.Color;
|
|
opacity: number;
|
|
}> = [];
|
|
const bounds = new THREE.Box3();
|
|
|
|
splatMesh.forEachSplat((index, center, _scales, _quaternion, opacity, color) => {
|
|
if (sampled.length >= sampleLimit) return;
|
|
if (opacity < 0.04) return;
|
|
if (index % sampleStep !== 0) return;
|
|
|
|
const target = center.clone();
|
|
bounds.expandByPoint(target);
|
|
sampled.push({
|
|
center: target,
|
|
color: color.clone(),
|
|
opacity,
|
|
});
|
|
});
|
|
|
|
if (!sampled.length) return null;
|
|
|
|
const sceneCenter = bounds.getCenter(new THREE.Vector3());
|
|
const sceneBoundsSize = bounds.getSize(new THREE.Vector3());
|
|
const sceneSize = sceneBoundsSize.length();
|
|
const sceneRadius = Math.max(sceneSize * 0.42, 1.6);
|
|
const white = new THREE.Color("#fff4de");
|
|
const colors = new Float32Array(sampled.length * 3);
|
|
const startPositions = new Float32Array(sampled.length * 3);
|
|
const targetPositions = new Float32Array(sampled.length * 3);
|
|
|
|
sampled.forEach((sample, index) => {
|
|
const stride = index * 3;
|
|
const direction = sample.center.clone().sub(sceneCenter);
|
|
if (direction.lengthSq() < 1e-4) {
|
|
direction.set(
|
|
seededUnit(index, 10) - 0.5,
|
|
seededUnit(index, 11) - 0.35,
|
|
seededUnit(index, 12) - 0.5
|
|
);
|
|
}
|
|
|
|
direction.normalize();
|
|
const swirlAxis = new THREE.Vector3(
|
|
seededUnit(index, 13) - 0.5,
|
|
seededUnit(index, 14) - 0.5,
|
|
seededUnit(index, 15) - 0.5
|
|
);
|
|
if (swirlAxis.lengthSq() < 1e-4) {
|
|
swirlAxis.set(0.3, 1, 0.2);
|
|
}
|
|
swirlAxis.normalize();
|
|
|
|
const tangent = new THREE.Vector3().crossVectors(direction, swirlAxis);
|
|
if (tangent.lengthSq() < 1e-4) {
|
|
tangent.set(1, 0, 0);
|
|
} else {
|
|
tangent.normalize();
|
|
}
|
|
|
|
const start = sample.center
|
|
.clone()
|
|
.sub(sceneCenter)
|
|
.multiplyScalar(1.08 + seededUnit(index, 16) * 0.38)
|
|
.add(sceneCenter)
|
|
.addScaledVector(
|
|
tangent,
|
|
sceneRadius * (0.018 + seededUnit(index, 17) * 0.085)
|
|
)
|
|
.addScaledVector(
|
|
direction,
|
|
sceneRadius * (0.035 + seededUnit(index, 18) * 0.12)
|
|
)
|
|
.add(
|
|
new THREE.Vector3(
|
|
0,
|
|
sceneBoundsSize.y * (seededUnit(index, 19) - 0.5) * 0.16,
|
|
0
|
|
)
|
|
);
|
|
|
|
const displayColor = sample.color
|
|
.clone()
|
|
.lerp(white, 0.42)
|
|
.multiplyScalar(0.88 + Math.min(sample.opacity, 1) * 0.24);
|
|
|
|
targetPositions[stride] = sample.center.x;
|
|
targetPositions[stride + 1] = sample.center.y;
|
|
targetPositions[stride + 2] = sample.center.z;
|
|
startPositions[stride] = start.x;
|
|
startPositions[stride + 1] = start.y;
|
|
startPositions[stride + 2] = start.z;
|
|
colors[stride] = displayColor.r;
|
|
colors[stride + 1] = displayColor.g;
|
|
colors[stride + 2] = displayColor.b;
|
|
});
|
|
|
|
return {
|
|
count: sampled.length,
|
|
colors,
|
|
startPositions,
|
|
targetPositions,
|
|
};
|
|
}
|
|
|
|
function SplatRevealCloud({
|
|
data,
|
|
phase,
|
|
}: {
|
|
data: SplatRevealCloudData;
|
|
phase: Exclude<SplatLoadingPhase, "hidden">;
|
|
}) {
|
|
const pointsRef = useRef<THREE.Points>(null);
|
|
const materialRef = useRef<THREE.PointsMaterial>(null);
|
|
const positionBuffer = useMemo(
|
|
() => data.startPositions.slice(),
|
|
[data.startPositions]
|
|
);
|
|
const geometryRef = useRef<THREE.BufferGeometry>(null);
|
|
const phaseRef = useRef(phase);
|
|
const phaseStartedAtRef = useRef(performance.now());
|
|
|
|
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 elapsed = state.clock.getElapsedTime();
|
|
const rawProgress = Math.min(
|
|
(performance.now() - phaseStartedAtRef.current) / 1450,
|
|
1
|
|
);
|
|
const easedProgress = 1 - Math.pow(1 - rawProgress, 3);
|
|
const positionAttribute = geometry.getAttribute("position");
|
|
const positions = positionAttribute.array as Float32Array;
|
|
|
|
for (let index = 0; index < data.count; index += 1) {
|
|
const stride = index * 3;
|
|
const swirl =
|
|
Math.sin(elapsed * 2.4 + index * 0.17) * (1 - easedProgress) * 0.018;
|
|
positions[stride] =
|
|
THREE.MathUtils.lerp(
|
|
data.startPositions[stride],
|
|
data.targetPositions[stride],
|
|
easedProgress
|
|
) +
|
|
swirl;
|
|
positions[stride + 1] =
|
|
THREE.MathUtils.lerp(
|
|
data.startPositions[stride + 1],
|
|
data.targetPositions[stride + 1],
|
|
easedProgress
|
|
) +
|
|
swirl * 0.45;
|
|
positions[stride + 2] =
|
|
THREE.MathUtils.lerp(
|
|
data.startPositions[stride + 2],
|
|
data.targetPositions[stride + 2],
|
|
easedProgress
|
|
) -
|
|
swirl * 0.65;
|
|
}
|
|
|
|
positionAttribute.needsUpdate = true;
|
|
material.opacity = THREE.MathUtils.lerp(0.9, 0, easedProgress);
|
|
material.size = THREE.MathUtils.lerp(0.04, 0.009, easedProgress);
|
|
});
|
|
|
|
return (
|
|
<group rotation={[Math.PI, 0, 0]}>
|
|
<points
|
|
frustumCulled={false}
|
|
ref={pointsRef}
|
|
renderOrder={2}
|
|
>
|
|
<bufferGeometry ref={geometryRef}>
|
|
<bufferAttribute attach="attributes-position" args={[positionBuffer, 3]} />
|
|
<bufferAttribute attach="attributes-color" args={[data.colors, 3]} />
|
|
</bufferGeometry>
|
|
<pointsMaterial
|
|
blending={THREE.AdditiveBlending}
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
ref={materialRef}
|
|
size={0.04}
|
|
sizeAttenuation
|
|
toneMapped={false}
|
|
transparent
|
|
vertexColors
|
|
/>
|
|
</points>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
function WorldSplatLoadingOverlay({
|
|
previewImageUrl,
|
|
phase,
|
|
}: {
|
|
previewImageUrl?: string | null;
|
|
phase: Exclude<SplatLoadingPhase, "hidden">;
|
|
}) {
|
|
const isRevealing = phase === "revealing";
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute inset-0 overflow-hidden transition-all duration-500",
|
|
isRevealing ? "opacity-0" : "opacity-100"
|
|
)}
|
|
>
|
|
{previewImageUrl ? (
|
|
<img
|
|
alt="世界预览"
|
|
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-[8px] saturate-[0.7]"
|
|
src={previewImageUrl}
|
|
/>
|
|
) : null}
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(255,245,220,0.08)_0%,_rgba(11,15,25,0.18)_18%,_rgba(2,6,23,0.72)_54%,_rgba(1,2,6,0.95)_100%)]" />
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(5,10,20,0.16)_0%,rgba(5,10,20,0.78)_100%)]" />
|
|
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(255,248,220,0.045)_48%,transparent_58%)] [background-size:220%_100%] animate-[worldlabs-scan_3.1s_ease-in-out_infinite]" />
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_52%,transparent_0%,transparent_28%,rgba(1,2,6,0.12)_58%,rgba(1,2,6,0.68)_100%)]" />
|
|
<div className="absolute inset-x-0 bottom-0 h-48 bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.92)_100%)]" />
|
|
|
|
<div className="absolute bottom-5 left-5 flex items-end gap-3">
|
|
<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 ? "真实点云正在成形" : "正在载入真实 Gaussian Splats"}</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 ? "点云聚合中" : "SPZ 数据准备中"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
@keyframes worldlabs-scan {
|
|
0% {
|
|
background-position: 120% 0;
|
|
}
|
|
100% {
|
|
background-position: -20% 0;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function createMovementState(): MovementState {
|
|
return { ...EMPTY_MOVEMENT_STATE };
|
|
}
|
|
|
|
function DirectionPad({
|
|
onCommandChange,
|
|
}: {
|
|
onCommandChange: (command: MovementCommand, active: boolean) => void;
|
|
}) {
|
|
const pressHandlers = (command: MovementCommand) => ({
|
|
onMouseDown: () => onCommandChange(command, true),
|
|
onMouseUp: () => onCommandChange(command, false),
|
|
onMouseLeave: () => onCommandChange(command, false),
|
|
onTouchStart: () => onCommandChange(command, true),
|
|
onTouchEnd: () => onCommandChange(command, false),
|
|
onTouchCancel: () => onCommandChange(command, false),
|
|
});
|
|
|
|
return (
|
|
<div className="pointer-events-auto rounded-2xl border border-white/10 bg-black/55 p-2 shadow-2xl backdrop-blur-sm">
|
|
<div className="mb-2 text-center text-[10px] text-zinc-400">方向移动</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div />
|
|
<button
|
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
|
type="button"
|
|
{...pressHandlers("forward")}
|
|
>
|
|
<ArrowUp className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
className="flex h-10 min-w-[3.25rem] items-center justify-center rounded-xl border border-white/10 bg-white/5 px-2 text-xs text-zinc-100 transition hover:bg-white/10"
|
|
type="button"
|
|
{...pressHandlers("up")}
|
|
>
|
|
上
|
|
</button>
|
|
|
|
<button
|
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
|
type="button"
|
|
{...pressHandlers("left")}
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
<div className="flex h-10 items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.03] px-2 text-[10px] text-zinc-500">
|
|
前后左右
|
|
</div>
|
|
<button
|
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
|
type="button"
|
|
{...pressHandlers("right")}
|
|
>
|
|
<ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
|
|
<div />
|
|
<button
|
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
|
|
type="button"
|
|
{...pressHandlers("backward")}
|
|
>
|
|
<ArrowDown className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
className="flex h-10 min-w-[3.25rem] items-center justify-center rounded-xl border border-white/10 bg-white/5 px-2 text-xs text-zinc-100 transition hover:bg-white/10"
|
|
type="button"
|
|
{...pressHandlers("down")}
|
|
>
|
|
下
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CameraMovementControls({
|
|
enabled,
|
|
firstPerson,
|
|
virtualMovement,
|
|
orbitControlsRef,
|
|
}: {
|
|
enabled: boolean;
|
|
firstPerson: boolean;
|
|
virtualMovement: 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();
|
|
return;
|
|
}
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const movement = KEY_TO_MOVEMENT[event.code];
|
|
if (!movement) return;
|
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
if (isEditableKeyboardTarget(event.target)) {
|
|
pressedKeysRef.current = createMovementState();
|
|
return;
|
|
}
|
|
pressedKeysRef.current[movement] = true;
|
|
};
|
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
const movement = KEY_TO_MOVEMENT[event.code];
|
|
if (!movement) return;
|
|
if (isEditableKeyboardTarget(event.target)) {
|
|
pressedKeysRef.current = createMovementState();
|
|
return;
|
|
}
|
|
pressedKeysRef.current[movement] = false;
|
|
};
|
|
const handleFocusIn = (event: FocusEvent) => {
|
|
if (isEditableKeyboardTarget(event.target)) {
|
|
pressedKeysRef.current = createMovementState();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
window.addEventListener("keyup", handleKeyUp);
|
|
window.addEventListener("focusin", handleFocusIn);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
window.removeEventListener("keyup", handleKeyUp);
|
|
window.removeEventListener("focusin", handleFocusIn);
|
|
};
|
|
}, [enabled]);
|
|
|
|
useFrame((_, delta) => {
|
|
if (!enabled) return;
|
|
|
|
const pressed = pressedKeysRef.current;
|
|
const hasMovement =
|
|
Object.values(pressed).some(Boolean) || Object.values(virtualMovement).some(Boolean);
|
|
if (!hasMovement) return;
|
|
|
|
const moveSpeed = 3.8;
|
|
const distance = moveSpeed * delta;
|
|
|
|
camera.getWorldDirection(directionRef.current);
|
|
directionRef.current.setY(0).normalize();
|
|
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
|
|
upRef.current.copy(camera.up).normalize();
|
|
|
|
moveRef.current.set(0, 0, 0);
|
|
|
|
if (pressed.forward || virtualMovement.forward) {
|
|
moveRef.current.add(directionRef.current);
|
|
}
|
|
if (pressed.backward || virtualMovement.backward) {
|
|
moveRef.current.addScaledVector(directionRef.current, -1);
|
|
}
|
|
if (pressed.left || virtualMovement.left) {
|
|
moveRef.current.addScaledVector(rightRef.current, -1);
|
|
}
|
|
if (pressed.right || virtualMovement.right) {
|
|
moveRef.current.add(rightRef.current);
|
|
}
|
|
if (pressed.up || virtualMovement.up) {
|
|
moveRef.current.add(upRef.current);
|
|
}
|
|
if (pressed.down || virtualMovement.down) {
|
|
moveRef.current.addScaledVector(upRef.current, -1);
|
|
}
|
|
|
|
if (moveRef.current.lengthSq() === 0) return;
|
|
|
|
moveRef.current.normalize().multiplyScalar(distance);
|
|
camera.position.add(moveRef.current);
|
|
|
|
if (!firstPerson && orbitControlsRef.current?.target) {
|
|
orbitControlsRef.current.target.add(moveRef.current);
|
|
orbitControlsRef.current.update();
|
|
}
|
|
});
|
|
|
|
if (!enabled || !firstPerson) return null;
|
|
return <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,
|
|
}: {
|
|
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;
|
|
}) {
|
|
const renderer = useThree((state) => state.gl);
|
|
const orbitControlsRef = useRef<any>(null);
|
|
const [virtualMovement, setVirtualMovement] =
|
|
useState<MovementState>(createMovementState);
|
|
const [revealCloudData, setRevealCloudData] =
|
|
useState<SplatRevealCloudData | 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),
|
|
}),
|
|
[renderer]
|
|
);
|
|
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]);
|
|
|
|
useEffect(() => {
|
|
sparkRenderer.frustumCulled = false;
|
|
sparkRenderer.renderOrder = 10;
|
|
sparkRenderer.minAlpha = 0.02;
|
|
sparkRenderer.preBlurAmount = 0.04;
|
|
return () => {
|
|
sparkRenderer.removeFromParent();
|
|
};
|
|
}, [sparkRenderer]);
|
|
|
|
useEffect(() => {
|
|
if (revealPhaseRef.current !== revealPhase) {
|
|
revealPhaseRef.current = revealPhase;
|
|
revealStartedAtRef.current = performance.now();
|
|
}
|
|
}, [revealPhase]);
|
|
|
|
useEffect(() => {
|
|
splatMesh.frustumCulled = false;
|
|
splatMesh.rotation.x = Math.PI;
|
|
splatMesh.opacity = 0;
|
|
sparkRenderer.blurAmount = 0.95;
|
|
sparkRenderer.preBlurAmount = 0.28;
|
|
sparkRenderer.minAlpha = 0.08;
|
|
sparkRenderer.maxStdDev = Math.sqrt(10);
|
|
setRevealCloudData(null);
|
|
|
|
let cancelled = false;
|
|
splatMesh.initialized
|
|
.then(() => {
|
|
if (cancelled) return;
|
|
setRevealCloudData(buildSplatRevealCloudData(splatMesh));
|
|
onLoadStateChange?.("ready");
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) onLoadStateChange?.("error");
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
splatMesh.dispose();
|
|
};
|
|
}, [onLoadStateChange, sparkRenderer, splatMesh]);
|
|
|
|
useFrame(() => {
|
|
const phase = revealPhaseRef.current;
|
|
const revealElapsed =
|
|
phase === "revealing"
|
|
? Math.min((performance.now() - revealStartedAtRef.current) / 1250, 1)
|
|
: 0;
|
|
const easedReveal = 1 - Math.pow(1 - revealElapsed, 3);
|
|
|
|
if (phase === "loading") {
|
|
splatMesh.opacity = 0;
|
|
sparkRenderer.blurAmount = 0.95;
|
|
sparkRenderer.preBlurAmount = 0.28;
|
|
sparkRenderer.minAlpha = 0.08;
|
|
sparkRenderer.maxStdDev = Math.sqrt(10);
|
|
} else if (phase === "revealing") {
|
|
splatMesh.opacity = THREE.MathUtils.lerp(0.02, 1, easedReveal);
|
|
sparkRenderer.blurAmount = THREE.MathUtils.lerp(0.95, 0.16, easedReveal);
|
|
sparkRenderer.preBlurAmount = THREE.MathUtils.lerp(0.28, 0.04, easedReveal);
|
|
sparkRenderer.minAlpha = THREE.MathUtils.lerp(0.08, 0.02, easedReveal);
|
|
sparkRenderer.maxStdDev = THREE.MathUtils.lerp(
|
|
Math.sqrt(10),
|
|
Math.sqrt(7),
|
|
easedReveal
|
|
);
|
|
} else {
|
|
splatMesh.opacity = 1;
|
|
sparkRenderer.blurAmount = 0.16;
|
|
sparkRenderer.preBlurAmount = 0.04;
|
|
sparkRenderer.minAlpha = 0.02;
|
|
sparkRenderer.maxStdDev = Math.sqrt(7);
|
|
}
|
|
|
|
sparkRenderer.needsUpdate = true;
|
|
});
|
|
|
|
const groundPlaneOffset =
|
|
typeof semanticsMetadata?.ground_plane_offset === "number"
|
|
? -semanticsMetadata.ground_plane_offset
|
|
: null;
|
|
|
|
const handleDirectionChange = (command: MovementCommand, active: boolean) => {
|
|
setVirtualMovement((prev) => {
|
|
if (prev[command] === active) return prev;
|
|
return {
|
|
...prev,
|
|
[command]: active,
|
|
};
|
|
});
|
|
};
|
|
|
|
const handlePointerDown = (event: ThreeEvent<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}
|
|
{revealPhase !== "hidden" && revealCloudData ? (
|
|
<SplatRevealCloud data={revealCloudData} phase={revealPhase} />
|
|
) : null}
|
|
<Suspense fallback={null}>
|
|
<primitive object={sparkRenderer}>
|
|
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
|
|
</primitive>
|
|
</Suspense>
|
|
{hotspots.map((hotspot) => (
|
|
<HotspotBillboard
|
|
hotspot={hotspot}
|
|
key={hotspot.id}
|
|
onSelect={onHotspotSelect}
|
|
/>
|
|
))}
|
|
<CameraMovementControls
|
|
enabled={interactionMode === "navigate"}
|
|
firstPerson={firstPerson}
|
|
orbitControlsRef={orbitControlsRef}
|
|
virtualMovement={virtualMovement}
|
|
/>
|
|
{!firstPerson ? (
|
|
<OrbitControls
|
|
autoRotate={false}
|
|
enablePan
|
|
enableRotate
|
|
enableZoom
|
|
makeDefault
|
|
maxDistance={16}
|
|
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>
|
|
<div className="flex items-end justify-between">
|
|
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-zinc-300 backdrop-blur-sm">
|
|
{firstPerson
|
|
? "方向键 / WASD 前后左右 · Q/E 或 PgUp/PgDn 上下 · 鼠标观察 · Esc 退出"
|
|
: "拖拽旋转 · 滚轮缩放 · 方向键 / 按钮可做六向移动"}
|
|
</div>
|
|
<div className="flex items-end gap-3">
|
|
{interactionMode === "navigate" ? (
|
|
<DirectionPad onCommandChange={handleDirectionChange} />
|
|
) : null}
|
|
{(firstPerson || interactionMode === "hotspot") && (
|
|
<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>
|
|
</div>
|
|
</Html>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function WorldSplatViewer({
|
|
splatUrl,
|
|
previewImageUrl,
|
|
firstPerson,
|
|
hotspots,
|
|
interactionMode,
|
|
onPick,
|
|
onHotspotSelect,
|
|
semanticsMetadata,
|
|
}: {
|
|
splatUrl?: string | null;
|
|
previewImageUrl?: string | null;
|
|
firstPerson?: boolean;
|
|
hotspots?: World3DHotspot[];
|
|
interactionMode?: "navigate" | "hotspot";
|
|
onPick?: (pick: World3DPickResult) => void;
|
|
onHotspotSelect?: (hotspotId: string) => void;
|
|
semanticsMetadata?: Record<string, unknown> | null;
|
|
}) {
|
|
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");
|
|
const timer = window.setTimeout(() => {
|
|
setOverlayPhase("hidden");
|
|
}, 1250);
|
|
return () => window.clearTimeout(timer);
|
|
}
|
|
|
|
if (loadState === "loading") {
|
|
setOverlayPhase("loading");
|
|
}
|
|
|
|
if (loadState === "error") {
|
|
setOverlayPhase("hidden");
|
|
}
|
|
}, [loadState]);
|
|
|
|
if (!splatUrl) {
|
|
return (
|
|
<WorldEmptyState
|
|
description="当前结果没有返回可用的 SPZ 世界资产。"
|
|
icon={Sparkles}
|
|
previewImageUrl={previewImageUrl}
|
|
title="准备加载 Splat 世界"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (loadState === "error") {
|
|
return (
|
|
<WorldEmptyState
|
|
description="SPZ 资源暂时无法加载,可以切换到 World、图像或 Mesh 视图继续查看。"
|
|
icon={AlertCircle}
|
|
previewImageUrl={previewImageUrl}
|
|
title="Splat 视图加载失败"
|
|
/>
|
|
);
|
|
}
|
|
|
|
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}
|
|
revealPhase={overlayPhase}
|
|
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,
|
|
}: 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}
|
|
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="World Labs 世界预览"
|
|
/>
|
|
) : (
|
|
<WorldEmptyState
|
|
description="当前结果还没有可嵌入的 World 页面链接,可以先切换到 Splat、图像或 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="当前结果还没有可用的图像资产,可以先切换到 Splat 或 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}
|
|
semanticsMetadata={semanticsMetadata}
|
|
splatUrl={splatUrl}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|