132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|