/* holder-viewer.js — Three.js scene for STL preview. * * Exports a single global `HolderViewer` (UMD-ish, attached to window) so the * non-module app script can use it. Set up via HolderViewer.init(canvasEl); * load STL bytes via HolderViewer.loadSTL(arrayBuffer). */ import * as THREE from "three"; import { STLLoader } from "three/addons/loaders/STLLoader.js"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; const HolderViewer = (() => { let scene, camera, renderer, controls; let mesh = null; let host = null; function init(hostEl) { host = hostEl; const w = host.clientWidth || 800; const h = host.clientHeight || 600; scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0d12); camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 5000); camera.position.set(120, 120, 120); camera.up.set(0, 0, 1); // Z-up (CAD convention) renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(w, h); host.innerHTML = ""; host.appendChild(renderer.domElement); controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.screenSpacePanning = true; // Lights scene.add(new THREE.AmbientLight(0xffffff, 0.45)); const key = new THREE.DirectionalLight(0xffffff, 0.7); key.position.set(1, 1, 2).normalize(); scene.add(key); const fill = new THREE.DirectionalLight(0xaccfff, 0.35); fill.position.set(-1, -1, 0.5).normalize(); scene.add(fill); // Build plate grid (10 mm minor, 50 mm major) at Z=0 const grid = new THREE.GridHelper(400, 40, 0x2a3140, 0x1f2530); grid.rotateX(Math.PI / 2); // make it the XY plane (camera up is Z) scene.add(grid); // Axes (small) const axes = new THREE.AxesHelper(20); scene.add(axes); // Resize observer new ResizeObserver(_onResize).observe(host); _animate(); } function _onResize() { if (!host) return; const w = host.clientWidth, h = host.clientHeight; if (w === 0 || h === 0) return; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); } function _animate() { requestAnimationFrame(_animate); controls.update(); renderer.render(scene, camera); } /** Load STL bytes (ArrayBuffer) and replace any existing mesh. */ function loadSTL(buf) { const loader = new STLLoader(); const geom = loader.parse(buf); geom.computeVertexNormals(); // Centre the geometry on its bounding box and align bottom to Z=0. geom.computeBoundingBox(); const bb = geom.boundingBox; const cx = (bb.min.x + bb.max.x) / 2; const cy = (bb.min.y + bb.max.y) / 2; geom.translate(-cx, -cy, -bb.min.z); if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); } const mat = new THREE.MeshStandardMaterial({ color: 0xf08a24, metalness: 0.15, roughness: 0.65, flatShading: false, }); mesh = new THREE.Mesh(geom, mat); scene.add(mesh); _fitCameraToMesh(geom); } function _fitCameraToMesh(geom) { const bb = geom.boundingBox; const size = bb.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const dist = maxDim * 1.6; camera.position.set(dist * 0.8, -dist * 0.8, dist * 0.8); controls.target.set(0, 0, size.z / 2); camera.lookAt(controls.target); camera.updateProjectionMatrix(); } function clear() { if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); mesh = null; } } return { init, loadSTL, clear }; })(); window.HolderViewer = HolderViewer;