Former/frontend/src/former3d.js

1751 lines
72 KiB
JavaScript

// 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);
}
}
}