"use client"; /* eslint-disable @next/next/no-img-element */ import { ContactShadows, Environment, Grid, OrbitControls, useGLTF, } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react"; import React, { Suspense, useEffect, useMemo } from "react"; import type { Mesh, Object3D } from "three"; import { Button } from "@/components/ui/button"; export type Model3DLayer = { id: string; name: string; triangles: number; vertices: number; }; export type Model3DStats = { triangles: number; vertices: number; meshCount: number; }; export type Model3DViewerSettings = { 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; settings: Model3DViewerSettings; layerVisibility?: Record; onLayersChange?: (layers: Model3DLayer[]) => void; onStatsChange?: (stats: Model3DStats) => void; }; // 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 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 ; } function isGlbOrGltf(url?: string | null) { const value = String(url || "").toLowerCase(); return /\.(gltf|glb)(\?|#|$)/.test(value); } function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) { return (
{previewImageUrl ? (
3D 预览
) : (

准备生成 3D 资产

)}
); } export function Model3DViewer({ modelUrl, previewImageUrl, settings, layerVisibility, onLayersChange, onStatsChange, }: Model3DViewerProps) { const resolvedModelUrl = useMemo(() => { const normalized = String(modelUrl || "").trim(); if (!normalized) return ""; try { const parsed = new URL(normalized); const hostname = parsed.hostname.toLowerCase(); if (hostname === "tripo3d.com" || hostname.endsWith(".tripo3d.com")) { return `/api/canvas/3d/model-proxy?url=${encodeURIComponent(normalized)}`; } } catch { // keep original url when parse fails (e.g. relative path) } return normalized; }, [modelUrl]); if (!modelUrl) { return ; } if (!isGlbOrGltf(modelUrl)) { return (

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

); } return (

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

} > {settings.showGrid ? ( ) : null} } >
); }