/* 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). * * Also exposes "ghost box" helpers for fit-checking the assembled battery * against a user-defined enclosure: * - setGhostBox(name, {w, d, h}, {visible, color}) * - clearGhostBox(name) * Ghost boxes are centred on (0, 0) in XY with their bottom face at Z=0, * matching how loadSTL positions the printed-part mesh. */ 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; // Named ghost boxes for fit-check: { name: { group, dims, color } } const ghosts = Object.create(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; } for (const name of Object.keys(ghosts)) clearGhostBox(name); } // ----- Ghost boxes (fit-check) ------------------------------------------- function _disposeGroup(g) { g.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) { if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose()); else o.material.dispose(); } }); } function _makeGhostGroup(w, d, h, color) { const group = new THREE.Group(); const geom = new THREE.BoxGeometry(w, d, h); const fillMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.08, depthWrite: false, side: THREE.DoubleSide, }); group.add(new THREE.Mesh(geom, fillMat)); const edges = new THREE.EdgesGeometry(geom); const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.85 }); group.add(new THREE.LineSegments(edges, lineMat)); // Centre XY at (0,0), bottom at Z=0 — matches mesh placement in loadSTL. group.position.set(0, 0, h / 2); return group; } /** Create / update / hide a named ghost box. * dims: {w, d, h} in mm. Pass `{visible: false}` to hide without disposing. */ function setGhostBox(name, dims, opts = {}) { if (!dims || !(dims.w > 0) || !(dims.d > 0) || !(dims.h > 0)) { clearGhostBox(name); return; } const visible = opts.visible !== false; const color = opts.color ?? 0x4a9eff; const existing = ghosts[name]; const needsRebuild = !existing || existing.dims.w !== dims.w || existing.dims.d !== dims.d || existing.dims.h !== dims.h || existing.color !== color; if (existing && needsRebuild) { scene.remove(existing.group); _disposeGroup(existing.group); delete ghosts[name]; } if (needsRebuild) { const group = _makeGhostGroup(dims.w, dims.d, dims.h, color); group.visible = visible; scene.add(group); ghosts[name] = { group, dims: { ...dims }, color }; } else { existing.group.visible = visible; } } function clearGhostBox(name) { const g = ghosts[name]; if (!g) return; scene.remove(g.group); _disposeGroup(g.group); delete ghosts[name]; } return { init, loadSTL, clear, setGhostBox, clearGhostBox }; })(); window.HolderViewer = HolderViewer;