import { useEffect, useRef, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js"; import { ThreeMFLoader } from "three/examples/jsm/loaders/3MFLoader.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { p4 } from "./p4"; import { t } from "./i18n"; type Api = { toggleWire: (b: boolean) => void; toggleGrid: (b: boolean) => void; reset: () => void }; export default function ModelViewer({ depot, name, spec, bytes, forceExt }: { depot: string; name: string; spec?: string; bytes?: ArrayBuffer; forceExt?: string }) { const mount = useRef(null); const api = useRef(null); const [status, setStatus] = useState<"loading" | "ok" | "error">("loading"); const [err, setErr] = useState(""); const [stats, setStats] = useState({ tris: 0, verts: 0, meshes: 0, mats: 0 }); const [wire, setWire] = useState(false); const [grid, setGrid] = useState(true); useEffect(() => { let disposed = false; let raf = 0; const el = mount.current!; setStatus("loading"); setErr(""); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(el.clientWidth || 600, el.clientHeight || 400); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; el.appendChild(renderer.domElement); const scene = new THREE.Scene(); // image-based lighting (soft studio environment) — this is what makes PBR look good const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000); // key light with shadows + soft fill const key = new THREE.DirectionalLight(0xffffff, 2.2); key.position.set(5, 8, 6); key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -0.0004; scene.add(key); scene.add(new THREE.DirectionalLight(0x8b7bf7, 0.35).translateX(-6)); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.autoRotate = true; controls.autoRotateSpeed = 1.1; renderer.domElement.addEventListener("pointerdown", () => (controls.autoRotate = false)); // ground + grid (added after fit, sized to model) const ground = new THREE.Mesh( new THREE.PlaneGeometry(1, 1), new THREE.ShadowMaterial({ opacity: 0.28 }) ); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground); let gridHelper: THREE.GridHelper | null = null; let home = { pos: new THREE.Vector3(3, 2, 3), tgt: new THREE.Vector3() }; const ext = (forceExt || name.toLowerCase().split(".").pop() || ""); function collectStats(obj: THREE.Object3D) { let tris = 0, verts = 0, meshes = 0; const mats = new Set(); obj.traverse((c) => { const m = c as THREE.Mesh; if ((m as unknown as { isMesh?: boolean }).isMesh && m.geometry) { meshes++; const pos = m.geometry.getAttribute("position"); verts += pos ? pos.count : 0; tris += m.geometry.index ? m.geometry.index.count / 3 : (pos ? pos.count / 3 : 0); (Array.isArray(m.material) ? m.material : [m.material]).forEach((x) => x && mats.add(x)); } }); setStats({ tris: Math.round(tris), verts, meshes, mats: mats.size }); } function place(obj: THREE.Object3D) { obj.traverse((c) => { const m = c as THREE.Mesh; if ((m as unknown as { isMesh?: boolean }).isMesh) { m.castShadow = true; m.receiveShadow = true; const has = m.material && !(Array.isArray(m.material) && m.material.length === 0); if (!has) m.material = new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5, metalness: 0.05 }); } }); const box = new THREE.Box3().setFromObject(obj); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z) || 1; obj.position.sub(center); obj.position.y += size.y / 2; // sit on the ground scene.add(obj); // ground + grid ground.scale.set(maxDim * 8, maxDim * 8, 1); gridHelper = new THREE.GridHelper(maxDim * 6, 30, 0x7c6ef6, 0x333344); (gridHelper.material as THREE.Material).transparent = true; (gridHelper.material as THREE.Material).opacity = 0.35; scene.add(gridHelper); gridHelper.visible = grid; // shadow camera frustum const s = maxDim * 2.5; Object.assign(key.shadow.camera, { left: -s, right: s, top: s, bottom: -s, near: 0.1, far: maxDim * 40 }); key.shadow.camera.updateProjectionMatrix(); const dist = maxDim * 2.1; home = { pos: new THREE.Vector3(dist * 0.8, dist * 0.65, dist), tgt: new THREE.Vector3(0, size.y / 2, 0) }; camera.position.copy(home.pos); camera.near = maxDim / 200; camera.far = maxDim * 200; camera.updateProjectionMatrix(); controls.target.copy(home.tgt); controls.maxDistance = maxDim * 25; controls.update(); collectStats(obj); setStatus("ok"); // expose controls to React buttons api.current = { toggleWire: (b) => obj.traverse((c) => { const m = c as THREE.Mesh; if (!(m as unknown as { isMesh?: boolean }).isMesh) return; (Array.isArray(m.material) ? m.material : [m.material]).forEach((mt) => { const s = mt as THREE.MeshStandardMaterial; if (s) s.wireframe = b; }); }), toggleGrid: (b) => { if (gridHelper) gridHelper.visible = b; }, reset: () => { camera.position.copy(home.pos); controls.target.copy(home.tgt); controls.update(); controls.autoRotate = false; }, }; } function fail(e: unknown) { if (!disposed) { setErr(String((e as { message?: string })?.message || e)); setStatus("error"); } } (async () => { try { // pre-supplied bytes (e.g. an FBX exported from Unreal) skip the p4 fetch const buf = bytes ? bytes : spec ? await p4.printDepot(spec) : await p4.readDepot(depot); if (disposed) return; const manager = new THREE.LoadingManager(); if (ext === "glb" || ext === "gltf") new GLTFLoader(manager).parse(buf, "", (g) => !disposed && place(g.scene), fail); else if (ext === "obj") place(new OBJLoader(manager).parse(new TextDecoder().decode(buf))); else if (ext === "fbx") place(new FBXLoader(manager).parse(buf, "")); else if (ext === "dae") { const c = new ColladaLoader(manager).parse(new TextDecoder().decode(buf), ""); if (c && c.scene) place(c.scene); else fail(t("Collada: empty scene")); } else if (ext === "3mf") place(new ThreeMFLoader(manager).parse(buf)); else if (ext === "stl") place(new THREE.Mesh(new STLLoader().parse(buf), new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5 }))); else if (ext === "ply") { const g = new PLYLoader().parse(buf); g.computeVertexNormals(); place(new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5, vertexColors: !!g.getAttribute("color") }))); } else { setErr(t("Format not supported by the viewer")); setStatus("error"); } } catch (e) { fail(e); } })(); const animate = () => { controls.update(); renderer.render(scene, camera); raf = requestAnimationFrame(animate); }; animate(); const onResize = () => { const w = el.clientWidth || 600, h = el.clientHeight || 400; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); }; const ro = new ResizeObserver(onResize); ro.observe(el); return () => { disposed = true; cancelAnimationFrame(raf); ro.disconnect(); controls.dispose(); pmrem.dispose(); scene.traverse((o) => { const m = o as THREE.Mesh; if (m.geometry) m.geometry.dispose(); const mat = m.material; if (mat) (Array.isArray(mat) ? mat : [mat]).forEach((x) => x && x.dispose()); }); renderer.dispose(); if (renderer.domElement.parentNode === el) el.removeChild(renderer.domElement); api.current = null; }; // eslint-disable-next-line }, [depot, name, spec, bytes, forceExt]); const fmt = (n: number) => n.toLocaleString("ru"); return (
3D · {(name.split(".").pop() || "").toUpperCase()} {status === "ok" && (
)} {status === "loading" &&
{t("Loading model…")}
} {status === "error" &&
{err}
} {status === "ok" && {t("drag — rotate · wheel — zoom · right-click — pan")}}
{t("Triangles")}{fmt(stats.tris)}
{t("Vertices")}{fmt(stats.verts)}
{t("Meshes · Materials")}{stats.meshes} · {stats.mats}
{t("Render")}three.js · PBR
); }