import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { STLLoader } from 'three/addons/loaders/STLLoader.js'; export const Z_SPACING = 3; export function dbg(...args) { try { const fn = window?.go?.main?.App?.JSDebugLog; if (!fn) return; const msg = '[engine] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); fn(msg); } catch (_) {} } export class FormerEngine { constructor(container) { this.container = container; this.layers = []; this.layerMeshes = []; this.selectedLayerIndex = -1; this.enclosureLayerIndex = -1; this.trayLayerIndex = -1; this.layerGroup = null; this.arrowGroup = null; this.elementGroup = null; this.selectionOutline = null; this._onLayerSelect = null; this._onCutoutSelect = null; this._onCutoutHover = null; this._onPlacedCutoutSelect = null; this._modes = {}; this._clickHandlers = []; this._mouseMoveHandlers = []; this._mouseDownHandlers = []; this._mouseUpHandlers = []; this._initScene(); this._initControls(); this._initGrid(); this._initRaycasting(); this._animate(); } // ===== Plugin API ===== use(mode) { this._modes[mode.name] = mode; mode.install(this); } getMode(name) { return this._modes[name]; } registerClickHandler(handler) { this._clickHandlers.push(handler); } unregisterClickHandler(handler) { const i = this._clickHandlers.indexOf(handler); if (i >= 0) this._clickHandlers.splice(i, 1); } registerMouseMoveHandler(handler) { this._mouseMoveHandlers.push(handler); } unregisterMouseMoveHandler(handler) { const i = this._mouseMoveHandlers.indexOf(handler); if (i >= 0) this._mouseMoveHandlers.splice(i, 1); } registerMouseDownHandler(handler) { this._mouseDownHandlers.push(handler); } unregisterMouseDownHandler(handler) { const i = this._mouseDownHandlers.indexOf(handler); if (i >= 0) this._mouseDownHandlers.splice(i, 1); } registerMouseUpHandler(handler) { this._mouseUpHandlers.push(handler); } unregisterMouseUpHandler(handler) { const i = this._mouseUpHandlers.indexOf(handler); if (i >= 0) this._mouseUpHandlers.splice(i, 1); } // ===== Callbacks ===== onLayerSelect(cb) { this._onLayerSelect = cb; } onCutoutSelect(cb) { this._onCutoutSelect = cb; } onCutoutHover(cb) { this._onCutoutHover = cb; } onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; } // ===== Scene Setup ===== _initScene() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x000000); const w = this.container.clientWidth; const h = this.container.clientHeight; this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 50000); this.camera.position.set(0, -60, 80); this.camera.up.set(0, 0, 1); this.camera.lookAt(0, 0, 0); this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true }); this.renderer.setSize(w, h); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.container.appendChild(this.renderer.domElement); this._ambientLight = new THREE.AmbientLight(0xffffff, 0.9); this.scene.add(this._ambientLight); this._dirLight = new THREE.DirectionalLight(0xffffff, 0.3); this._dirLight.position.set(50, -50, 100); this.scene.add(this._dirLight); this.layerGroup = new THREE.Group(); this.scene.add(this.layerGroup); this.arrowGroup = new THREE.Group(); this.arrowGroup.visible = false; this.scene.add(this.arrowGroup); this.elementGroup = new THREE.Group(); this.elementGroup.visible = false; this.scene.add(this.elementGroup); this._resizeObserver = new ResizeObserver(() => this._onResize()); this._resizeObserver.observe(this.container); } _initControls() { this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.1; this.controls.enableZoom = true; this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.DOLLY }; this.controls.target.set(0, 0, 0); this.controls.update(); this.renderer.domElement.addEventListener('wheel', (e) => { e.preventDefault(); if (this._traditionalControls) { if (e.shiftKey && e.deltaX === 0) { e.stopImmediatePropagation(); const dist = this.camera.position.distanceTo(this.controls.target); const panSpeed = dist * 0.001; const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0); const offset = new THREE.Vector3(); offset.addScaledVector(right, e.deltaY * panSpeed); this.camera.position.add(offset); this.controls.target.add(offset); this.controls.update(); } return; } e.stopImmediatePropagation(); if (e.ctrlKey || e.metaKey) { const factor = 1 + e.deltaY * 0.01; const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target); dir.multiplyScalar(factor); this.camera.position.copy(this.controls.target).add(dir); } else { let dx = e.deltaX; let dy = e.deltaY; if (e.shiftKey && dx === 0) { dx = dy; dy = 0; } const dist = this.camera.position.distanceTo(this.controls.target); const panSpeed = dist * 0.001; const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0); const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1); const offset = new THREE.Vector3(); offset.addScaledVector(right, dx * panSpeed); offset.addScaledVector(up, -dy * panSpeed); this.camera.position.add(offset); this.controls.target.add(offset); } this.controls.update(); }, { passive: false, capture: true }); this._ctrlDragging = false; this._ctrlDragLastY = 0; this.renderer.domElement.addEventListener('mousedown', (e) => { if (e.button === 0 && (e.ctrlKey || e.metaKey)) { this._ctrlDragging = true; this._ctrlDragLastY = e.clientY; e.preventDefault(); e.stopImmediatePropagation(); } }, { capture: true }); const onCtrlDragMove = (e) => { if (!this._ctrlDragging) return; const dy = e.clientY - this._ctrlDragLastY; this._ctrlDragLastY = e.clientY; const factor = 1 + dy * 0.005; const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target); dir.multiplyScalar(factor); this.camera.position.copy(this.controls.target).add(dir); this.controls.update(); }; const onCtrlDragUp = () => { this._ctrlDragging = false; }; window.addEventListener('mousemove', onCtrlDragMove); window.addEventListener('mouseup', onCtrlDragUp); this._ctrlDragCleanup = () => { window.removeEventListener('mousemove', onCtrlDragMove); window.removeEventListener('mouseup', onCtrlDragUp); }; } _initGrid() { this.gridHelper = new THREE.GridHelper(2000, 100, 0x333333, 0x222222); this.gridHelper.rotation.x = Math.PI / 2; this.gridHelper.position.z = -0.5; this.scene.add(this.gridHelper); this.gridVisible = true; } toggleGrid() { this.gridVisible = !this.gridVisible; this.gridHelper.visible = this.gridVisible; return this.gridVisible; } _initRaycasting() { this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); this._isDragging = false; this._mouseDownPos = { x: 0, y: 0 }; const canvas = this.renderer.domElement; canvas.addEventListener('mousedown', e => { this._isDragging = false; this._mouseDownPos = { x: e.clientX, y: e.clientY }; for (const h of this._mouseDownHandlers) h(e); }); canvas.addEventListener('mousemove', e => { const dx = e.clientX - this._mouseDownPos.x; const dy = e.clientY - this._mouseDownPos.y; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._isDragging = true; for (const h of this._mouseMoveHandlers) h(e); }); canvas.addEventListener('mouseup', e => { for (const h of this._mouseUpHandlers) h(e); }); canvas.addEventListener('click', e => { if (this._isDragging) return; this._updateMouse(e); this.raycaster.setFromCamera(this.mouse, this.camera); for (const h of this._clickHandlers) { if (h(e)) return; } // Default: layer selection const clickables = this.layerMeshes.filter((m, i) => m && this.layers[i]?.visible); const hits = this.raycaster.intersectObjects(clickables, true); if (hits.length > 0) { let hitObj = hits[0].object; let idx = this.layerMeshes.indexOf(hitObj); if (idx < 0) { hitObj.traverseAncestors(p => { const ei = this.layerMeshes.indexOf(p); if (ei >= 0) idx = ei; }); } if (idx >= 0) this.selectLayer(idx); } else { this.selectLayer(-1); } }); } _updateMouse(e) { const rect = this.renderer.domElement.getBoundingClientRect(); this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; } _onResize() { const w = this.container.clientWidth; const h = this.container.clientHeight; if (w === 0 || h === 0) return; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); } _animate() { this._animId = requestAnimationFrame(() => this._animate()); this.controls.update(); this.renderer.render(this.scene, this.camera); } // ===== Layer Loading ===== async loadLayers(layers, imageUrls) { this.layers = layers; const loader = new THREE.TextureLoader(); while (this.layerGroup.children.length > 0) { const child = this.layerGroup.children[0]; if (child.geometry) child.geometry.dispose(); if (child.material) { if (child.material.map) child.material.map.dispose(); child.material.dispose(); } this.layerGroup.remove(child); } this.layerMeshes = []; this.enclosureLayerIndex = -1; this.trayLayerIndex = -1; // Reset enclosure mode state if present const enc = this.getMode('enclosure'); if (enc) { enc.enclosureMesh = null; enc.trayMesh = null; } let maxW = 0, maxH = 0; for (let i = 0; i < layers.length; i++) { const layer = layers[i]; const url = imageUrls[i]; if (layer.name === 'Enclosure') { this.enclosureLayerIndex = i; this.layerMeshes.push(null); continue; } if (layer.name === 'Tray') { this.trayLayerIndex = i; this.layerMeshes.push(null); continue; } if (!url) { this.layerMeshes.push(null); continue; } try { const tex = await new Promise((resolve, reject) => { loader.load(url, resolve, undefined, reject); }); tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; const imgW = tex.image.width; const imgH = tex.image.height; if (imgW > maxW) maxW = imgW; if (imgH > maxH) maxH = imgH; const geo = new THREE.PlaneGeometry(imgW, imgH); const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: layer.visible ? layer.baseAlpha : 0, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.set(imgW / 2, -imgH / 2, i * Z_SPACING); mesh.visible = layer.visible; mesh.userData = { layerIndex: i }; this.layerGroup.add(mesh); this.layerMeshes.push(mesh); } catch (e) { console.warn(`Failed to load layer ${i}:`, e); this.layerMeshes.push(null); } } this._maxW = maxW; this._maxH = maxH; if (maxW > 0 && maxH > 0) { const cx = maxW / 2; const cy = -maxH / 2; const cz = (layers.length * Z_SPACING) / 2; this.controls.target.set(cx, cy, cz); const dist = Math.max(maxW, maxH) * 0.7; this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6); this.camera.lookAt(cx, cy, cz); this.controls.update(); this.gridHelper.position.set(cx, cy, -0.5); } } // ===== Layer Visibility ===== setLayerVisibility(index, visible) { if (index < 0 || index >= this.layerMeshes.length) return; const mesh = this.layerMeshes[index]; if (!mesh) return; this.layers[index].visible = visible; mesh.visible = visible; if (mesh.material && !mesh.userData?.isEnclosure) { if (!visible) mesh.material.opacity = 0; else mesh.material.opacity = this.layers[index].baseAlpha; } } setGroupOpacity(group, opacity) { group.traverse(c => { if (c.material) c.material.opacity = opacity; }); } setLayerHighlight(index, highlight) { const hasHL = highlight && index >= 0; this.layers.forEach((l, i) => { l.highlight = (i === index && highlight); const mesh = this.layerMeshes[i]; if (!mesh || !l.visible) return; if (mesh.userData?.isEnclosure) { this.setGroupOpacity(mesh, (hasHL && !l.highlight) ? 0.15 : 0.55); } else if (mesh.material) { mesh.material.opacity = (hasHL && !l.highlight) ? l.baseAlpha * 0.3 : l.baseAlpha; } }); } // ===== Selection ===== selectLayer(index) { this.selectedLayerIndex = index; if (this.selectionOutline) { this.scene.remove(this.selectionOutline); this.selectionOutline.geometry?.dispose(); this.selectionOutline.material?.dispose(); this.selectionOutline = null; } this.arrowGroup.visible = false; while (this.arrowGroup.children.length) { const c = this.arrowGroup.children[0]; this.arrowGroup.remove(c); } if (index < 0 || index >= this.layerMeshes.length) { if (this._onLayerSelect) this._onLayerSelect(-1); return; } const mesh = this.layerMeshes[index]; if (!mesh) { if (this._onLayerSelect) this._onLayerSelect(index); return; } if (mesh.geometry && !mesh.userData?.isEnclosure) { const edges = new THREE.EdgesGeometry(mesh.geometry); const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x89b4fa, linewidth: 2, })); line.position.copy(mesh.position); line.position.z += 0.1; this.selectionOutline = line; this.scene.add(line); } const pos = mesh.position.clone(); if (mesh.userData?.isEnclosure) { const box = new THREE.Box3().setFromObject(mesh); box.getCenter(pos); } const arrowLen = 8; const upArrow = new THREE.ArrowHelper( new THREE.Vector3(0, 0, 1), new THREE.Vector3(pos.x, pos.y, pos.z + 2), arrowLen, 0x89b4fa, 3, 2 ); const downArrow = new THREE.ArrowHelper( new THREE.Vector3(0, 0, -1), new THREE.Vector3(pos.x, pos.y, pos.z - 2), arrowLen, 0x89b4fa, 3, 2 ); this.arrowGroup.add(upArrow); this.arrowGroup.add(downArrow); this.arrowGroup.visible = true; if (this._onLayerSelect) this._onLayerSelect(index); } moveSelectedZ(delta) { if (this.selectedLayerIndex < 0) return; const mesh = this.layerMeshes[this.selectedLayerIndex]; if (!mesh) return; mesh.position.z += delta; if (this.selectionOutline) this.selectionOutline.position.z = mesh.position.z + 0.1; if (this.arrowGroup.children.length >= 2) { const pos = mesh.position; this.arrowGroup.children[0].position.set(pos.x, pos.y, pos.z + 2); this.arrowGroup.children[1].position.set(pos.x, pos.y, pos.z - 2); } } // ===== Camera ===== _homeTopDown(layerIndex) { const mesh = this.layerMeshes[layerIndex]; if (!mesh) return; const pos = mesh.position.clone(); if (mesh.userData?.isEnclosure) { const box = new THREE.Box3().setFromObject(mesh); box.getCenter(pos); } let imgW, imgH; if (mesh.geometry?.parameters) { imgW = mesh.geometry.parameters.width; imgH = mesh.geometry.parameters.height; } else { imgW = this._maxW || 500; imgH = this._maxH || 500; } const dist = Math.max(imgW, imgH) * 1.1; this.camera.position.set(pos.x, pos.y - dist * 0.01, pos.z + dist); this.camera.up.set(0, 0, 1); this.controls.target.set(pos.x, pos.y, pos.z); this.controls.update(); } homeTopDown(layerIndex) { if (layerIndex !== undefined && layerIndex >= 0) { this._homeTopDown(layerIndex); } else if (this.selectedLayerIndex >= 0) { this._homeTopDown(this.selectedLayerIndex); } else { const cx = (this._maxW || 500) / 2; const cy = -(this._maxH || 500) / 2; const cz = (this.layers.length * Z_SPACING) / 2; const dist = Math.max(this._maxW || 500, this._maxH || 500) * 1.1; this.camera.position.set(cx, cy - dist * 0.01, cz + dist); this.camera.up.set(0, 0, 1); this.controls.target.set(cx, cy, cz); this.controls.update(); } } resetView() { if (this.layers.length === 0) return; const maxW = this._maxW || 500; const maxH = this._maxH || 500; const cx = maxW / 2; const cy = -maxH / 2; const cz = (this.layers.length * Z_SPACING) / 2; const dist = Math.max(maxW, maxH) * 0.7; this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6); this.camera.up.set(0, 0, 1); this.controls.target.set(cx, cy, cz); this.controls.update(); } setControlScheme(traditional) { this._traditionalControls = traditional; if (traditional) { this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; } else { this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.DOLLY }; } } // ===== Shared Utilities ===== disposeGroup(group) { if (!group) return; if (group.parent) group.parent.remove(group); group.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) { if (c.material.map) c.material.map.dispose(); c.material.dispose(); } }); } loadSTLCentered(arrayBuffer, materialOpts) { const loader = new STLLoader(); const geometry = loader.parse(arrayBuffer); geometry.computeBoundingBox(); if (materialOpts.computeNormals) { geometry.computeVertexNormals(); delete materialOpts.computeNormals; } const mat = new THREE.MeshPhongMaterial(materialOpts); const mesh = new THREE.Mesh(geometry, mat); const box = geometry.boundingBox; const center = new THREE.Vector3( (box.min.x + box.max.x) / 2, (box.min.y + box.max.y) / 2, (box.min.z + box.max.z) / 2 ); mesh.position.set(-center.x, -center.y, -center.z); const size = box.getSize(new THREE.Vector3()); return { mesh, center, size, box }; } fitCamera(size, target) { const maxDim = Math.max(size.x, size.y, size.z); const dist = maxDim * 1.5; const t = target || new THREE.Vector3(0, 0, 0); this.controls.target.copy(t); this.camera.position.set(t.x, t.y - dist * 0.5, t.z + dist * 0.6); this.camera.up.set(0, 0, 1); this.controls.update(); } // ===== Dispose ===== dispose() { if (this._ctrlDragCleanup) this._ctrlDragCleanup(); if (this._animId) cancelAnimationFrame(this._animId); if (this._resizeObserver) this._resizeObserver.disconnect(); for (const mode of Object.values(this._modes)) { if (mode.dispose) mode.dispose(); } this.controls.dispose(); this.renderer.dispose(); if (this.renderer.domElement.parentNode) { this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); } } }