printhive/public/viewer.js
dlawler489 fbf1b706bf Fix model view for archives with long paths; preview inside archive-only models
- 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>
2026-09-15 08:01:04 +10:00

297 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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