ai-chatbot/components/canvas/world3d/World3DViewer.tsx
Your Name 67602b0af7
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add native world3d canvas workflow
2026-04-04 22:59:00 +08:00

535 lines
16 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,
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 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,
};
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>
);
}
function FirstPersonMovement({ enabled }: { enabled: boolean }) {
const { camera } = useThree();
const pressedKeysRef = useRef<Record<string, boolean>>({});
const directionRef = useRef(new THREE.Vector3());
const rightRef = useRef(new THREE.Vector3());
const upRef = useRef(new THREE.Vector3());
useEffect(() => {
if (!enabled) {
pressedKeysRef.current = {};
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
pressedKeysRef.current[event.code] = true;
};
const handleKeyUp = (event: KeyboardEvent) => {
pressedKeysRef.current[event.code] = false;
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [enabled]);
useFrame((_, delta) => {
if (!enabled) return;
const pressed = pressedKeysRef.current;
const moveSpeed = pressed.ShiftLeft || pressed.ShiftRight ? 6.5 : 3.4;
const distance = moveSpeed * delta;
camera.getWorldDirection(directionRef.current);
directionRef.current.normalize();
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
upRef.current.copy(camera.up).normalize();
if (pressed.KeyW) camera.position.addScaledVector(directionRef.current, distance);
if (pressed.KeyS) camera.position.addScaledVector(directionRef.current, -distance);
if (pressed.KeyA) camera.position.addScaledVector(rightRef.current, -distance);
if (pressed.KeyD) camera.position.addScaledVector(rightRef.current, distance);
if (pressed.KeyQ) camera.position.addScaledVector(upRef.current, distance);
if (pressed.KeyE) camera.position.addScaledVector(upRef.current, -distance);
});
if (!enabled) 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,
firstPerson = false,
hotspots = [],
interactionMode = "navigate",
onPick,
onHotspotSelect,
semanticsMetadata,
onLoadStateChange,
}: {
splatUrl: string;
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 semanticFacts = useMemo(
() => getSemanticFacts(semanticsMetadata),
[semanticsMetadata]
);
const sparkRenderer = useMemo(
() =>
new SparkRenderer({
renderer,
blurAmount: 0.2,
maxStdDev: Math.sqrt(7),
}),
[renderer]
);
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]);
useEffect(() => {
sparkRenderer.frustumCulled = false;
sparkRenderer.renderOrder = 10;
return () => {
sparkRenderer.removeFromParent();
};
}, [sparkRenderer]);
useEffect(() => {
splatMesh.frustumCulled = false;
let cancelled = false;
splatMesh.initialized
.then(() => {
if (!cancelled) onLoadStateChange?.("ready");
})
.catch(() => {
if (!cancelled) onLoadStateChange?.("error");
});
return () => {
cancelled = true;
splatMesh.dispose();
};
}, [onLoadStateChange, splatMesh]);
useFrame(() => {
sparkRenderer.needsUpdate = true;
});
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
? semanticsMetadata.ground_plane_offset
: null;
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>
{hotspots.map((hotspot) => (
<HotspotBillboard
hotspot={hotspot}
key={hotspot.id}
onSelect={onHotspotSelect}
/>
))}
{!firstPerson ? (
<OrbitControls
autoRotate={false}
enablePan
enableRotate
enableZoom
makeDefault
maxDistance={16}
minDistance={0.5}
/>
) : (
<FirstPersonMovement enabled />
)}
<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 移动 · 鼠标观察 · Shift 加速 · Esc 退出" : "拖拽旋转 · 滚轮缩放 · 原生 SPZ 渲染"}
</div>
{(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>
</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");
useEffect(() => {
setLoadState("loading");
}, [splatUrl]);
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}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}
/>
</Canvas>
{loadState === "loading" ? (
<div className="absolute inset-0">
<WorldEmptyState
description="正在初始化 Gaussian Splat 资源,这一步会比普通 Mesh 略慢。"
icon={Sparkles}
previewImageUrl={previewImageUrl}
title="正在加载 Splat 世界"
/>
</div>
) : 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>
);
}