@@ -30,14 +36,18 @@
-
+
+
+
+
+
diff --git a/frontend/src/engine/former-engine.js b/frontend/src/engine/former-engine.js
new file mode 100644
index 0000000..85a3207
--- /dev/null
+++ b/frontend/src/engine/former-engine.js
@@ -0,0 +1,652 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { STLLoader } from 'three/addons/loaders/STLLoader.js';
+
+export const Z_SPACING = 3;
+
+export function dbg(...args) {
+ try {
+ const fn = window?.go?.main?.App?.JSDebugLog;
+ if (!fn) return;
+ const msg = '[engine] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
+ fn(msg);
+ } catch (_) {}
+}
+
+export class FormerEngine {
+ constructor(container) {
+ this.container = container;
+ this.layers = [];
+ this.layerMeshes = [];
+ this.selectedLayerIndex = -1;
+ this.enclosureLayerIndex = -1;
+ this.trayLayerIndex = -1;
+
+ this.layerGroup = null;
+ this.arrowGroup = null;
+ this.elementGroup = null;
+ this.selectionOutline = null;
+
+ this._onLayerSelect = null;
+ this._onCutoutSelect = null;
+ this._onCutoutHover = null;
+ this._onPlacedCutoutSelect = null;
+
+ this._modes = {};
+
+ this._clickHandlers = [];
+ this._mouseMoveHandlers = [];
+ this._mouseDownHandlers = [];
+ this._mouseUpHandlers = [];
+
+ this._initScene();
+ this._initControls();
+ this._initGrid();
+ this._initRaycasting();
+ this._animate();
+ }
+
+ // ===== Plugin API =====
+
+ use(mode) {
+ this._modes[mode.name] = mode;
+ mode.install(this);
+ }
+
+ getMode(name) {
+ return this._modes[name];
+ }
+
+ registerClickHandler(handler) { this._clickHandlers.push(handler); }
+ unregisterClickHandler(handler) {
+ const i = this._clickHandlers.indexOf(handler);
+ if (i >= 0) this._clickHandlers.splice(i, 1);
+ }
+
+ registerMouseMoveHandler(handler) { this._mouseMoveHandlers.push(handler); }
+ unregisterMouseMoveHandler(handler) {
+ const i = this._mouseMoveHandlers.indexOf(handler);
+ if (i >= 0) this._mouseMoveHandlers.splice(i, 1);
+ }
+
+ registerMouseDownHandler(handler) { this._mouseDownHandlers.push(handler); }
+ unregisterMouseDownHandler(handler) {
+ const i = this._mouseDownHandlers.indexOf(handler);
+ if (i >= 0) this._mouseDownHandlers.splice(i, 1);
+ }
+
+ registerMouseUpHandler(handler) { this._mouseUpHandlers.push(handler); }
+ unregisterMouseUpHandler(handler) {
+ const i = this._mouseUpHandlers.indexOf(handler);
+ if (i >= 0) this._mouseUpHandlers.splice(i, 1);
+ }
+
+ // ===== Callbacks =====
+
+ onLayerSelect(cb) { this._onLayerSelect = cb; }
+ onCutoutSelect(cb) { this._onCutoutSelect = cb; }
+ onCutoutHover(cb) { this._onCutoutHover = cb; }
+ onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; }
+
+ // ===== Scene Setup =====
+
+ _initScene() {
+ this.scene = new THREE.Scene();
+ this.scene.background = new THREE.Color(0x000000);
+
+ const w = this.container.clientWidth;
+ const h = this.container.clientHeight;
+ this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 50000);
+ this.camera.position.set(0, -60, 80);
+ this.camera.up.set(0, 0, 1);
+ this.camera.lookAt(0, 0, 0);
+
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true });
+ this.renderer.setSize(w, h);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ this.container.appendChild(this.renderer.domElement);
+
+ this._ambientLight = new THREE.AmbientLight(0xffffff, 0.9);
+ this.scene.add(this._ambientLight);
+ this._dirLight = new THREE.DirectionalLight(0xffffff, 0.3);
+ this._dirLight.position.set(50, -50, 100);
+ this.scene.add(this._dirLight);
+
+ this.layerGroup = new THREE.Group();
+ this.scene.add(this.layerGroup);
+
+ this.arrowGroup = new THREE.Group();
+ this.arrowGroup.visible = false;
+ this.scene.add(this.arrowGroup);
+
+ this.elementGroup = new THREE.Group();
+ this.elementGroup.visible = false;
+ this.scene.add(this.elementGroup);
+
+ this._resizeObserver = new ResizeObserver(() => this._onResize());
+ this._resizeObserver.observe(this.container);
+ }
+
+ _initControls() {
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.1;
+ this.controls.enableZoom = true;
+ this.controls.mouseButtons = {
+ LEFT: THREE.MOUSE.ROTATE,
+ MIDDLE: THREE.MOUSE.PAN,
+ RIGHT: THREE.MOUSE.DOLLY
+ };
+ this.controls.target.set(0, 0, 0);
+ this.controls.update();
+
+ this.renderer.domElement.addEventListener('wheel', (e) => {
+ e.preventDefault();
+
+ if (this._traditionalControls) {
+ if (e.shiftKey && e.deltaX === 0) {
+ e.stopImmediatePropagation();
+ const dist = this.camera.position.distanceTo(this.controls.target);
+ const panSpeed = dist * 0.001;
+ const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0);
+ const offset = new THREE.Vector3();
+ offset.addScaledVector(right, e.deltaY * panSpeed);
+ this.camera.position.add(offset);
+ this.controls.target.add(offset);
+ this.controls.update();
+ }
+ return;
+ }
+
+ e.stopImmediatePropagation();
+
+ if (e.ctrlKey || e.metaKey) {
+ const factor = 1 + e.deltaY * 0.01;
+ const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
+ dir.multiplyScalar(factor);
+ this.camera.position.copy(this.controls.target).add(dir);
+ } else {
+ let dx = e.deltaX;
+ let dy = e.deltaY;
+ if (e.shiftKey && dx === 0) {
+ dx = dy;
+ dy = 0;
+ }
+ const dist = this.camera.position.distanceTo(this.controls.target);
+ const panSpeed = dist * 0.001;
+ const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0);
+ const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1);
+ const offset = new THREE.Vector3();
+ offset.addScaledVector(right, dx * panSpeed);
+ offset.addScaledVector(up, -dy * panSpeed);
+ this.camera.position.add(offset);
+ this.controls.target.add(offset);
+ }
+ this.controls.update();
+ }, { passive: false, capture: true });
+
+ this._ctrlDragging = false;
+ this._ctrlDragLastY = 0;
+
+ this.renderer.domElement.addEventListener('mousedown', (e) => {
+ if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
+ this._ctrlDragging = true;
+ this._ctrlDragLastY = e.clientY;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ }
+ }, { capture: true });
+
+ const onCtrlDragMove = (e) => {
+ if (!this._ctrlDragging) return;
+ const dy = e.clientY - this._ctrlDragLastY;
+ this._ctrlDragLastY = e.clientY;
+ const factor = 1 + dy * 0.005;
+ const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
+ dir.multiplyScalar(factor);
+ this.camera.position.copy(this.controls.target).add(dir);
+ this.controls.update();
+ };
+
+ const onCtrlDragUp = () => {
+ this._ctrlDragging = false;
+ };
+
+ window.addEventListener('mousemove', onCtrlDragMove);
+ window.addEventListener('mouseup', onCtrlDragUp);
+ this._ctrlDragCleanup = () => {
+ window.removeEventListener('mousemove', onCtrlDragMove);
+ window.removeEventListener('mouseup', onCtrlDragUp);
+ };
+ }
+
+ _initGrid() {
+ this.gridHelper = new THREE.GridHelper(2000, 100, 0x333333, 0x222222);
+ this.gridHelper.rotation.x = Math.PI / 2;
+ this.gridHelper.position.z = -0.5;
+ this.scene.add(this.gridHelper);
+ this.gridVisible = true;
+ }
+
+ toggleGrid() {
+ this.gridVisible = !this.gridVisible;
+ this.gridHelper.visible = this.gridVisible;
+ return this.gridVisible;
+ }
+
+ _initRaycasting() {
+ this.raycaster = new THREE.Raycaster();
+ this.mouse = new THREE.Vector2();
+ this._isDragging = false;
+ this._mouseDownPos = { x: 0, y: 0 };
+
+ const canvas = this.renderer.domElement;
+
+ canvas.addEventListener('mousedown', e => {
+ this._isDragging = false;
+ this._mouseDownPos = { x: e.clientX, y: e.clientY };
+ for (const h of this._mouseDownHandlers) h(e);
+ });
+
+ canvas.addEventListener('mousemove', e => {
+ const dx = e.clientX - this._mouseDownPos.x;
+ const dy = e.clientY - this._mouseDownPos.y;
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._isDragging = true;
+ for (const h of this._mouseMoveHandlers) h(e);
+ });
+
+ canvas.addEventListener('mouseup', e => {
+ for (const h of this._mouseUpHandlers) h(e);
+ });
+
+ canvas.addEventListener('click', e => {
+ if (this._isDragging) return;
+ this._updateMouse(e);
+ this.raycaster.setFromCamera(this.mouse, this.camera);
+
+ for (const h of this._clickHandlers) {
+ if (h(e)) return;
+ }
+
+ // Default: layer selection
+ const clickables = this.layerMeshes.filter((m, i) => m && this.layers[i]?.visible);
+ const hits = this.raycaster.intersectObjects(clickables, true);
+ if (hits.length > 0) {
+ let hitObj = hits[0].object;
+ let idx = this.layerMeshes.indexOf(hitObj);
+ if (idx < 0) {
+ hitObj.traverseAncestors(p => {
+ const ei = this.layerMeshes.indexOf(p);
+ if (ei >= 0) idx = ei;
+ });
+ }
+ if (idx >= 0) this.selectLayer(idx);
+ } else {
+ this.selectLayer(-1);
+ }
+ });
+ }
+
+ _updateMouse(e) {
+ const rect = this.renderer.domElement.getBoundingClientRect();
+ this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
+ }
+
+ _onResize() {
+ const w = this.container.clientWidth;
+ const h = this.container.clientHeight;
+ if (w === 0 || h === 0) return;
+ this.camera.aspect = w / h;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(w, h);
+ }
+
+ _animate() {
+ this._animId = requestAnimationFrame(() => this._animate());
+ this.controls.update();
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ // ===== Layer Loading =====
+
+ async loadLayers(layers, imageUrls) {
+ this.layers = layers;
+ const loader = new THREE.TextureLoader();
+
+ while (this.layerGroup.children.length > 0) {
+ const child = this.layerGroup.children[0];
+ if (child.geometry) child.geometry.dispose();
+ if (child.material) {
+ if (child.material.map) child.material.map.dispose();
+ child.material.dispose();
+ }
+ this.layerGroup.remove(child);
+ }
+ this.layerMeshes = [];
+ this.enclosureLayerIndex = -1;
+ this.trayLayerIndex = -1;
+
+ // Reset enclosure mode state if present
+ const enc = this.getMode('enclosure');
+ if (enc) {
+ enc.enclosureMesh = null;
+ enc.trayMesh = null;
+ }
+
+ let maxW = 0, maxH = 0;
+
+ for (let i = 0; i < layers.length; i++) {
+ const layer = layers[i];
+ const url = imageUrls[i];
+
+ if (layer.name === 'Enclosure') {
+ this.enclosureLayerIndex = i;
+ this.layerMeshes.push(null);
+ continue;
+ }
+ if (layer.name === 'Tray') {
+ this.trayLayerIndex = i;
+ this.layerMeshes.push(null);
+ continue;
+ }
+
+ if (!url) { this.layerMeshes.push(null); continue; }
+
+ try {
+ const tex = await new Promise((resolve, reject) => {
+ loader.load(url, resolve, undefined, reject);
+ });
+ tex.minFilter = THREE.LinearFilter;
+ tex.magFilter = THREE.LinearFilter;
+
+ const imgW = tex.image.width;
+ const imgH = tex.image.height;
+ if (imgW > maxW) maxW = imgW;
+ if (imgH > maxH) maxH = imgH;
+
+ const geo = new THREE.PlaneGeometry(imgW, imgH);
+ const mat = new THREE.MeshBasicMaterial({
+ map: tex,
+ transparent: true,
+ opacity: layer.visible ? layer.baseAlpha : 0,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ });
+
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(imgW / 2, -imgH / 2, i * Z_SPACING);
+ mesh.visible = layer.visible;
+ mesh.userData = { layerIndex: i };
+
+ this.layerGroup.add(mesh);
+ this.layerMeshes.push(mesh);
+ } catch (e) {
+ console.warn(`Failed to load layer ${i}:`, e);
+ this.layerMeshes.push(null);
+ }
+ }
+
+ this._maxW = maxW;
+ this._maxH = maxH;
+
+ if (maxW > 0 && maxH > 0) {
+ const cx = maxW / 2;
+ const cy = -maxH / 2;
+ const cz = (layers.length * Z_SPACING) / 2;
+ this.controls.target.set(cx, cy, cz);
+ const dist = Math.max(maxW, maxH) * 0.7;
+ this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
+ this.camera.lookAt(cx, cy, cz);
+ this.controls.update();
+
+ this.gridHelper.position.set(cx, cy, -0.5);
+ }
+ }
+
+ // ===== Layer Visibility =====
+
+ setLayerVisibility(index, visible) {
+ if (index < 0 || index >= this.layerMeshes.length) return;
+ const mesh = this.layerMeshes[index];
+ if (!mesh) return;
+ this.layers[index].visible = visible;
+ mesh.visible = visible;
+ if (mesh.material && !mesh.userData?.isEnclosure) {
+ if (!visible) mesh.material.opacity = 0;
+ else mesh.material.opacity = this.layers[index].baseAlpha;
+ }
+ }
+
+ setGroupOpacity(group, opacity) {
+ group.traverse(c => {
+ if (c.material) c.material.opacity = opacity;
+ });
+ }
+
+ setLayerHighlight(index, highlight) {
+ const hasHL = highlight && index >= 0;
+ this.layers.forEach((l, i) => {
+ l.highlight = (i === index && highlight);
+ const mesh = this.layerMeshes[i];
+ if (!mesh || !l.visible) return;
+ if (mesh.userData?.isEnclosure) {
+ this.setGroupOpacity(mesh, (hasHL && !l.highlight) ? 0.15 : 0.55);
+ } else if (mesh.material) {
+ mesh.material.opacity = (hasHL && !l.highlight) ? l.baseAlpha * 0.3 : l.baseAlpha;
+ }
+ });
+ }
+
+ // ===== Selection =====
+
+ selectLayer(index) {
+ this.selectedLayerIndex = index;
+
+ if (this.selectionOutline) {
+ this.scene.remove(this.selectionOutline);
+ this.selectionOutline.geometry?.dispose();
+ this.selectionOutline.material?.dispose();
+ this.selectionOutline = null;
+ }
+
+ this.arrowGroup.visible = false;
+ while (this.arrowGroup.children.length) {
+ const c = this.arrowGroup.children[0];
+ this.arrowGroup.remove(c);
+ }
+
+ if (index < 0 || index >= this.layerMeshes.length) {
+ if (this._onLayerSelect) this._onLayerSelect(-1);
+ return;
+ }
+
+ const mesh = this.layerMeshes[index];
+ if (!mesh) {
+ if (this._onLayerSelect) this._onLayerSelect(index);
+ return;
+ }
+
+ if (mesh.geometry && !mesh.userData?.isEnclosure) {
+ const edges = new THREE.EdgesGeometry(mesh.geometry);
+ const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
+ color: 0x89b4fa, linewidth: 2,
+ }));
+ line.position.copy(mesh.position);
+ line.position.z += 0.1;
+ this.selectionOutline = line;
+ this.scene.add(line);
+ }
+
+ const pos = mesh.position.clone();
+ if (mesh.userData?.isEnclosure) {
+ const box = new THREE.Box3().setFromObject(mesh);
+ box.getCenter(pos);
+ }
+ const arrowLen = 8;
+ const upArrow = new THREE.ArrowHelper(
+ new THREE.Vector3(0, 0, 1),
+ new THREE.Vector3(pos.x, pos.y, pos.z + 2),
+ arrowLen, 0x89b4fa, 3, 2
+ );
+ const downArrow = new THREE.ArrowHelper(
+ new THREE.Vector3(0, 0, -1),
+ new THREE.Vector3(pos.x, pos.y, pos.z - 2),
+ arrowLen, 0x89b4fa, 3, 2
+ );
+ this.arrowGroup.add(upArrow);
+ this.arrowGroup.add(downArrow);
+ this.arrowGroup.visible = true;
+
+ if (this._onLayerSelect) this._onLayerSelect(index);
+ }
+
+ moveSelectedZ(delta) {
+ if (this.selectedLayerIndex < 0) return;
+ const mesh = this.layerMeshes[this.selectedLayerIndex];
+ if (!mesh) return;
+ mesh.position.z += delta;
+ if (this.selectionOutline) this.selectionOutline.position.z = mesh.position.z + 0.1;
+ if (this.arrowGroup.children.length >= 2) {
+ const pos = mesh.position;
+ this.arrowGroup.children[0].position.set(pos.x, pos.y, pos.z + 2);
+ this.arrowGroup.children[1].position.set(pos.x, pos.y, pos.z - 2);
+ }
+ }
+
+ // ===== Camera =====
+
+ _homeTopDown(layerIndex) {
+ const mesh = this.layerMeshes[layerIndex];
+ if (!mesh) return;
+ const pos = mesh.position.clone();
+ if (mesh.userData?.isEnclosure) {
+ const box = new THREE.Box3().setFromObject(mesh);
+ box.getCenter(pos);
+ }
+ let imgW, imgH;
+ if (mesh.geometry?.parameters) {
+ imgW = mesh.geometry.parameters.width;
+ imgH = mesh.geometry.parameters.height;
+ } else {
+ imgW = this._maxW || 500;
+ imgH = this._maxH || 500;
+ }
+ const dist = Math.max(imgW, imgH) * 1.1;
+ this.camera.position.set(pos.x, pos.y - dist * 0.01, pos.z + dist);
+ this.camera.up.set(0, 0, 1);
+ this.controls.target.set(pos.x, pos.y, pos.z);
+ this.controls.update();
+ }
+
+ homeTopDown(layerIndex) {
+ if (layerIndex !== undefined && layerIndex >= 0) {
+ this._homeTopDown(layerIndex);
+ } else if (this.selectedLayerIndex >= 0) {
+ this._homeTopDown(this.selectedLayerIndex);
+ } else {
+ const cx = (this._maxW || 500) / 2;
+ const cy = -(this._maxH || 500) / 2;
+ const cz = (this.layers.length * Z_SPACING) / 2;
+ const dist = Math.max(this._maxW || 500, this._maxH || 500) * 1.1;
+ this.camera.position.set(cx, cy - dist * 0.01, cz + dist);
+ this.camera.up.set(0, 0, 1);
+ this.controls.target.set(cx, cy, cz);
+ this.controls.update();
+ }
+ }
+
+ resetView() {
+ if (this.layers.length === 0) return;
+ const maxW = this._maxW || 500;
+ const maxH = this._maxH || 500;
+ const cx = maxW / 2;
+ const cy = -maxH / 2;
+ const cz = (this.layers.length * Z_SPACING) / 2;
+ const dist = Math.max(maxW, maxH) * 0.7;
+ this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
+ this.camera.up.set(0, 0, 1);
+ this.controls.target.set(cx, cy, cz);
+ this.controls.update();
+ }
+
+ setControlScheme(traditional) {
+ this._traditionalControls = traditional;
+ if (traditional) {
+ this.controls.mouseButtons = {
+ LEFT: THREE.MOUSE.ROTATE,
+ MIDDLE: THREE.MOUSE.PAN,
+ RIGHT: THREE.MOUSE.PAN
+ };
+ } else {
+ this.controls.mouseButtons = {
+ LEFT: THREE.MOUSE.ROTATE,
+ MIDDLE: THREE.MOUSE.PAN,
+ RIGHT: THREE.MOUSE.DOLLY
+ };
+ }
+ }
+
+ // ===== Shared Utilities =====
+
+ disposeGroup(group) {
+ if (!group) return;
+ if (group.parent) group.parent.remove(group);
+ group.traverse(c => {
+ if (c.geometry) c.geometry.dispose();
+ if (c.material) {
+ if (c.material.map) c.material.map.dispose();
+ c.material.dispose();
+ }
+ });
+ }
+
+ loadSTLCentered(arrayBuffer, materialOpts) {
+ const loader = new STLLoader();
+ const geometry = loader.parse(arrayBuffer);
+ geometry.computeBoundingBox();
+ if (materialOpts.computeNormals) {
+ geometry.computeVertexNormals();
+ delete materialOpts.computeNormals;
+ }
+ const mat = new THREE.MeshPhongMaterial(materialOpts);
+ const mesh = new THREE.Mesh(geometry, mat);
+ const box = geometry.boundingBox;
+ const center = new THREE.Vector3(
+ (box.min.x + box.max.x) / 2,
+ (box.min.y + box.max.y) / 2,
+ (box.min.z + box.max.z) / 2
+ );
+ mesh.position.set(-center.x, -center.y, -center.z);
+ const size = box.getSize(new THREE.Vector3());
+ return { mesh, center, size, box };
+ }
+
+ fitCamera(size, target) {
+ const maxDim = Math.max(size.x, size.y, size.z);
+ const dist = maxDim * 1.5;
+ const t = target || new THREE.Vector3(0, 0, 0);
+ this.controls.target.copy(t);
+ this.camera.position.set(t.x, t.y - dist * 0.5, t.z + dist * 0.6);
+ this.camera.up.set(0, 0, 1);
+ this.controls.update();
+ }
+
+ // ===== Dispose =====
+
+ dispose() {
+ if (this._ctrlDragCleanup) this._ctrlDragCleanup();
+ if (this._animId) cancelAnimationFrame(this._animId);
+ if (this._resizeObserver) this._resizeObserver.disconnect();
+
+ for (const mode of Object.values(this._modes)) {
+ if (mode.dispose) mode.dispose();
+ }
+
+ this.controls.dispose();
+ this.renderer.dispose();
+ if (this.renderer.domElement.parentNode) {
+ this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
+ }
+ }
+}
diff --git a/frontend/src/engine/modes/cutout-mode.js b/frontend/src/engine/modes/cutout-mode.js
new file mode 100644
index 0000000..54e5d5c
--- /dev/null
+++ b/frontend/src/engine/modes/cutout-mode.js
@@ -0,0 +1,420 @@
+import * as THREE from 'three';
+import { Z_SPACING } from '../former-engine.js';
+
+export function createCutoutMode() {
+ const mode = {
+ name: 'cutout',
+ engine: null,
+
+ cutoutMode: false,
+ isDadoMode: false,
+ elements: [],
+ elementMeshes: [],
+ hoveredElement: -1,
+ cutouts: [],
+
+ _rectSelecting: false,
+ _rectStart: null,
+ _rectOverlay: null,
+ _cutoutLayerZ: 0,
+
+ _clickHandler: null,
+ _moveHandler: null,
+ _downHandler: null,
+ _upHandler: null,
+
+ install(engine) {
+ this.engine = engine;
+ },
+
+ enterCutoutMode(elements, layerIndex, isDado) {
+ const eng = this.engine;
+ this.cutoutMode = true;
+ this.isDadoMode = isDado || false;
+ this.elements = elements;
+ this.hoveredElement = -1;
+ this.cutouts = [];
+ this._rectSelecting = false;
+ this._rectStart = null;
+ this._rectOverlay = null;
+
+ while (eng.elementGroup.children.length) {
+ const c = eng.elementGroup.children[0];
+ c.geometry?.dispose();
+ c.material?.dispose();
+ eng.elementGroup.remove(c);
+ }
+ this.elementMeshes = [];
+
+ const layerMesh = eng.layerMeshes[layerIndex];
+ const layerZ = layerMesh ? layerMesh.position.z : 0;
+ this._cutoutLayerZ = layerZ;
+
+ const encMode = eng.getMode('enclosure');
+
+ 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 isCircle = el.shape === 'circle';
+ const isObround = el.shape === 'obround';
+ let geo;
+ if (isCircle) {
+ geo = new THREE.CircleGeometry(Math.max(w, h) / 2, 32);
+ } else if (isObround && encMode) {
+ geo = encMode._makeCutoutGeo(w, h, 0, 'obround');
+ } else {
+ 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 };
+
+ eng.elementGroup.add(mesh);
+ this.elementMeshes.push(mesh);
+ }
+
+ eng.elementGroup.visible = true;
+
+ // Register event handlers
+ this._clickHandler = (e) => this._handleClick(e);
+ this._moveHandler = (e) => this._handleMouseMove(e);
+ this._downHandler = (e) => this._handleMouseDown(e);
+ this._upHandler = (e) => this._handleMouseUp(e);
+ eng.registerClickHandler(this._clickHandler);
+ eng.registerMouseMoveHandler(this._moveHandler);
+ eng.registerMouseDownHandler(this._downHandler);
+ eng.registerMouseUpHandler(this._upHandler);
+
+ if (isDado) {
+ eng.controls.enabled = false;
+ } else {
+ eng._homeTopDown(layerIndex);
+ }
+ },
+
+ exitCutoutMode() {
+ const eng = this.engine;
+ 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;
+ }
+ eng.controls.enabled = true;
+ eng.elementGroup.visible = false;
+ while (eng.elementGroup.children.length) {
+ const c = eng.elementGroup.children[0];
+ c.geometry?.dispose();
+ c.material?.dispose();
+ eng.elementGroup.remove(c);
+ }
+ this.elementMeshes = [];
+
+ // Unregister event handlers
+ if (this._clickHandler) eng.unregisterClickHandler(this._clickHandler);
+ if (this._moveHandler) eng.unregisterMouseMoveHandler(this._moveHandler);
+ if (this._downHandler) eng.unregisterMouseDownHandler(this._downHandler);
+ if (this._upHandler) eng.unregisterMouseUpHandler(this._upHandler);
+ this._clickHandler = null;
+ this._moveHandler = null;
+ this._downHandler = null;
+ this._upHandler = null;
+ },
+
+ _handleClick(e) {
+ if (!this.cutoutMode) return false;
+ const eng = this.engine;
+
+ 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 (eng._onCutoutSelect) eng._onCutoutSelect(el, m.userData.selected);
+ }
+ return true;
+ },
+
+ _handleMouseMove(e) {
+ if (!this.cutoutMode || this.elementMeshes.length === 0) return;
+ const eng = this.engine;
+
+ // 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';
+ }
+
+ // Element hover
+ eng._updateMouse(e);
+ eng.raycaster.setFromCamera(eng.mouse, eng.camera);
+ const hits = eng.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 (eng._onCutoutHover) eng._onCutoutHover(newHover);
+ }
+ },
+
+ _handleMouseDown(e) {
+ if (!this.cutoutMode || !this.isDadoMode || e.button !== 0) return;
+
+ this._rectSelecting = true;
+ this._rectStart = { x: e.clientX, y: e.clientY };
+ 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);
+ }
+ },
+
+ _handleMouseUp(e) {
+ if (!this._rectSelecting || !this._rectStart || !this.engine._isDragging) {
+ this._rectSelecting = false;
+ this._rectStart = null;
+ if (this._rectOverlay) this._rectOverlay.style.display = 'none';
+ return;
+ }
+
+ const eng = this.engine;
+ 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) {
+ const topLeft = this._screenToLayerPixel(x1, y1);
+ const bottomRight = this._screenToLayerPixel(x2, y2);
+ if (topLeft && bottomRight) {
+ const rMinX = Math.min(topLeft.x, bottomRight.x);
+ const rMinY = Math.min(topLeft.y, bottomRight.y);
+ const rMaxX = Math.max(topLeft.x, bottomRight.x);
+ const rMaxY = Math.max(topLeft.y, bottomRight.y);
+ const synth = {
+ id: Date.now(),
+ minX: rMinX, minY: rMinY, maxX: rMaxX, maxY: rMaxY,
+ type: 'custom', shape: 'rect',
+ };
+ const w = rMaxX - rMinX;
+ const h = rMaxY - rMinY;
+ const geo = new THREE.PlaneGeometry(w, h);
+ const mat = new THREE.MeshBasicMaterial({
+ color: 0xf9e2af, transparent: true, opacity: 0.7,
+ side: THREE.DoubleSide, depthWrite: false,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(rMinX + w / 2, -(rMinY + h / 2), (this._cutoutLayerZ || 0) + 0.3);
+ mesh.userData = { elementId: synth.id, selected: true };
+ eng.elementGroup.add(mesh);
+ this.elementMeshes.push(mesh);
+ this.cutouts.push(synth);
+ if (eng._onCutoutSelect) eng._onCutoutSelect(synth, true);
+ }
+ }
+ this._rectSelecting = false;
+ this._rectStart = null;
+ if (this._rectOverlay) this._rectOverlay.style.display = 'none';
+ },
+
+ _screenToLayerPixel(screenX, screenY) {
+ const eng = this.engine;
+ const rect = eng.renderer.domElement.getBoundingClientRect();
+ const ndcX = ((screenX - rect.left) / rect.width) * 2 - 1;
+ const ndcY = -((screenY - rect.top) / rect.height) * 2 + 1;
+ const rc = new THREE.Raycaster();
+ rc.setFromCamera(new THREE.Vector2(ndcX, ndcY), eng.camera);
+ const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -(this._cutoutLayerZ || 0));
+ const target = new THREE.Vector3();
+ if (rc.ray.intersectPlane(plane, target)) {
+ return { x: target.x, y: -target.y };
+ }
+ return null;
+ },
+
+ enterSidePlacementMode(projectedCutouts, sideNum) {
+ return new Promise((resolve) => {
+ const eng = this.engine;
+ const encMode = eng.getMode('enclosure');
+ if (!encMode?._encData) { resolve(null); return; }
+ const encData = encMode._encData;
+ const side = encData.sides.find(sd => sd.num === sideNum);
+ if (!side) { resolve(null); return; }
+
+ const s = encMode._s;
+ const cl = encData.clearance;
+ const wt = encData.wallThickness;
+ const trayFloor = encData.trayFloor;
+ const pcbT = encData.pcbThickness;
+ const totalH = encData.totalH;
+ const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0;
+
+ const [startPx, startPy] = encMode._toPixel(side.startX, side.startY);
+ const [endPx, endPy] = encMode._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;
+
+ encMode.lookAtSide(sideNum);
+ encMode.highlightSide(sideNum);
+
+ 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'));
+ eng.scene.add(planeMesh);
+
+ const ghostMeshes = [];
+ for (const pc of projectedCutouts) {
+ const geo = encMode._makeCutoutGeo(pc.width * s, pc.height * s, 0, pc.shape || 'rect');
+ 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'));
+
+ const posX = side.startX + (side.endX - side.startX) * ((pc.x + pc.width / 2) / side.length);
+ const posY = side.startY + (side.endY - side.startY) * ((pc.x + pc.width / 2) / side.length);
+ const [px, py] = encMode._toPixel(posX, posY);
+
+ mesh.position.set(px + nx * offset, py + ny * offset, encZ + totalH * s / 2);
+ mesh.userData = { projectedCutout: pc };
+ eng.scene.add(mesh);
+ ghostMeshes.push(mesh);
+ }
+
+ 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 + (totalH - fullZ) * s;
+ }
+ };
+ updateGhostZ(currentYMM);
+
+ const moveHandler = (e) => {
+ eng._updateMouse(e);
+ eng.raycaster.setFromCamera(eng.mouse, eng.camera);
+ const hits = eng.raycaster.intersectObject(planeMesh);
+ if (hits.length > 0) {
+ const hitZ = hits[0].point.z;
+ const zMM = totalH - (hitZ - encZ) / s;
+ 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 = eng.renderer.domElement;
+
+ const clickHandler = () => {
+ cleanup();
+ const results = projectedCutouts.map(pc => ({
+ x: pc.x,
+ y: currentYMM,
+ width: pc.width,
+ height: pc.height,
+ shape: pc.shape || 'rect',
+ }));
+ resolve(results);
+ };
+
+ const escHandler = (e) => {
+ if (e.key === 'Escape') {
+ cleanup();
+ resolve(null);
+ }
+ };
+
+ const cleanup = () => {
+ canvas.removeEventListener('mousemove', moveHandler);
+ canvas.removeEventListener('click', clickHandler);
+ document.removeEventListener('keydown', escHandler);
+ eng.scene.remove(planeMesh);
+ planeGeo.dispose();
+ planeMat.dispose();
+ for (const gm of ghostMeshes) {
+ eng.scene.remove(gm);
+ gm.geometry.dispose();
+ gm.material.dispose();
+ }
+ encMode.clearSideHighlight();
+ };
+
+ canvas.addEventListener('mousemove', moveHandler);
+ canvas.addEventListener('click', clickHandler);
+ document.addEventListener('keydown', escHandler);
+ });
+ },
+
+ dispose() {
+ if (this.cutoutMode) this.exitCutoutMode();
+ if (this._rectOverlay) {
+ this._rectOverlay.remove();
+ this._rectOverlay = null;
+ }
+ },
+ };
+
+ return mode;
+}
diff --git a/frontend/src/engine/modes/enclosure-mode.js b/frontend/src/engine/modes/enclosure-mode.js
new file mode 100644
index 0000000..450f24f
--- /dev/null
+++ b/frontend/src/engine/modes/enclosure-mode.js
@@ -0,0 +1,798 @@
+import * as THREE from 'three';
+import { STLLoader } from 'three/addons/loaders/STLLoader.js';
+import { Z_SPACING, dbg } from '../former-engine.js';
+
+export function createEnclosureMode() {
+ const mode = {
+ name: 'enclosure',
+ engine: null,
+
+ enclosureMesh: null,
+ trayMesh: null,
+
+ _encData: null,
+ _dpi: null,
+ _minX: null,
+ _maxY: null,
+ _s: null,
+
+ _renderedSTLLoaded: false,
+ _savedVisibility: null,
+ _savedEnclosurePos: null,
+ _savedEnclosureRot: null,
+ _savedTrayPos: null,
+ _savedTrayRot: null,
+ _solidFillLight: null,
+ _sideHighlightMesh: null,
+ _sideHighlightLabel: null,
+
+ _cutoutVizGroup: null,
+ _cutoutVizMeshes: [],
+ _cutoutOutlines: [],
+ selectedCutoutIds: new Set(),
+
+ _vizClickHandler: null,
+
+ install(engine) {
+ this.engine = engine;
+ this._vizClickHandler = (e) => this._handleVizClick(e);
+ engine.registerClickHandler(this._vizClickHandler);
+ },
+
+ _handleVizClick(e) {
+ if (!this._cutoutVizMeshes || this._cutoutVizMeshes.length === 0) return false;
+
+ const eng = this.engine;
+ const cutoutHits = eng.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 true;
+ }
+
+ // Clicked empty — deselect all cutouts (don't consume event)
+ this._deselectAllCutouts();
+ return false;
+ },
+
+ _selectCutout(cutoutId) {
+ this.selectedCutoutIds = new Set([cutoutId]);
+ this._updateCutoutHighlights();
+ if (this.engine._onPlacedCutoutSelect) this.engine._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.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([...this.selectedCutoutIds]);
+ },
+
+ _deselectAllCutouts() {
+ if (this.selectedCutoutIds && this.selectedCutoutIds.size > 0) {
+ this.selectedCutoutIds = new Set();
+ this._updateCutoutHighlights();
+ if (this.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([]);
+ }
+ },
+
+ _updateCutoutHighlights() {
+ const scene = this.engine.scene;
+ if (this._cutoutOutlines) {
+ for (const ol of this._cutoutOutlines) {
+ 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);
+ scene.add(line);
+ }
+ }
+ },
+
+ getSelectedCutouts() {
+ if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return [];
+ return this._cutoutVizMeshes
+ .filter(m => this.selectedCutoutIds.has(m.userData.cutoutId))
+ .map(m => m.userData.cutout);
+ },
+
+ 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];
+ },
+
+ loadEnclosureGeometry(encData, dpi, minX, maxY) {
+ if (!encData || !encData.outlinePoints || encData.outlinePoints.length < 3) return;
+ const eng = this.engine;
+ this.storeEnclosureContext(encData, dpi, minX, maxY);
+ this._disposeEnclosureMeshes();
+
+ const s = dpi / 25.4;
+ const toPixel = (mmX, mmY) => [
+ (mmX - minX) * s,
+ -(maxY - mmY) * s
+ ];
+
+ let pts = encData.outlinePoints.map(p => toPixel(p[0], p[1]));
+ 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);
+ }
+ }
+
+ 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;
+
+ const offsetPoly = (points, dist) => {
+ const n = points.length;
+ const result = [];
+ const maxMiter = Math.abs(dist) * 2;
+ 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;
+ 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 };
+ };
+
+ const cl = encData.clearance;
+ const wt = encData.wallThickness;
+ const trayFloor = encData.trayFloor;
+ const snapH = encData.snapHeight;
+ const lidThick = encData.lidThick;
+ const totalH = encData.totalH;
+
+ const polyInner = offsetPoly(pts, sign * cl * s);
+ const polyTrayWall = offsetPoly(pts, sign * (cl + wt) * s);
+ const polyOuter = offsetPoly(pts, sign * (cl + 2 * wt) * s);
+
+ const enclosureParts = [];
+ const trayParts = [];
+ const eps = 0.05 * s;
+
+ enclosureParts.push(makeSolid(polyOuter, lidThick * s, (totalH - lidThick) * s + eps));
+ const upperWallH = totalH - lidThick - (trayFloor + snapH);
+ if (upperWallH > 0.1) {
+ enclosureParts.push(makeRing(polyOuter, polyInner, upperWallH * s - eps, (trayFloor + snapH) * s + eps));
+ }
+ if (snapH > 0.1) {
+ enclosureParts.push(makeRing(polyOuter, polyTrayWall, snapH * s - eps, trayFloor * s));
+ }
+
+ 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);
+ enclosureParts.push({ geo: cylGeo, zPos: pegH / 2, cx: px, cy: py, isCyl: true });
+ }
+ }
+
+ trayParts.push(makeSolid(polyOuter, trayFloor * s, 0));
+ if (snapH > 0.1) {
+ trayParts.push(makeRing(polyTrayWall, polyInner, snapH * s - eps, trayFloor * s + eps));
+ }
+
+ 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 (eng.enclosureLayerIndex >= 0) {
+ encGroup.scale.z = -1;
+ encGroup.position.z = eng.enclosureLayerIndex * Z_SPACING + totalH * s;
+ encGroup.userData = { layerIndex: eng.enclosureLayerIndex, isEnclosure: true };
+ const layer = eng.layers[eng.enclosureLayerIndex];
+ encGroup.visible = layer ? layer.visible : true;
+ this.enclosureMesh = encGroup;
+ eng.layerMeshes[eng.enclosureLayerIndex] = encGroup;
+ eng.layerGroup.add(encGroup);
+ }
+
+ 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 (eng.trayLayerIndex >= 0) {
+ trayGroup.scale.z = -1;
+ trayGroup.position.z = eng.trayLayerIndex * Z_SPACING + totalH * s;
+ trayGroup.userData = { layerIndex: eng.trayLayerIndex, isEnclosure: true };
+ const layer = eng.layers[eng.trayLayerIndex];
+ trayGroup.visible = layer ? layer.visible : false;
+ this.trayMesh = trayGroup;
+ eng.layerMeshes[eng.trayLayerIndex] = trayGroup;
+ eng.layerGroup.add(trayGroup);
+ }
+ },
+
+ _disposeEnclosureMeshes() {
+ const eng = this.engine;
+ for (const mesh of [this.enclosureMesh, this.trayMesh]) {
+ if (mesh) {
+ eng.layerGroup.remove(mesh);
+ mesh.traverse(c => {
+ if (c.geometry) c.geometry.dispose();
+ if (c.material) c.material.dispose();
+ });
+ }
+ }
+ this.enclosureMesh = null;
+ this.trayMesh = null;
+ },
+
+ enterSolidView() {
+ const eng = this.engine;
+ this._savedVisibility = eng.layers.map(l => l.visible);
+ this._savedEnclosurePos = null;
+ this._savedEnclosureRot = null;
+ this._savedTrayPos = null;
+
+ for (let i = 0; i < eng.layers.length; i++) {
+ const mesh = eng.layerMeshes[i];
+ if (!mesh) continue;
+ if (i === eng.enclosureLayerIndex || i === eng.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;
+ }
+ }
+
+ if (this.enclosureMesh && this.trayMesh) {
+ this._savedEnclosurePos = this.enclosureMesh.position.clone();
+ this._savedEnclosureRot = this.enclosureMesh.quaternion.clone();
+ this._savedTrayPos = this.trayMesh.position.clone();
+ this._savedTrayRot = this.trayMesh.quaternion.clone();
+
+ this.enclosureMesh.rotateX(Math.PI);
+ this.trayMesh.rotateZ(Math.PI);
+ this.enclosureMesh.position.z = this.trayMesh.position.z;
+
+ const encBox = new THREE.Box3().setFromObject(this.enclosureMesh);
+ const trayBox = new THREE.Box3().setFromObject(this.trayMesh);
+ const encCY = (encBox.min.y + encBox.max.y) / 2;
+ const trayCY = (trayBox.min.y + trayBox.max.y) / 2;
+ this.trayMesh.position.y += encCY - trayCY;
+
+ 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;
+ }
+
+ eng.selectLayer(-1);
+ if (this._cutoutVizGroup) this._cutoutVizGroup.visible = false;
+ if (this._cutoutOutlines) {
+ for (const ol of this._cutoutOutlines) ol.visible = false;
+ }
+ this.clearSideHighlight();
+ eng.gridHelper.visible = false;
+ eng.scene.background = new THREE.Color(0x1e1e2e);
+
+ eng._ambientLight.intensity = 0.45;
+ eng._dirLight.intensity = 0.7;
+ eng._dirLight.position.set(1, -1, 2).normalize();
+ this._solidFillLight = new THREE.DirectionalLight(0xffffff, 0.25);
+ this._solidFillLight.position.set(-1, 1, 0.5).normalize();
+ eng.scene.add(this._solidFillLight);
+
+ 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;
+ eng.controls.target.copy(center);
+ eng.camera.position.set(center.x, center.y - dist * 0.5, center.z + dist * 0.6);
+ eng.camera.up.set(0, 0, 1);
+ eng.controls.update();
+ } else {
+ eng.resetView();
+ }
+ },
+
+ exitSolidView() {
+ const eng = this.engine;
+ eng.scene.background = new THREE.Color(0x000000);
+ eng.gridHelper.visible = eng.gridVisible;
+
+ eng._ambientLight.intensity = 0.9;
+ eng._dirLight.intensity = 0.3;
+ eng._dirLight.position.set(50, -50, 100);
+ if (this._solidFillLight) {
+ eng.scene.remove(this._solidFillLight);
+ this._solidFillLight = null;
+ }
+
+ if (this._renderedSTLLoaded) {
+ this._disposeEnclosureMeshes();
+ this._renderedSTLLoaded = false;
+ if (this._encData && this._dpi && this._minX !== undefined && this._maxY !== undefined) {
+ this.loadEnclosureGeometry(this._encData, this._dpi, this._minX, this._maxY);
+ }
+ } else {
+ 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 < eng.layers.length; i++) {
+ const mesh = eng.layerMeshes[i];
+ if (!mesh) continue;
+ const wasVisible = this._savedVisibility ? this._savedVisibility[i] : eng.layers[i].visible;
+ eng.layers[i].visible = wasVisible;
+ mesh.visible = wasVisible;
+ if (i === eng.enclosureLayerIndex || i === eng.trayLayerIndex) {
+ const baseOpacity = i === eng.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;
+ }
+ });
+ }
+ }
+ if (this._cutoutVizGroup) this._cutoutVizGroup.visible = true;
+ if (this._cutoutOutlines) {
+ for (const ol of this._cutoutOutlines) ol.visible = true;
+ }
+ this._savedVisibility = null;
+ eng.resetView();
+ },
+
+ loadRenderedSTL(enclosureArrayBuffer, trayArrayBuffer) {
+ const eng = this.engine;
+ dbg('loadRenderedSTL: called, encBuf=', enclosureArrayBuffer?.byteLength || 0, 'trayBuf=', trayArrayBuffer?.byteLength || 0);
+ this._disposeEnclosureMeshes();
+
+ const loader = new STLLoader();
+ const s = (this._dpi || 600) / 25.4;
+ const minX = this._minX || 0;
+ const maxY = this._maxY || 0;
+
+ const encData = this._encData;
+ const totalH = encData ? encData.totalH : 0;
+ let centerX = 0, centerY = 0;
+ if (encData) {
+ if (encData.outlinePoints && encData.outlinePoints.length > 0) {
+ centerX = (minX + (minX + (eng._maxW || 500) / s)) / 2;
+ centerY = (maxY + (maxY - (eng._maxH || 500) / s)) / 2;
+ }
+ }
+
+ const originPx = (0 + centerX - minX) * s;
+ const originPy = -(maxY - (0 + centerY)) * s;
+
+ const createMeshFromSTL = (arrayBuffer, color, label) => {
+ const geometry = loader.parse(arrayBuffer);
+ geometry.scale(s, s, s);
+ geometry.scale(1, -1, 1);
+ const pos = geometry.attributes.position.array;
+ for (let i = 0; i < pos.length; i += 9) {
+ 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();
+ 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);
+ mesh.position.set(originPx, originPy, 0);
+ return mesh;
+ };
+
+ if (enclosureArrayBuffer && enclosureArrayBuffer.byteLength > 84) {
+ const encGroup = new THREE.Group();
+ const encMesh = createMeshFromSTL(enclosureArrayBuffer, 0xffe090, 'enclosure');
+ encGroup.add(encMesh);
+
+ if (eng.enclosureLayerIndex >= 0) {
+ encGroup.scale.z = -1;
+ encGroup.position.z = eng.enclosureLayerIndex * Z_SPACING + totalH * s;
+ encGroup.userData = { layerIndex: eng.enclosureLayerIndex, isEnclosure: true };
+ const layer = eng.layers[eng.enclosureLayerIndex];
+ encGroup.visible = layer ? layer.visible : true;
+ this.enclosureMesh = encGroup;
+ eng.layerMeshes[eng.enclosureLayerIndex] = encGroup;
+ eng.layerGroup.add(encGroup);
+ }
+ }
+
+ if (trayArrayBuffer && trayArrayBuffer.byteLength > 84) {
+ const trayGroup = new THREE.Group();
+ const trayMesh = createMeshFromSTL(trayArrayBuffer, 0xa0d880, 'tray');
+ trayGroup.add(trayMesh);
+
+ if (eng.trayLayerIndex >= 0) {
+ trayGroup.scale.z = -1;
+ trayGroup.position.z = eng.trayLayerIndex * Z_SPACING + totalH * s;
+ trayGroup.userData = { layerIndex: eng.trayLayerIndex, isEnclosure: true };
+ const layer = eng.layers[eng.trayLayerIndex];
+ trayGroup.visible = layer ? layer.visible : false;
+ this.trayMesh = trayGroup;
+ eng.layerMeshes[eng.trayLayerIndex] = trayGroup;
+ eng.layerGroup.add(trayGroup);
+ }
+ }
+
+ this._renderedSTLLoaded = true;
+ },
+
+ highlightSide(sideNum) {
+ this.clearSideHighlight();
+ if (!this._encData) return;
+ const eng = this.engine;
+ 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);
+
+ const midX = (startPx + endPx) / 2;
+ const midY = (startPy + endPy) / 2;
+ const offset = (cl + wt) * s;
+ const nx = Math.cos(side.angle);
+ const ny = -Math.sin(side.angle);
+ const wallAngle = Math.atan2(dy, dx);
+
+ const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0;
+ mesh.position.set(midX + nx * offset, midY + ny * offset, encZ + totalH / 2);
+ mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX'));
+
+ this._sideHighlightMesh = mesh;
+ eng.scene.add(mesh);
+
+ 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;
+ eng.scene.add(sprite);
+ },
+
+ clearSideHighlight() {
+ const eng = this.engine;
+ if (!eng) return;
+ if (this._sideHighlightMesh) {
+ eng.scene.remove(this._sideHighlightMesh);
+ this._sideHighlightMesh.geometry.dispose();
+ this._sideHighlightMesh.material.dispose();
+ this._sideHighlightMesh = null;
+ }
+ if (this._sideHighlightLabel) {
+ eng.scene.remove(this._sideHighlightLabel);
+ this._sideHighlightLabel.material.map.dispose();
+ this._sideHighlightLabel.material.dispose();
+ this._sideHighlightLabel = null;
+ }
+ },
+
+ lookAtSide(sideNum) {
+ if (!this._encData) return;
+ const eng = this.engine;
+ 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 = eng.enclosureLayerIndex >= 0 ? eng.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(eng._maxW || 500, eng._maxH || 500) * 0.5;
+
+ eng.camera.position.set(midX + nx * dist, midY + ny * dist, midZ);
+ eng.camera.up.set(0, 0, 1);
+ eng.controls.target.set(midX, midY, midZ);
+ eng.controls.update();
+ },
+
+ _makeCutoutGeo(w, h, r, geoShape) {
+ if (geoShape === 'circle') return new THREE.CircleGeometry(Math.max(w, h) / 2, 32);
+ if (geoShape === 'obround') {
+ const shape = new THREE.Shape();
+ const hw = w / 2, hh = h / 2;
+ const cr = Math.min(hw, hh);
+ if (w >= h) {
+ shape.absarc(hw - cr, 0, cr, -Math.PI / 2, Math.PI / 2, false);
+ shape.absarc(-hw + cr, 0, cr, Math.PI / 2, 3 * Math.PI / 2, false);
+ } else {
+ shape.absarc(0, hh - cr, cr, 0, Math.PI, false);
+ shape.absarc(0, -hh + cr, cr, Math.PI, 2 * Math.PI, false);
+ }
+ shape.closePath();
+ return new THREE.ShapeGeometry(shape);
+ }
+ if (!r || r <= 0) return new THREE.PlaneGeometry(w, h);
+ 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;
+ const eng = this.engine;
+
+ 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 = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0;
+
+ for (const c of cutouts) {
+ let mesh;
+ const color = c.isDado ? 0xf9e2af : 0xa6e3a1;
+ const cr = (c.r || 0) * s;
+
+ if (c.surface === 'top') {
+ const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape);
+ 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 === 'bottom') {
+ const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape);
+ 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 === '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, c.shape);
+ const mat = new THREE.MeshBasicMaterial({
+ color, transparent: true, opacity: 0.6,
+ side: THREE.DoubleSide, depthWrite: false,
+ });
+ mesh = new THREE.Mesh(geo, mat);
+
+ 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);
+
+ const zMM = trayFloor + pcbT + c.y + c.h / 2;
+ 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 + (totalH - zMM) * s);
+ 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);
+ }
+ }
+
+ eng.scene.add(this._cutoutVizGroup);
+ },
+
+ _disposeCutoutViz() {
+ if (this._cutoutVizGroup) {
+ this.engine.disposeGroup(this._cutoutVizGroup);
+ this._cutoutVizGroup = null;
+ }
+ this._cutoutVizMeshes = [];
+ },
+
+ dispose() {
+ if (this._vizClickHandler) {
+ this.engine.unregisterClickHandler(this._vizClickHandler);
+ }
+ this.clearSideHighlight();
+ this._disposeCutoutViz();
+ this._disposeEnclosureMeshes();
+ },
+ };
+
+ return mode;
+}
diff --git a/frontend/src/engine/modes/structural-mode.js b/frontend/src/engine/modes/structural-mode.js
new file mode 100644
index 0000000..964b978
--- /dev/null
+++ b/frontend/src/engine/modes/structural-mode.js
@@ -0,0 +1,12 @@
+export function createStructuralMode() {
+ return {
+ name: 'structural',
+ engine: null,
+
+ install(engine) {
+ this.engine = engine;
+ },
+
+ dispose() {},
+ };
+}
diff --git a/frontend/src/engine/modes/vectorwrap-mode.js b/frontend/src/engine/modes/vectorwrap-mode.js
new file mode 100644
index 0000000..2c16e22
--- /dev/null
+++ b/frontend/src/engine/modes/vectorwrap-mode.js
@@ -0,0 +1,798 @@
+import * as THREE from 'three';
+import { dbg } from '../former-engine.js';
+
+export function createVectorWrapMode() {
+ const mode = {
+ name: 'vectorwrap',
+ engine: null,
+
+ _externalModelMesh: null,
+ _externalModelCenter: null,
+ _externalModelSize: null,
+
+ _projEncGroup: null,
+ _projEncLidMesh: null,
+ _projEncTrayMesh: null,
+ _projEncAssembled: true,
+
+ _vwGroup: null,
+ _vectorOverlayMesh: null,
+ _vwTexture: null,
+ _vwWidth: 0,
+ _vwHeight: 0,
+ _vwRestPositions: null,
+
+ _ffdCols: 0,
+ _ffdRows: 0,
+ _ffdGroup: null,
+ _ffdControlPoints: null,
+ _ffdCurrentPoints: null,
+ _ffdSpheres: null,
+ _ffdLines: null,
+ _ffdGridVisible: true,
+ _ffdDragging: null,
+ _ffdDragCleanup: null,
+
+ _cameraLocked: false,
+ _sleeveMesh: null,
+ _surfaceDragCleanup: null,
+ _dragStartUV: null,
+
+ install(engine) {
+ this.engine = engine;
+ },
+
+ loadExternalModel(stlArrayBuffer) {
+ this._disposeExternalModel();
+ const eng = this.engine;
+
+ const result = eng.loadSTLCentered(stlArrayBuffer, {
+ color: 0x888888,
+ side: THREE.DoubleSide,
+ flatShading: true,
+ });
+
+ this._externalModelMesh = result.mesh;
+ this._externalModelCenter = result.center;
+ this._externalModelSize = result.size;
+ eng.scene.add(result.mesh);
+
+ eng.fitCamera(result.size);
+ eng.gridHelper.position.set(0, 0, result.box.min.z - result.center.z - 0.5);
+ },
+
+ _disposeExternalModel() {
+ if (this._externalModelMesh) {
+ this.engine.scene.remove(this._externalModelMesh);
+ this._externalModelMesh.geometry.dispose();
+ this._externalModelMesh.material.dispose();
+ this._externalModelMesh = null;
+ }
+ },
+
+ loadProjectEnclosure(enclosureSTL, traySTL) {
+ dbg('vw-mode: loadProjectEnclosure called, encSTL=', enclosureSTL?.byteLength || 0, 'traySTL=', traySTL?.byteLength || 0);
+ this._disposeExternalModel();
+ this._disposeProjectEnclosure();
+ const eng = this.engine;
+
+ this._projEncGroup = new THREE.Group();
+ this._projEncAssembled = true;
+
+ if (enclosureSTL && enclosureSTL.byteLength > 84) {
+ dbg('vw-mode: parsing enclosure STL...');
+ try {
+ const loader = new (await_stl_loader())();
+ const geometry = loader.parse(enclosureSTL);
+ geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+ dbg('vw-mode: enclosure geometry verts=', geometry.attributes.position.count, 'bbox=', JSON.stringify(geometry.boundingBox));
+ const mat = new THREE.MeshPhongMaterial({
+ color: 0xffe090, side: THREE.DoubleSide, flatShading: true,
+ });
+ this._projEncLidMesh = new THREE.Mesh(geometry, mat);
+ this._projEncGroup.add(this._projEncLidMesh);
+ dbg('vw-mode: enclosure lid mesh added');
+ } catch (e) {
+ dbg('vw-mode: enclosure STL parse FAILED:', e?.message || e);
+ }
+ } else {
+ dbg('vw-mode: skipping enclosure STL (empty or too small)');
+ }
+
+ if (traySTL && traySTL.byteLength > 84) {
+ dbg('vw-mode: parsing tray STL...');
+ try {
+ const loader = new (await_stl_loader())();
+ const geometry = loader.parse(traySTL);
+ geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+ dbg('vw-mode: tray geometry verts=', geometry.attributes.position.count, 'bbox=', JSON.stringify(geometry.boundingBox));
+ const mat = new THREE.MeshPhongMaterial({
+ color: 0xa0d880, side: THREE.DoubleSide, flatShading: true,
+ });
+ this._projEncTrayMesh = new THREE.Mesh(geometry, mat);
+ this._projEncGroup.add(this._projEncTrayMesh);
+ dbg('vw-mode: tray mesh added');
+ } catch (e) {
+ dbg('vw-mode: tray STL parse FAILED:', e?.message || e);
+ }
+ } else {
+ dbg('vw-mode: skipping tray STL (empty or too small)');
+ }
+
+ dbg('vw-mode: projEncGroup children=', this._projEncGroup.children.length);
+ if (this._projEncGroup.children.length === 0) {
+ dbg('vw-mode: NO meshes loaded, returning early');
+ return;
+ }
+
+ const box = new THREE.Box3().setFromObject(this._projEncGroup);
+ const cx = (box.min.x + box.max.x) / 2;
+ const cy = (box.min.y + box.max.y) / 2;
+ const cz = (box.min.z + box.max.z) / 2;
+ this._projEncGroup.position.set(-cx, -cy, -cz);
+
+ this._externalModelCenter = new THREE.Vector3(cx, cy, cz);
+ this._externalModelSize = box.getSize(new THREE.Vector3());
+ dbg('vw-mode: group centered, size=', this._externalModelSize.x.toFixed(1), 'x', this._externalModelSize.y.toFixed(1), 'x', this._externalModelSize.z.toFixed(1));
+ eng.scene.add(this._projEncGroup);
+
+ eng.fitCamera(this._externalModelSize);
+ eng.gridHelper.position.set(0, 0, box.min.z - cz - 0.5);
+ dbg('vw-mode: loadProjectEnclosure complete');
+ },
+
+ toggleProjectEnclosurePart(part) {
+ const mesh = part === 'lid' ? this._projEncLidMesh : this._projEncTrayMesh;
+ if (!mesh) return true;
+ mesh.visible = !mesh.visible;
+ return mesh.visible;
+ },
+
+ toggleProjectEnclosureAssembly() {
+ if (!this._projEncLidMesh || !this._projEncGroup) return true;
+ this._projEncAssembled = !this._projEncAssembled;
+
+ if (this._projEncAssembled) {
+ this._projEncLidMesh.position.set(0, 0, 0);
+ } else {
+ const box = new THREE.Box3().setFromObject(this._projEncGroup);
+ const width = box.max.x - box.min.x;
+ this._projEncLidMesh.position.x = width * 1.2;
+ }
+ return this._projEncAssembled;
+ },
+
+ _disposeProjectEnclosure() {
+ if (this._projEncGroup) {
+ this.engine.disposeGroup(this._projEncGroup);
+ this._projEncGroup = null;
+ }
+ this._projEncLidMesh = null;
+ this._projEncTrayMesh = null;
+ this._projEncAssembled = true;
+ },
+
+ loadVectorOverlay(imageUrl, svgWidthMM, svgHeightMM) {
+ this._disposeVectorOverlay();
+ const eng = this.engine;
+
+ const loader = new THREE.TextureLoader();
+ loader.load(imageUrl, (tex) => {
+ tex.minFilter = THREE.LinearFilter;
+ tex.magFilter = THREE.LinearFilter;
+
+ const w = svgWidthMM || tex.image.width * (25.4 / 96);
+ const h = svgHeightMM || tex.image.height * (25.4 / 96);
+ this._vwWidth = w;
+ this._vwHeight = h;
+ this._vwTexture = tex;
+
+ const segsX = 32, segsY = 32;
+ const geo = new THREE.PlaneGeometry(w, h, segsX, segsY);
+ const mat = new THREE.MeshBasicMaterial({
+ map: tex,
+ transparent: true,
+ opacity: 0.7,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+
+ let zPos = 0;
+ const targetObj = this._externalModelMesh || this._projEncGroup;
+ if (targetObj) {
+ const box = new THREE.Box3().setFromObject(targetObj);
+ zPos = box.max.z + 2;
+ }
+
+ this._vwGroup = new THREE.Group();
+ this._vwGroup.position.set(0, 0, zPos);
+ this._vwGroup.add(mesh);
+ this._vectorOverlayMesh = mesh;
+
+ const pos = geo.attributes.position;
+ this._vwRestPositions = new Float32Array(pos.count * 3);
+ for (let i = 0; i < pos.count; i++) {
+ this._vwRestPositions[i * 3] = pos.getX(i);
+ this._vwRestPositions[i * 3 + 1] = pos.getY(i);
+ this._vwRestPositions[i * 3 + 2] = pos.getZ(i);
+ }
+
+ eng.scene.add(this._vwGroup);
+ this.initFFDGrid(4, 4);
+ });
+ },
+
+ initFFDGrid(cols, rows) {
+ this._disposeFFDGrid();
+ const eng = this.engine;
+ const w = this._vwWidth;
+ const h = this._vwHeight;
+ if (!w || !h) return;
+
+ this._ffdCols = cols;
+ this._ffdRows = rows;
+ this._ffdGroup = new THREE.Group();
+ this._ffdControlPoints = [];
+ this._ffdCurrentPoints = [];
+ this._ffdSpheres = [];
+
+ const halfW = w / 2, halfH = h / 2;
+
+ for (let iy = 0; iy <= rows; iy++) {
+ for (let ix = 0; ix <= cols; ix++) {
+ const px = -halfW + (ix / cols) * w;
+ const py = -halfH + (iy / rows) * h;
+ this._ffdControlPoints.push(new THREE.Vector2(px, py));
+ this._ffdCurrentPoints.push(new THREE.Vector2(px, py));
+
+ const sphere = new THREE.Mesh(
+ new THREE.SphereGeometry(Math.max(w, h) * 0.012, 8, 8),
+ new THREE.MeshBasicMaterial({ color: 0x89b4fa, transparent: true, opacity: 0.8 })
+ );
+ sphere.position.set(px, py, 0.5);
+ sphere.userData.ffdIndex = iy * (cols + 1) + ix;
+ this._ffdSpheres.push(sphere);
+ this._ffdGroup.add(sphere);
+ }
+ }
+
+ this._ffdLines = [];
+ this._rebuildFFDLines();
+
+ this._vwGroup.add(this._ffdGroup);
+ this._ffdGridVisible = true;
+ this._ffdDragging = null;
+ this._setupFFDDrag();
+ },
+
+ _rebuildFFDLines() {
+ if (this._ffdLines) {
+ for (const line of this._ffdLines) {
+ this._ffdGroup.remove(line);
+ line.geometry.dispose();
+ line.material.dispose();
+ }
+ }
+ this._ffdLines = [];
+
+ const cols = this._ffdCols, rows = this._ffdRows;
+ const cp = this._ffdCurrentPoints;
+ const lineMat = new THREE.LineBasicMaterial({ color: 0x585b70, opacity: 0.5, transparent: true });
+
+ for (let iy = 0; iy <= rows; iy++) {
+ const pts = [];
+ for (let ix = 0; ix <= cols; ix++) {
+ const p = cp[iy * (cols + 1) + ix];
+ pts.push(new THREE.Vector3(p.x, p.y, 0.3));
+ }
+ const geo = new THREE.BufferGeometry().setFromPoints(pts);
+ const line = new THREE.Line(geo, lineMat.clone());
+ this._ffdLines.push(line);
+ this._ffdGroup.add(line);
+ }
+
+ for (let ix = 0; ix <= cols; ix++) {
+ const pts = [];
+ for (let iy = 0; iy <= rows; iy++) {
+ const p = cp[iy * (cols + 1) + ix];
+ pts.push(new THREE.Vector3(p.x, p.y, 0.3));
+ }
+ const geo = new THREE.BufferGeometry().setFromPoints(pts);
+ const line = new THREE.Line(geo, lineMat.clone());
+ this._ffdLines.push(line);
+ this._ffdGroup.add(line);
+ }
+ },
+
+ _applyFFD() {
+ if (!this._vectorOverlayMesh || !this._ffdCurrentPoints) return;
+
+ const pos = this._vectorOverlayMesh.geometry.attributes.position;
+ const rest = this._vwRestPositions;
+ const cols = this._ffdCols, rows = this._ffdRows;
+ const cp = this._ffdCurrentPoints;
+ const restCp = this._ffdControlPoints;
+ const w = this._vwWidth, h = this._vwHeight;
+ const halfW = w / 2, halfH = h / 2;
+
+ for (let i = 0; i < pos.count; i++) {
+ const rx = rest[i * 3];
+ const ry = rest[i * 3 + 1];
+
+ let u = (rx + halfW) / w;
+ let v = (ry + halfH) / h;
+ u = Math.max(0, Math.min(1, u));
+ v = Math.max(0, Math.min(1, v));
+
+ const cellX = Math.min(Math.floor(u * cols), cols - 1);
+ const cellY = Math.min(Math.floor(v * rows), rows - 1);
+
+ const lu = (u * cols) - cellX;
+ const lv = (v * rows) - cellY;
+
+ const i00 = cellY * (cols + 1) + cellX;
+ const i10 = i00 + 1;
+ const i01 = i00 + (cols + 1);
+ const i11 = i01 + 1;
+
+ const r00 = restCp[i00], r10 = restCp[i10];
+ const r01 = restCp[i01], r11 = restCp[i11];
+
+ const c00 = cp[i00], c10 = cp[i10];
+ const c01 = cp[i01], c11 = cp[i11];
+
+ const d00x = c00.x - r00.x, d00y = c00.y - r00.y;
+ const d10x = c10.x - r10.x, d10y = c10.y - r10.y;
+ const d01x = c01.x - r01.x, d01y = c01.y - r01.y;
+ const d11x = c11.x - r11.x, d11y = c11.y - r11.y;
+
+ const dx = (1 - lu) * (1 - lv) * d00x + lu * (1 - lv) * d10x
+ + (1 - lu) * lv * d01x + lu * lv * d11x;
+ const dy = (1 - lu) * (1 - lv) * d00y + lu * (1 - lv) * d10y
+ + (1 - lu) * lv * d01y + lu * lv * d11y;
+
+ pos.setXY(i, rx + dx, ry + dy);
+ }
+
+ pos.needsUpdate = true;
+ this._vectorOverlayMesh.geometry.computeBoundingBox();
+ this._vectorOverlayMesh.geometry.computeBoundingSphere();
+ },
+
+ _setupFFDDrag() {
+ const eng = this.engine;
+ const canvas = eng.renderer.domElement;
+ const raycaster = new THREE.Raycaster();
+ const mouse = new THREE.Vector2();
+ let dragPlane = null;
+ let dragOffset = new THREE.Vector3();
+
+ const getWorldPos = (e) => {
+ const rect = canvas.getBoundingClientRect();
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
+ return mouse;
+ };
+
+ const onDown = (e) => {
+ if (e.button !== 0 || !this._ffdGridVisible) return;
+ if (e.ctrlKey || e.metaKey || e.shiftKey) return;
+
+ getWorldPos(e);
+ raycaster.setFromCamera(mouse, eng.camera);
+ const hits = raycaster.intersectObjects(this._ffdSpheres);
+ if (hits.length === 0) return;
+
+ e.stopImmediatePropagation();
+ eng.controls.enabled = false;
+
+ const sphere = hits[0].object;
+ this._ffdDragging = sphere.userData.ffdIndex;
+
+ const normal = eng.camera.getWorldDirection(new THREE.Vector3());
+ const worldPos = new THREE.Vector3();
+ sphere.getWorldPosition(worldPos);
+ dragPlane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, worldPos);
+
+ const intersection = new THREE.Vector3();
+ raycaster.ray.intersectPlane(dragPlane, intersection);
+ dragOffset.copy(worldPos).sub(intersection);
+ };
+
+ const onMove = (e) => {
+ if (this._ffdDragging === null) return;
+
+ getWorldPos(e);
+ raycaster.setFromCamera(mouse, eng.camera);
+
+ const intersection = new THREE.Vector3();
+ if (!raycaster.ray.intersectPlane(dragPlane, intersection)) return;
+ intersection.add(dragOffset);
+
+ const local = this._vwGroup.worldToLocal(intersection.clone());
+ const idx = this._ffdDragging;
+ this._ffdCurrentPoints[idx].set(local.x, local.y);
+ this._ffdSpheres[idx].position.set(local.x, local.y, 0.5);
+
+ this._applyFFD();
+ this._rebuildFFDLines();
+ };
+
+ const onUp = () => {
+ if (this._ffdDragging !== null) {
+ this._ffdDragging = null;
+ eng.controls.enabled = true;
+ }
+ };
+
+ canvas.addEventListener('pointerdown', onDown, { capture: true });
+ canvas.addEventListener('pointermove', onMove);
+ canvas.addEventListener('pointerup', onUp);
+
+ this._ffdDragCleanup = () => {
+ canvas.removeEventListener('pointerdown', onDown, { capture: true });
+ canvas.removeEventListener('pointermove', onMove);
+ canvas.removeEventListener('pointerup', onUp);
+ };
+ },
+
+ setFFDGridVisible(visible) {
+ this._ffdGridVisible = visible;
+ if (this._ffdGroup) this._ffdGroup.visible = visible;
+ },
+
+ resetFFDGrid() {
+ if (!this._ffdControlPoints || !this._ffdCurrentPoints) return;
+ for (let i = 0; i < this._ffdControlPoints.length; i++) {
+ this._ffdCurrentPoints[i].copy(this._ffdControlPoints[i]);
+ if (this._ffdSpheres[i]) {
+ this._ffdSpheres[i].position.set(
+ this._ffdControlPoints[i].x,
+ this._ffdControlPoints[i].y,
+ 0.5
+ );
+ }
+ }
+ this._applyFFD();
+ this._rebuildFFDLines();
+ },
+
+ setFFDResolution(cols, rows) {
+ this.initFFDGrid(cols, rows);
+ },
+
+ getFFDState() {
+ if (!this._ffdCurrentPoints) return null;
+ return {
+ cols: this._ffdCols,
+ rows: this._ffdRows,
+ points: this._ffdCurrentPoints.map(p => [p.x, p.y]),
+ };
+ },
+
+ setFFDState(state) {
+ if (!state || !state.points) return;
+ this.initFFDGrid(state.cols, state.rows);
+ for (let i = 0; i < state.points.length && i < this._ffdCurrentPoints.length; i++) {
+ this._ffdCurrentPoints[i].set(state.points[i][0], state.points[i][1]);
+ if (this._ffdSpheres[i]) {
+ this._ffdSpheres[i].position.set(state.points[i][0], state.points[i][1], 0.5);
+ }
+ }
+ this._applyFFD();
+ this._rebuildFFDLines();
+ },
+
+ setVWTranslation(x, y) {
+ if (this._vwGroup) {
+ this._vwGroup.position.x = x;
+ this._vwGroup.position.y = y;
+ }
+ },
+
+ setVWRotation(degrees) {
+ if (this._vwGroup) {
+ this._vwGroup.rotation.z = degrees * Math.PI / 180;
+ }
+ },
+
+ setVWScale(sx, sy) {
+ if (this._vwGroup) {
+ this._vwGroup.scale.set(sx, sy, 1);
+ }
+ },
+
+ _disposeFFDGrid() {
+ if (this._ffdDragCleanup) {
+ this._ffdDragCleanup();
+ this._ffdDragCleanup = null;
+ }
+ if (this._ffdLines) {
+ for (const line of this._ffdLines) {
+ if (line.parent) line.parent.remove(line);
+ line.geometry.dispose();
+ line.material.dispose();
+ }
+ this._ffdLines = null;
+ }
+ if (this._ffdSpheres) {
+ for (const s of this._ffdSpheres) {
+ if (s.parent) s.parent.remove(s);
+ s.geometry.dispose();
+ s.material.dispose();
+ }
+ this._ffdSpheres = null;
+ }
+ if (this._ffdGroup && this._vwGroup) {
+ this._vwGroup.remove(this._ffdGroup);
+ }
+ this._ffdGroup = null;
+ this._ffdControlPoints = null;
+ this._ffdCurrentPoints = null;
+ this._ffdDragging = null;
+ },
+
+ _disposeVectorOverlay() {
+ this._disposeFFDGrid();
+ if (this._vwGroup) {
+ this.engine.scene.remove(this._vwGroup);
+ }
+ if (this._vectorOverlayMesh) {
+ this._vectorOverlayMesh.geometry.dispose();
+ if (this._vectorOverlayMesh.material.map) {
+ this._vectorOverlayMesh.material.map.dispose();
+ }
+ this._vectorOverlayMesh.material.dispose();
+ this._vectorOverlayMesh = null;
+ }
+ this._vwGroup = null;
+ this._vwTexture = null;
+ this._vwRestPositions = null;
+ },
+
+ setVWCameraLock(locked) {
+ this._cameraLocked = locked;
+ const eng = this.engine;
+ if (!eng) return;
+
+ if (locked) {
+ eng.controls.enabled = false;
+ this._buildSleeveMesh();
+ this._setupSurfaceDrag();
+ } else {
+ eng.controls.enabled = true;
+ this._disposeSleeveMesh();
+ if (this._surfaceDragCleanup) {
+ this._surfaceDragCleanup();
+ this._surfaceDragCleanup = null;
+ }
+ }
+ },
+
+ _buildSleeveMesh() {
+ this._disposeSleeveMesh();
+ if (!this._vwTexture || !this._vwGroup) return;
+
+ const targetObj = this._externalModelMesh || this._projEncGroup;
+ if (!targetObj) return;
+
+ const box = new THREE.Box3().setFromObject(targetObj);
+ const size = box.getSize(new THREE.Vector3());
+ const center = box.getCenter(new THREE.Vector3());
+
+ // Build a box-shaped sleeve around the enclosure
+ const hw = size.x / 2, hh = size.y / 2, hz = size.z / 2;
+ const perimeter = 2 * (size.x + size.y);
+
+ // Create a merged geometry wrapping around the box perimeter
+ // 4 side planes + top + bottom
+ const segments = [];
+
+ // Front face (Y = -hh)
+ segments.push({ pos: [0, -hh, 0], rot: [Math.PI/2, 0, 0], w: size.x, h: size.z, uStart: 0, uEnd: size.x / perimeter });
+ // Right face (X = hw)
+ segments.push({ pos: [hw, 0, 0], rot: [Math.PI/2, 0, -Math.PI/2], w: size.y, h: size.z, uStart: size.x / perimeter, uEnd: (size.x + size.y) / perimeter });
+ // Back face (Y = hh)
+ segments.push({ pos: [0, hh, 0], rot: [Math.PI/2, 0, Math.PI], w: size.x, h: size.z, uStart: (size.x + size.y) / perimeter, uEnd: (2 * size.x + size.y) / perimeter });
+ // Left face (X = -hw)
+ segments.push({ pos: [-hw, 0, 0], rot: [Math.PI/2, 0, Math.PI/2], w: size.y, h: size.z, uStart: (2 * size.x + size.y) / perimeter, uEnd: 1.0 });
+
+ const geometries = [];
+
+ for (const seg of segments) {
+ const geo = new THREE.PlaneGeometry(seg.w, seg.h, 8, 8);
+ const pos = geo.attributes.position;
+ const uv = geo.attributes.uv;
+
+ // Remap UVs for continuous wrapping
+ for (let i = 0; i < uv.count; i++) {
+ const u = uv.getX(i);
+ const v = uv.getY(i);
+ uv.setXY(i, seg.uStart + u * (seg.uEnd - seg.uStart), v);
+ }
+ uv.needsUpdate = true;
+
+ // Apply rotation and position
+ const euler = new THREE.Euler(seg.rot[0], seg.rot[1], seg.rot[2]);
+ const quat = new THREE.Quaternion().setFromEuler(euler);
+ const offset = new THREE.Vector3(seg.pos[0], seg.pos[1], seg.pos[2]);
+
+ for (let i = 0; i < pos.count; i++) {
+ const v3 = new THREE.Vector3(pos.getX(i), pos.getY(i), pos.getZ(i));
+ v3.applyQuaternion(quat);
+ v3.add(offset);
+ pos.setXYZ(i, v3.x, v3.y, v3.z);
+ }
+ pos.needsUpdate = true;
+ geometries.push(geo);
+ }
+
+ // Merge geometries
+ const merged = this._mergeGeometries(geometries);
+ if (!merged) return;
+
+ const tex = this._vwTexture.clone();
+ tex.wrapS = THREE.RepeatWrapping;
+ tex.wrapT = THREE.RepeatWrapping;
+ tex.needsUpdate = true;
+
+ const mat = new THREE.MeshBasicMaterial({
+ map: tex,
+ transparent: true,
+ opacity: 0.7,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ });
+
+ this._sleeveMesh = new THREE.Mesh(merged, mat);
+ // Position relative to the target object's local space
+ const eng = this.engine;
+ eng.scene.add(this._sleeveMesh);
+
+ // Hide the flat overlay while sleeve is active
+ if (this._vectorOverlayMesh) {
+ this._vectorOverlayMesh.visible = false;
+ }
+ if (this._ffdGroup) {
+ this._ffdGroup.visible = false;
+ }
+ },
+
+ _mergeGeometries(geometries) {
+ if (geometries.length === 0) return null;
+
+ let totalVerts = 0;
+ let totalIdx = 0;
+ for (const g of geometries) {
+ totalVerts += g.attributes.position.count;
+ totalIdx += g.index ? g.index.count : 0;
+ }
+
+ const pos = new Float32Array(totalVerts * 3);
+ const uv = new Float32Array(totalVerts * 2);
+ const idx = new Uint32Array(totalIdx);
+
+ let vOff = 0, iOff = 0, vBase = 0;
+ for (const g of geometries) {
+ const gPos = g.attributes.position;
+ const gUV = g.attributes.uv;
+ for (let i = 0; i < gPos.count; i++) {
+ pos[(vOff + i) * 3] = gPos.getX(i);
+ pos[(vOff + i) * 3 + 1] = gPos.getY(i);
+ pos[(vOff + i) * 3 + 2] = gPos.getZ(i);
+ uv[(vOff + i) * 2] = gUV.getX(i);
+ uv[(vOff + i) * 2 + 1] = gUV.getY(i);
+ }
+ if (g.index) {
+ for (let i = 0; i < g.index.count; i++) {
+ idx[iOff + i] = g.index.getX(i) + vBase;
+ }
+ iOff += g.index.count;
+ }
+ vBase += gPos.count;
+ vOff += gPos.count;
+ g.dispose();
+ }
+
+ const merged = new THREE.BufferGeometry();
+ merged.setAttribute('position', new THREE.BufferAttribute(pos, 3));
+ merged.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
+ merged.setIndex(new THREE.BufferAttribute(idx, 1));
+ merged.computeVertexNormals();
+ return merged;
+ },
+
+ _setupSurfaceDrag() {
+ if (this._surfaceDragCleanup) {
+ this._surfaceDragCleanup();
+ }
+ if (!this._sleeveMesh) return;
+
+ const canvas = this.engine.renderer.domElement;
+ const camera = this.engine.camera;
+ let dragging = false;
+ let lastX = 0, lastY = 0;
+
+ const onDown = (e) => {
+ if (e.button !== 0) return;
+ dragging = true;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ e.preventDefault();
+ };
+
+ const onMove = (e) => {
+ if (!dragging || !this._sleeveMesh) return;
+ const dx = e.clientX - lastX;
+ const dy = e.clientY - lastY;
+ lastX = e.clientX;
+ lastY = e.clientY;
+
+ // Convert pixel delta to UV offset based on camera FOV and distance
+ const rect = canvas.getBoundingClientRect();
+ const fovRad = camera.fov * Math.PI / 180;
+ const dist = camera.position.length();
+ const viewH = 2 * dist * Math.tan(fovRad / 2);
+ const pxToMM = viewH / rect.height;
+
+ const tex = this._sleeveMesh.material.map;
+ if (tex) {
+ tex.offset.x -= (dx * pxToMM) / (tex.image?.width || 100);
+ tex.offset.y += (dy * pxToMM) / (tex.image?.height || 100);
+ }
+ };
+
+ const onUp = () => {
+ dragging = false;
+ };
+
+ canvas.addEventListener('pointerdown', onDown);
+ canvas.addEventListener('pointermove', onMove);
+ canvas.addEventListener('pointerup', onUp);
+
+ this._surfaceDragCleanup = () => {
+ canvas.removeEventListener('pointerdown', onDown);
+ canvas.removeEventListener('pointermove', onMove);
+ canvas.removeEventListener('pointerup', onUp);
+ };
+ },
+
+ _disposeSleeveMesh() {
+ if (this._sleeveMesh) {
+ this.engine.scene.remove(this._sleeveMesh);
+ this._sleeveMesh.geometry.dispose();
+ if (this._sleeveMesh.material.map) {
+ this._sleeveMesh.material.map.dispose();
+ }
+ this._sleeveMesh.material.dispose();
+ this._sleeveMesh = null;
+ }
+ // Restore flat overlay visibility
+ if (this._vectorOverlayMesh) {
+ this._vectorOverlayMesh.visible = true;
+ }
+ if (this._ffdGroup && this._ffdGridVisible) {
+ this._ffdGroup.visible = true;
+ }
+ },
+
+ dispose() {
+ this._disposeExternalModel();
+ this._disposeProjectEnclosure();
+ this._disposeVectorOverlay();
+ this._disposeSleeveMesh();
+ if (this._surfaceDragCleanup) {
+ this._surfaceDragCleanup();
+ this._surfaceDragCleanup = null;
+ }
+ },
+ };
+
+ return mode;
+}
+
+import { STLLoader } from 'three/addons/loaders/STLLoader.js';
+function await_stl_loader() { return STLLoader; }
diff --git a/frontend/src/former3d.js b/frontend/src/former3d.js
index 21f2312..fbbc587 100644
--- a/frontend/src/former3d.js
+++ b/frontend/src/former3d.js
@@ -1,1750 +1,102 @@
-// 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 (_) {}
-}
+import { FormerEngine } from './engine/former-engine.js';
+import { createEnclosureMode } from './engine/modes/enclosure-mode.js';
+import { createCutoutMode } from './engine/modes/cutout-mode.js';
+import { createVectorWrapMode } from './engine/modes/vectorwrap-mode.js';
+import { createStructuralMode } from './engine/modes/structural-mode.js';
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();
+ this.engine = new FormerEngine(container);
+ this.engine.use(createEnclosureMode());
+ this.engine.use(createCutoutMode());
+ this.engine.use(createVectorWrapMode());
+ this.engine.use(createStructuralMode());
}
- _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);
- }
- }
+ // ===== Engine delegates =====
+
+ get layers() { return this.engine.layers; }
+ get layerMeshes() { return this.engine.layerMeshes; }
+ get selectedLayerIndex() { return this.engine.selectedLayerIndex; }
+ get gridHelper() { return this.engine.gridHelper; }
+ get gridVisible() { return this.engine.gridVisible; }
+ set gridVisible(v) { this.engine.gridVisible = v; }
+
+ loadLayers(...args) { return this.engine.loadLayers(...args); }
+ setLayerVisibility(...args) { return this.engine.setLayerVisibility(...args); }
+ setLayerHighlight(...args) { return this.engine.setLayerHighlight(...args); }
+ selectLayer(...args) { return this.engine.selectLayer(...args); }
+ moveSelectedZ(...args) { return this.engine.moveSelectedZ(...args); }
+ toggleGrid() { return this.engine.toggleGrid(); }
+ homeTopDown(...args) { return this.engine.homeTopDown(...args); }
+ resetView() { return this.engine.resetView(); }
+ setControlScheme(...args) { return this.engine.setControlScheme(...args); }
+
+ onLayerSelect(cb) { this.engine.onLayerSelect(cb); }
+ onCutoutSelect(cb) { this.engine.onCutoutSelect(cb); }
+ onCutoutHover(cb) { this.engine.onCutoutHover(cb); }
+ onPlacedCutoutSelect(cb) { this.engine.onPlacedCutoutSelect(cb); }
+
+ // ===== Enclosure mode delegates =====
+
+ get enclosureMesh() { return this._enc.enclosureMesh; }
+ get trayMesh() { return this._enc.trayMesh; }
+
+ loadEnclosureGeometry(...args) { return this._enc.loadEnclosureGeometry(...args); }
+ enterSolidView() { return this._enc.enterSolidView(); }
+ exitSolidView() { return this._enc.exitSolidView(); }
+ loadRenderedSTL(...args) { return this._enc.loadRenderedSTL(...args); }
+ highlightSide(...args) { return this._enc.highlightSide(...args); }
+ clearSideHighlight() { return this._enc.clearSideHighlight(); }
+ lookAtSide(...args) { return this._enc.lookAtSide(...args); }
+ refreshCutouts(...args) { return this._enc.refreshCutouts(...args); }
+ storeEnclosureContext(...args) { return this._enc.storeEnclosureContext(...args); }
+ getSelectedCutouts() { return this._enc.getSelectedCutouts(); }
+ get selectedCutoutIds() { return this._enc.selectedCutoutIds; }
+ set selectedCutoutIds(v) { this._enc.selectedCutoutIds = v; }
+ _deselectAllCutouts() { return this._enc._deselectAllCutouts(); }
+
+ // ===== Cutout mode delegates =====
+
+ get cutoutMode() { return this._cut.cutoutMode; }
+ get isDadoMode() { return this._cut.isDadoMode; }
+ get cutouts() { return this._cut.cutouts; }
+ set cutouts(v) { this._cut.cutouts = v; }
+
+ enterCutoutMode(...args) { return this._cut.enterCutoutMode(...args); }
+ exitCutoutMode() { return this._cut.exitCutoutMode(); }
+ enterSidePlacementMode(...args) { return this._cut.enterSidePlacementMode(...args); }
+
+ // ===== Vector wrap mode delegates =====
+
+ get _externalModelMesh() { return this._vw._externalModelMesh; }
+ get _projEncGroup() { return this._vw._projEncGroup; }
+ get _projEncLidMesh() { return this._vw._projEncLidMesh; }
+ get _projEncTrayMesh() { return this._vw._projEncTrayMesh; }
+ get _vectorOverlayMesh() { return this._vw._vectorOverlayMesh; }
+ get _vwGroup() { return this._vw._vwGroup; }
+ get _ffdGridVisible() { return this._vw._ffdGridVisible; }
+
+ loadExternalModel(...args) { return this._vw.loadExternalModel(...args); }
+ loadProjectEnclosure(...args) { return this._vw.loadProjectEnclosure(...args); }
+ toggleProjectEnclosurePart(...args) { return this._vw.toggleProjectEnclosurePart(...args); }
+ toggleProjectEnclosureAssembly() { return this._vw.toggleProjectEnclosureAssembly(); }
+ loadVectorOverlay(...args) { return this._vw.loadVectorOverlay(...args); }
+ setFFDGridVisible(...args) { return this._vw.setFFDGridVisible(...args); }
+ resetFFDGrid() { return this._vw.resetFFDGrid(); }
+ setFFDResolution(...args) { return this._vw.setFFDResolution(...args); }
+ getFFDState() { return this._vw.getFFDState(); }
+ setFFDState(...args) { return this._vw.setFFDState(...args); }
+ setVWTranslation(...args) { return this._vw.setVWTranslation(...args); }
+ setVWRotation(...args) { return this._vw.setVWRotation(...args); }
+ setVWScale(...args) { return this._vw.setVWScale(...args); }
+ setVWCameraLock(...args) { return this._vw.setVWCameraLock(...args); }
+
+ // ===== Mode accessors =====
+
+ get _enc() { return this.engine.getMode('enclosure'); }
+ get _cut() { return this.engine.getMode('cutout'); }
+ get _vw() { return this.engine.getMode('vectorwrap'); }
+
+ dispose() { this.engine.dispose(); }
}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index c62be46..549f668 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -2,6 +2,10 @@
import './style.css';
import { Former3D } from './former3d.js';
import { OpenSCADService } from './openscad-service.js';
+import { buildEnclosureSidebar } from './ui/enclosure-ui.js';
+import { buildVectorWrapSidebar } from './ui/vectorwrap-ui.js';
+import { buildStructuralSidebar } from './ui/structural-ui.js';
+import { buildUnwrapSidebar } from './ui/unwrap-ui.js';
let openscadService = null;
@@ -25,10 +29,14 @@ function wails() { return window.go?.main?.App; }
// ===== State =====
const state = {
+ project: null,
stencil: { gerberPath: '', outlinePath: '' },
enclosure: { gbrjobPath: '', gerberPaths: [], drillPath: '', npthPath: '', sourceDir: '' },
preview: { activeSide: 0, sessionInfo: null, cutouts: [], boardRect: null },
former: { layers: [], images: {}, scale: 1, offsetX: 0, offsetY: 0, cutoutType: 'cutout', dadoDepth: 0.5 },
+ vectorwrap: { svgPath: '', modelPath: '', modelType: '' },
+ structural: { svgPath: '', pattern: 'hexagon', cellSize: 10, wallThick: 1.2, height: 20 },
+ scanhelper: { pageW: 210, pageH: 297, gridSpacing: 10, pagesWide: 1, pagesTall: 1, dpi: 300 },
};
// ===== Loading =====
@@ -59,18 +67,41 @@ function navigate(page) {
const el = $(`#page-${page}`);
if (el) show(el);
+ // Show/hide project-aware nav elements
+ const modeTabs = $('#nav-mode-tabs');
+ const projectName = $('#nav-project-name');
+ const openOutput = $('#nav-open-output');
+ if (state.project && page !== 'landing') {
+ if (modeTabs) modeTabs.style.display = '';
+ if (projectName) {
+ projectName.style.display = '';
+ projectName.textContent = state.project.name;
+ }
+ if (openOutput) openOutput.style.display = '';
+ } else {
+ if (modeTabs) modeTabs.style.display = 'none';
+ if (projectName) projectName.style.display = 'none';
+ if (page === 'landing' && openOutput) openOutput.style.display = 'none';
+ }
+
// Initialize page content
switch (page) {
case 'landing': initLanding(); break;
+ case 'dashboard': initDashboard(); break;
case 'stencil': initStencil(); break;
case 'enclosure': initEnclosure(); break;
case 'preview': initPreview(); break;
case 'former': initFormer(); break;
+ case 'vectorwrap': initVectorWrap(); break;
+ case 'structural': initStructural(); break;
+ case 'scanhelper': initScanHelper(); break;
+ case 'unwrap': initUnwrap(); break;
}
}
// ===== Landing Page =====
async function initLanding() {
+ state.project = null;
const page = $('#page-landing');
const projects = await wails()?.GetRecentProjects() || [];
const logoSrc = await wails()?.GetLogoSVGDataURL() || '';
@@ -80,13 +111,20 @@ async function initLanding() {
projectsHTML = `
Recent Projects
- ${projects.map(p => `
+ ${projects.map(p => {
+ const modes = [];
+ if (p.hasEnclosure) modes.push('Enclosure');
+ if (p.hasStencil) modes.push('Stencil');
+ if (p.hasVectorWrap) modes.push('Vector Wrap');
+ if (p.hasStructural) modes.push('Structural');
+ if (p.hasScanHelper) modes.push('Scan Helper');
+ const modeStr = modes.length > 0 ? modes.join(', ') : 'No modes configured';
+ return `
${esc(p.name)}
- ${p.type}
- ${p.boardW > 0 ? p.boardW.toFixed(1) + ' × ' + p.boardH.toFixed(1) + ' mm' : ''}
-
- `).join('')}
+
${esc(modeStr)}
+
`;
+ }).join('')}
`;
}
@@ -94,23 +132,99 @@ async function initLanding() {
page.innerHTML = `