ai-chatbot/components/canvas/model3d/Model3DViewer.tsx
Your Name 89ec01cfb8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: restore 3d pbr textures in canvas viewer
2026-05-14 10:48:39 +08:00

498 lines
13 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, useThree } from "@react-three/fiber";
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
import React, { Suspense, useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import type { Mesh, Object3D } from "three";
import { KTX2Loader } from "three-stdlib";
import { Button } from "@/components/ui/button";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
export type Model3DLayer = {
id: string;
name: string;
triangles: number;
vertices: number;
};
export type Model3DStats = {
triangles: number;
vertices: number;
meshCount: number;
};
export type Model3DCameraPreset = "front" | "iso" | "right" | "top";
export type Model3DViewerSettings = {
cameraPreset: Model3DCameraPreset;
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;
emptyStateTitle?: string;
settings: Model3DViewerSettings;
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
};
const CAMERA_PRESET_POSITIONS: Record<
Model3DCameraPreset,
[number, number, number]
> = {
front: [0, 1.1, 3.2],
iso: [2, 1.5, 2],
right: [3.2, 1.1, 0],
top: [0, 3.4, 0.001],
};
const KTX2_TRANSCODER_PATH = "/three/basis/";
function CameraPresetController({
controlsRef,
preset,
resetToken,
}: {
controlsRef: React.MutableRefObject<any>;
preset: Model3DCameraPreset;
resetToken?: number;
}) {
const { camera } = useThree();
useEffect(() => {
const [x, y, z] =
CAMERA_PRESET_POSITIONS[preset] || CAMERA_PRESET_POSITIONS.iso;
camera.position.set(x, y, z);
if (preset === "top") {
camera.up.set(0, 0, -1);
} else {
camera.up.set(0, 1, 0);
}
camera.lookAt(0, 0, 0);
const controls = controlsRef.current;
if (controls) {
controls.target.set(0, 0, 0);
controls.update();
}
}, [camera, controlsRef, preset, resetToken]);
return null;
}
// 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 { gl } = useThree();
const ktx2Loader = useMemo(() => {
const nextLoader = new KTX2Loader();
nextLoader.setTranscoderPath(KTX2_TRANSCODER_PATH);
nextLoader.detectSupport(gl);
return nextLoader;
}, [gl]);
useEffect(() => {
return () => {
ktx2Loader.dispose();
};
}, [ktx2Loader]);
const model = useGLTF(
url,
true,
true,
(loader) => {
loader.setKTX2Loader(ktx2Loader);
},
);
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) {
mesh.castShadow = true;
mesh.receiveShadow = true;
const materials = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
for (const material of materials) {
if (!material) continue;
const textureLikeKeys = ["map", "emissiveMap"] as const;
for (const key of textureLikeKeys) {
const texture = (material as any)?.[key];
if (texture && "colorSpace" in texture) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
}
}
if ("wireframe" in material) {
(material as any).wireframe = settings.wireframe;
}
material.needsUpdate = true;
}
}
}, [meta.meshRefs, settings.wireframe]);
return <primitive object={scene} scale={1} />;
}
function ViewerCaptureBridge({
onCaptureReady,
}: {
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
}) {
const { camera, gl, scene } = useThree();
useEffect(() => {
if (!onCaptureReady) return;
onCaptureReady(async () => {
const canvas = gl.domElement;
gl.render(scene, camera);
const blob = await new Promise<Blob | null>((resolve) => {
if (typeof canvas.toBlob === "function") {
canvas.toBlob(resolve, "image/png");
return;
}
try {
const dataUrl = canvas.toDataURL("image/png");
fetch(dataUrl)
.then((response) => response.blob())
.then((result) => resolve(result))
.catch(() => resolve(null));
} catch {
resolve(null);
}
});
return blob;
});
return () => onCaptureReady(null);
}, [camera, gl, onCaptureReady, scene]);
return null;
}
function isGlbOrGltf(url?: string | null) {
const value = String(url || "").toLowerCase();
return /\.(gltf|glb)(\?|#|$)/.test(value);
}
function EmptyState({
previewImageUrl,
title,
}: {
previewImageUrl?: string | null;
title: string;
}) {
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">{title}</p>
</div>
)}
</div>
);
}
export function Model3DViewer({
modelUrl,
previewImageUrl,
emptyStateTitle = "准备生成 3D 资产",
settings,
layerVisibility,
onLayersChange,
onStatsChange,
resetViewToken,
onCaptureReady,
}: Model3DViewerProps) {
const controlsRef = useRef<any>(null);
const resolvedModelUrl = useMemo(() => {
return buildThreeAssetUrl(modelUrl);
}, [modelUrl]);
if (!modelUrl) {
return (
<EmptyState previewImageUrl={previewImageUrl} title={emptyStateTitle} />
);
}
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:
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
CAMERA_PRESET_POSITIONS.iso,
fov: 45,
}}
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
gl={{ preserveDrawingBuffer: true }}
onCreated={({ gl }) => {
gl.outputColorSpace = THREE.SRGBColorSpace;
gl.toneMapping = THREE.ACESFilmicToneMapping;
gl.toneMappingExposure = 1;
}}
>
<ViewerCaptureBridge onCaptureReady={onCaptureReady} />
<CameraPresetController
controlsRef={controlsRef}
preset={settings.cameraPreset}
resetToken={resetViewToken}
/>
<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={resolvedModelUrl}
/>
<ContactShadows
blur={2}
color="#000000"
far={4}
opacity={0.4}
resolution={256}
scale={10}
/>
</group>
<Environment preset={settings.envPreset} />
</Suspense>
<OrbitControls
autoRotate={settings.autoRotate}
dampingFactor={0.08}
enableDamping
enablePan={false}
enableRotate
enableZoom
makeDefault
maxPolarAngle={Math.PI / 1.75}
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE,
}}
minPolarAngle={0}
ref={controlsRef}
/>
</Canvas>
</ViewerErrorBoundary>
);
}