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:
parent
968fd5a6c4
commit
e8da7fb77f
54
app.go
54
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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
10
debug_on.go
10
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">⚙</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.183.1"
|
||||
"three": "^0.183.1",
|
||||
"openscad-wasm": "^0.0.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
98e9f2b9e6d5bad224e73bd97622e3b9
|
||||
2b620bab3b4918be94638ecc29cb51b4
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 & 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">−</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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
15
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")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue