import { useEffect, useRef } from "react"; import * as THREE from "three"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { OBJLoader } from "three/addons/loaders/OBJLoader.js"; import { FBXLoader } from "three/addons/loaders/FBXLoader.js"; import { PLYLoader } from "three/addons/loaders/PLYLoader.js"; import { STLLoader } from "three/addons/loaders/STLLoader.js"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; export default function Viewer({ url, ext }) { const mountRef = useRef(null); useEffect(() => { const mount = mountRef.current; if (!mount || !url) return; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x101014); const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 1000); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.outputColorSpace = THREE.SRGBColorSpace; mount.appendChild(renderer.domElement); const resize = () => { const w = mount.clientWidth, h = mount.clientHeight; renderer.setSize(w, h); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); camera.aspect = w / h; camera.updateProjectionMatrix(); }; resize(); const ro = new ResizeObserver(resize); ro.observe(mount); scene.add(new THREE.HemisphereLight(0xffffff, 0x334, 1.2)); const dir = new THREE.DirectionalLight(0xffffff, 2.0); dir.position.set(3, 5, 4); scene.add(dir); const grid = new THREE.GridHelper(10, 20, 0x333344, 0x22222c); scene.add(grid); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; let mixer = null; const clock = new THREE.Clock(); const frame = (obj) => { const box = new THREE.Box3().setFromObject(obj); const size = box.getSize(new THREE.Vector3()).length() || 1; const center = box.getCenter(new THREE.Vector3()); obj.position.sub(center); obj.position.y += (box.getSize(new THREE.Vector3()).y / 2); camera.position.set(size * 0.7, size * 0.5, size * 0.9); controls.target.set(0, box.getSize(new THREE.Vector3()).y / 4, 0); camera.near = size / 100; camera.far = size * 20; camera.updateProjectionMatrix(); }; const addDefaultMaterial = (geometry) => { const hasColor = !!geometry.attributes.color; const mat = new THREE.MeshStandardMaterial({ color: hasColor ? 0xffffff : 0x8899bb, vertexColors: hasColor, roughness: 0.7, }); const mesh = new THREE.Mesh(geometry, mat); scene.add(mesh); frame(mesh); }; const onLoad = (obj) => { scene.add(obj.scene || obj); const target = obj.scene || obj; if (obj.animations?.length) { mixer = new THREE.AnimationMixer(target); mixer.clipAction(obj.animations[0]).play(); } frame(target); }; const err = (e) => console.error("viewer load error", e); if (ext === "glb" || ext === "gltf") new GLTFLoader().load(url, onLoad, undefined, err); else if (ext === "obj") new OBJLoader().load(url, onLoad, undefined, err); else if (ext === "fbx") new FBXLoader().load(url, onLoad, undefined, err); else if (ext === "ply") new PLYLoader().load(url, (g) => { g.computeVertexNormals(); addDefaultMaterial(g); }, undefined, err); else if (ext === "stl") new STLLoader().load(url, (g) => { g.computeVertexNormals(); addDefaultMaterial(g); }, undefined, err); let raf; const loop = () => { raf = requestAnimationFrame(loop); if (mixer) mixer.update(clock.getDelta()); controls.update(); renderer.render(scene, camera); }; loop(); return () => { cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); mount.removeChild(renderer.domElement); }; }, [url, ext]); return
; }