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

This commit is contained in:
pszsh 2026-02-27 01:52:16 -08:00
parent 968fd5a6c4
commit e8da7fb77f
13 changed files with 1056 additions and 49 deletions

54
app.go
View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -14,6 +14,19 @@
<div class="nav-spacer"></div>
<button class="nav-btn" onclick="navigate('stencil')">Stencil</button>
<button class="nav-btn" onclick="navigate('enclosure')">Enclosure</button>
<div class="nav-settings-wrap" style="--wails-draggable:no-drag">
<button class="nav-btn nav-settings-btn" onclick="toggleSettings()" title="Settings">&#9881;</button>
<div class="settings-panel" id="settings-panel" style="display:none">
<div class="settings-toggle-row">
<span>Show Grid</span>
<input type="checkbox" id="settings-grid" checked onchange="toggleSettingGrid()">
</div>
<div class="settings-toggle-row">
<span>Traditional Controls</span>
<input type="checkbox" id="settings-traditional" onchange="toggleTraditionalControls()">
</div>
</div>
</div>
<button class="nav-btn" onclick="openOutputFolder()" id="nav-open-output" style="display:none">Open Output</button>
</nav>

View File

@ -11,6 +11,7 @@
"vite": "^6.0.0"
},
"dependencies": {
"three": "^0.183.1"
"three": "^0.183.1",
"openscad-wasm": "^0.0.4"
}
}

View File

@ -1 +1 @@
98e9f2b9e6d5bad224e73bd97622e3b9
2b620bab3b4918be94638ecc29cb51b4

View File

@ -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) {

View File

@ -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 = `<div class="spinner"></div><div class="loading-text">${msg}</div>`;
@ -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 = `
<div class="former-container">
<div class="former-canvas-wrap" id="former-canvas-wrap"></div>
<div class="former-canvas-wrap" id="former-canvas-wrap">
<div class="former-floating-actions" id="former-floating-actions">
<button class="btn btn-sm" onclick="resetFormerView()">Reset View</button>
<button class="btn btn-sm btn-primary" onclick="formerRenderAndView()">Render &amp; View</button>
</div>
</div>
<div class="former-sidebar">
<div class="former-sidebar-header">
<h3>Layers</h3>
@ -692,9 +717,9 @@ async function initFormer() {
</div>
<div class="former-layers" id="former-layers">
${layers.map((l, i) => `
<div class="former-layer-row ${l.highlight ? 'highlighted' : ''}" id="layer-row-${i}">
<button class="layer-vis-btn ${l.visible ? 'active' : ''}" id="layer-vis-${i}" onclick="toggleLayerVis(${i})" title="Toggle visibility">${l.visible ? '👁' : '○'}</button>
<button class="layer-hl-btn ${l.highlight ? 'active' : ''}" id="layer-hl-${i}" onclick="toggleLayerHL(${i})" title="Highlight"></button>
<div class="former-layer-row ${l.highlight ? 'highlighted' : ''}" id="layer-row-${i}" onclick="selectLayerFromSidebar(${i})">
<button class="layer-vis-btn ${l.visible ? 'active' : ''}" id="layer-vis-${i}" onclick="event.stopPropagation();toggleLayerVis(${i})" title="Toggle visibility">${l.visible ? '👁' : '○'}</button>
<button class="layer-hl-btn ${l.highlight ? 'active' : ''}" id="layer-hl-${i}" onclick="event.stopPropagation();toggleLayerHL(${i})" title="Highlight"></button>
<div class="layer-swatch" style="background:${l.colorHex}"></div>
<span class="layer-name" id="layer-name-${i}">${esc(l.name)}</span>
</div>
@ -702,9 +727,14 @@ async function initFormer() {
</div>
<div id="former-selection-tools" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light)">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">Selected: <span id="former-sel-name"></span></div>
<div style="display:flex;gap:4px;flex-wrap:wrap">
<button class="btn btn-sm" onclick="formerMoveZ(3)">Z +</button>
<button class="btn btn-sm" onclick="formerMoveZ(-3)">Z -</button>
<div style="font-size:10px;color:var(--text-subtle);margin-bottom:6px">Z Position</div>
<div class="z-joystick" id="z-joystick">
<div class="z-joystick-track"></div>
<span class="z-joystick-label-l">&minus;</span>
<span class="z-joystick-label-r">+</span>
<div class="z-joystick-knob" id="z-joystick-knob"></div>
</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:6px">
<button class="btn btn-sm" onclick="formerSelectCutoutElement()" style="border-color:var(--accent);color:var(--accent)">Select Cutout Element</button>
<button class="btn btn-sm" onclick="formerSelectDadoElement()" style="border-color:#f9e2af;color:#f9e2af">Engrave Text</button>
<button class="btn btn-sm" onclick="formerCutoutAll()" style="border-color:var(--warning);color:var(--warning)">Cutout All</button>
@ -725,14 +755,6 @@ async function initFormer() {
<button class="btn btn-sm" onclick="openOutputFolder()" style="flex:1;border-color:var(--accent);color:var(--accent)">Open Folder</button>
</div>
</div>
<div class="former-actions" id="former-actions-normal">
<div style="font-size:12px;color:var(--text-subtle);margin-bottom:8px">Left-click: rotate. Right-click: zoom. Middle: pan.</div>
<div style="display:flex;gap:4px;margin-bottom:4px">
<button class="btn btn-sm" onclick="resetFormerView()" style="flex:1">Reset View</button>
<button class="btn btn-sm" onclick="formerToggleGrid()" id="former-grid-btn" style="flex:1">Grid: On</button>
</div>
<button class="btn btn-sm btn-primary" onclick="formerRenderAndView()" style="width:100%;margin-top:4px">Render & View</button>
</div>
</div>
</div>
`;
@ -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('<br>');
if (filesDiv) {
const label = wasmRendered ? '<em>OpenSCAD WASM render</em><br>' : '';
filesDiv.innerHTML = label + result.files.map(f => esc(f.split('/').pop())).join('<br>');
}
}
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');
});

View File

@ -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<ArrayBuffer>} - 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();
}
}

View File

@ -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();
};

View File

@ -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;

View File

@ -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',
},
});

15
scad.go
View File

@ -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")