- Long file names inside archives (deep folder paths) widened the details column until the viewer was crushed to a sliver and content ran off the modal. Both columns now keep their share (minmax(0, …)), long file names wrap, and viewer tabs truncate. - Archive entries show the file name first with its folder dimmed after. - A model whose only file is an archive opens the most useful file inside it (a slicer project, then other 3D files, then pictures/PDFs) instead of "No 3D preview for .none files". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
// 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 = `<div class="viewer-msg">${text}</div>`;
|
||
}
|
||
|
||
// 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 =
|
||
`<div class="viewer-fallback"><img src="${image}" alt=""><span>${text}</span></div>`;
|
||
} 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, opts.message || `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 };
|