// Former 3D — Three.js layer viewer with orbit controls, layer selection, cutout tools, and grid import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { STLLoader } from 'three/addons/loaders/STLLoader.js'; const Z_SPACING = 3; // Debug bridge — pipes to Go's debugLog via Wails binding when built with -tags debug. function dbg(...args) { try { const fn = window?.go?.main?.App?.JSDebugLog; if (!fn) return; const msg = '[former3d] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); fn(msg); } catch (_) {} } export class Former3D { constructor(container) { this.container = container; this.layers = []; this.layerMeshes = []; this.selectedLayerIndex = -1; this.cutoutMode = false; this.elements = []; this.elementMeshes = []; this.hoveredElement = -1; this.cutouts = []; this.enclosureMesh = null; this.trayMesh = null; this.enclosureLayerIndex = -1; this.trayLayerIndex = -1; this._onLayerSelect = null; this._onCutoutSelect = null; this._onCutoutHover = null; this._initScene(); this._initControls(); this._initGrid(); this._initRaycasting(); this._animate(); } _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.selectionOutline = null; 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; // Must stay true for right-drag dolly to work 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(); // Custom scroll handler — capture phase so we fire before OrbitControls' bubble listener. // In default mode we stopImmediatePropagation to prevent OrbitControls scroll-zoom. this.renderer.domElement.addEventListener('wheel', (e) => { e.preventDefault(); if (this._traditionalControls) { // Traditional: shift+scroll = horizontal pan (we handle, block OrbitControls) 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(); } // Regular scroll: let OrbitControls handle zoom return; } // Default (modern) mode: we handle everything, block OrbitControls scroll-zoom e.stopImmediatePropagation(); if (e.ctrlKey || e.metaKey) { // Ctrl+scroll / trackpad pinch: zoom 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 { // Scroll: pan let dx = e.deltaX; let dy = e.deltaY; // Shift+vertical scroll → horizontal pan (for mouse users) 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 }); // Ctrl/Cmd + left-drag: zoom fallback (Cmd on Mac, Ctrl on Windows/Linux) 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(); // prevent OrbitControls rotate } }, { 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 }; // Start rectangle selection in dado mode (left button only) if (this.cutoutMode && this.isDadoMode && e.button === 0) { this._rectSelecting = true; this._rectStart = { x: e.clientX, y: e.clientY }; // Create overlay div if (!this._rectOverlay) { this._rectOverlay = document.createElement('div'); this._rectOverlay.style.cssText = 'position:fixed;border:2px dashed #f9e2af;background:rgba(249,226,175,0.1);pointer-events:none;z-index:9999;display:none;'; document.body.appendChild(this._rectOverlay); } } }); 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; } // Update rectangle overlay during dado drag if (this._rectSelecting && this._rectStart && this._rectOverlay) { const x1 = Math.min(this._rectStart.x, e.clientX); const y1 = Math.min(this._rectStart.y, e.clientY); const w = Math.abs(e.clientX - this._rectStart.x); const h = Math.abs(e.clientY - this._rectStart.y); this._rectOverlay.style.left = x1 + 'px'; this._rectOverlay.style.top = y1 + 'px'; this._rectOverlay.style.width = w + 'px'; this._rectOverlay.style.height = h + 'px'; this._rectOverlay.style.display = (w > 5 || h > 5) ? 'block' : 'none'; } if (this.cutoutMode && this.elementMeshes.length > 0) { this._updateMouse(e); this.raycaster.setFromCamera(this.mouse, this.camera); const hits = this.raycaster.intersectObjects(this.elementMeshes); const newHover = hits.length > 0 ? this.elementMeshes.indexOf(hits[0].object) : -1; if (newHover !== this.hoveredElement) { if (this.hoveredElement >= 0 && this.hoveredElement < this.elementMeshes.length) { const m = this.elementMeshes[this.hoveredElement]; if (!m.userData.selected) { m.material.opacity = 0.2; m.material.color.setHex(0x89b4fa); } } if (newHover >= 0) { const m = this.elementMeshes[newHover]; if (!m.userData.selected) { m.material.opacity = 0.6; m.material.color.setHex(0xfab387); } } this.hoveredElement = newHover; if (this._onCutoutHover) this._onCutoutHover(newHover); } } }); canvas.addEventListener('mouseup', e => { if (this._rectSelecting && this._rectStart && this._isDragging) { const x1 = Math.min(this._rectStart.x, e.clientX); const y1 = Math.min(this._rectStart.y, e.clientY); const x2 = Math.max(this._rectStart.x, e.clientX); const y2 = Math.max(this._rectStart.y, e.clientY); if (x2 - x1 > 10 && y2 - y1 > 10) { // Select all elements whose projected center falls within the rectangle for (let i = 0; i < this.elementMeshes.length; i++) { const m = this.elementMeshes[i]; if (m.userData.selected) continue; // Project mesh center to screen const pos = m.position.clone(); pos.project(this.camera); const rect = this.renderer.domElement.getBoundingClientRect(); const sx = (pos.x * 0.5 + 0.5) * rect.width + rect.left; const sy = (-pos.y * 0.5 + 0.5) * rect.height + rect.top; if (sx >= x1 && sx <= x2 && sy >= y1 && sy <= y2) { m.userData.selected = true; m.material.color.setHex(0xf9e2af); m.material.opacity = 0.7; this.cutouts.push(this.elements[i]); if (this._onCutoutSelect) this._onCutoutSelect(this.elements[i], true); } } } } this._rectSelecting = false; this._rectStart = null; if (this._rectOverlay) this._rectOverlay.style.display = 'none'; }); canvas.addEventListener('click', e => { if (this._isDragging) return; this._updateMouse(e); this.raycaster.setFromCamera(this.mouse, this.camera); if (this.cutoutMode) { if (this.hoveredElement >= 0 && this.hoveredElement < this.elements.length) { const el = this.elements[this.hoveredElement]; const m = this.elementMeshes[this.hoveredElement]; if (m.userData.selected) { m.userData.selected = false; m.material.color.setHex(0xfab387); m.material.opacity = 0.6; this.cutouts = this.cutouts.filter(c => c.id !== el.id); } else { m.userData.selected = true; const selColor = this.isDadoMode ? 0xf9e2af : 0xa6e3a1; m.material.color.setHex(selColor); m.material.opacity = 0.7; this.cutouts.push(el); } if (this._onCutoutSelect) this._onCutoutSelect(el, m.userData.selected); } } else { // Check for cutout viz click first if (this._cutoutVizMeshes && this._cutoutVizMeshes.length > 0) { const cutoutHits = this.raycaster.intersectObjects(this._cutoutVizMeshes); if (cutoutHits.length > 0) { const hitMesh = cutoutHits[0].object; const cutoutId = hitMesh.userData.cutoutId; if (e.shiftKey) { this._toggleCutoutSelection(cutoutId); } else { this._selectCutout(cutoutId); } return; } } 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) { // For enclosure mesh, check ancestors hitObj.traverseAncestors(p => { const ei = this.layerMeshes.indexOf(p); if (ei >= 0) idx = ei; }); } if (idx >= 0) this.selectLayer(idx); } else { this._deselectAllCutouts(); 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.enclosureMesh = null; this.trayMesh = null; this.enclosureLayerIndex = -1; this.trayLayerIndex = -1; 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); // placeholder, replaced by loadEnclosureGeometry continue; } if (layer.name === 'Tray') { this.trayLayerIndex = i; this.layerMeshes.push(null); // placeholder, replaced by loadEnclosureGeometry 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); } } // ===== 3D Enclosure geometry ===== // Creates full enclosure + tray geometry from the same parameters as the SCAD output. loadEnclosureGeometry(encData, dpi, minX, maxY) { if (!encData || !encData.outlinePoints || encData.outlinePoints.length < 3) return; // Store context for cutout viz and side highlighting this.storeEnclosureContext(encData, dpi, minX, maxY); // Remove previous meshes this._disposeEnclosureMeshes(); const s = dpi / 25.4; // mm to pixels // Convert mm to 3D pixel-space coordinates (Y inverted for image space) const toPixel = (mmX, mmY) => [ (mmX - minX) * s, -(maxY - mmY) * s ]; let pts = encData.outlinePoints.map(p => toPixel(p[0], p[1])); // Strip closing duplicate vertex (Go closes polygons by repeating first point) if (pts.length > 2) { const first = pts[0], last = pts[pts.length - 1]; if (Math.abs(first[0] - last[0]) < 0.01 && Math.abs(first[1] - last[1]) < 0.01) { pts = pts.slice(0, -1); } } // Compute winding and offset function let area = 0; for (let i = 0; i < pts.length; i++) { const j = (i + 1) % pts.length; area += pts[i][0] * pts[j][1] - pts[j][0] * pts[i][1]; } const sign = area < 0 ? 1 : -1; // outward offset sign const offsetPoly = (points, dist) => { const n = points.length; const result = []; const maxMiter = Math.abs(dist) * 2; // clamp miter to 2x offset (bevel-style at sharp corners) for (let i = 0; i < n; i++) { const prev = points[(i - 1 + n) % n]; const curr = points[i]; const next = points[(i + 1) % n]; const e1x = curr[0] - prev[0], e1y = curr[1] - prev[1]; const e2x = next[0] - curr[0], e2y = next[1] - curr[1]; const len1 = Math.sqrt(e1x * e1x + e1y * e1y) || 1; const len2 = Math.sqrt(e2x * e2x + e2y * e2y) || 1; const n1x = -e1y / len1, n1y = e1x / len1; const n2x = -e2y / len2, n2y = e2x / len2; let nx = n1x + n2x, ny = n1y + n2y; const nlen = Math.sqrt(nx * nx + ny * ny) || 1; nx /= nlen; ny /= nlen; const dot = n1x * nx + n1y * ny; const rawMiter = dot > 0.01 ? dist / dot : dist; // Clamp: if corner is too sharp, insert bevel (two points) if (Math.abs(rawMiter) > maxMiter) { const d = dist; result.push([curr[0] + n1x * d, curr[1] + n1y * d]); result.push([curr[0] + n2x * d, curr[1] + n2y * d]); } else { result.push([curr[0] + nx * rawMiter, curr[1] + ny * rawMiter]); } } return result; }; const makeShape = (poly) => { const shape = new THREE.Shape(); shape.moveTo(poly[0][0], poly[0][1]); for (let i = 1; i < poly.length; i++) shape.lineTo(poly[i][0], poly[i][1]); shape.closePath(); return shape; }; const makeHole = (poly) => { const path = new THREE.Path(); path.moveTo(poly[0][0], poly[0][1]); for (let i = 1; i < poly.length; i++) path.lineTo(poly[i][0], poly[i][1]); path.closePath(); return path; }; const makeRing = (outerPoly, innerPoly, depth, zPos) => { const shape = makeShape(outerPoly); shape.holes.push(makeHole(innerPoly)); const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false }); return { geo, zPos }; }; const makeSolid = (poly, depth, zPos) => { const shape = makeShape(poly); const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false }); return { geo, zPos }; }; // Key dimensions from SCAD (converted to pixels for XY, raw mm for Z which we scale) const cl = encData.clearance; const wt = encData.wallThickness; const trayFloor = encData.trayFloor; const snapH = encData.snapHeight; const lidThick = encData.lidThick; const totalH = encData.totalH; // Pre-compute offset polygons in pixel space const polyInner = offsetPoly(pts, sign * cl * s); // offset(clearance) const polyTrayWall = offsetPoly(pts, sign * (cl + wt) * s); // offset(clearance + wt) const polyOuter = offsetPoly(pts, sign * (cl + 2 * wt) * s); // offset(clearance + 2*wt) const enclosureParts = []; const trayParts = []; // ===== ENCLOSURE (lid-on-top piece) ===== // Small epsilon to prevent Z-fighting at shared boundaries const eps = 0.05 * s; // 1. Lid plate: solid from totalH-lidThick to totalH enclosureParts.push(makeSolid(polyOuter, lidThick * s, (totalH - lidThick) * s + eps)); // 2. Upper wall ring: outer to inner, from trayFloor+snapH to totalH-lidThick const upperWallH = totalH - lidThick - (trayFloor + snapH); if (upperWallH > 0.1) { enclosureParts.push(makeRing(polyOuter, polyInner, upperWallH * s - eps, (trayFloor + snapH) * s + eps)); } // 3. Lower wall ring: outer to trayWall, from trayFloor to trayFloor+snapH // (wider inner cavity for tray snap-fit recess) if (snapH > 0.1) { enclosureParts.push(makeRing(polyOuter, polyTrayWall, snapH * s - eps, trayFloor * s)); } // 4. Mounting pegs (cylinders) if (encData.mountingHoles) { for (const h of encData.mountingHoles) { const [px, py] = toPixel(h.x, h.y); const r = ((h.diameter / 2) - 0.15) * s; const pegH = (totalH - lidThick) * s; const cylGeo = new THREE.CylinderGeometry(r, r, pegH, 16); cylGeo.rotateX(Math.PI / 2); // align cylinder with Z axis enclosureParts.push({ geo: cylGeo, zPos: pegH / 2, cx: px, cy: py, isCyl: true }); } } // ===== TRAY ===== // 1. Tray floor: solid, from 0 to trayFloor trayParts.push(makeSolid(polyOuter, trayFloor * s, 0)); // 2. Tray inner wall: ring from trayWall to inner, from trayFloor to trayFloor+snapH if (snapH > 0.1) { trayParts.push(makeRing(polyTrayWall, polyInner, snapH * s - eps, trayFloor * s + eps)); } // Build enclosure group mesh const encMat = new THREE.MeshPhongMaterial({ color: 0xfffdcc, transparent: true, opacity: 0.55, side: THREE.DoubleSide, depthWrite: false, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, }); const encGroup = new THREE.Group(); for (const part of enclosureParts) { const mesh = new THREE.Mesh(part.geo, encMat.clone()); if (part.isCyl) { mesh.position.set(part.cx, part.cy, part.zPos); } else { mesh.position.z = part.zPos; } encGroup.add(mesh); } if (this.enclosureLayerIndex >= 0) { encGroup.position.z = this.enclosureLayerIndex * Z_SPACING; encGroup.userData = { layerIndex: this.enclosureLayerIndex, isEnclosure: true }; const layer = this.layers[this.enclosureLayerIndex]; encGroup.visible = layer ? layer.visible : true; this.enclosureMesh = encGroup; this.layerMeshes[this.enclosureLayerIndex] = encGroup; this.layerGroup.add(encGroup); } // Build tray group mesh const trayMat = new THREE.MeshPhongMaterial({ color: 0xb8c8a0, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, }); const trayGroup = new THREE.Group(); for (const part of trayParts) { const mesh = new THREE.Mesh(part.geo, trayMat.clone()); mesh.position.z = part.zPos; trayGroup.add(mesh); } if (this.trayLayerIndex >= 0) { trayGroup.position.z = this.trayLayerIndex * Z_SPACING; trayGroup.userData = { layerIndex: this.trayLayerIndex, isEnclosure: true }; const layer = this.layers[this.trayLayerIndex]; trayGroup.visible = layer ? layer.visible : false; this.trayMesh = trayGroup; this.layerMeshes[this.trayLayerIndex] = trayGroup; this.layerGroup.add(trayGroup); } } _disposeEnclosureMeshes() { for (const mesh of [this.enclosureMesh, this.trayMesh]) { if (mesh) { this.layerGroup.remove(mesh); mesh.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); }); } } this.enclosureMesh = null; this.trayMesh = null; } // ===== 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) { // Still fire callback so sidebar tools appear if (this._onLayerSelect) this._onLayerSelect(index); return; } // Selection outline (skip for enclosure — too complex) 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); } // Z-axis arrows 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); } } // ===== Cutout mode ===== enterCutoutMode(elements, layerIndex, isDado = false) { this.cutoutMode = true; this.isDadoMode = isDado; this.elements = elements; this.hoveredElement = -1; this.cutouts = []; this._rectSelecting = false; this._rectStart = null; this._rectOverlay = null; while (this.elementGroup.children.length) { const c = this.elementGroup.children[0]; c.geometry?.dispose(); c.material?.dispose(); this.elementGroup.remove(c); } this.elementMeshes = []; const layerMesh = this.layerMeshes[layerIndex]; const layerZ = layerMesh ? layerMesh.position.z : 0; for (const el of elements) { const w = el.maxX - el.minX; const h = el.maxY - el.minY; if (w < 0.5 || h < 0.5) continue; const geo = new THREE.PlaneGeometry(w, h); const mat = new THREE.MeshBasicMaterial({ color: 0x89b4fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); const elCx = el.minX + w / 2; const elCy = el.minY + h / 2; mesh.position.set(elCx, -elCy, layerZ + 0.2); mesh.userData = { elementId: el.id, selected: false }; this.elementGroup.add(mesh); this.elementMeshes.push(mesh); } this.elementGroup.visible = true; this._homeTopDown(layerIndex); } exitCutoutMode() { this.cutoutMode = false; this.isDadoMode = false; this.elements = []; this.hoveredElement = -1; this._rectSelecting = false; this._rectStart = null; if (this._rectOverlay) { this._rectOverlay.remove(); this._rectOverlay = null; } this.elementGroup.visible = false; while (this.elementGroup.children.length) { const c = this.elementGroup.children[0]; c.geometry?.dispose(); c.material?.dispose(); this.elementGroup.remove(c); } this.elementMeshes = []; } // ===== 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; // Slight Y offset avoids gimbal lock at the Z-axis pole; // keep camera.up as Z-up so OrbitControls stays consistent. 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(); } // Switch to solid render preview: show only enclosure+tray opaque, hide all other layers. // Flips the enclosure (lid) upside-down and places it beside the tray so both are visible. enterSolidView() { this._savedVisibility = this.layers.map(l => l.visible); this._savedEnclosurePos = null; this._savedEnclosureRot = null; this._savedTrayPos = null; for (let i = 0; i < this.layers.length; i++) { const mesh = this.layerMeshes[i]; if (!mesh) continue; if (i === this.enclosureLayerIndex || i === this.trayLayerIndex) { mesh.visible = true; mesh.traverse(c => { if (c.material) { c.material.opacity = 1.0; c.material.transparent = false; c.material.depthWrite = true; c.material.side = THREE.FrontSide; c.material.needsUpdate = true; } }); } else { mesh.visible = false; } } // Pop the lid off: flip enclosure 180° around X and lay it beside the tray if (this.enclosureMesh && this.trayMesh) { // Save original transforms for exitSolidView this._savedEnclosurePos = this.enclosureMesh.position.clone(); this._savedEnclosureRot = this.enclosureMesh.quaternion.clone(); this._savedTrayPos = this.trayMesh.position.clone(); this._savedTrayRot = this.trayMesh.quaternion.clone(); // Flip enclosure upside-down (rotate 180° around local X axis) this.enclosureMesh.rotateX(Math.PI); // Rotate tray 180° in XY plane so both face the same direction this.trayMesh.rotateZ(Math.PI); // Put both at same Z layer this.enclosureMesh.position.z = this.trayMesh.position.z; // Compute world bounding boxes after rotation const encBox = new THREE.Box3().setFromObject(this.enclosureMesh); const trayBox = new THREE.Box3().setFromObject(this.trayMesh); // Align Y centers (rotations may shift Y) const encCY = (encBox.min.y + encBox.max.y) / 2; const trayCY = (trayBox.min.y + trayBox.max.y) / 2; this.trayMesh.position.y += encCY - trayCY; // Place tray to the left of enclosure with small padding const trayBox2 = new THREE.Box3().setFromObject(this.trayMesh); const encWidth = encBox.max.x - encBox.min.x; const gap = Math.max(encWidth * 0.05, 5); this.trayMesh.position.x += encBox.min.x - trayBox2.max.x - gap; } this.selectLayer(-1); // Hide cutout visualization, outlines, and side highlights if (this._cutoutVizGroup) this._cutoutVizGroup.visible = false; if (this._cutoutOutlines) { for (const ol of this._cutoutOutlines) ol.visible = false; } this.clearSideHighlight(); this.gridHelper.visible = false; this.scene.background = new THREE.Color(0x1e1e2e); // Solid view lighting: lower ambient + stronger directional for boundary clarity this._ambientLight.intensity = 0.45; this._dirLight.intensity = 0.7; this._dirLight.position.set(1, -1, 2).normalize(); this._solidFillLight = new THREE.DirectionalLight(0xffffff, 0.25); this._solidFillLight.position.set(-1, 1, 0.5).normalize(); this.scene.add(this._solidFillLight); // Center camera on enclosure if (this.enclosureMesh) { const box = new THREE.Box3().setFromObject(this.enclosureMesh); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const dist = Math.max(size.x, size.y, size.z) * 1.2; this.controls.target.copy(center); this.camera.position.set(center.x, center.y - dist * 0.5, center.z + dist * 0.6); this.camera.up.set(0, 0, 1); this.controls.update(); } else { this.resetView(); } } // Return from solid view to normal editor exitSolidView() { this.scene.background = new THREE.Color(0x000000); this.gridHelper.visible = this.gridVisible; // Restore base lighting this._ambientLight.intensity = 0.9; this._dirLight.intensity = 0.3; this._dirLight.position.set(50, -50, 100); if (this._solidFillLight) { this.scene.remove(this._solidFillLight); this._solidFillLight = null; } // If WASM-rendered STL meshes are loaded, dispose them and reload approximate geometry if (this._renderedSTLLoaded) { this._disposeEnclosureMeshes(); this._renderedSTLLoaded = false; // Reload approximate geometry if we have enclosure context if (this._encData && this._dpi && this._minX !== undefined && this._maxY !== undefined) { this.loadEnclosureGeometry(this._encData, this._dpi, this._minX, this._maxY); } } else { // Restore saved transforms (from lid separation) if (this._savedEnclosurePos && this.enclosureMesh) { this.enclosureMesh.position.copy(this._savedEnclosurePos); this.enclosureMesh.quaternion.copy(this._savedEnclosureRot); } if (this._savedTrayPos && this.trayMesh) { this.trayMesh.position.copy(this._savedTrayPos); if (this._savedTrayRot) this.trayMesh.quaternion.copy(this._savedTrayRot); } } this._savedEnclosurePos = null; this._savedEnclosureRot = null; this._savedTrayPos = null; this._savedTrayRot = null; for (let i = 0; i < this.layers.length; i++) { const mesh = this.layerMeshes[i]; if (!mesh) continue; const wasVisible = this._savedVisibility ? this._savedVisibility[i] : this.layers[i].visible; this.layers[i].visible = wasVisible; mesh.visible = wasVisible; if (i === this.enclosureLayerIndex || i === this.trayLayerIndex) { const baseOpacity = i === this.enclosureLayerIndex ? 0.55 : 0.5; mesh.traverse(c => { if (c.material) { c.material.opacity = baseOpacity; c.material.transparent = true; c.material.depthWrite = false; c.material.side = THREE.DoubleSide; c.material.needsUpdate = true; } }); } } // Restore cutout visualization if (this._cutoutVizGroup) this._cutoutVizGroup.visible = true; if (this._cutoutOutlines) { for (const ol of this._cutoutOutlines) ol.visible = true; } this._savedVisibility = null; this.resetView(); } // ===== Placed cutout selection ===== _selectCutout(cutoutId) { this.selectedCutoutIds = new Set([cutoutId]); this._updateCutoutHighlights(); if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([...this.selectedCutoutIds]); } _toggleCutoutSelection(cutoutId) { if (!this.selectedCutoutIds) this.selectedCutoutIds = new Set(); if (this.selectedCutoutIds.has(cutoutId)) { this.selectedCutoutIds.delete(cutoutId); } else { this.selectedCutoutIds.add(cutoutId); } this._updateCutoutHighlights(); if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([...this.selectedCutoutIds]); } _deselectAllCutouts() { if (this.selectedCutoutIds && this.selectedCutoutIds.size > 0) { this.selectedCutoutIds = new Set(); this._updateCutoutHighlights(); if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([]); } } _updateCutoutHighlights() { // Remove old outlines if (this._cutoutOutlines) { for (const ol of this._cutoutOutlines) { this.scene.remove(ol); ol.geometry.dispose(); ol.material.dispose(); } } this._cutoutOutlines = []; if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return; for (const mesh of this._cutoutVizMeshes) { const isSelected = this.selectedCutoutIds.has(mesh.userData.cutoutId); mesh.material.opacity = isSelected ? 0.9 : 0.6; if (isSelected) { 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.quaternion.copy(mesh.quaternion); this._cutoutOutlines.push(line); this.scene.add(line); } } } onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; } getSelectedCutouts() { if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return []; return this._cutoutVizMeshes .filter(m => this.selectedCutoutIds.has(m.userData.cutoutId)) .map(m => m.userData.cutout); } // ===== Store enclosure context for cutout viz / side highlight ===== storeEnclosureContext(encData, dpi, minX, maxY) { this._encData = encData; this._dpi = dpi; this._minX = minX; this._maxY = maxY; this._s = dpi / 25.4; } _toPixel(mmX, mmY) { const s = this._s; return [(mmX - this._minX) * s, -(this._maxY - mmY) * s]; } // ===== Side Highlight ===== highlightSide(sideNum) { this.clearSideHighlight(); if (!this._encData) return; const side = this._encData.sides.find(s => s.num === sideNum); if (!side) return; const s = this._s; const [startPx, startPy] = this._toPixel(side.startX, side.startY); const [endPx, endPy] = this._toPixel(side.endX, side.endY); const dx = endPx - startPx; const dy = endPy - startPy; const len = Math.sqrt(dx * dx + dy * dy); const totalH = this._encData.totalH * s; const cl = this._encData.clearance; const wt = this._encData.wallThickness; const geo = new THREE.PlaneGeometry(len, totalH); const sideColors = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0x8b5cf6, 0xec4899, 0x14b8a6, 0xf97316]; const color = sideColors[(sideNum - 1) % sideColors.length]; const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.4, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); // Position at wall midpoint const midX = (startPx + endPx) / 2; const midY = (startPy + endPy) / 2; const wallAngle = Math.atan2(dy, dx); // Offset outward to wall exterior const offset = (cl + wt) * s; const nx = Math.cos(side.angle); const ny = -Math.sin(side.angle); const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; mesh.position.set( midX + nx * offset, midY + ny * offset, encZ + totalH / 2 ); // Rotate: first face XY plane upright, then rotate around Z to match wall direction mesh.rotation.set(Math.PI / 2, 0, wallAngle); mesh.rotation.order = 'ZXY'; mesh.rotation.set(0, 0, 0); // Build rotation manually: wall runs along wallAngle in XY, and is vertical in Z mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); this._sideHighlightMesh = mesh; this.scene.add(mesh); // Add label sprite const canvas2d = document.createElement('canvas'); canvas2d.width = 64; canvas2d.height = 64; const ctx = canvas2d.getContext('2d'); ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`; ctx.beginPath(); ctx.arc(32, 32, 28, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'white'; ctx.font = 'bold 32px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(sideNum.toString(), 32, 33); const tex = new THREE.CanvasTexture(canvas2d); const spriteMat = new THREE.SpriteMaterial({ map: tex, depthWrite: false }); const sprite = new THREE.Sprite(spriteMat); const labelScale = Math.max(len, totalH) * 0.15; sprite.scale.set(labelScale, labelScale, 1); sprite.position.set( midX + nx * offset * 1.5, midY + ny * offset * 1.5, encZ + totalH / 2 ); this._sideHighlightLabel = sprite; this.scene.add(sprite); } clearSideHighlight() { if (this._sideHighlightMesh) { this.scene.remove(this._sideHighlightMesh); this._sideHighlightMesh.geometry.dispose(); this._sideHighlightMesh.material.dispose(); this._sideHighlightMesh = null; } if (this._sideHighlightLabel) { this.scene.remove(this._sideHighlightLabel); this._sideHighlightLabel.material.map.dispose(); this._sideHighlightLabel.material.dispose(); this._sideHighlightLabel = null; } } lookAtSide(sideNum) { if (!this._encData) return; const side = this._encData.sides.find(s => s.num === sideNum); if (!side) return; const s = this._s; const [startPx, startPy] = this._toPixel(side.startX, side.startY); const [endPx, endPy] = this._toPixel(side.endX, side.endY); const midX = (startPx + endPx) / 2; const midY = (startPy + endPy) / 2; const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; const totalH = this._encData.totalH * s; const midZ = encZ + totalH / 2; const nx = Math.cos(side.angle); const ny = -Math.sin(side.angle); const dist = Math.max(this._maxW || 500, this._maxH || 500) * 0.5; this.camera.position.set(midX + nx * dist, midY + ny * dist, midZ); this.camera.up.set(0, 0, 1); this.controls.target.set(midX, midY, midZ); this.controls.update(); } // ===== Cutout Visualization ===== // Create a rounded rectangle geometry for cutout visualization _makeCutoutGeo(w, h, r) { if (!r || r <= 0) return new THREE.PlaneGeometry(w, h); // Clamp radius to half the smallest dimension const cr = Math.min(r, w / 2, h / 2); const hw = w / 2, hh = h / 2; const shape = new THREE.Shape(); shape.moveTo(-hw + cr, -hh); shape.lineTo(hw - cr, -hh); shape.quadraticCurveTo(hw, -hh, hw, -hh + cr); shape.lineTo(hw, hh - cr); shape.quadraticCurveTo(hw, hh, hw - cr, hh); shape.lineTo(-hw + cr, hh); shape.quadraticCurveTo(-hw, hh, -hw, hh - cr); shape.lineTo(-hw, -hh + cr); shape.quadraticCurveTo(-hw, -hh, -hw + cr, -hh); return new THREE.ShapeGeometry(shape); } refreshCutouts(cutouts, encData, dpi, minX, maxY) { this._disposeCutoutViz(); if (!cutouts || cutouts.length === 0 || !encData) return; this.storeEnclosureContext(encData, dpi, minX, maxY); const s = this._s; this._cutoutVizGroup = new THREE.Group(); this._cutoutVizMeshes = []; const cl = encData.clearance; const wt = encData.wallThickness; const trayFloor = encData.trayFloor; const pcbT = encData.pcbThickness; const totalH = encData.totalH; const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; for (const c of cutouts) { let mesh; const color = c.isDado ? 0xf9e2af : 0xa6e3a1; const cr = (c.r || 0) * s; // corner radius in pixel-space if (c.surface === 'top') { const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr); const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6, side: THREE.DoubleSide, depthWrite: false, }); mesh = new THREE.Mesh(geo, mat); const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); mesh.position.set(px, py, encZ + totalH * s + 0.5); } else if (c.surface === 'bottom') { const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr); const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6, side: THREE.DoubleSide, depthWrite: false, }); mesh = new THREE.Mesh(geo, mat); const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); mesh.position.set(px, py, encZ - 0.5); } else if (c.surface === 'side') { const side = encData.sides.find(sd => sd.num === c.sideNum); if (!side) continue; const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr); const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6, side: THREE.DoubleSide, depthWrite: false, }); mesh = new THREE.Mesh(geo, mat); // Position along the side edge const sdx = side.endX - side.startX; const sdy = side.endY - side.startY; const sLen = Math.sqrt(sdx * sdx + sdy * sdy); const ux = sdx / sLen, uy = sdy / sLen; const midAlongSide = c.x + c.w / 2; const mmX = side.startX + ux * midAlongSide; const mmY = side.startY + uy * midAlongSide; const [px, py] = this._toPixel(mmX, mmY); // Z = trayFloor + pcbT + y + h/2 const zMM = trayFloor + pcbT + c.y + c.h / 2; // Offset outward to wall exterior const offset = (cl + wt) * s; const nx = Math.cos(side.angle); const ny = -Math.sin(side.angle); mesh.position.set( px + nx * offset, py + ny * offset, encZ + zMM * s ); // Rotate to face outward along the wall const wallAngle = Math.atan2( this._toPixel(side.endX, side.endY)[1] - this._toPixel(side.startX, side.startY)[1], this._toPixel(side.endX, side.endY)[0] - this._toPixel(side.startX, side.startY)[0] ); mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); } if (mesh) { mesh.userData.cutoutId = c.id; mesh.userData.cutout = c; this._cutoutVizGroup.add(mesh); this._cutoutVizMeshes.push(mesh); } } this.scene.add(this._cutoutVizGroup); } _disposeCutoutViz() { if (this._cutoutVizGroup) { this.scene.remove(this._cutoutVizGroup); this._cutoutVizGroup.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); }); this._cutoutVizGroup = null; } this._cutoutVizMeshes = []; } // ===== Side Placement Mode ===== // Enter mode where ghost cutouts follow mouse on a side wall. // Returns a promise that resolves with array of {y} positions when user clicks to place. enterSidePlacementMode(projectedCutouts, sideNum) { return new Promise((resolve) => { if (!this._encData) { resolve(null); return; } const side = this._encData.sides.find(sd => sd.num === sideNum); if (!side) { resolve(null); return; } const s = this._s; const cl = this._encData.clearance; const wt = this._encData.wallThickness; const trayFloor = this._encData.trayFloor; const pcbT = this._encData.pcbThickness; const totalH = this._encData.totalH; const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; // Compute wall geometry const [startPx, startPy] = this._toPixel(side.startX, side.startY); const [endPx, endPy] = this._toPixel(side.endX, side.endY); const wallDx = endPx - startPx; const wallDy = endPy - startPy; const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy); const wallAngle = Math.atan2(wallDy, wallDx); const nx = Math.cos(side.angle); const ny = -Math.sin(side.angle); const offset = (cl + wt) * s; // Move camera to face the side this.lookAtSide(sideNum); this.highlightSide(sideNum); // Create invisible raycast plane covering the side wall const planeW = wallLen * 2; const planeH = totalH * s * 2; const planeGeo = new THREE.PlaneGeometry(planeW, planeH); const planeMat = new THREE.MeshBasicMaterial({ visible: false, side: THREE.DoubleSide }); const planeMesh = new THREE.Mesh(planeGeo, planeMat); const midX = (startPx + endPx) / 2 + nx * offset; const midY = (startPy + endPy) / 2 + ny * offset; planeMesh.position.set(midX, midY, encZ + totalH * s / 2); planeMesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); this.scene.add(planeMesh); // Create ghost meshes const ghostMeshes = []; for (const pc of projectedCutouts) { const geo = new THREE.PlaneGeometry(pc.width * s, pc.height * s); const mat = new THREE.MeshBasicMaterial({ color: 0xa6e3a1, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); // Position along wall at projected X const ux = wallDx / wallLen, uy = wallDy / wallLen; const t = pc.x + pc.width / 2; // center along side in mm const mmX = side.startX + (ux / s) * t * s; // hmm, just use side coords const posX = side.startX + (side.endX - side.startX) * (t / side.length); const posY = side.startY + (side.endY - side.startY) * (t / side.length); const [px, py] = this._toPixel(posX, posY); mesh.position.set(px + nx * offset, py + ny * offset, encZ + totalH * s / 2); mesh.userData = { projectedCutout: pc }; this.scene.add(mesh); ghostMeshes.push(mesh); } // Default Y (mm above PCB) = wall center const wallHeight = totalH - trayFloor - pcbT; let currentYMM = wallHeight / 2; const updateGhostZ = (zMM) => { for (const gm of ghostMeshes) { const fullZ = trayFloor + pcbT + zMM; gm.position.z = encZ + fullZ * s; } }; updateGhostZ(currentYMM); // Mouse move handler: update Z from raycast const moveHandler = (e) => { this._updateMouse(e); this.raycaster.setFromCamera(this.mouse, this.camera); const hits = this.raycaster.intersectObject(planeMesh); if (hits.length > 0) { const hitZ = hits[0].point.z; // Convert back to mm const zMM = (hitZ - encZ) / s; // Y above PCB const yAbovePCB = zMM - trayFloor - pcbT; const maxH = Math.max(...projectedCutouts.map(pc => pc.height)); const clamped = Math.max(0, Math.min(wallHeight - maxH, yAbovePCB - maxH / 2)); currentYMM = clamped; updateGhostZ(currentYMM); } }; const canvas = this.renderer.domElement; // Click handler: place and resolve const clickHandler = (e) => { cleanup(); const results = projectedCutouts.map(pc => ({ x: pc.x, y: currentYMM, width: pc.width, height: pc.height, })); resolve(results); }; // Escape handler: cancel const escHandler = (e) => { if (e.key === 'Escape') { cleanup(); resolve(null); } }; const cleanup = () => { canvas.removeEventListener('mousemove', moveHandler); canvas.removeEventListener('click', clickHandler); document.removeEventListener('keydown', escHandler); this.scene.remove(planeMesh); planeGeo.dispose(); planeMat.dispose(); for (const gm of ghostMeshes) { this.scene.remove(gm); gm.geometry.dispose(); gm.material.dispose(); } this.clearSideHighlight(); }; canvas.addEventListener('mousemove', moveHandler); canvas.addEventListener('click', clickHandler); document.addEventListener('keydown', escHandler); }); } // Callbacks onLayerSelect(cb) { this._onLayerSelect = cb; } onCutoutSelect(cb) { this._onCutoutSelect = cb; } onCutoutHover(cb) { this._onCutoutHover = cb; } // Load real SCAD-rendered binary STL into the viewer, replacing the approximate geometry. // enclosureArrayBuffer and trayArrayBuffer are ArrayBuffer of binary STL data. // The STL is in mm, centered at origin by the SCAD translate([-centerX, -centerY, 0]). // We need to convert from mm to pixel-space to match the existing viewer coordinate system. loadRenderedSTL(enclosureArrayBuffer, trayArrayBuffer) { dbg('loadRenderedSTL: called, encBuf=', enclosureArrayBuffer?.byteLength || 0, 'trayBuf=', trayArrayBuffer?.byteLength || 0); this._disposeEnclosureMeshes(); const loader = new STLLoader(); const s = (this._dpi || 600) / 25.4; // mm to pixel-space scale const minX = this._minX || 0; const maxY = this._maxY || 0; dbg('loadRenderedSTL: scale=', s, 'minX=', minX, 'maxY=', maxY); dbg('loadRenderedSTL: _maxW=', this._maxW, '_maxH=', this._maxH); // The SCAD output is centered at origin via translate([-centerX, -centerY, 0]). // centerX/Y are the midpoint of the outline bounds in mm. // In our viewer, pixel coords are: px = (mmX - minX) * s, py = -(maxY - mmY) * s // At origin (0,0) in SCAD space, the mm coords were (centerX, centerY). // So we offset the STL group to place it at the correct pixel position. const encData = this._encData; let centerX = 0, centerY = 0; if (encData) { const bounds = { minX: minX, maxX: minX + (this._maxW || 500) / s, minY: maxY - (this._maxH || 500) / s, maxY: maxY, }; dbg('loadRenderedSTL: computed bounds=', JSON.stringify(bounds)); // The SCAD code uses: translate([-centerX, -centerY, 0]) // where centerX/Y come from cfg.OutlineBounds // These are the same bounds we have stored. Let's compute them. if (encData.outlinePoints && encData.outlinePoints.length > 0) { let bminX = Infinity, bmaxX = -Infinity, bminY = Infinity, bmaxY = -Infinity; for (const p of encData.outlinePoints) { if (p[0] < bminX) bminX = p[0]; if (p[0] > bmaxX) bmaxX = p[0]; if (p[1] < bminY) bminY = p[1]; if (p[1] > bmaxY) bmaxY = p[1]; } dbg('loadRenderedSTL: outline bbox (mm):', bminX, bminY, bmaxX, bmaxY); // The SCAD OutlineBounds include margin. But the centering in SCAD uses // cfg.OutlineBounds which IS the session OutlineBounds (with margin). // The viewer's minX and maxY ARE from those same bounds. centerX = (minX + (minX + (this._maxW || 500) / s)) / 2; centerY = (maxY + (maxY - (this._maxH || 500) / s)) / 2; dbg('loadRenderedSTL: centerX=', centerX, 'centerY=', centerY); } } else { dbg('loadRenderedSTL: WARNING no encData stored'); } // Position in pixel-space where the SCAD origin maps to const originPx = (0 + centerX - minX) * s; const originPy = -(maxY - (0 + centerY)) * s; dbg('loadRenderedSTL: originPx=', originPx, 'originPy=', originPy); const createMeshFromSTL = (arrayBuffer, color, label) => { dbg(`loadRenderedSTL: parsing ${label} STL (${arrayBuffer.byteLength} bytes)...`); const geometry = loader.parse(arrayBuffer); dbg(`loadRenderedSTL: ${label} parsed, vertices=${geometry.attributes.position?.count || 0}`); // Scale from mm to pixel-space and flip Y geometry.scale(s, s, s); // Flip the Y axis: SCAD Y+ is up, viewer Y is inverted (image space) geometry.scale(1, -1, 1); // The Y-mirror reverses triangle winding, making normals point inward. // Flip winding order so FrontSide renders the outside correctly. const pos = geometry.attributes.position.array; for (let i = 0; i < pos.length; i += 9) { // Swap vertices 1 and 2 of each triangle for (let j = 0; j < 3; j++) { const tmp = pos[i + 3 + j]; pos[i + 3 + j] = pos[i + 6 + j]; pos[i + 6 + j] = tmp; } } geometry.attributes.position.needsUpdate = true; geometry.computeBoundingBox(); dbg(`loadRenderedSTL: ${label} bbox after scale:`, geometry.boundingBox?.min?.toArray(), geometry.boundingBox?.max?.toArray()); // Use MeshStandardMaterial for proper solid rendering — no polygon offset // needed since this is real CSG geometry (no coplanar faces to fight). geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness: 0.0, side: THREE.FrontSide, flatShading: true, }); const mesh = new THREE.Mesh(geometry, material); // Position at the pixel-space location of the SCAD origin mesh.position.set(originPx, originPy, 0); dbg(`loadRenderedSTL: ${label} mesh positioned at (${originPx}, ${originPy}, 0)`); return mesh; }; // Enclosure if (enclosureArrayBuffer && enclosureArrayBuffer.byteLength > 84) { dbg('loadRenderedSTL: creating enclosure mesh, layerIndex=', this.enclosureLayerIndex); const encGroup = new THREE.Group(); const encMesh = createMeshFromSTL(enclosureArrayBuffer, 0xffe090, 'enclosure'); encGroup.add(encMesh); if (this.enclosureLayerIndex >= 0) { encGroup.position.z = this.enclosureLayerIndex * Z_SPACING; encGroup.userData = { layerIndex: this.enclosureLayerIndex, isEnclosure: true }; const layer = this.layers[this.enclosureLayerIndex]; encGroup.visible = layer ? layer.visible : true; this.enclosureMesh = encGroup; this.layerMeshes[this.enclosureLayerIndex] = encGroup; this.layerGroup.add(encGroup); dbg('loadRenderedSTL: enclosure added to scene at z=', encGroup.position.z); } else { dbg('loadRenderedSTL: WARNING enclosureLayerIndex < 0, enclosure not added'); } } else { dbg('loadRenderedSTL: skipping enclosure (no data or too small)'); } // Tray if (trayArrayBuffer && trayArrayBuffer.byteLength > 84) { dbg('loadRenderedSTL: creating tray mesh, layerIndex=', this.trayLayerIndex); const trayGroup = new THREE.Group(); const trayMesh = createMeshFromSTL(trayArrayBuffer, 0xa0d880, 'tray'); trayGroup.add(trayMesh); if (this.trayLayerIndex >= 0) { trayGroup.position.z = this.trayLayerIndex * Z_SPACING; trayGroup.userData = { layerIndex: this.trayLayerIndex, isEnclosure: true }; const layer = this.layers[this.trayLayerIndex]; trayGroup.visible = layer ? layer.visible : false; this.trayMesh = trayGroup; this.layerMeshes[this.trayLayerIndex] = trayGroup; this.layerGroup.add(trayGroup); dbg('loadRenderedSTL: tray added to scene at z=', trayGroup.position.z); } else { dbg('loadRenderedSTL: WARNING trayLayerIndex < 0, tray not added'); } } else { dbg('loadRenderedSTL: skipping tray (no data or too small)'); } this._renderedSTLLoaded = true; dbg('loadRenderedSTL: complete'); } 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 }; } } dispose() { if (this._ctrlDragCleanup) this._ctrlDragCleanup(); if (this._animId) cancelAnimationFrame(this._animId); if (this._resizeObserver) this._resizeObserver.disconnect(); if (this._rectOverlay) { this._rectOverlay.remove(); this._rectOverlay = null; } this.clearSideHighlight(); this._disposeCutoutViz(); this.controls.dispose(); this.renderer.dispose(); if (this.renderer.domElement.parentNode) { this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); } } }