// 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 };