1751 lines
72 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|