import { Icon } from "./Icon"; // 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM). // Loaded on demand only when such a file is opened. 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 { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; import occtScriptUrl from "occt-import-js/dist/occt-import-js.js?url"; import occtWasmUrl from "occt-import-js/dist/occt-import-js.wasm?url"; let occtPromise: Promise | undefined; /** Load the OpenCascade importer once (script tag + wasm URL). */ function loadOcct(): Promise { occtPromise ??= new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = occtScriptUrl; s.onload = () => (window as any).occtimportjs({ locateFile: () => occtWasmUrl }).then(resolve, reject); s.onerror = () => reject(new Error("could not load the STEP importer")); document.head.appendChild(s); }); return occtPromise; } function cssColor(name: string, fallback: string) { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } async function loadObject(url: string, ext: string): Promise { const material = new THREE.MeshStandardMaterial({ color: cssColor("--blue", "#83a598"), metalness: 0.1, roughness: 0.6, side: THREE.DoubleSide }); switch (ext) { case "glb": case "gltf": return (await new GLTFLoader().loadAsync(url)).scene; case "stl": { const geo = await new STLLoader().loadAsync(url); geo.computeVertexNormals(); return new THREE.Mesh(geo, material); } case "obj": { const obj = await new OBJLoader().loadAsync(url); obj.traverse((c) => c instanceof THREE.Mesh && (c.material = material)); return obj; } default: { // STEP / IGES: tessellate with OpenCascade in the browser. const [occt, buf] = await Promise.all([loadOcct(), fetch(url, { credentials: "same-origin" }).then((r) => r.arrayBuffer())]); const data = new Uint8Array(buf); const res = ext === "igs" || ext === "iges" ? occt.ReadIgesFile(data, null) : occt.ReadStepFile(data, null); if (!res.success) throw new Error("the file could not be read"); const group = new THREE.Group(); for (const m of res.meshes) { const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(m.attributes.position.array, 3)); if (m.attributes.normal) geo.setAttribute("normal", new THREE.Float32BufferAttribute(m.attributes.normal.array, 3)); else geo.computeVertexNormals(); if (m.index) geo.setIndex(new THREE.Uint32BufferAttribute(m.index.array, 1)); const mat = m.color ? material.clone() : material; if (m.color) (mat as THREE.MeshStandardMaterial).color = new THREE.Color(m.color[0], m.color[1], m.color[2]); group.add(new THREE.Mesh(geo, mat)); } return group; } } } export function ModelViewer({ url, ext }: { url: string; ext: string }) { const host = useRef(null); const [state, setState] = useState<{ loading: boolean; error?: string; info?: string }>({ loading: true }); useEffect(() => { const el = host.current!; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); el.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(cssColor("--bg0", "#282828")); scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6)); const sun = new THREE.DirectionalLight(0xffffff, 1.8); sun.position.set(3, 5, 4); scene.add(sun); const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1e6); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; const resize = () => { const w = el.clientWidth || 300; const h = el.clientHeight || 300; renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); }; const ro = new ResizeObserver(resize); ro.observe(el); resize(); let alive = true; let frame = 0; const loop = () => { if (!alive) return; controls.update(); renderer.render(scene, camera); frame = requestAnimationFrame(loop); }; loop(); loadObject(url, ext) .then((obj) => { if (!alive) return; scene.add(obj); // Frame the model. const box = new THREE.Box3().setFromObject(obj); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const radius = Math.max(size.length() / 2, 1e-3); camera.position.copy(center).add(new THREE.Vector3(radius * 1.4, radius * 1.0, radius * 1.8)); camera.near = radius / 100; camera.far = radius * 100; camera.updateProjectionMatrix(); controls.target.copy(center); let tris = 0; obj.traverse((c) => { if (c instanceof THREE.Mesh) tris += (c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count) / 3; }); setState({ loading: false, info: `${Math.round(tris).toLocaleString()} triangles · ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}` }); }) .catch((e) => alive && setState({ loading: false, error: String(e?.message ?? e) })); return () => { alive = false; cancelAnimationFrame(frame); ro.disconnect(); controls.dispose(); scene.traverse((c) => { if (c instanceof THREE.Mesh) { c.geometry.dispose(); (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose()); } }); renderer.dispose(); el.removeChild(renderer.domElement); }; }, [url, ext]); return (
{state.loading ? "…" : state.error ? <> {state.error} : state.info}
); }