ai-chatbot/components/canvas/model3d/Model3DViewer.tsx
Your Name bff8ffa100
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: sync 3d canvas updates and block trackpad history swipe
2026-03-04 01:59:23 +08:00

332 lines
9.0 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 {
ContactShadows,
Environment,
Grid,
OrbitControls,
useGLTF,
} from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
import React, { Suspense, useEffect, useMemo } from "react";
import type { Mesh, Object3D } from "three";
import { Button } from "@/components/ui/button";
export type Model3DLayer = {
id: string;
name: string;
triangles: number;
vertices: number;
};
export type Model3DStats = {
triangles: number;
vertices: number;
meshCount: number;
};
export type Model3DViewerSettings = {
envPreset:
| "city"
| "sunset"
| "studio"
| "dawn"
| "night"
| "warehouse"
| "forest"
| "apartment"
| "park"
| "lobby";
lightIntensity: number;
autoRotate: boolean;
showGrid: boolean;
wireframe: boolean;
};
type Model3DViewerProps = {
modelUrl?: string | null;
previewImageUrl?: string | null;
settings: Model3DViewerSettings;
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
};
// eslint-disable-next-line
class ViewerErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidUpdate(prevProps: { children: React.ReactNode }) {
if (prevProps.children !== this.props.children && this.state.hasError) {
this.setState({ hasError: false });
}
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function isMesh(node: Object3D): node is Mesh {
return (node as Mesh).isMesh === true;
}
function collectSceneMeta(scene: Object3D): {
layers: Model3DLayer[];
stats: Model3DStats;
meshRefs: Array<{ layerId: string; mesh: Mesh }>;
} {
const layers: Model3DLayer[] = [];
const meshRefs: Array<{ layerId: string; mesh: Mesh }> = [];
let triangles = 0;
let vertices = 0;
let meshCount = 0;
let meshIndex = 0;
scene.traverse((node) => {
if (!isMesh(node)) return;
const geometry = node.geometry;
if (!geometry) return;
const id = `${node.name || "Mesh"}__${meshIndex}`;
meshIndex += 1;
const indexCount = geometry.index?.count || 0;
const positionCount = geometry.attributes.position?.count || 0;
const meshTriangles =
indexCount > 0
? Math.floor(indexCount / 3)
: Math.floor(positionCount / 3);
const meshVertices = positionCount;
triangles += meshTriangles;
vertices += meshVertices;
meshCount += 1;
layers.push({
id,
name: node.name?.trim() || `mesh_${meshCount}`,
triangles: meshTriangles,
vertices: meshVertices,
});
meshRefs.push({ layerId: id, mesh: node });
});
return {
layers,
stats: {
triangles,
vertices,
meshCount,
},
meshRefs,
};
}
function ModelScene({
url,
settings,
layerVisibility,
onLayersChange,
onStatsChange,
}: {
url: string;
settings: Model3DViewerSettings;
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
}) {
const model = useGLTF(url);
const scene = useMemo(() => model.scene.clone(), [model.scene]);
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
useEffect(() => {
onLayersChange?.(meta.layers);
onStatsChange?.(meta.stats);
}, [meta.layers, meta.stats, onLayersChange, onStatsChange]);
useEffect(() => {
for (const { layerId, mesh } of meta.meshRefs) {
const nextVisible = layerVisibility?.[layerId];
mesh.visible = nextVisible !== false;
}
}, [layerVisibility, meta.meshRefs]);
useEffect(() => {
for (const { mesh } of meta.meshRefs) {
const materials = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
for (const material of materials) {
if (!material) continue;
if ("wireframe" in material) {
(material as any).wireframe = settings.wireframe;
material.needsUpdate = true;
}
}
}
}, [meta.meshRefs, settings.wireframe]);
return <primitive object={scene} scale={1} />;
}
function isGlbOrGltf(url?: string | null) {
const value = String(url || "").toLowerCase();
return /\.(gltf|glb)(\?|#|$)/.test(value);
}
function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/50 p-4">
{previewImageUrl ? (
<div className="relative aspect-square w-64 overflow-hidden rounded-xl border border-white/10 bg-zinc-900/50 shadow-2xl">
<img
alt="3D 预览"
className="h-full w-full object-cover opacity-50 transition-opacity hover:opacity-100"
src={previewImageUrl}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[1px]">
<Loader2 className="h-8 w-8 animate-spin text-white/50" />
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 text-zinc-500">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/50 shadow-inner">
<Box className="h-10 w-10 opacity-20" />
</div>
<p className="font-medium text-sm">准备生成 3D 资产</p>
</div>
)}
</div>
);
}
export function Model3DViewer({
modelUrl,
previewImageUrl,
settings,
layerVisibility,
onLayersChange,
onStatsChange,
}: Model3DViewerProps) {
if (!modelUrl) {
return <EmptyState previewImageUrl={previewImageUrl} />;
}
if (!isGlbOrGltf(modelUrl)) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-zinc-950 p-4 text-zinc-400">
<AlertCircle className="h-8 w-8 text-yellow-500/50" />
<p className="text-sm">当前文件格式不支持内嵌预览,请在新窗口打开</p>
<Button
asChild
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
variant="outline"
>
<a href={modelUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
打开3D文件
</a>
</Button>
</div>
);
}
return (
<ViewerErrorBoundary
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-zinc-950 p-4 text-zinc-400">
<AlertCircle className="h-8 w-8 text-red-500/50" />
<p className="text-sm">3D 预览加载失败,请尝试在新窗口打开</p>
<Button
asChild
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
variant="outline"
>
<a href={modelUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
打开3D文件
</a>
</Button>
</div>
}
>
<Canvas
camera={{ position: [2, 1.5, 2], fov: 45 }}
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
>
<ambientLight intensity={0.5 * settings.lightIntensity} />
<directionalLight
intensity={1.2 * settings.lightIntensity}
position={[5, 8, 5]}
shadow-bias={-0.0001}
/>
{settings.showGrid ? (
<Grid
args={[20, 20]}
cellColor="#3f3f46"
cellSize={0.5}
fadeDistance={30}
fadeStrength={1}
position={[0, -0.01, 0]}
sectionColor="#52525b"
sectionSize={2}
/>
) : null}
<Suspense
fallback={
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#27272a" wireframe />
</mesh>
}
>
<group position={[0, 0, 0]}>
<ModelScene
layerVisibility={layerVisibility}
onLayersChange={onLayersChange}
onStatsChange={onStatsChange}
settings={settings}
url={modelUrl}
/>
<ContactShadows
blur={2}
color="#000000"
far={4}
opacity={0.4}
resolution={256}
scale={10}
/>
</group>
<Environment preset={settings.envPreset} />
</Suspense>
<OrbitControls
autoRotate={settings.autoRotate}
enablePan
enableRotate
enableZoom
makeDefault
maxPolarAngle={Math.PI / 1.75}
minPolarAngle={0}
/>
</Canvas>
</ViewerErrorBoundary>
);
}