"use client"; /* eslint-disable @next/next/no-img-element */ import { ContactShadows, Environment, Grid, OrbitControls, useGLTF, } from "@react-three/drei"; import { Canvas, useFrame, 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; modelRotation?: [number, number, number]; showMovementControls?: boolean; layerVisibility?: Record; onLayersChange?: (layers: Model3DLayer[]) => void; onStatsChange?: (stats: Model3DStats) => void; resetViewToken?: number; onCaptureReady?: (capture: (() => Promise) | null) => void; }; type MovementCommand = "forward" | "backward" | "left" | "right" | "up" | "down"; type MovementState = Record; const EMPTY_MOVEMENT_STATE: MovementState = { forward: false, backward: false, left: false, right: false, up: false, down: false, }; const KEY_TO_MOVEMENT: Partial> = { KeyW: "forward", ArrowUp: "forward", KeyS: "backward", ArrowDown: "backward", KeyA: "left", ArrowLeft: "left", KeyD: "right", ArrowRight: "right", KeyQ: "up", PageUp: "up", KeyE: "down", PageDown: "down", }; 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 createMovementState(): MovementState { return { ...EMPTY_MOVEMENT_STATE }; } function isEditableKeyboardTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true; const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']"; if (target.matches(editableSelector)) return true; return Boolean(target.closest(editableSelector)); } 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, modelRotation, layerVisibility, onLayersChange, onStatsChange, }: { url: string; settings: Model3DViewerSettings; modelRotation?: [number, number, number]; 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 ModelMovementControls({ enabled, movementStateRef, orbitControlsRef, }: { enabled: boolean; movementStateRef: React.MutableRefObject; orbitControlsRef: React.MutableRefObject; }) { const { camera } = useThree(); const directionRef = useRef(new THREE.Vector3()); const rightRef = useRef(new THREE.Vector3()); const upRef = useRef(new THREE.Vector3()); const moveRef = useRef(new THREE.Vector3()); useEffect(() => { if (!enabled) { movementStateRef.current = createMovementState(); return; } const handleKeyDown = (event: KeyboardEvent) => { const movement = KEY_TO_MOVEMENT[event.code]; if (!movement) return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (isEditableKeyboardTarget(event.target)) { movementStateRef.current = createMovementState(); return; } event.preventDefault(); movementStateRef.current[movement] = true; }; const handleKeyUp = (event: KeyboardEvent) => { const movement = KEY_TO_MOVEMENT[event.code]; if (!movement) return; event.preventDefault(); movementStateRef.current[movement] = false; }; const handleBlur = () => { movementStateRef.current = createMovementState(); }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); window.addEventListener("blur", handleBlur); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", handleBlur); }; }, [enabled, movementStateRef]); useFrame((_, delta) => { if (!enabled) return; const pressed = movementStateRef.current; if (!Object.values(pressed).some(Boolean)) return; camera.getWorldDirection(directionRef.current); directionRef.current.setY(0); if (directionRef.current.lengthSq() < 0.0001) { directionRef.current.set(0, 0, -1); } directionRef.current.normalize(); rightRef.current.crossVectors(directionRef.current, camera.up).normalize(); upRef.current.copy(camera.up).normalize(); moveRef.current.set(0, 0, 0); if (pressed.forward) moveRef.current.add(directionRef.current); if (pressed.backward) moveRef.current.addScaledVector(directionRef.current, -1); if (pressed.left) moveRef.current.addScaledVector(rightRef.current, -1); if (pressed.right) moveRef.current.add(rightRef.current); if (pressed.up) moveRef.current.add(upRef.current); if (pressed.down) moveRef.current.addScaledVector(upRef.current, -1); if (moveRef.current.lengthSq() === 0) return; const distance = 2.6 * delta; moveRef.current.normalize().multiplyScalar(distance); camera.position.add(moveRef.current); if (orbitControlsRef.current?.target) { orbitControlsRef.current.target.add(moveRef.current); orbitControlsRef.current.update?.(); } }); return null; } function ModelMovementPad({ movementStateRef, }: { movementStateRef: React.MutableRefObject; }) { const setPressed = React.useCallback( (command: MovementCommand, pressed: boolean) => { movementStateRef.current[command] = pressed; }, [movementStateRef] ); const clearMovement = React.useCallback(() => { movementStateRef.current = createMovementState(); }, [movementStateRef]); const buttonClass = "flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95"; const buttonProps = (command: MovementCommand) => ({ "aria-label": command, className: buttonClass, onPointerDown: (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture?.(event.pointerId); setPressed(command, true); }, onPointerUp: (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); setPressed(command, false); }, onPointerCancel: (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); clearMovement(); }, onPointerLeave: (event: React.PointerEvent) => { event.stopPropagation(); setPressed(command, false); }, type: "button" as const, }); return (
event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} >
); } 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, modelRotation, showMovementControls = false, layerVisibility, onLayersChange, onStatsChange, resetViewToken, onCaptureReady, }: Model3DViewerProps) { const controlsRef = useRef(null); const movementStateRef = useRef(createMovementState()); 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} } > {showMovementControls ? : null}
); }