// In-browser preview for 3D models (STL, OBJ + MTL, 3MF via GLB, FBX, STEP, 3DM) // and for the pictures, PDFs and text files that come with them. import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { STLLoader } from 'three/addons/loaders/STLLoader.js'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; import { MTLLoader } from 'three/addons/loaders/MTLLoader.js'; import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { Rhino3dmLoader } from 'three/addons/loaders/3DMLoader.js'; // Formats the 3D renderer draws directly (3MF arrives as a server-converted GLB). const MODEL_FORMATS = ['stl', 'obj', 'glb', 'fbx', '3dm', 'step', 'stp']; const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']; const TEXT_FORMATS = ['txt', 'md', 'mtl']; const DOCUMENT_FORMATS = [...IMAGE_FORMATS, 'pdf', ...TEXT_FORMATS]; // What the app offers a preview for. const RENDERABLE = ['stl', '3mf', 'obj', 'fbx', '3dm', 'step', 'stp']; const PREVIEWABLE = [...RENDERABLE, ...DOCUMENT_FORMATS]; // Formats authored Z-up; turned to sit upright in three.js's Y-up world. const Z_UP = ['stl', 'glb', '3dm', 'step', 'stp']; let active = null; // { renderer, controls, rafId } let renderToken = 0; // bumps on every render, so a slow earlier load can't draw over a newer one function dispose() { if (!active) return; cancelAnimationFrame(active.rafId); active.controls.dispose(); active.renderer.dispose(); const el = active.renderer.domElement; if (el.parentNode) el.parentNode.removeChild(el); active = null; } function message(container, text) { container.innerHTML = `
${text}
`; } // When 3D rendering isn't possible, show the model's preview image instead of an error. function showFallback(container, image, text) { if (image) { container.innerHTML = `
${text}
`; } else { message(container, text); } } async function fetchOk(url) { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`); return res; } const basename = (p) => String(p).split(/[\\/]/).pop(); function defaultMaterial(color) { return new THREE.MeshStandardMaterial({ color: color || 0xc4cad6, metalness: 0.15, roughness: 0.65 }); } // A loading manager that points material and texture references ("wood.png", // "../tex/wood.png") at the files shipped alongside the model. `own` is the // URL being loaded itself, which is left alone. function managerFor(resolve, own) { const manager = new THREE.LoadingManager(); if (resolve) { manager.setURLModifier((url) => { if (url === own || url.startsWith('blob:') || url.startsWith('data:')) return url; return resolve(decodeURIComponent(basename(url.split('?')[0]))) || url; }); } return manager; } async function loadObj(url, resolve) { const text = await (await fetchOk(url)).text(); const loader = new OBJLoader(); const mtllib = /^mtllib\s+(.+)$/m.exec(text); const mtlUrl = mtllib && resolve ? resolve(basename(mtllib[1].trim())) : null; let textured = false; if (mtlUrl) { try { const materials = await new MTLLoader(managerFor(resolve, mtlUrl)).loadAsync(mtlUrl); materials.preload(); loader.setMaterials(materials); textured = true; } catch { /* fall back to plain grey */ } } const object = loader.parse(text); if (!textured) object.traverse((c) => { if (c.isMesh) c.material = defaultMaterial(); }); return object; } // OpenCascade (WASM) is large, so it's fetched the first time a STEP file is opened. let occtPromise = null; function occt() { if (!occtPromise) { occtPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = '/vendor/occt/occt-import-js.js'; script.onload = () => resolve(window.occtimportjs({ locateFile: (name) => `/vendor/occt/${name}` })); script.onerror = () => { occtPromise = null; reject(new Error('Could not load the STEP reader')); }; document.head.appendChild(script); }); } return occtPromise; } async function loadStep(url) { const [reader, buffer] = await Promise.all([occt(), fetchOk(url).then((r) => r.arrayBuffer())]); const result = reader.ReadStepFile(new Uint8Array(buffer), null); if (!result.success || !result.meshes.length) throw new Error('No geometry in this STEP file'); const group = new THREE.Group(); for (const m of result.meshes) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(m.attributes.position.array, 3)); if (m.attributes.normal) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(m.attributes.normal.array, 3)); geometry.setIndex(Array.from(m.index.array)); if (!m.attributes.normal) geometry.computeVertexNormals(); const color = m.color ? new THREE.Color(m.color[0], m.color[1], m.color[2]) : null; group.add(new THREE.Mesh(geometry, defaultMaterial(color))); } return group; } async function load3dm(url) { const loader = new Rhino3dmLoader().setLibraryPath('/vendor/rhino3dm/'); try { return await loader.loadAsync(url); } finally { loader.dispose(); } } async function loadObject(url, format, opts = {}) { let object; if (format === 'stl') { const geometry = await new STLLoader().loadAsync(url); geometry.computeVertexNormals(); object = new THREE.Mesh(geometry, defaultMaterial()); } else if (format === 'obj') { object = await loadObj(url, opts.resolve); } else if (format === 'fbx') { object = await new FBXLoader(managerFor(opts.resolve, url)).loadAsync(url); } else if (format === 'glb') { object = (await new GLTFLoader().loadAsync(url)).scene; } else if (format === '3dm') { object = await load3dm(url); } else if (format === 'step' || format === 'stp') { object = await loadStep(url); } else { throw new Error(`Unsupported format ${format}`); } if (Z_UP.includes(format)) object.rotation.x = -Math.PI / 2; return object; } async function renderDocument(container, url, format, token) { if (IMAGE_FORMATS.includes(format)) { const img = document.createElement('img'); img.className = 'viewer-image'; img.alt = ''; img.src = url; container.appendChild(img); } else if (format === 'pdf') { const frame = document.createElement('iframe'); frame.className = 'viewer-pdf'; frame.title = 'PDF preview'; frame.src = url; container.appendChild(frame); } else { let text; try { text = await (await fetchOk(url)).text(); } catch { text = 'Could not load this file.'; } if (token !== renderToken) return; const pre = document.createElement('pre'); pre.className = 'viewer-text'; pre.textContent = text.length > 200000 ? `${text.slice(0, 200000)}\n…` : text; container.appendChild(pre); } } async function render(container, url, format, opts = {}) { dispose(); const token = ++renderToken; container.innerHTML = ''; if (DOCUMENT_FORMATS.includes(format)) { await renderDocument(container, url, format, token); return; } if (!MODEL_FORMATS.includes(format)) { showFallback(container, opts.fallbackImage, `No 3D preview for .${format} files.`); return; } message(container, 'Loading preview…'); let object; try { object = await loadObject(url, format, opts); } catch (err) { console.warn('Preview failed:', err); if (token !== renderToken) return; const note = format === 'glb' ? 'Interactive 3D isn’t available for this 3MF.' : 'This file couldn’t be rendered in 3D.'; showFallback(container, opts.fallbackImage, note); return; } if (token !== renderToken) return; // another file was opened meanwhile container.innerHTML = ''; const width = container.clientWidth || 600; const height = container.clientHeight || 420; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1e6); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(width, height); container.appendChild(renderer.domElement); scene.add(new THREE.AmbientLight(0xffffff, 0.65)); const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(1, 1, 1); scene.add(key); const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // Center the object and frame the camera around it. const box = new THREE.Box3().setFromObject(object); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); object.position.sub(center); scene.add(object); const maxDim = Math.max(size.x, size.y, size.z) || 1; camera.position.set(maxDim * 1.4, maxDim * 1.1, maxDim * 1.8); camera.near = maxDim / 100; camera.far = maxDim * 100; camera.updateProjectionMatrix(); controls.target.set(0, 0, 0); controls.update(); active = { renderer, controls, rafId: 0 }; const loop = () => { if (!active) return; active.rafId = requestAnimationFrame(loop); controls.update(); renderer.render(scene, camera); }; loop(); } // Render a model once to an offscreen canvas and return a PNG data URL. // Used to generate card thumbnails. Transparent background so it composites // over the card surface. async function snapshot(url, format, size = 512, opts = {}) { if (!MODEL_FORMATS.includes(format)) return null; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); renderer.setSize(size, size); renderer.setClearColor(0x000000, 0); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1e6); scene.add(new THREE.AmbientLight(0xffffff, 0.65)); const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(1, 1, 1); scene.add(key); const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill); try { const object = await loadObject(url, format, opts); const box = new THREE.Box3().setFromObject(object); const dim = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); object.position.sub(center); scene.add(object); const maxDim = Math.max(dim.x, dim.y, dim.z) || 1; camera.position.set(maxDim * 1.3, maxDim * 1.0, maxDim * 1.7); camera.near = maxDim / 100; camera.far = maxDim * 100; camera.lookAt(0, 0, 0); camera.updateProjectionMatrix(); renderer.render(scene, camera); return renderer.domElement.toDataURL('image/png'); } finally { renderer.dispose(); renderer.forceContextLoss(); } } window.ModelViewer = { render, dispose, snapshot, MODEL_FORMATS, DOCUMENT_FORMATS, RENDERABLE, PREVIEWABLE };