ai-chatbot/components/canvas/model3d/Model3DViewer.tsx
Your Name 507615e984
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 调整3D生成前端界面与预览交互
2026-02-28 23:24:43 +08:00

294 lines
7.8 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";
import { Environment, Grid, OrbitControls, useGLTF } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { AlertCircle, Box, ExternalLink } from "lucide-react";
import React, { Suspense, useEffect, useMemo } from "react";
import type { Mesh, Object3D } from "three";
import { Button } from "@/components/ui/button";
export interface Model3DLayer {
id: string;
name: string;
triangles: number;
vertices: number;
}
export interface Model3DStats {
triangles: number;
vertices: number;
meshCount: number;
}
export interface Model3DViewerSettings {
envPreset:
| "city"
| "sunset"
| "studio"
| "dawn"
| "night"
| "warehouse"
| "forest"
| "apartment"
| "park"
| "lobby";
lightIntensity: number;
autoRotate: boolean;
showGrid: boolean;
wireframe: boolean;
}
interface Model3DViewerProps {
modelUrl?: string | null;
previewImageUrl?: string | null;
settings: Model3DViewerSettings;
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
}
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-3 bg-muted/20 p-4">
{previewImageUrl ? (
<img
alt="3D 预览"
className="max-h-[65%] max-w-full rounded-lg border object-contain"
src={previewImageUrl}
/>
) : (
<Box className="h-14 w-14 text-muted-foreground/60" />
)}
<p className="text-muted-foreground text-sm">
生成完成后可在此区域预览 3D 资产
</p>
</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-muted/20 p-4">
<AlertCircle className="h-8 w-8 text-amber-500" />
<p className="text-muted-foreground text-sm">
当前文件格式不支持内嵌预览,请在新窗口打开
</p>
<Button asChild className="gap-2" variant="secondary">
<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-muted/20 p-4">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-muted-foreground text-sm">
3D 预览加载失败,请尝试在新窗口打开
</p>
<Button asChild className="gap-2" variant="secondary">
<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 }}>
<ambientLight intensity={0.5 * settings.lightIntensity} />
<directionalLight
intensity={1.2 * settings.lightIntensity}
position={[5, 8, 5]}
/>
{settings.showGrid ? (
<Grid
args={[12, 12]}
cellColor="#8c8c8c"
cellSize={0.5}
fadeDistance={40}
fadeStrength={1.5}
position={[0, -0.01, 0]}
sectionColor="#525252"
sectionSize={2}
/>
) : null}
<Suspense
fallback={
<mesh>
<boxGeometry args={[0.6, 0.6, 0.6]} />
<meshStandardMaterial color="#94a3b8" wireframe />
</mesh>
}
>
<ModelScene
layerVisibility={layerVisibility}
onLayersChange={onLayersChange}
onStatsChange={onStatsChange}
settings={settings}
url={modelUrl}
/>
<Environment preset={settings.envPreset} />
</Suspense>
<OrbitControls
autoRotate={settings.autoRotate}
enablePan
enableRotate
enableZoom
/>
</Canvas>
</ViewerErrorBoundary>
);
}