"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; onLayersChange?: (layers: Model3DLayer[]) => void; onStatsChange?: (stats: Model3DStats) => void; resetViewToken?: number; onCaptureReady?: (capture: (() => Promise) | 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; 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; 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 ; } function ViewerCaptureBridge({ onCaptureReady, }: { onCaptureReady?: (capture: (() => Promise) | 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((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 (
{previewImageUrl ? (
3D 预览
) : (

{title}

)}
); } export function Model3DViewer({ modelUrl, previewImageUrl, emptyStateTitle = "准备生成 3D 资产", settings, layerVisibility, onLayersChange, onStatsChange, resetViewToken, onCaptureReady, }: Model3DViewerProps) { const controlsRef = useRef(null); const resolvedModelUrl = useMemo(() => { return buildThreeAssetUrl(modelUrl); }, [modelUrl]); if (!modelUrl) { return ( ); } if (!isGlbOrGltf(modelUrl)) { return (

当前文件格式不支持内嵌预览,请在新窗口打开

); } return (

3D 预览加载失败,请尝试在新窗口打开

} > { gl.outputColorSpace = THREE.SRGBColorSpace; gl.toneMapping = THREE.ACESFilmicToneMapping; gl.toneMappingExposure = 1; }} > {settings.showGrid ? ( ) : null} } >
); }