ai-chatbot/components/canvas/model3d/Model3DViewer.tsx
Your Name d272bd5ee8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(canvas): add Tripo3D generation workspace and provider config
2026-02-28 21:57:51 +08:00

132 lines
4.2 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, useMemo } from "react";
import { Canvas } from "@react-three/fiber";
import { Environment, OrbitControls, useGLTF } from "@react-three/drei";
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Model3DViewerProps {
modelUrl?: string | null;
previewImageUrl?: string | null;
}
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 ModelScene({ url }: { url: string }) {
const model = useGLTF(url);
const scene = useMemo(() => model.scene.clone(), [model.scene]);
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 }: 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.8} />
<directionalLight position={[5, 8, 5]} intensity={1.2} />
<Suspense
fallback={
<mesh>
<boxGeometry args={[0.6, 0.6, 0.6]} />
<meshStandardMaterial color="#94a3b8" wireframe />
</mesh>
}
>
<ModelScene url={modelUrl} />
<Environment preset="city" />
</Suspense>
<OrbitControls enablePan enableZoom enableRotate />
</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>
);
}