modelbeast/web/src/Viewer.jsx
2026-07-12 21:05:22 +10:00

108 lines
3.8 KiB
JavaScript

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 <div className="viewer" ref={mountRef} />;
}