diff --git a/app.go b/app.go index 162f6ca..c302f97 100644 --- a/app.go +++ b/app.go @@ -992,6 +992,60 @@ func (a *App) RenderFromFormer() (*GenerateResultJS, error) { return a.GenerateEnclosureOutputs() } +// SCADResult holds generated SCAD source code for both enclosure and tray. +type SCADResult struct { + EnclosureSCAD string `json:"enclosureSCAD"` + TraySCAD string `json:"traySCAD"` +} + +// GetEnclosureSCAD generates enclosure and tray SCAD source strings for WASM rendering. +func (a *App) GetEnclosureSCAD() (*SCADResult, error) { + debugLog("GetEnclosureSCAD() called") + a.mu.RLock() + session := a.enclosureSession + allCutouts := make([]Cutout, len(a.cutouts)) + copy(allCutouts, a.cutouts) + a.mu.RUnlock() + + if session == nil { + debugLog(" ERROR: no enclosure session active") + return nil, fmt.Errorf("no enclosure session active") + } + + debugLog(" extracting outline polygon...") + outlinePoly := ExtractPolygonFromGerber(session.OutlineGf) + if outlinePoly == nil { + debugLog(" ERROR: could not extract board outline polygon") + return nil, fmt.Errorf("could not extract board outline polygon") + } + debugLog(" outline polygon: %d vertices", len(outlinePoly)) + + sideCutouts, lidCutouts := SplitCutouts(allCutouts) + debugLog(" cutouts: %d side, %d lid", len(sideCutouts), len(lidCutouts)) + + debugLog(" generating enclosure SCAD string...") + encSCAD, err := GenerateNativeSCADString(false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) + if err != nil { + debugLog(" ERROR enclosure SCAD: %v", err) + return nil, fmt.Errorf("generate enclosure SCAD: %v", err) + } + debugLog(" enclosure SCAD: %d chars", len(encSCAD)) + + debugLog(" generating tray SCAD string...") + traySCAD, err := GenerateNativeSCADString(true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) + if err != nil { + debugLog(" ERROR tray SCAD: %v", err) + return nil, fmt.Errorf("generate tray SCAD: %v", err) + } + debugLog(" tray SCAD: %d chars", len(traySCAD)) + debugLog("GetEnclosureSCAD() returning OK") + + return &SCADResult{ + EnclosureSCAD: encSCAD, + TraySCAD: traySCAD, + }, nil +} + // GetOutputDir returns the output directory path for the current session. func (a *App) GetOutputDir() (string, error) { a.mu.RLock() diff --git a/debug_off.go b/debug_off.go index caa6310..4e236a9 100644 --- a/debug_off.go +++ b/debug_off.go @@ -3,3 +3,11 @@ package main func debugLog(format string, args ...interface{}) {} + +// JSDebugLog is a no-op in release builds. +func (a *App) JSDebugLog(msg string) {} + +// IsDebugMode returns false in release builds. +func (a *App) IsDebugMode() bool { + return false +} diff --git a/debug_on.go b/debug_on.go index 1e273df..60c885a 100644 --- a/debug_on.go +++ b/debug_on.go @@ -37,3 +37,13 @@ func debugLog(format string, args ...interface{}) { debugLogger.Printf(format, args...) } } + +// JSDebugLog is called from the frontend to pipe browser console messages into the debug log. +func (a *App) JSDebugLog(msg string) { + debugLog("[JS] %s", msg) +} + +// IsDebugMode returns true when the app was built with the debug tag. +func (a *App) IsDebugMode() bool { + return true +} diff --git a/frontend/index.html b/frontend/index.html index a47569c..cde0206 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -14,6 +14,19 @@
+ diff --git a/frontend/package.json b/frontend/package.json index 15fe86f..c62ea27 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "vite": "^6.0.0" }, "dependencies": { - "three": "^0.183.1" + "three": "^0.183.1", + "openscad-wasm": "^0.0.4" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index af9ef55..6a1bd8c 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -98e9f2b9e6d5bad224e73bd97622e3b9 \ No newline at end of file +2b620bab3b4918be94638ecc29cb51b4 \ No newline at end of file diff --git a/frontend/src/former3d.js b/frontend/src/former3d.js index edda5d8..21f2312 100644 --- a/frontend/src/former3d.js +++ b/frontend/src/former3d.js @@ -1,9 +1,20 @@ // Former 3D — Three.js layer viewer with orbit controls, layer selection, cutout tools, and grid import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { STLLoader } from 'three/addons/loaders/STLLoader.js'; const Z_SPACING = 3; +// Debug bridge — pipes to Go's debugLog via Wails binding when built with -tags debug. +function dbg(...args) { + try { + const fn = window?.go?.main?.App?.JSDebugLog; + if (!fn) return; + const msg = '[former3d] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); + fn(msg); + } catch (_) {} +} + export class Former3D { constructor(container) { this.container = container; @@ -47,10 +58,11 @@ export class Former3D { this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.container.appendChild(this.renderer.domElement); - this.scene.add(new THREE.AmbientLight(0xffffff, 0.9)); - const dirLight = new THREE.DirectionalLight(0xffffff, 0.3); - dirLight.position.set(50, -50, 100); - this.scene.add(dirLight); + 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); @@ -73,6 +85,7 @@ export class Former3D { 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, @@ -80,6 +93,94 @@ export class Former3D { }; 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() { @@ -392,7 +493,15 @@ export class Former3D { -(maxY - mmY) * s ]; - const pts = encData.outlinePoints.map(p => toPixel(p[0], p[1])); + 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; @@ -641,7 +750,11 @@ export class Former3D { } const mesh = this.layerMeshes[index]; - if (!mesh) return; + 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) { @@ -780,8 +893,10 @@ export class Former3D { imgH = this._maxH || 500; } const dist = Math.max(imgW, imgH) * 1.1; - this.camera.position.set(pos.x, pos.y, pos.z + dist); - this.camera.up.set(0, 1, 0); + // 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(); } @@ -796,8 +911,8 @@ export class Former3D { 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, cz + dist); - this.camera.up.set(0, 1, 0); + 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(); } @@ -817,9 +932,14 @@ export class Former3D { this.controls.update(); } - // Switch to solid render preview: show only enclosure+tray opaque, hide all other layers + // 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; @@ -838,16 +958,111 @@ export class Former3D { 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); - this.resetView(); + + // 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; @@ -867,6 +1082,11 @@ export class Former3D { }); } } + // 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(); } @@ -1071,6 +1291,26 @@ export class Former3D { } // ===== 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; @@ -1091,9 +1331,10 @@ export class Former3D { 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 = new THREE.PlaneGeometry(c.w * s, c.h * s); + 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, @@ -1103,7 +1344,7 @@ export class Former3D { mesh.position.set(px, py, encZ + totalH * s + 0.5); } else if (c.surface === 'bottom') { - const geo = new THREE.PlaneGeometry(c.w * s, c.h * s); + 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, @@ -1116,7 +1357,7 @@ export class Former3D { const side = encData.sides.find(sd => sd.num === c.sideNum); if (!side) continue; - const geo = new THREE.PlaneGeometry(c.w * s, c.h * s); + 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, @@ -1326,7 +1567,172 @@ export class Former3D { 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) { diff --git a/frontend/src/main.js b/frontend/src/main.js index 7932802..c62be46 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,6 +1,18 @@ // Former — PCB Stencil & Enclosure Generator import './style.css'; import { Former3D } from './former3d.js'; +import { OpenSCADService } from './openscad-service.js'; + +let openscadService = null; + +// Debug bridge — pipes JS logs to Go's debugLog (~/former/debug.log) when built with -tags debug. +// Initialized async in DOMContentLoaded; until then dbg() is a no-op. +let _dbgEnabled = false; +function dbg(...args) { + if (!_dbgEnabled) return; + const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); + try { wails()?.JSDebugLog(msg); } catch (_) {} +} // ===== Helpers ===== const $ = (sel, ctx = document) => ctx.querySelector(sel); @@ -21,7 +33,14 @@ const state = { // ===== Loading ===== function showLoading(msg = 'Processing...') { - const el = document.createElement('div'); + // Reuse existing overlay if present (avoids stacking duplicates) + let el = $('#loading'); + if (el) { + const txt = el.querySelector('.loading-text'); + if (txt) txt.textContent = msg; + return; + } + el = document.createElement('div'); el.className = 'loading-overlay'; el.id = 'loading'; el.innerHTML = `