Self-hosted Express + Postgres app for organizing STL/3MF/OBJ/etc. with: - Model/file library, tags, collections, print settings - URL import: Thingiverse (API), Printables (GraphQL), MakerWorld (FlareSolverr + token), cubee3d/other (metadata + manual files) - In-browser 3D viewer; 3MF rendered via Python/trimesh GLB conversion - Auto thumbnails, license/commercial-use tracking, in-app editing - Settings (API tokens / FlareSolverr) stored in DB with .env fallback Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
6 KiB
JavaScript
162 lines
6 KiB
JavaScript
// 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 = `<div class="viewer-msg">${text}</div>`;
|
||
}
|
||
|
||
// 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 =
|
||
`<div class="viewer-fallback"><img src="${image}" alt=""><span>${text}</span></div>`;
|
||
} 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 };
|