From e8da7fb77f3dfaa50dbc044056559162e710762e Mon Sep 17 00:00:00 2001 From: pszsh Date: Fri, 27 Feb 2026 01:52:16 -0800 Subject: [PATCH] Lots of bug fixed before moving to the next major feature: custom/non-pcb vector forming (stencils for other kinds of objects, think spray painting a motorcycle type of things) if only one could make a stencil the exact curvature of the gas tank... ;D --- app.go | 54 ++++ debug_off.go | 8 + debug_on.go | 10 + frontend/index.html | 13 + frontend/package.json | 3 +- frontend/package.json.md5 | 2 +- frontend/src/former3d.js | 436 +++++++++++++++++++++++++++++-- frontend/src/main.js | 242 +++++++++++++++-- frontend/src/openscad-service.js | 95 +++++++ frontend/src/openscad-worker.js | 93 +++++++ frontend/src/style.css | 121 ++++++++- frontend/vite.config.js | 13 + scad.go | 15 ++ 13 files changed, 1056 insertions(+), 49 deletions(-) create mode 100644 frontend/src/openscad-service.js create mode 100644 frontend/src/openscad-worker.js 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 = `
${msg}
`; @@ -29,8 +48,9 @@ function showLoading(msg = 'Processing...') { } function hideLoading() { - const el = $('#loading'); - if (el) el.remove(); + // Remove all loading overlays (safety net) + let el; + while ((el = $('#loading'))) el.remove(); } // ===== Navigation ===== @@ -684,7 +704,12 @@ async function initFormer() { page.innerHTML = `
-
+
+
+ + +
+

Layers

@@ -692,9 +717,9 @@ async function initFormer() {
${layers.map((l, i) => ` -
- - +
+ +
${esc(l.name)}
@@ -702,9 +727,14 @@ async function initFormer() {
`; @@ -743,6 +765,14 @@ async function initFormer() { // Create 3D scene former3d = new Former3D(wrap); + // Apply control scheme from localStorage + const traditional = localStorage.getItem('former-traditional-controls') === '1'; + former3d.setControlScheme(traditional); + const tradCheck = $('#settings-traditional'); + if (tradCheck) tradCheck.checked = traditional; + const gridCheck = $('#settings-grid'); + if (gridCheck) gridCheck.checked = former3d.gridVisible; + // Layer selection callback — update sidebar former3d.onLayerSelect(index => { state.former.selectedLayer = index; @@ -760,6 +790,9 @@ async function initFormer() { } }); + // Initialize Z-axis joystick control + initZJoystick(); + former3d.onCutoutSelect((el, selected) => { console.log('Cutout element:', el.id, selected ? 'selected' : 'deselected'); const count = former3d.cutouts.length; @@ -846,6 +879,11 @@ async function initFormer() { } } +function selectLayerFromSidebar(index) { + if (!former3d) return; + former3d.selectLayer(index); +} + function toggleLayerVis(index) { const layer = state.former.layers[index]; if (!layer) return; @@ -901,6 +939,66 @@ function formerMoveZ(delta) { if (former3d) former3d.moveSelectedZ(delta); } +function initZJoystick() { + const joystick = $('#z-joystick'); + const knob = $('#z-joystick-knob'); + if (!joystick || !knob) return; + + let dragging = false; + let animFrame = null; + + const getDeflection = (e) => { + const rect = joystick.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const dx = (e.clientX || e.touches?.[0]?.clientX || cx) - cx; + const maxDx = rect.width / 2 - 14; + return Math.max(-1, Math.min(1, dx / maxDx)); + }; + + const tick = (deflection) => { + if (!dragging) return; + // Speed proportional to deflection: small near center, fast at edges + const speed = deflection * Math.abs(deflection) * 0.6; + if (Math.abs(speed) > 0.01) formerMoveZ(speed); + animFrame = requestAnimationFrame(() => tick(deflection)); + }; + + let currentDeflection = 0; + + const onStart = (e) => { + e.preventDefault(); + dragging = true; + joystick.classList.add('active'); + currentDeflection = getDeflection(e); + knob.style.left = `${50 + currentDeflection * 40}%`; + tick(currentDeflection); + }; + + const onMove = (e) => { + if (!dragging) return; + currentDeflection = getDeflection(e); + knob.style.left = `${50 + currentDeflection * 40}%`; + if (animFrame) cancelAnimationFrame(animFrame); + tick(currentDeflection); + }; + + const onEnd = () => { + if (!dragging) return; + dragging = false; + joystick.classList.remove('active'); + if (animFrame) cancelAnimationFrame(animFrame); + animFrame = null; + knob.style.left = '50%'; + }; + + joystick.addEventListener('mousedown', onStart); + joystick.addEventListener('touchstart', onStart, { passive: false }); + window.addEventListener('mousemove', onMove); + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('mouseup', onEnd); + window.addEventListener('touchend', onEnd); +} + async function formerSelectCutoutElement() { state.former.cutoutType = 'cutout'; await _enterElementSelection(false); @@ -1240,39 +1338,106 @@ async function nudgeSelectedCutouts(key) { function formerToggleGrid() { if (!former3d) return; const on = former3d.toggleGrid(); - const btn = $('#former-grid-btn'); - if (btn) btn.textContent = on ? 'Grid: On' : 'Grid: Off'; + const checkbox = $('#settings-grid'); + if (checkbox) checkbox.checked = on; +} + +function toggleSettings() { + const panel = $('#settings-panel'); + if (!panel) return; + panel.style.display = panel.style.display === 'none' ? '' : 'none'; +} + +function toggleSettingGrid() { + if (!former3d) return; + const on = $('#settings-grid')?.checked ?? true; + former3d.gridHelper.visible = on; + former3d.gridVisible = on; +} + +function toggleTraditionalControls() { + const on = $('#settings-traditional')?.checked ?? false; + localStorage.setItem('former-traditional-controls', on ? '1' : ''); + if (former3d) former3d.setControlScheme(on); } async function formerRenderAndView() { if (!former3d) return; + dbg('formerRenderAndView: starting'); showLoading('Rendering outputs...'); try { - const result = await wails()?.RenderFromFormer(); - hideLoading(); + // Get SCAD source and generate file outputs in parallel + dbg('formerRenderAndView: calling GetEnclosureSCAD + RenderFromFormer in parallel'); + const t0 = performance.now(); + const [scadResult, result] = await Promise.all([ + wails()?.GetEnclosureSCAD().catch(e => { dbg('GetEnclosureSCAD failed:', e); return null; }), + wails()?.RenderFromFormer(), + ]); + dbg(`formerRenderAndView: backend calls done in ${(performance.now() - t0).toFixed(0)}ms`); + dbg('formerRenderAndView: scadResult:', scadResult ? `enc=${scadResult.enclosureSCAD?.length || 0}chars, tray=${scadResult.traySCAD?.length || 0}chars` : 'null'); + dbg('formerRenderAndView: renderResult:', result ? `${result.files?.length || 0} files` : 'null'); + if (!result || !result.files || result.files.length === 0) { + hideLoading(); alert('Render produced no output files.'); return; } + + // Try WASM rendering if SCAD source is available + let wasmRendered = false; + if (scadResult?.enclosureSCAD) { + try { + showLoading('Running OpenSCAD WASM...'); + dbg('formerRenderAndView: creating OpenSCADService...'); + if (!openscadService) openscadService = new OpenSCADService(); + dbg('formerRenderAndView: service ready, dispatching enclosure + tray renders...'); + const t1 = performance.now(); + const renders = await Promise.all([ + openscadService.renderSCAD(scadResult.enclosureSCAD), + scadResult.traySCAD ? openscadService.renderSCAD(scadResult.traySCAD) : Promise.resolve(null), + ]); + dbg(`formerRenderAndView: WASM renders complete in ${(performance.now() - t1).toFixed(0)}ms`); + dbg(`formerRenderAndView: encSTL=${renders[0]?.byteLength || 0}bytes, traySTL=${renders[1]?.byteLength || 0}bytes`); + dbg('formerRenderAndView: loading rendered STL into viewer...'); + former3d.loadRenderedSTL(renders[0], renders[1]); + dbg('formerRenderAndView: loadRenderedSTL done'); + wasmRendered = true; + } catch (wasmErr) { + dbg('formerRenderAndView: WASM render FAILED:', wasmErr.message || wasmErr); + console.warn('OpenSCAD WASM render failed, falling back to approximate view:', wasmErr); + } + } else { + dbg('formerRenderAndView: no SCAD source available, skipping WASM render'); + } + + hideLoading(); + // Switch to solid view + dbg(`formerRenderAndView: entering solid view (wasmRendered=${wasmRendered})`); former3d.enterSolidView(); + // Show render result panel, hide normal actions const renderResult = $('#former-render-result'); - const normalActions = $('#former-actions-normal'); + const floatingActions = $('#former-floating-actions'); const selTools = $('#former-selection-tools'); const cutoutTools = $('#former-cutout-tools'); if (renderResult) { renderResult.style.display = 'block'; const filesDiv = $('#former-render-files'); - if (filesDiv) filesDiv.innerHTML = result.files.map(f => esc(f.split('/').pop())).join('
'); + if (filesDiv) { + const label = wasmRendered ? 'OpenSCAD WASM render
' : ''; + filesDiv.innerHTML = label + result.files.map(f => esc(f.split('/').pop())).join('
'); + } } - if (normalActions) normalActions.style.display = 'none'; + if (floatingActions) floatingActions.style.display = 'none'; if (selTools) selTools.style.display = 'none'; if (cutoutTools) cutoutTools.style.display = 'none'; // Show nav button const navBtn = $('#nav-open-output'); if (navBtn) navBtn.style.display = ''; + dbg('formerRenderAndView: complete'); } catch (e) { + dbg('formerRenderAndView: FATAL ERROR:', e.message || e); hideLoading(); alert('Render failed: ' + e); } @@ -1282,9 +1447,9 @@ function formerReturnToEditor() { if (!former3d) return; former3d.exitSolidView(); const renderResult = $('#former-render-result'); - const normalActions = $('#former-actions-normal'); + const floatingActions = $('#former-floating-actions'); if (renderResult) renderResult.style.display = 'none'; - if (normalActions) normalActions.style.display = 'block'; + if (floatingActions) floatingActions.style.display = 'flex'; } async function openOutputFolder() { @@ -1713,9 +1878,30 @@ Object.assign(window, { formerMoveZ, formerSelectCutoutElement, formerSelectDadoElement, formerCutoutAll, formerExitCutoutMode, formerToggleGrid, formerRenderAndView, formerReturnToEditor, openOutputFolder, refreshCutoutViz, deleteSelectedCutouts, duplicateSelectedCutouts, + toggleSettings, toggleSettingGrid, toggleTraditionalControls, }); // ===== Init ===== -window.addEventListener('DOMContentLoaded', () => { +window.addEventListener('DOMContentLoaded', async () => { + // Probe Go backend for debug mode — enables dbg() piping to ~/former/debug.log + try { + const isDebug = await wails()?.IsDebugMode(); + if (isDebug) { + _dbgEnabled = true; + dbg('=== Frontend debug logging active ==='); + } + } catch (_) {} + + // Close settings panel on click outside + document.addEventListener('click', e => { + const panel = $('#settings-panel'); + if (!panel || panel.style.display === 'none') return; + if (!e.target.closest('.nav-settings-wrap')) panel.style.display = 'none'; + }); + + // Initialize traditional controls checkbox from localStorage + const tradCheck = $('#settings-traditional'); + if (tradCheck) tradCheck.checked = localStorage.getItem('former-traditional-controls') === '1'; + navigate('landing'); }); diff --git a/frontend/src/openscad-service.js b/frontend/src/openscad-service.js new file mode 100644 index 0000000..db7722f --- /dev/null +++ b/frontend/src/openscad-service.js @@ -0,0 +1,95 @@ +// OpenSCAD Service — Promise-based wrapper around the Web Worker + +// 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 = '[openscad-service] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); + fn(msg); + } catch (_) {} +} + +export class OpenSCADService { + constructor() { + dbg('constructor: creating Web Worker...'); + this.worker = new Worker( + new URL('./openscad-worker.js', import.meta.url), + { type: 'module' } + ); + this.pending = new Map(); + this.nextId = 0; + + // Send debug flag to worker (check if Go debug mode is active) + const isDebugMode = !!window?.go?.main?.App?.JSDebugLog; + this.worker.postMessage({ enableDebug: isDebugMode }); + dbg('constructor: worker created, debug flag sent (' + isDebugMode + ')'); + + this.worker.onmessage = (e) => { + // Forward worker debug messages to Go debug log + if (e.data._debug !== undefined) { + dbg('[worker] ' + e.data._debug); + return; + } + const { id, stl, error } = e.data; + // Ignore messages without an id (e.g. debug enable ack) + if (id === undefined) return; + dbg('onmessage: id=' + id + ', hasSTL=' + !!stl + ', stlSize=' + (stl?.byteLength || 0) + ', error=' + (error || 'none')); + const entry = this.pending.get(id); + if (!entry) { + dbg('onmessage: WARNING no pending entry for id=' + id); + return; + } + this.pending.delete(id); + if (entry.timer) clearTimeout(entry.timer); + if (error) { + entry.reject(new Error(error)); + } else { + entry.resolve(stl); + } + }; + + this.worker.onerror = (e) => { + dbg('onerror: fatal worker error:', e.message || e); + // Reject all pending on fatal worker error + for (const [id, entry] of this.pending) { + if (entry.timer) clearTimeout(entry.timer); + entry.reject(new Error('OpenSCAD worker error: ' + (e.message || 'unknown'))); + } + this.pending.clear(); + }; + } + + /** + * Render SCAD source code to binary STL via WASM. + * @param {string} scadSource - OpenSCAD source code + * @param {number} [timeoutMs=120000] - Timeout in ms (default 2 minutes) + * @returns {Promise} - Binary STL data + */ + renderSCAD(scadSource, timeoutMs = 120000) { + return new Promise((resolve, reject) => { + const id = this.nextId++; + dbg(`renderSCAD: id=${id}, scadLength=${scadSource.length}, timeout=${timeoutMs}ms`); + + const timer = setTimeout(() => { + dbg(`renderSCAD: id=${id} TIMED OUT after ${timeoutMs}ms`); + this.pending.delete(id); + reject(new Error(`OpenSCAD WASM render timed out after ${timeoutMs / 1000}s`)); + }, timeoutMs); + + this.pending.set(id, { resolve, reject, timer }); + this.worker.postMessage({ id, scadSource }); + dbg(`renderSCAD: id=${id} message posted to worker`); + }); + } + + dispose() { + dbg('dispose: terminating worker'); + this.worker.terminate(); + for (const [, entry] of this.pending) { + if (entry.timer) clearTimeout(entry.timer); + entry.reject(new Error('OpenSCAD service disposed')); + } + this.pending.clear(); + } +} diff --git a/frontend/src/openscad-worker.js b/frontend/src/openscad-worker.js new file mode 100644 index 0000000..51500da --- /dev/null +++ b/frontend/src/openscad-worker.js @@ -0,0 +1,93 @@ +// OpenSCAD WASM Web Worker +// Loads the OpenSCAD WASM module and renders SCAD source to binary STL + +import { createOpenSCAD } from 'openscad-wasm'; + +let debug = false; +let busy = false; +let queue = []; + +function dbg(...args) { + if (!debug) return; + const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); + self.postMessage({ _debug: msg }); +} + +async function renderOne(id, scadSource) { + // Create a fresh WASM instance for each render — OpenSCAD's main() leaves + // global state that prevents re-entry on the same instance. + dbg('render[' + id + ']: creating fresh OpenSCAD WASM instance...'); + const t0 = performance.now(); + const inst = await createOpenSCAD({ + noInitialRun: true, + print: (text) => dbg('render[' + id + '] stdout: ' + text), + printErr: (text) => dbg('render[' + id + '] stderr: ' + text), + }); + dbg('render[' + id + ']: WASM instance ready in ' + (performance.now() - t0).toFixed(0) + 'ms'); + + const openscad = inst.getInstance(); + + // Write SCAD source to virtual FS + openscad.FS.writeFile('/input.scad', scadSource); + dbg('render[' + id + ']: input file written, calling callMain...'); + + // Run OpenSCAD to produce binary STL + const t1 = performance.now(); + const exitCode = openscad.callMain(['/input.scad', '-o', '/output.stl', '--export-format', 'binstl']); + dbg('render[' + id + ']: callMain returned exitCode=' + exitCode + ' in ' + (performance.now() - t1).toFixed(0) + 'ms'); + + if (exitCode !== 0) { + dbg('render[' + id + ']: non-zero exit, retrying without --export-format...'); + const inst2 = await createOpenSCAD({ noInitialRun: true }); + const openscad2 = inst2.getInstance(); + openscad2.FS.writeFile('/input.scad', scadSource); + const exitCode2 = openscad2.callMain(['/input.scad', '-o', '/output.stl']); + dbg('render[' + id + ']: retry callMain returned exitCode=' + exitCode2); + if (exitCode2 !== 0) { + throw new Error('OpenSCAD exited with code ' + exitCode2); + } + const stlData = openscad2.FS.readFile('/output.stl', { encoding: 'binary' }); + const buffer = stlData.buffer.slice(stlData.byteOffset, stlData.byteOffset + stlData.byteLength); + return buffer; + } + + // Read binary STL output + dbg('render[' + id + ']: reading output STL...'); + const stlData = openscad.FS.readFile('/output.stl', { encoding: 'binary' }); + dbg('render[' + id + ']: STL data size = ' + stlData.byteLength + ' bytes'); + + const buffer = stlData.buffer.slice(stlData.byteOffset, stlData.byteOffset + stlData.byteLength); + return buffer; +} + +async function processQueue() { + if (busy || queue.length === 0) return; + busy = true; + const { id, scadSource } = queue.shift(); + try { + dbg('render[' + id + ']: received SCAD source (' + scadSource.length + ' chars)'); + const buffer = await renderOne(id, scadSource); + dbg('render[' + id + ']: posting ' + buffer.byteLength + ' bytes back to main thread'); + self.postMessage({ id, stl: buffer }, [buffer]); + } catch (err) { + dbg('render[' + id + ']: ERROR: ' + (err.message || err)); + self.postMessage({ id, error: err.message || String(err) }); + } + busy = false; + processQueue(); +} + +self.onmessage = (e) => { + const { id, scadSource, enableDebug } = e.data; + + if (enableDebug !== undefined) { + debug = enableDebug; + dbg('debug mode enabled in worker'); + } + + if (id === undefined) return; + + // Queue renders and process sequentially to avoid concurrent WASM issues + queue.push({ id, scadSource }); + processQueue(); +}; diff --git a/frontend/src/style.css b/frontend/src/style.css index a77a2b6..4fb72e2 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -603,9 +603,10 @@ select.form-input { .former-layer-row { display: flex; align-items: center; - gap: 8px; - padding: 8px 10px; + gap: 6px; + padding: 5px 10px; border-radius: var(--radius-sm); + cursor: pointer; transition: background var(--transition); } @@ -625,8 +626,8 @@ select.form-input { .layer-vis-btn, .layer-hl-btn { - width: 24px; - height: 24px; + width: 22px; + height: 22px; display: flex; align-items: center; justify-content: center; @@ -677,6 +678,118 @@ select.form-input { border-top: 1px solid var(--border-light); } +/* Settings panel */ +.nav-settings-wrap { + position: relative; + -webkit-app-region: no-drag; +} + +.nav-settings-btn { + font-size: 16px; +} + +.settings-panel { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + min-width: 220px; + z-index: 200; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); +} + +.settings-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + font-size: 13px; + color: var(--text-secondary); +} + +.settings-toggle-row input[type="checkbox"] { + accent-color: var(--text-primary); +} + +/* Floating action bar */ +.former-floating-actions { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + backdrop-filter: blur(8px); + background: rgba(30,31,32,0.8); + padding: 8px 16px; + border-radius: var(--radius); + z-index: 10; +} + +/* Z-axis joystick control */ +.z-joystick { + position: relative; + width: 100%; + height: 28px; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: 14px; + cursor: grab; + touch-action: none; + user-select: none; + overflow: hidden; +} + +.z-joystick:active { + cursor: grabbing; +} + +.z-joystick-track { + position: absolute; + top: 50%; + left: 16px; + right: 16px; + height: 2px; + background: var(--border); + transform: translateY(-50%); +} + +.z-joystick-label-l, +.z-joystick-label-r { + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 9px; + font-weight: 600; + color: var(--text-subtle); + pointer-events: none; +} + +.z-joystick-label-l { left: 6px; } +.z-joystick-label-r { right: 4px; } + +.z-joystick-knob { + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + background: var(--text-secondary); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: left 80ms ease-out, background 100ms; + pointer-events: none; +} + +.z-joystick.active .z-joystick-knob { + background: var(--text-primary); + transition: none; +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 8px; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 78e7e46..682f801 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,4 +4,17 @@ export default defineConfig({ build: { outDir: 'dist', }, + optimizeDeps: { + exclude: ['openscad-wasm'], + }, + assetsInclude: ['**/*.wasm'], + server: { + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + }, + worker: { + format: 'es', + }, }); diff --git a/scad.go b/scad.go index 419afdf..00946ed 100644 --- a/scad.go +++ b/scad.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "fmt" + "io" "math" "os" ) @@ -660,7 +662,20 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, return err } defer f.Close() + return writeNativeSCADTo(f, isTray, outlineVertices, cfg, holes, cutouts, lidCutouts, sides, minBX, maxBX, boardCenterY) +} +// GenerateNativeSCADString generates native SCAD code and returns it as a string. +func GenerateNativeSCADString(isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) (string, error) { + var buf bytes.Buffer + if err := writeNativeSCADTo(&buf, isTray, outlineVertices, cfg, holes, cutouts, lidCutouts, sides, minBX, maxBX, boardCenterY); err != nil { + return "", err + } + return buf.String(), nil +} + +// writeNativeSCADTo writes native parametric CSG OpenSCAD code to any io.Writer. +func writeNativeSCADTo(f io.Writer, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error { fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n") fmt.Fprintf(f, "$fn = 60;\n\n")