// In-browser 3D preview for STL / 3MF / OBJ files, loaded as an ES module. 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 { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; // Formats the renderer can draw directly. 3MF is rendered via server-converted // GLB (see RENDERABLE for what the app treats as previewable input). const SUPPORTED = ['stl', 'obj', 'glb']; const RENDERABLE = ['stl', '3mf', 'obj']; let active = null; // { renderer, controls, rafId } 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 (e.g. Bambu/MakerWorld 3MF project files that // the loader can't parse), 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 loadObject(url, format) { if (format === 'stl') { const geometry = await new STLLoader().loadAsync(url); geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ color: 0xc4cad6, metalness: 0.15, roughness: 0.65 }); const mesh = new THREE.Mesh(geometry, material); mesh.rotation.x = -Math.PI / 2; // STL is typically Z-up; sit it flat return mesh; } if (format === 'obj') return new OBJLoader().loadAsync(url); if (format === '3mf') return new ThreeMFLoader().loadAsync(url); if (format === 'glb') { const gltf = await new GLTFLoader().loadAsync(url); gltf.scene.rotation.x = -Math.PI / 2; // 3MF/trimesh is Z-up; sit it upright return gltf.scene; } throw new Error(`Unsupported format ${format}`); } async function render(container, url, format, opts = {}) { dispose(); container.innerHTML = ''; if (!SUPPORTED.includes(format)) { showFallback(container, opts.fallbackImage, `No 3D preview for .${format} files.`); return; } 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; let object; try { object = await loadObject(url, format); } catch (err) { renderer.dispose(); renderer.forceContextLoss(); if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement); const note = format === '3mf' ? 'Interactive 3D isn’t supported for this 3MF (Bambu/MakerWorld project file).' : 'This file couldn’t be rendered in 3D.'; showFallback(container, opts.fallbackImage, note); return; } // 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) { if (!SUPPORTED.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); 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, SUPPORTED, RENDERABLE };