printhive/public/viewer.js
dlawler489 a37db15a3f Initial commit: Printhive 3D-print file library
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>
2026-06-22 13:15:17 +10:00

162 lines
6 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 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 isnt supported for this 3MF (Bambu/MakerWorld project file).'
: 'This file couldnt 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 };