ai-chatbot/components/canvas/model3d/Model3DViewer.tsx
Your Name f6979f0196
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 完善无限画布3D生成与实时画布能力
2026-02-28 22:33:16 +08:00

284 lines
7.9 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 React, { Suspense, useEffect, useMemo } from "react";
import { Canvas } from "@react-three/fiber";
import { Environment, Grid, OrbitControls, useGLTF } from "@react-three/drei";
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Mesh, Object3D } from "three";
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
src={previewImageUrl}
alt="3D 预览"
className="max-h-[65%] max-w-full rounded-lg border object-contain"
/>
) : (
<Box className="h-14 w-14 text-muted-foreground/60" />
)}
<p className="text-sm text-muted-foreground">生成完成后可在此区域预览 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-sm text-muted-foreground">
当前文件格式不支持内嵌预览,请在新窗口打开
</p>
<Button asChild variant="secondary" className="gap-2">
<a href={modelUrl} target="_blank" rel="noreferrer">
<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-sm text-muted-foreground">3D 预览加载失败,请尝试在新窗口打开</p>
<Button asChild variant="secondary" className="gap-2">
<a href={modelUrl} target="_blank" rel="noreferrer">
<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 position={[5, 8, 5]} intensity={1.2 * settings.lightIntensity} />
{settings.showGrid ? (
<Grid
position={[0, -0.01, 0]}
args={[12, 12]}
cellColor="#8c8c8c"
sectionColor="#525252"
cellSize={0.5}
sectionSize={2}
fadeDistance={40}
fadeStrength={1.5}
/>
) : null}
<Suspense
fallback={
<mesh>
<boxGeometry args={[0.6, 0.6, 0.6]} />
<meshStandardMaterial color="#94a3b8" wireframe />
</mesh>
}
>
<ModelScene
url={modelUrl}
settings={settings}
layerVisibility={layerVisibility}
onLayersChange={onLayersChange}
onStatsChange={onStatsChange}
/>
<Environment preset={settings.envPreset} />
</Suspense>
<OrbitControls enablePan enableZoom enableRotate autoRotate={settings.autoRotate} />
</Canvas>
<div className="pointer-events-none absolute left-3 top-3 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3" />
拖拽旋转 / 滚轮缩放
</span>
</div>
</ViewerErrorBoundary>
);
}