let's call this the half-way point for the implementation of lots of the new features. framework is in place, bugs are like oxygen and we are at normal atmospheric pressure: there's lots

This commit is contained in:
pszsh 2026-03-01 15:59:16 -08:00
parent e8da7fb77f
commit 6bf857e58c
29 changed files with 7614 additions and 2272 deletions

21
Dockerfile.linux Normal file
View File

@ -0,0 +1,21 @@
FROM --platform=linux/amd64 golang:1.23-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs npm \
libgtk-3-dev libwebkit2gtk-4.0-dev \
pkg-config build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN cd frontend && npm install && cd ..
RUN wails build -skipbindings -platform linux/amd64
FROM --platform=linux/amd64 debian:bookworm-slim
COPY --from=builder /src/build/bin/Former /Former-linux-amd64
CMD ["cp", "/Former-linux-amd64", "/out/Former-linux-amd64"]

995
app.go

File diff suppressed because it is too large Load Diff

14
build-linux64.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
# Build Former for Linux amd64 via Docker
set -e
if ! command -v docker &>/dev/null || ! docker info &>/dev/null 2>&1; then
echo "ERROR: Docker is required for cross-compiling the Linux build."
echo " Install: https://docs.docker.com/get-docker/"
exit 1
fi
echo "Building Linux amd64 via Docker..."
docker build --platform linux/amd64 -t former-linux-build -f Dockerfile.linux . 2>&1
docker run --rm --platform linux/amd64 -v "$(pwd)/build/bin:/out" former-linux-build 2>&1
echo "Linux binary: build/bin/Former-linux-amd64"

View File

@ -40,6 +40,7 @@ type SideCutout struct {
Height float64 `json:"h"`
CornerRadius float64 `json:"r"`
Layer string `json:"l"`
Shape string `json:"shape"`
}
// LidCutout defines a cutout on the lid or tray plane (top/bottom flat surfaces)
@ -52,6 +53,9 @@ type LidCutout struct {
MaxY float64 `json:"maxY"`
IsDado bool `json:"isDado"`
Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut
Shape string `json:"shape"` // "circle" or "rect"
// Text engrave: gerber source for clipped 2D shape rendering
GerberFile *GerberFile `json:"-"`
}
// Cutout is the unified cutout type — replaces separate SideCutout/LidCutout.
@ -67,6 +71,8 @@ type Cutout struct {
IsDado bool `json:"isDado"`
Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut
SourceLayer string `json:"sourceLayer"` // "F" or "B" for side cutouts
Shape string `json:"shape"` // "circle" or "rect" (default "rect")
GerberSource string `json:"gerberSource"` // gerber filename for text engrave dados
}
// CutoutToSideCutout converts a unified Cutout (surface="side") to legacy SideCutout
@ -79,6 +85,7 @@ func CutoutToSideCutout(c Cutout) SideCutout {
Height: c.Height,
CornerRadius: c.CornerRadius,
Layer: c.SourceLayer,
Shape: c.Shape,
}
}
@ -96,11 +103,13 @@ func CutoutToLidCutout(c Cutout) LidCutout {
MaxY: c.Y + c.Height,
IsDado: c.IsDado,
Depth: c.Depth,
Shape: c.Shape,
}
}
// SplitCutouts partitions unified cutouts into side and lid slices for SCAD/STL generation.
func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) {
// gerberMap is optional; when provided, text engrave dados resolve their GerberFile reference.
func SplitCutouts(cutouts []Cutout, gerberMap map[string]*GerberFile) ([]SideCutout, []LidCutout) {
var sides []SideCutout
var lids []LidCutout
for _, c := range cutouts {
@ -108,7 +117,11 @@ func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) {
case "side":
sides = append(sides, CutoutToSideCutout(c))
case "top", "bottom":
lids = append(lids, CutoutToLidCutout(c))
lc := CutoutToLidCutout(c)
if c.GerberSource != "" && gerberMap != nil {
lc.GerberFile = gerberMap[c.GerberSource]
}
lids = append(lids, lc)
}
}
return sides, lids

291
former.go
View File

@ -3,6 +3,7 @@ package main
import (
"image"
"image/color"
"math"
"strings"
)
@ -42,9 +43,114 @@ type ElementBBox struct {
MaxX float64 `json:"maxX"`
MaxY float64 `json:"maxY"`
Type string `json:"type"`
Shape string `json:"shape"` // "circle" or "rect"
Footprint string `json:"footprint"`
}
// macroApertureSize examines a macro's primitives to determine shape and half-extents
// by computing the full bounding box from all primitive positions and sizes.
func macroApertureSize(macro Macro, params []float64) (float64, float64, string) {
onlyCircles := true // track if macro has ONLY circle primitives centered at origin
minX, minY := math.MaxFloat64, math.MaxFloat64
maxX, maxY := -math.MaxFloat64, -math.MaxFloat64
hasPrimitives := false
expand := func(x1, y1, x2, y2 float64) {
if x1 < minX { minX = x1 }
if y1 < minY { minY = y1 }
if x2 > maxX { maxX = x2 }
if y2 > maxY { maxY = y2 }
hasPrimitives = true
}
for _, prim := range macro.Primitives {
switch prim.Code {
case 1: // Circle: exposure, diameter, centerX, centerY
if len(prim.Modifiers) >= 4 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 { continue }
dia := evaluateMacroExpression(prim.Modifiers[1], params)
cx := evaluateMacroExpression(prim.Modifiers[2], params)
cy := evaluateMacroExpression(prim.Modifiers[3], params)
r := dia / 2.0
expand(cx-r, cy-r, cx+r, cy+r)
if cx != 0 || cy != 0 { onlyCircles = false }
} else if len(prim.Modifiers) >= 2 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 { continue }
dia := evaluateMacroExpression(prim.Modifiers[1], params)
r := dia / 2.0
expand(-r, -r, r, r)
}
case 5: // Regular polygon: exposure, numVerts, centerX, centerY, diameter, rotation
if len(prim.Modifiers) >= 5 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 { continue }
cx := evaluateMacroExpression(prim.Modifiers[2], params)
cy := evaluateMacroExpression(prim.Modifiers[3], params)
dia := evaluateMacroExpression(prim.Modifiers[4], params)
r := dia / 2.0
expand(cx-r, cy-r, cx+r, cy+r)
if cx != 0 || cy != 0 { onlyCircles = false }
}
case 4: // Outline polygon: exposure, numVerts, x1, y1, ..., xn, yn, rotation
onlyCircles = false
if len(prim.Modifiers) >= 3 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 { continue }
numVerts := int(evaluateMacroExpression(prim.Modifiers[1], params))
for i := 0; i < numVerts && 2+i*2+1 < len(prim.Modifiers); i++ {
vx := evaluateMacroExpression(prim.Modifiers[2+i*2], params)
vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], params)
expand(vx, vy, vx, vy)
}
}
case 20: // Vector line: exposure, width, startX, startY, endX, endY, rotation
onlyCircles = false
if len(prim.Modifiers) >= 6 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 { continue }
w := evaluateMacroExpression(prim.Modifiers[1], params)
sx := evaluateMacroExpression(prim.Modifiers[2], params)
sy := evaluateMacroExpression(prim.Modifiers[3], params)
ex := evaluateMacroExpression(prim.Modifiers[4], params)
ey := evaluateMacroExpression(prim.Modifiers[5], params)
hw := w / 2
expand(math.Min(sx, ex)-hw, math.Min(sy, ey)-hw,
math.Max(sx, ex)+hw, math.Max(sy, ey)+hw)
}
case 21: // Center line: exposure, width, height, centerX, centerY, rotation
onlyCircles = false
if len(prim.Modifiers) >= 5 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 { continue }
w := evaluateMacroExpression(prim.Modifiers[1], params)
h := evaluateMacroExpression(prim.Modifiers[2], params)
cx := evaluateMacroExpression(prim.Modifiers[3], params)
cy := evaluateMacroExpression(prim.Modifiers[4], params)
expand(cx-w/2, cy-h/2, cx+w/2, cy+h/2)
}
}
}
if !hasPrimitives {
if len(params) > 0 {
r := params[0] / 2.0
return r, r, "rect"
}
return 0.25, 0.25, "rect"
}
hw := (maxX - minX) / 2.0
hh := (maxY - minY) / 2.0
// If the macro only has circle primitives centered at origin, it's a circle
if onlyCircles {
return hw, hh, "circle"
}
return hw, hh, "rect"
}
// ExtractElementBBoxes walks gerber commands and returns bounding boxes in image pixel coordinates.
func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []ElementBBox {
if gf == nil {
@ -57,6 +163,42 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element
return px, py
}
apertureSize := func(dcode int) (float64, float64, string) {
ap, ok := gf.State.Apertures[dcode]
if !ok || len(ap.Modifiers) == 0 {
return 0.25, 0.25, "rect"
}
switch ap.Type {
case ApertureCircle:
r := ap.Modifiers[0] / 2.0
return r, r, "circle"
case ApertureRect:
hw := ap.Modifiers[0] / 2.0
hh := hw
if len(ap.Modifiers) >= 2 {
hh = ap.Modifiers[1] / 2.0
}
return hw, hh, "rect"
case ApertureObround:
hw := ap.Modifiers[0] / 2.0
hh := hw
if len(ap.Modifiers) >= 2 {
hh = ap.Modifiers[1] / 2.0
}
return hw, hh, "obround"
case AperturePolygon:
r := ap.Modifiers[0] / 2.0
return r, r, "circle"
default:
// Check if this is a macro aperture and determine shape from primitives
if macro, mok := gf.State.Macros[ap.Type]; mok {
return macroApertureSize(macro, ap.Modifiers)
}
r := ap.Modifiers[0] / 2.0
return r, r, "rect"
}
}
apertureRadius := func(dcode int) float64 {
if ap, ok := gf.State.Apertures[dcode]; ok && len(ap.Modifiers) > 0 {
return ap.Modifiers[0] / 2.0
@ -68,6 +210,7 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element
id := 0
curX, curY := 0.0, 0.0
curDCode := 0
interpMode := "G01" // linear by default
for _, cmd := range gf.Commands {
switch cmd.Type {
@ -76,7 +219,10 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element
curDCode = *cmd.D
}
continue
case "G01", "G02", "G03", "G36", "G37":
case "G01", "G02", "G03":
interpMode = cmd.Type
continue
case "G36", "G37":
continue
}
@ -90,41 +236,55 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element
switch cmd.Type {
case "FLASH": // D03
r := apertureRadius(curDCode)
hw, hh, shape := apertureSize(curDCode)
px, py := mmToPx(curX, curY)
rpx := r * dpi / 25.4
hwPx := hw * dpi / 25.4
hhPx := hh * dpi / 25.4
elements = append(elements, ElementBBox{
ID: id,
MinX: px - rpx,
MinY: py - rpx,
MaxX: px + rpx,
MaxY: py + rpx,
MinX: px - hwPx,
MinY: py - hhPx,
MaxX: px + hwPx,
MaxY: py + hhPx,
Type: "pad",
Shape: shape,
Footprint: cmd.Footprint,
})
id++
case "DRAW": // D01
r := apertureRadius(curDCode)
rpx := r * dpi / 25.4
if (interpMode == "G02" || interpMode == "G03") && cmd.I != nil && cmd.J != nil {
// Arc draw: compute actual arc bounding box from center + radius
ci := *cmd.I
cj := *cmd.J
cx := prevX + ci
cy := prevY + cj
arcR := math.Sqrt(ci*ci + cj*cj)
// Arc bounding box: conservatively use the full circle extent
// (precise arc bbox requires start/end angle clipping, but this is good enough)
arcRPx := arcR * dpi / 25.4
cpx, cpy := mmToPx(cx, cy)
elements = append(elements, ElementBBox{
ID: id,
MinX: cpx - arcRPx - rpx,
MinY: cpy - arcRPx - rpx,
MaxX: cpx + arcRPx + rpx,
MaxY: cpy + arcRPx + rpx,
Type: "arc",
Shape: "circle",
Footprint: cmd.Footprint,
})
} else {
// Linear draw
px1, py1 := mmToPx(prevX, prevY)
px2, py2 := mmToPx(curX, curY)
rpx := r * dpi / 25.4
minPx := px1
if px2 < minPx {
minPx = px2
}
maxPx := px1
if px2 > maxPx {
maxPx = px2
}
minPy := py1
if py2 < minPy {
minPy = py2
}
maxPy := py1
if py2 > maxPy {
maxPy = py2
}
minPx := math.Min(px1, px2)
maxPx := math.Max(px1, px2)
minPy := math.Min(py1, py2)
maxPy := math.Max(py1, py2)
elements = append(elements, ElementBBox{
ID: id,
MinX: minPx - rpx,
@ -132,12 +292,95 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element
MaxX: maxPx + rpx,
MaxY: maxPy + rpx,
Type: "trace",
Shape: "rect",
Footprint: cmd.Footprint,
})
}
id++
}
}
// Post-process: merge overlapping arc elements into single circles.
// Half-circle arcs sharing the same center produce overlapping bboxes.
if len(elements) > 1 {
var merged []ElementBBox
used := make([]bool, len(elements))
for i := 0; i < len(elements); i++ {
if used[i] {
continue
}
el := elements[i]
if el.Type == "arc" {
// Try to merge with nearby arcs that overlap substantially
for j := i + 1; j < len(elements); j++ {
if used[j] || elements[j].Type != "arc" {
continue
}
ej := elements[j]
// Check if centers are close (overlapping circles from same center)
cx1 := (el.MinX + el.MaxX) / 2
cy1 := (el.MinY + el.MaxY) / 2
cx2 := (ej.MinX + ej.MaxX) / 2
cy2 := (ej.MinY + ej.MaxY) / 2
r1 := (el.MaxX - el.MinX) / 2
dist := math.Sqrt((cx2-cx1)*(cx2-cx1) + (cy2-cy1)*(cy2-cy1))
if dist < r1*0.5 {
// Merge: expand bbox
if ej.MinX < el.MinX { el.MinX = ej.MinX }
if ej.MinY < el.MinY { el.MinY = ej.MinY }
if ej.MaxX > el.MaxX { el.MaxX = ej.MaxX }
if ej.MaxY > el.MaxY { el.MaxY = ej.MaxY }
used[j] = true
}
}
}
merged = append(merged, el)
}
elements = merged
// Also merge trace elements by footprint
type fpGroup struct {
minX, minY, maxX, maxY float64
}
groups := map[string]*fpGroup{}
var final []ElementBBox
for _, el := range elements {
if el.Footprint == "" || el.Type == "arc" || el.Type == "pad" {
final = append(final, el)
continue
}
g, ok := groups[el.Footprint]
if !ok {
g = &fpGroup{minX: el.MinX, minY: el.MinY, maxX: el.MaxX, maxY: el.MaxY}
groups[el.Footprint] = g
} else {
if el.MinX < g.minX { g.minX = el.MinX }
if el.MinY < g.minY { g.minY = el.MinY }
if el.MaxX > g.maxX { g.maxX = el.MaxX }
if el.MaxY > g.maxY { g.maxY = el.MaxY }
}
}
for fp, g := range groups {
w := g.maxX - g.minX
h := g.maxY - g.minY
shape := "rect"
if w > 0 && h > 0 {
ratio := w / h
if ratio > 0.85 && ratio < 1.15 {
shape = "circle"
}
}
final = append(final, ElementBBox{
ID: id, MinX: g.minX, MinY: g.minY, MaxX: g.maxX, MaxY: g.maxY,
Type: "component", Shape: shape, Footprint: fp,
})
id++
}
if len(groups) > 0 {
elements = final
}
}
return elements
}

View File

@ -8,12 +8,18 @@
</head>
<body>
<div id="app">
<!-- Navigation (includes macOS draggable titlebar region) -->
<nav id="nav" style="--wails-draggable:drag">
<span class="nav-brand" onclick="navigate('landing')">Former</span>
<span class="nav-project-name" id="nav-project-name" style="display:none"></span>
<div class="nav-spacer"></div>
<div id="nav-mode-tabs" style="display:none">
<button class="nav-btn" onclick="navigate('stencil')">Stencil</button>
<button class="nav-btn" onclick="navigate('enclosure')">Enclosure</button>
<button class="nav-btn" onclick="navigate('vectorwrap')">Vector Wrap</button>
<button class="nav-btn" onclick="navigate('structural')">Structural</button>
<button class="nav-btn" onclick="navigate('scanhelper')">Scan Helper</button>
<button class="nav-btn" onclick="navigate('unwrap')">Unwrap</button>
</div>
<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">
@ -30,14 +36,18 @@
<button class="nav-btn" onclick="openOutputFolder()" id="nav-open-output" style="display:none">Open Output</button>
</nav>
<!-- Pages -->
<main id="main">
<section id="page-landing" class="page active"></section>
<section id="page-dashboard" class="page"></section>
<section id="page-stencil" class="page"></section>
<section id="page-enclosure" class="page"></section>
<section id="page-preview" class="page"></section>
<section id="page-stencil-result" class="page"></section>
<section id="page-enclosure-result" class="page"></section>
<section id="page-vectorwrap" class="page"></section>
<section id="page-structural" class="page"></section>
<section id="page-scanhelper" class="page"></section>
<section id="page-unwrap" class="page"></section>
<section id="page-former" class="page page-former"></section>
</main>
</div>

View File

@ -0,0 +1,652 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
export const Z_SPACING = 3;
export function dbg(...args) {
try {
const fn = window?.go?.main?.App?.JSDebugLog;
if (!fn) return;
const msg = '[engine] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
fn(msg);
} catch (_) {}
}
export class FormerEngine {
constructor(container) {
this.container = container;
this.layers = [];
this.layerMeshes = [];
this.selectedLayerIndex = -1;
this.enclosureLayerIndex = -1;
this.trayLayerIndex = -1;
this.layerGroup = null;
this.arrowGroup = null;
this.elementGroup = null;
this.selectionOutline = null;
this._onLayerSelect = null;
this._onCutoutSelect = null;
this._onCutoutHover = null;
this._onPlacedCutoutSelect = null;
this._modes = {};
this._clickHandlers = [];
this._mouseMoveHandlers = [];
this._mouseDownHandlers = [];
this._mouseUpHandlers = [];
this._initScene();
this._initControls();
this._initGrid();
this._initRaycasting();
this._animate();
}
// ===== Plugin API =====
use(mode) {
this._modes[mode.name] = mode;
mode.install(this);
}
getMode(name) {
return this._modes[name];
}
registerClickHandler(handler) { this._clickHandlers.push(handler); }
unregisterClickHandler(handler) {
const i = this._clickHandlers.indexOf(handler);
if (i >= 0) this._clickHandlers.splice(i, 1);
}
registerMouseMoveHandler(handler) { this._mouseMoveHandlers.push(handler); }
unregisterMouseMoveHandler(handler) {
const i = this._mouseMoveHandlers.indexOf(handler);
if (i >= 0) this._mouseMoveHandlers.splice(i, 1);
}
registerMouseDownHandler(handler) { this._mouseDownHandlers.push(handler); }
unregisterMouseDownHandler(handler) {
const i = this._mouseDownHandlers.indexOf(handler);
if (i >= 0) this._mouseDownHandlers.splice(i, 1);
}
registerMouseUpHandler(handler) { this._mouseUpHandlers.push(handler); }
unregisterMouseUpHandler(handler) {
const i = this._mouseUpHandlers.indexOf(handler);
if (i >= 0) this._mouseUpHandlers.splice(i, 1);
}
// ===== Callbacks =====
onLayerSelect(cb) { this._onLayerSelect = cb; }
onCutoutSelect(cb) { this._onCutoutSelect = cb; }
onCutoutHover(cb) { this._onCutoutHover = cb; }
onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; }
// ===== Scene Setup =====
_initScene() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x000000);
const w = this.container.clientWidth;
const h = this.container.clientHeight;
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 50000);
this.camera.position.set(0, -60, 80);
this.camera.up.set(0, 0, 1);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true });
this.renderer.setSize(w, h);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.container.appendChild(this.renderer.domElement);
this._ambientLight = new THREE.AmbientLight(0xffffff, 0.9);
this.scene.add(this._ambientLight);
this._dirLight = new THREE.DirectionalLight(0xffffff, 0.3);
this._dirLight.position.set(50, -50, 100);
this.scene.add(this._dirLight);
this.layerGroup = new THREE.Group();
this.scene.add(this.layerGroup);
this.arrowGroup = new THREE.Group();
this.arrowGroup.visible = false;
this.scene.add(this.arrowGroup);
this.elementGroup = new THREE.Group();
this.elementGroup.visible = false;
this.scene.add(this.elementGroup);
this._resizeObserver = new ResizeObserver(() => this._onResize());
this._resizeObserver.observe(this.container);
}
_initControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.1;
this.controls.enableZoom = true;
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.DOLLY
};
this.controls.target.set(0, 0, 0);
this.controls.update();
this.renderer.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
if (this._traditionalControls) {
if (e.shiftKey && e.deltaX === 0) {
e.stopImmediatePropagation();
const dist = this.camera.position.distanceTo(this.controls.target);
const panSpeed = dist * 0.001;
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0);
const offset = new THREE.Vector3();
offset.addScaledVector(right, e.deltaY * panSpeed);
this.camera.position.add(offset);
this.controls.target.add(offset);
this.controls.update();
}
return;
}
e.stopImmediatePropagation();
if (e.ctrlKey || e.metaKey) {
const factor = 1 + e.deltaY * 0.01;
const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
dir.multiplyScalar(factor);
this.camera.position.copy(this.controls.target).add(dir);
} else {
let dx = e.deltaX;
let dy = e.deltaY;
if (e.shiftKey && dx === 0) {
dx = dy;
dy = 0;
}
const dist = this.camera.position.distanceTo(this.controls.target);
const panSpeed = dist * 0.001;
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0);
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1);
const offset = new THREE.Vector3();
offset.addScaledVector(right, dx * panSpeed);
offset.addScaledVector(up, -dy * panSpeed);
this.camera.position.add(offset);
this.controls.target.add(offset);
}
this.controls.update();
}, { passive: false, capture: true });
this._ctrlDragging = false;
this._ctrlDragLastY = 0;
this.renderer.domElement.addEventListener('mousedown', (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
this._ctrlDragging = true;
this._ctrlDragLastY = e.clientY;
e.preventDefault();
e.stopImmediatePropagation();
}
}, { capture: true });
const onCtrlDragMove = (e) => {
if (!this._ctrlDragging) return;
const dy = e.clientY - this._ctrlDragLastY;
this._ctrlDragLastY = e.clientY;
const factor = 1 + dy * 0.005;
const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
dir.multiplyScalar(factor);
this.camera.position.copy(this.controls.target).add(dir);
this.controls.update();
};
const onCtrlDragUp = () => {
this._ctrlDragging = false;
};
window.addEventListener('mousemove', onCtrlDragMove);
window.addEventListener('mouseup', onCtrlDragUp);
this._ctrlDragCleanup = () => {
window.removeEventListener('mousemove', onCtrlDragMove);
window.removeEventListener('mouseup', onCtrlDragUp);
};
}
_initGrid() {
this.gridHelper = new THREE.GridHelper(2000, 100, 0x333333, 0x222222);
this.gridHelper.rotation.x = Math.PI / 2;
this.gridHelper.position.z = -0.5;
this.scene.add(this.gridHelper);
this.gridVisible = true;
}
toggleGrid() {
this.gridVisible = !this.gridVisible;
this.gridHelper.visible = this.gridVisible;
return this.gridVisible;
}
_initRaycasting() {
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this._isDragging = false;
this._mouseDownPos = { x: 0, y: 0 };
const canvas = this.renderer.domElement;
canvas.addEventListener('mousedown', e => {
this._isDragging = false;
this._mouseDownPos = { x: e.clientX, y: e.clientY };
for (const h of this._mouseDownHandlers) h(e);
});
canvas.addEventListener('mousemove', e => {
const dx = e.clientX - this._mouseDownPos.x;
const dy = e.clientY - this._mouseDownPos.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._isDragging = true;
for (const h of this._mouseMoveHandlers) h(e);
});
canvas.addEventListener('mouseup', e => {
for (const h of this._mouseUpHandlers) h(e);
});
canvas.addEventListener('click', e => {
if (this._isDragging) return;
this._updateMouse(e);
this.raycaster.setFromCamera(this.mouse, this.camera);
for (const h of this._clickHandlers) {
if (h(e)) return;
}
// Default: layer selection
const clickables = this.layerMeshes.filter((m, i) => m && this.layers[i]?.visible);
const hits = this.raycaster.intersectObjects(clickables, true);
if (hits.length > 0) {
let hitObj = hits[0].object;
let idx = this.layerMeshes.indexOf(hitObj);
if (idx < 0) {
hitObj.traverseAncestors(p => {
const ei = this.layerMeshes.indexOf(p);
if (ei >= 0) idx = ei;
});
}
if (idx >= 0) this.selectLayer(idx);
} else {
this.selectLayer(-1);
}
});
}
_updateMouse(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
}
_onResize() {
const w = this.container.clientWidth;
const h = this.container.clientHeight;
if (w === 0 || h === 0) return;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
_animate() {
this._animId = requestAnimationFrame(() => this._animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
// ===== Layer Loading =====
async loadLayers(layers, imageUrls) {
this.layers = layers;
const loader = new THREE.TextureLoader();
while (this.layerGroup.children.length > 0) {
const child = this.layerGroup.children[0];
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (child.material.map) child.material.map.dispose();
child.material.dispose();
}
this.layerGroup.remove(child);
}
this.layerMeshes = [];
this.enclosureLayerIndex = -1;
this.trayLayerIndex = -1;
// Reset enclosure mode state if present
const enc = this.getMode('enclosure');
if (enc) {
enc.enclosureMesh = null;
enc.trayMesh = null;
}
let maxW = 0, maxH = 0;
for (let i = 0; i < layers.length; i++) {
const layer = layers[i];
const url = imageUrls[i];
if (layer.name === 'Enclosure') {
this.enclosureLayerIndex = i;
this.layerMeshes.push(null);
continue;
}
if (layer.name === 'Tray') {
this.trayLayerIndex = i;
this.layerMeshes.push(null);
continue;
}
if (!url) { this.layerMeshes.push(null); continue; }
try {
const tex = await new Promise((resolve, reject) => {
loader.load(url, resolve, undefined, reject);
});
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
const imgW = tex.image.width;
const imgH = tex.image.height;
if (imgW > maxW) maxW = imgW;
if (imgH > maxH) maxH = imgH;
const geo = new THREE.PlaneGeometry(imgW, imgH);
const mat = new THREE.MeshBasicMaterial({
map: tex,
transparent: true,
opacity: layer.visible ? layer.baseAlpha : 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(imgW / 2, -imgH / 2, i * Z_SPACING);
mesh.visible = layer.visible;
mesh.userData = { layerIndex: i };
this.layerGroup.add(mesh);
this.layerMeshes.push(mesh);
} catch (e) {
console.warn(`Failed to load layer ${i}:`, e);
this.layerMeshes.push(null);
}
}
this._maxW = maxW;
this._maxH = maxH;
if (maxW > 0 && maxH > 0) {
const cx = maxW / 2;
const cy = -maxH / 2;
const cz = (layers.length * Z_SPACING) / 2;
this.controls.target.set(cx, cy, cz);
const dist = Math.max(maxW, maxH) * 0.7;
this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
this.camera.lookAt(cx, cy, cz);
this.controls.update();
this.gridHelper.position.set(cx, cy, -0.5);
}
}
// ===== Layer Visibility =====
setLayerVisibility(index, visible) {
if (index < 0 || index >= this.layerMeshes.length) return;
const mesh = this.layerMeshes[index];
if (!mesh) return;
this.layers[index].visible = visible;
mesh.visible = visible;
if (mesh.material && !mesh.userData?.isEnclosure) {
if (!visible) mesh.material.opacity = 0;
else mesh.material.opacity = this.layers[index].baseAlpha;
}
}
setGroupOpacity(group, opacity) {
group.traverse(c => {
if (c.material) c.material.opacity = opacity;
});
}
setLayerHighlight(index, highlight) {
const hasHL = highlight && index >= 0;
this.layers.forEach((l, i) => {
l.highlight = (i === index && highlight);
const mesh = this.layerMeshes[i];
if (!mesh || !l.visible) return;
if (mesh.userData?.isEnclosure) {
this.setGroupOpacity(mesh, (hasHL && !l.highlight) ? 0.15 : 0.55);
} else if (mesh.material) {
mesh.material.opacity = (hasHL && !l.highlight) ? l.baseAlpha * 0.3 : l.baseAlpha;
}
});
}
// ===== Selection =====
selectLayer(index) {
this.selectedLayerIndex = index;
if (this.selectionOutline) {
this.scene.remove(this.selectionOutline);
this.selectionOutline.geometry?.dispose();
this.selectionOutline.material?.dispose();
this.selectionOutline = null;
}
this.arrowGroup.visible = false;
while (this.arrowGroup.children.length) {
const c = this.arrowGroup.children[0];
this.arrowGroup.remove(c);
}
if (index < 0 || index >= this.layerMeshes.length) {
if (this._onLayerSelect) this._onLayerSelect(-1);
return;
}
const mesh = this.layerMeshes[index];
if (!mesh) {
if (this._onLayerSelect) this._onLayerSelect(index);
return;
}
if (mesh.geometry && !mesh.userData?.isEnclosure) {
const edges = new THREE.EdgesGeometry(mesh.geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
color: 0x89b4fa, linewidth: 2,
}));
line.position.copy(mesh.position);
line.position.z += 0.1;
this.selectionOutline = line;
this.scene.add(line);
}
const pos = mesh.position.clone();
if (mesh.userData?.isEnclosure) {
const box = new THREE.Box3().setFromObject(mesh);
box.getCenter(pos);
}
const arrowLen = 8;
const upArrow = new THREE.ArrowHelper(
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(pos.x, pos.y, pos.z + 2),
arrowLen, 0x89b4fa, 3, 2
);
const downArrow = new THREE.ArrowHelper(
new THREE.Vector3(0, 0, -1),
new THREE.Vector3(pos.x, pos.y, pos.z - 2),
arrowLen, 0x89b4fa, 3, 2
);
this.arrowGroup.add(upArrow);
this.arrowGroup.add(downArrow);
this.arrowGroup.visible = true;
if (this._onLayerSelect) this._onLayerSelect(index);
}
moveSelectedZ(delta) {
if (this.selectedLayerIndex < 0) return;
const mesh = this.layerMeshes[this.selectedLayerIndex];
if (!mesh) return;
mesh.position.z += delta;
if (this.selectionOutline) this.selectionOutline.position.z = mesh.position.z + 0.1;
if (this.arrowGroup.children.length >= 2) {
const pos = mesh.position;
this.arrowGroup.children[0].position.set(pos.x, pos.y, pos.z + 2);
this.arrowGroup.children[1].position.set(pos.x, pos.y, pos.z - 2);
}
}
// ===== Camera =====
_homeTopDown(layerIndex) {
const mesh = this.layerMeshes[layerIndex];
if (!mesh) return;
const pos = mesh.position.clone();
if (mesh.userData?.isEnclosure) {
const box = new THREE.Box3().setFromObject(mesh);
box.getCenter(pos);
}
let imgW, imgH;
if (mesh.geometry?.parameters) {
imgW = mesh.geometry.parameters.width;
imgH = mesh.geometry.parameters.height;
} else {
imgW = this._maxW || 500;
imgH = this._maxH || 500;
}
const dist = Math.max(imgW, imgH) * 1.1;
this.camera.position.set(pos.x, pos.y - dist * 0.01, pos.z + dist);
this.camera.up.set(0, 0, 1);
this.controls.target.set(pos.x, pos.y, pos.z);
this.controls.update();
}
homeTopDown(layerIndex) {
if (layerIndex !== undefined && layerIndex >= 0) {
this._homeTopDown(layerIndex);
} else if (this.selectedLayerIndex >= 0) {
this._homeTopDown(this.selectedLayerIndex);
} else {
const cx = (this._maxW || 500) / 2;
const cy = -(this._maxH || 500) / 2;
const cz = (this.layers.length * Z_SPACING) / 2;
const dist = Math.max(this._maxW || 500, this._maxH || 500) * 1.1;
this.camera.position.set(cx, cy - dist * 0.01, cz + dist);
this.camera.up.set(0, 0, 1);
this.controls.target.set(cx, cy, cz);
this.controls.update();
}
}
resetView() {
if (this.layers.length === 0) return;
const maxW = this._maxW || 500;
const maxH = this._maxH || 500;
const cx = maxW / 2;
const cy = -maxH / 2;
const cz = (this.layers.length * Z_SPACING) / 2;
const dist = Math.max(maxW, maxH) * 0.7;
this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
this.camera.up.set(0, 0, 1);
this.controls.target.set(cx, cy, cz);
this.controls.update();
}
setControlScheme(traditional) {
this._traditionalControls = traditional;
if (traditional) {
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.PAN
};
} else {
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.DOLLY
};
}
}
// ===== Shared Utilities =====
disposeGroup(group) {
if (!group) return;
if (group.parent) group.parent.remove(group);
group.traverse(c => {
if (c.geometry) c.geometry.dispose();
if (c.material) {
if (c.material.map) c.material.map.dispose();
c.material.dispose();
}
});
}
loadSTLCentered(arrayBuffer, materialOpts) {
const loader = new STLLoader();
const geometry = loader.parse(arrayBuffer);
geometry.computeBoundingBox();
if (materialOpts.computeNormals) {
geometry.computeVertexNormals();
delete materialOpts.computeNormals;
}
const mat = new THREE.MeshPhongMaterial(materialOpts);
const mesh = new THREE.Mesh(geometry, mat);
const box = geometry.boundingBox;
const center = new THREE.Vector3(
(box.min.x + box.max.x) / 2,
(box.min.y + box.max.y) / 2,
(box.min.z + box.max.z) / 2
);
mesh.position.set(-center.x, -center.y, -center.z);
const size = box.getSize(new THREE.Vector3());
return { mesh, center, size, box };
}
fitCamera(size, target) {
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 1.5;
const t = target || new THREE.Vector3(0, 0, 0);
this.controls.target.copy(t);
this.camera.position.set(t.x, t.y - dist * 0.5, t.z + dist * 0.6);
this.camera.up.set(0, 0, 1);
this.controls.update();
}
// ===== Dispose =====
dispose() {
if (this._ctrlDragCleanup) this._ctrlDragCleanup();
if (this._animId) cancelAnimationFrame(this._animId);
if (this._resizeObserver) this._resizeObserver.disconnect();
for (const mode of Object.values(this._modes)) {
if (mode.dispose) mode.dispose();
}
this.controls.dispose();
this.renderer.dispose();
if (this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
}
}

View File

@ -0,0 +1,420 @@
import * as THREE from 'three';
import { Z_SPACING } from '../former-engine.js';
export function createCutoutMode() {
const mode = {
name: 'cutout',
engine: null,
cutoutMode: false,
isDadoMode: false,
elements: [],
elementMeshes: [],
hoveredElement: -1,
cutouts: [],
_rectSelecting: false,
_rectStart: null,
_rectOverlay: null,
_cutoutLayerZ: 0,
_clickHandler: null,
_moveHandler: null,
_downHandler: null,
_upHandler: null,
install(engine) {
this.engine = engine;
},
enterCutoutMode(elements, layerIndex, isDado) {
const eng = this.engine;
this.cutoutMode = true;
this.isDadoMode = isDado || false;
this.elements = elements;
this.hoveredElement = -1;
this.cutouts = [];
this._rectSelecting = false;
this._rectStart = null;
this._rectOverlay = null;
while (eng.elementGroup.children.length) {
const c = eng.elementGroup.children[0];
c.geometry?.dispose();
c.material?.dispose();
eng.elementGroup.remove(c);
}
this.elementMeshes = [];
const layerMesh = eng.layerMeshes[layerIndex];
const layerZ = layerMesh ? layerMesh.position.z : 0;
this._cutoutLayerZ = layerZ;
const encMode = eng.getMode('enclosure');
for (const el of elements) {
const w = el.maxX - el.minX;
const h = el.maxY - el.minY;
if (w < 0.5 || h < 0.5) continue;
const isCircle = el.shape === 'circle';
const isObround = el.shape === 'obround';
let geo;
if (isCircle) {
geo = new THREE.CircleGeometry(Math.max(w, h) / 2, 32);
} else if (isObround && encMode) {
geo = encMode._makeCutoutGeo(w, h, 0, 'obround');
} else {
geo = new THREE.PlaneGeometry(w, h);
}
const mat = new THREE.MeshBasicMaterial({
color: 0x89b4fa,
transparent: true,
opacity: 0.2,
side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
const elCx = el.minX + w / 2;
const elCy = el.minY + h / 2;
mesh.position.set(elCx, -elCy, layerZ + 0.2);
mesh.userData = { elementId: el.id, selected: false };
eng.elementGroup.add(mesh);
this.elementMeshes.push(mesh);
}
eng.elementGroup.visible = true;
// Register event handlers
this._clickHandler = (e) => this._handleClick(e);
this._moveHandler = (e) => this._handleMouseMove(e);
this._downHandler = (e) => this._handleMouseDown(e);
this._upHandler = (e) => this._handleMouseUp(e);
eng.registerClickHandler(this._clickHandler);
eng.registerMouseMoveHandler(this._moveHandler);
eng.registerMouseDownHandler(this._downHandler);
eng.registerMouseUpHandler(this._upHandler);
if (isDado) {
eng.controls.enabled = false;
} else {
eng._homeTopDown(layerIndex);
}
},
exitCutoutMode() {
const eng = this.engine;
this.cutoutMode = false;
this.isDadoMode = false;
this.elements = [];
this.hoveredElement = -1;
this._rectSelecting = false;
this._rectStart = null;
if (this._rectOverlay) {
this._rectOverlay.remove();
this._rectOverlay = null;
}
eng.controls.enabled = true;
eng.elementGroup.visible = false;
while (eng.elementGroup.children.length) {
const c = eng.elementGroup.children[0];
c.geometry?.dispose();
c.material?.dispose();
eng.elementGroup.remove(c);
}
this.elementMeshes = [];
// Unregister event handlers
if (this._clickHandler) eng.unregisterClickHandler(this._clickHandler);
if (this._moveHandler) eng.unregisterMouseMoveHandler(this._moveHandler);
if (this._downHandler) eng.unregisterMouseDownHandler(this._downHandler);
if (this._upHandler) eng.unregisterMouseUpHandler(this._upHandler);
this._clickHandler = null;
this._moveHandler = null;
this._downHandler = null;
this._upHandler = null;
},
_handleClick(e) {
if (!this.cutoutMode) return false;
const eng = this.engine;
if (this.hoveredElement >= 0 && this.hoveredElement < this.elements.length) {
const el = this.elements[this.hoveredElement];
const m = this.elementMeshes[this.hoveredElement];
if (m.userData.selected) {
m.userData.selected = false;
m.material.color.setHex(0xfab387);
m.material.opacity = 0.6;
this.cutouts = this.cutouts.filter(c => c.id !== el.id);
} else {
m.userData.selected = true;
const selColor = this.isDadoMode ? 0xf9e2af : 0xa6e3a1;
m.material.color.setHex(selColor);
m.material.opacity = 0.7;
this.cutouts.push(el);
}
if (eng._onCutoutSelect) eng._onCutoutSelect(el, m.userData.selected);
}
return true;
},
_handleMouseMove(e) {
if (!this.cutoutMode || this.elementMeshes.length === 0) return;
const eng = this.engine;
// Update rectangle overlay during dado drag
if (this._rectSelecting && this._rectStart && this._rectOverlay) {
const x1 = Math.min(this._rectStart.x, e.clientX);
const y1 = Math.min(this._rectStart.y, e.clientY);
const w = Math.abs(e.clientX - this._rectStart.x);
const h = Math.abs(e.clientY - this._rectStart.y);
this._rectOverlay.style.left = x1 + 'px';
this._rectOverlay.style.top = y1 + 'px';
this._rectOverlay.style.width = w + 'px';
this._rectOverlay.style.height = h + 'px';
this._rectOverlay.style.display = (w > 5 || h > 5) ? 'block' : 'none';
}
// Element hover
eng._updateMouse(e);
eng.raycaster.setFromCamera(eng.mouse, eng.camera);
const hits = eng.raycaster.intersectObjects(this.elementMeshes);
const newHover = hits.length > 0 ? this.elementMeshes.indexOf(hits[0].object) : -1;
if (newHover !== this.hoveredElement) {
if (this.hoveredElement >= 0 && this.hoveredElement < this.elementMeshes.length) {
const m = this.elementMeshes[this.hoveredElement];
if (!m.userData.selected) {
m.material.opacity = 0.2;
m.material.color.setHex(0x89b4fa);
}
}
if (newHover >= 0) {
const m = this.elementMeshes[newHover];
if (!m.userData.selected) {
m.material.opacity = 0.6;
m.material.color.setHex(0xfab387);
}
}
this.hoveredElement = newHover;
if (eng._onCutoutHover) eng._onCutoutHover(newHover);
}
},
_handleMouseDown(e) {
if (!this.cutoutMode || !this.isDadoMode || e.button !== 0) return;
this._rectSelecting = true;
this._rectStart = { x: e.clientX, y: e.clientY };
if (!this._rectOverlay) {
this._rectOverlay = document.createElement('div');
this._rectOverlay.style.cssText = 'position:fixed;border:2px dashed #f9e2af;background:rgba(249,226,175,0.1);pointer-events:none;z-index:9999;display:none;';
document.body.appendChild(this._rectOverlay);
}
},
_handleMouseUp(e) {
if (!this._rectSelecting || !this._rectStart || !this.engine._isDragging) {
this._rectSelecting = false;
this._rectStart = null;
if (this._rectOverlay) this._rectOverlay.style.display = 'none';
return;
}
const eng = this.engine;
const x1 = Math.min(this._rectStart.x, e.clientX);
const y1 = Math.min(this._rectStart.y, e.clientY);
const x2 = Math.max(this._rectStart.x, e.clientX);
const y2 = Math.max(this._rectStart.y, e.clientY);
if (x2 - x1 > 10 && y2 - y1 > 10) {
const topLeft = this._screenToLayerPixel(x1, y1);
const bottomRight = this._screenToLayerPixel(x2, y2);
if (topLeft && bottomRight) {
const rMinX = Math.min(topLeft.x, bottomRight.x);
const rMinY = Math.min(topLeft.y, bottomRight.y);
const rMaxX = Math.max(topLeft.x, bottomRight.x);
const rMaxY = Math.max(topLeft.y, bottomRight.y);
const synth = {
id: Date.now(),
minX: rMinX, minY: rMinY, maxX: rMaxX, maxY: rMaxY,
type: 'custom', shape: 'rect',
};
const w = rMaxX - rMinX;
const h = rMaxY - rMinY;
const geo = new THREE.PlaneGeometry(w, h);
const mat = new THREE.MeshBasicMaterial({
color: 0xf9e2af, transparent: true, opacity: 0.7,
side: THREE.DoubleSide, depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(rMinX + w / 2, -(rMinY + h / 2), (this._cutoutLayerZ || 0) + 0.3);
mesh.userData = { elementId: synth.id, selected: true };
eng.elementGroup.add(mesh);
this.elementMeshes.push(mesh);
this.cutouts.push(synth);
if (eng._onCutoutSelect) eng._onCutoutSelect(synth, true);
}
}
this._rectSelecting = false;
this._rectStart = null;
if (this._rectOverlay) this._rectOverlay.style.display = 'none';
},
_screenToLayerPixel(screenX, screenY) {
const eng = this.engine;
const rect = eng.renderer.domElement.getBoundingClientRect();
const ndcX = ((screenX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((screenY - rect.top) / rect.height) * 2 + 1;
const rc = new THREE.Raycaster();
rc.setFromCamera(new THREE.Vector2(ndcX, ndcY), eng.camera);
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -(this._cutoutLayerZ || 0));
const target = new THREE.Vector3();
if (rc.ray.intersectPlane(plane, target)) {
return { x: target.x, y: -target.y };
}
return null;
},
enterSidePlacementMode(projectedCutouts, sideNum) {
return new Promise((resolve) => {
const eng = this.engine;
const encMode = eng.getMode('enclosure');
if (!encMode?._encData) { resolve(null); return; }
const encData = encMode._encData;
const side = encData.sides.find(sd => sd.num === sideNum);
if (!side) { resolve(null); return; }
const s = encMode._s;
const cl = encData.clearance;
const wt = encData.wallThickness;
const trayFloor = encData.trayFloor;
const pcbT = encData.pcbThickness;
const totalH = encData.totalH;
const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0;
const [startPx, startPy] = encMode._toPixel(side.startX, side.startY);
const [endPx, endPy] = encMode._toPixel(side.endX, side.endY);
const wallDx = endPx - startPx;
const wallDy = endPy - startPy;
const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy);
const wallAngle = Math.atan2(wallDy, wallDx);
const nx = Math.cos(side.angle);
const ny = -Math.sin(side.angle);
const offset = (cl + wt) * s;
encMode.lookAtSide(sideNum);
encMode.highlightSide(sideNum);
const planeW = wallLen * 2;
const planeH = totalH * s * 2;
const planeGeo = new THREE.PlaneGeometry(planeW, planeH);
const planeMat = new THREE.MeshBasicMaterial({ visible: false, side: THREE.DoubleSide });
const planeMesh = new THREE.Mesh(planeGeo, planeMat);
const midX = (startPx + endPx) / 2 + nx * offset;
const midY = (startPy + endPy) / 2 + ny * offset;
planeMesh.position.set(midX, midY, encZ + totalH * s / 2);
planeMesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX'));
eng.scene.add(planeMesh);
const ghostMeshes = [];
for (const pc of projectedCutouts) {
const geo = encMode._makeCutoutGeo(pc.width * s, pc.height * s, 0, pc.shape || 'rect');
const mat = new THREE.MeshBasicMaterial({
color: 0xa6e3a1, transparent: true, opacity: 0.5,
side: THREE.DoubleSide, depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX'));
const posX = side.startX + (side.endX - side.startX) * ((pc.x + pc.width / 2) / side.length);
const posY = side.startY + (side.endY - side.startY) * ((pc.x + pc.width / 2) / side.length);
const [px, py] = encMode._toPixel(posX, posY);
mesh.position.set(px + nx * offset, py + ny * offset, encZ + totalH * s / 2);
mesh.userData = { projectedCutout: pc };
eng.scene.add(mesh);
ghostMeshes.push(mesh);
}
const wallHeight = totalH - trayFloor - pcbT;
let currentYMM = wallHeight / 2;
const updateGhostZ = (zMM) => {
for (const gm of ghostMeshes) {
const fullZ = trayFloor + pcbT + zMM;
gm.position.z = encZ + (totalH - fullZ) * s;
}
};
updateGhostZ(currentYMM);
const moveHandler = (e) => {
eng._updateMouse(e);
eng.raycaster.setFromCamera(eng.mouse, eng.camera);
const hits = eng.raycaster.intersectObject(planeMesh);
if (hits.length > 0) {
const hitZ = hits[0].point.z;
const zMM = totalH - (hitZ - encZ) / s;
const yAbovePCB = zMM - trayFloor - pcbT;
const maxH = Math.max(...projectedCutouts.map(pc => pc.height));
const clamped = Math.max(0, Math.min(wallHeight - maxH, yAbovePCB - maxH / 2));
currentYMM = clamped;
updateGhostZ(currentYMM);
}
};
const canvas = eng.renderer.domElement;
const clickHandler = () => {
cleanup();
const results = projectedCutouts.map(pc => ({
x: pc.x,
y: currentYMM,
width: pc.width,
height: pc.height,
shape: pc.shape || 'rect',
}));
resolve(results);
};
const escHandler = (e) => {
if (e.key === 'Escape') {
cleanup();
resolve(null);
}
};
const cleanup = () => {
canvas.removeEventListener('mousemove', moveHandler);
canvas.removeEventListener('click', clickHandler);
document.removeEventListener('keydown', escHandler);
eng.scene.remove(planeMesh);
planeGeo.dispose();
planeMat.dispose();
for (const gm of ghostMeshes) {
eng.scene.remove(gm);
gm.geometry.dispose();
gm.material.dispose();
}
encMode.clearSideHighlight();
};
canvas.addEventListener('mousemove', moveHandler);
canvas.addEventListener('click', clickHandler);
document.addEventListener('keydown', escHandler);
});
},
dispose() {
if (this.cutoutMode) this.exitCutoutMode();
if (this._rectOverlay) {
this._rectOverlay.remove();
this._rectOverlay = null;
}
},
};
return mode;
}

View File

@ -0,0 +1,798 @@
import * as THREE from 'three';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { Z_SPACING, dbg } from '../former-engine.js';
export function createEnclosureMode() {
const mode = {
name: 'enclosure',
engine: null,
enclosureMesh: null,
trayMesh: null,
_encData: null,
_dpi: null,
_minX: null,
_maxY: null,
_s: null,
_renderedSTLLoaded: false,
_savedVisibility: null,
_savedEnclosurePos: null,
_savedEnclosureRot: null,
_savedTrayPos: null,
_savedTrayRot: null,
_solidFillLight: null,
_sideHighlightMesh: null,
_sideHighlightLabel: null,
_cutoutVizGroup: null,
_cutoutVizMeshes: [],
_cutoutOutlines: [],
selectedCutoutIds: new Set(),
_vizClickHandler: null,
install(engine) {
this.engine = engine;
this._vizClickHandler = (e) => this._handleVizClick(e);
engine.registerClickHandler(this._vizClickHandler);
},
_handleVizClick(e) {
if (!this._cutoutVizMeshes || this._cutoutVizMeshes.length === 0) return false;
const eng = this.engine;
const cutoutHits = eng.raycaster.intersectObjects(this._cutoutVizMeshes);
if (cutoutHits.length > 0) {
const hitMesh = cutoutHits[0].object;
const cutoutId = hitMesh.userData.cutoutId;
if (e.shiftKey) {
this._toggleCutoutSelection(cutoutId);
} else {
this._selectCutout(cutoutId);
}
return true;
}
// Clicked empty — deselect all cutouts (don't consume event)
this._deselectAllCutouts();
return false;
},
_selectCutout(cutoutId) {
this.selectedCutoutIds = new Set([cutoutId]);
this._updateCutoutHighlights();
if (this.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([...this.selectedCutoutIds]);
},
_toggleCutoutSelection(cutoutId) {
if (!this.selectedCutoutIds) this.selectedCutoutIds = new Set();
if (this.selectedCutoutIds.has(cutoutId)) {
this.selectedCutoutIds.delete(cutoutId);
} else {
this.selectedCutoutIds.add(cutoutId);
}
this._updateCutoutHighlights();
if (this.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([...this.selectedCutoutIds]);
},
_deselectAllCutouts() {
if (this.selectedCutoutIds && this.selectedCutoutIds.size > 0) {
this.selectedCutoutIds = new Set();
this._updateCutoutHighlights();
if (this.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([]);
}
},
_updateCutoutHighlights() {
const scene = this.engine.scene;
if (this._cutoutOutlines) {
for (const ol of this._cutoutOutlines) {
scene.remove(ol);
ol.geometry.dispose();
ol.material.dispose();
}
}
this._cutoutOutlines = [];
if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return;
for (const mesh of this._cutoutVizMeshes) {
const isSelected = this.selectedCutoutIds.has(mesh.userData.cutoutId);
mesh.material.opacity = isSelected ? 0.9 : 0.6;
if (isSelected) {
const edges = new THREE.EdgesGeometry(mesh.geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
color: 0x89b4fa, linewidth: 2,
}));
line.position.copy(mesh.position);
line.quaternion.copy(mesh.quaternion);
this._cutoutOutlines.push(line);
scene.add(line);
}
}
},
getSelectedCutouts() {
if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return [];
return this._cutoutVizMeshes
.filter(m => this.selectedCutoutIds.has(m.userData.cutoutId))
.map(m => m.userData.cutout);
},
storeEnclosureContext(encData, dpi, minX, maxY) {
this._encData = encData;
this._dpi = dpi;
this._minX = minX;
this._maxY = maxY;
this._s = dpi / 25.4;
},
_toPixel(mmX, mmY) {
const s = this._s;
return [(mmX - this._minX) * s, -(this._maxY - mmY) * s];
},
loadEnclosureGeometry(encData, dpi, minX, maxY) {
if (!encData || !encData.outlinePoints || encData.outlinePoints.length < 3) return;
const eng = this.engine;
this.storeEnclosureContext(encData, dpi, minX, maxY);
this._disposeEnclosureMeshes();
const s = dpi / 25.4;
const toPixel = (mmX, mmY) => [
(mmX - minX) * s,
-(maxY - mmY) * s
];
let pts = encData.outlinePoints.map(p => toPixel(p[0], p[1]));
if (pts.length > 2) {
const first = pts[0], last = pts[pts.length - 1];
if (Math.abs(first[0] - last[0]) < 0.01 && Math.abs(first[1] - last[1]) < 0.01) {
pts = pts.slice(0, -1);
}
}
let area = 0;
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length;
area += pts[i][0] * pts[j][1] - pts[j][0] * pts[i][1];
}
const sign = area < 0 ? 1 : -1;
const offsetPoly = (points, dist) => {
const n = points.length;
const result = [];
const maxMiter = Math.abs(dist) * 2;
for (let i = 0; i < n; i++) {
const prev = points[(i - 1 + n) % n];
const curr = points[i];
const next = points[(i + 1) % n];
const e1x = curr[0] - prev[0], e1y = curr[1] - prev[1];
const e2x = next[0] - curr[0], e2y = next[1] - curr[1];
const len1 = Math.sqrt(e1x * e1x + e1y * e1y) || 1;
const len2 = Math.sqrt(e2x * e2x + e2y * e2y) || 1;
const n1x = -e1y / len1, n1y = e1x / len1;
const n2x = -e2y / len2, n2y = e2x / len2;
let nx = n1x + n2x, ny = n1y + n2y;
const nlen = Math.sqrt(nx * nx + ny * ny) || 1;
nx /= nlen; ny /= nlen;
const dot = n1x * nx + n1y * ny;
const rawMiter = dot > 0.01 ? dist / dot : dist;
if (Math.abs(rawMiter) > maxMiter) {
const d = dist;
result.push([curr[0] + n1x * d, curr[1] + n1y * d]);
result.push([curr[0] + n2x * d, curr[1] + n2y * d]);
} else {
result.push([curr[0] + nx * rawMiter, curr[1] + ny * rawMiter]);
}
}
return result;
};
const makeShape = (poly) => {
const shape = new THREE.Shape();
shape.moveTo(poly[0][0], poly[0][1]);
for (let i = 1; i < poly.length; i++) shape.lineTo(poly[i][0], poly[i][1]);
shape.closePath();
return shape;
};
const makeHole = (poly) => {
const path = new THREE.Path();
path.moveTo(poly[0][0], poly[0][1]);
for (let i = 1; i < poly.length; i++) path.lineTo(poly[i][0], poly[i][1]);
path.closePath();
return path;
};
const makeRing = (outerPoly, innerPoly, depth, zPos) => {
const shape = makeShape(outerPoly);
shape.holes.push(makeHole(innerPoly));
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
return { geo, zPos };
};
const makeSolid = (poly, depth, zPos) => {
const shape = makeShape(poly);
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
return { geo, zPos };
};
const cl = encData.clearance;
const wt = encData.wallThickness;
const trayFloor = encData.trayFloor;
const snapH = encData.snapHeight;
const lidThick = encData.lidThick;
const totalH = encData.totalH;
const polyInner = offsetPoly(pts, sign * cl * s);
const polyTrayWall = offsetPoly(pts, sign * (cl + wt) * s);
const polyOuter = offsetPoly(pts, sign * (cl + 2 * wt) * s);
const enclosureParts = [];
const trayParts = [];
const eps = 0.05 * s;
enclosureParts.push(makeSolid(polyOuter, lidThick * s, (totalH - lidThick) * s + eps));
const upperWallH = totalH - lidThick - (trayFloor + snapH);
if (upperWallH > 0.1) {
enclosureParts.push(makeRing(polyOuter, polyInner, upperWallH * s - eps, (trayFloor + snapH) * s + eps));
}
if (snapH > 0.1) {
enclosureParts.push(makeRing(polyOuter, polyTrayWall, snapH * s - eps, trayFloor * s));
}
if (encData.mountingHoles) {
for (const h of encData.mountingHoles) {
const [px, py] = toPixel(h.x, h.y);
const r = ((h.diameter / 2) - 0.15) * s;
const pegH = (totalH - lidThick) * s;
const cylGeo = new THREE.CylinderGeometry(r, r, pegH, 16);
cylGeo.rotateX(Math.PI / 2);
enclosureParts.push({ geo: cylGeo, zPos: pegH / 2, cx: px, cy: py, isCyl: true });
}
}
trayParts.push(makeSolid(polyOuter, trayFloor * s, 0));
if (snapH > 0.1) {
trayParts.push(makeRing(polyTrayWall, polyInner, snapH * s - eps, trayFloor * s + eps));
}
const encMat = new THREE.MeshPhongMaterial({
color: 0xfffdcc, transparent: true, opacity: 0.55,
side: THREE.DoubleSide, depthWrite: false,
polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1,
});
const encGroup = new THREE.Group();
for (const part of enclosureParts) {
const mesh = new THREE.Mesh(part.geo, encMat.clone());
if (part.isCyl) {
mesh.position.set(part.cx, part.cy, part.zPos);
} else {
mesh.position.z = part.zPos;
}
encGroup.add(mesh);
}
if (eng.enclosureLayerIndex >= 0) {
encGroup.scale.z = -1;
encGroup.position.z = eng.enclosureLayerIndex * Z_SPACING + totalH * s;
encGroup.userData = { layerIndex: eng.enclosureLayerIndex, isEnclosure: true };
const layer = eng.layers[eng.enclosureLayerIndex];
encGroup.visible = layer ? layer.visible : true;
this.enclosureMesh = encGroup;
eng.layerMeshes[eng.enclosureLayerIndex] = encGroup;
eng.layerGroup.add(encGroup);
}
const trayMat = new THREE.MeshPhongMaterial({
color: 0xb8c8a0, transparent: true, opacity: 0.5,
side: THREE.DoubleSide, depthWrite: false,
polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1,
});
const trayGroup = new THREE.Group();
for (const part of trayParts) {
const mesh = new THREE.Mesh(part.geo, trayMat.clone());
mesh.position.z = part.zPos;
trayGroup.add(mesh);
}
if (eng.trayLayerIndex >= 0) {
trayGroup.scale.z = -1;
trayGroup.position.z = eng.trayLayerIndex * Z_SPACING + totalH * s;
trayGroup.userData = { layerIndex: eng.trayLayerIndex, isEnclosure: true };
const layer = eng.layers[eng.trayLayerIndex];
trayGroup.visible = layer ? layer.visible : false;
this.trayMesh = trayGroup;
eng.layerMeshes[eng.trayLayerIndex] = trayGroup;
eng.layerGroup.add(trayGroup);
}
},
_disposeEnclosureMeshes() {
const eng = this.engine;
for (const mesh of [this.enclosureMesh, this.trayMesh]) {
if (mesh) {
eng.layerGroup.remove(mesh);
mesh.traverse(c => {
if (c.geometry) c.geometry.dispose();
if (c.material) c.material.dispose();
});
}
}
this.enclosureMesh = null;
this.trayMesh = null;
},
enterSolidView() {
const eng = this.engine;
this._savedVisibility = eng.layers.map(l => l.visible);
this._savedEnclosurePos = null;
this._savedEnclosureRot = null;
this._savedTrayPos = null;
for (let i = 0; i < eng.layers.length; i++) {
const mesh = eng.layerMeshes[i];
if (!mesh) continue;
if (i === eng.enclosureLayerIndex || i === eng.trayLayerIndex) {
mesh.visible = true;
mesh.traverse(c => {
if (c.material) {
c.material.opacity = 1.0;
c.material.transparent = false;
c.material.depthWrite = true;
c.material.side = THREE.FrontSide;
c.material.needsUpdate = true;
}
});
} else {
mesh.visible = false;
}
}
if (this.enclosureMesh && this.trayMesh) {
this._savedEnclosurePos = this.enclosureMesh.position.clone();
this._savedEnclosureRot = this.enclosureMesh.quaternion.clone();
this._savedTrayPos = this.trayMesh.position.clone();
this._savedTrayRot = this.trayMesh.quaternion.clone();
this.enclosureMesh.rotateX(Math.PI);
this.trayMesh.rotateZ(Math.PI);
this.enclosureMesh.position.z = this.trayMesh.position.z;
const encBox = new THREE.Box3().setFromObject(this.enclosureMesh);
const trayBox = new THREE.Box3().setFromObject(this.trayMesh);
const encCY = (encBox.min.y + encBox.max.y) / 2;
const trayCY = (trayBox.min.y + trayBox.max.y) / 2;
this.trayMesh.position.y += encCY - trayCY;
const trayBox2 = new THREE.Box3().setFromObject(this.trayMesh);
const encWidth = encBox.max.x - encBox.min.x;
const gap = Math.max(encWidth * 0.05, 5);
this.trayMesh.position.x += encBox.min.x - trayBox2.max.x - gap;
}
eng.selectLayer(-1);
if (this._cutoutVizGroup) this._cutoutVizGroup.visible = false;
if (this._cutoutOutlines) {
for (const ol of this._cutoutOutlines) ol.visible = false;
}
this.clearSideHighlight();
eng.gridHelper.visible = false;
eng.scene.background = new THREE.Color(0x1e1e2e);
eng._ambientLight.intensity = 0.45;
eng._dirLight.intensity = 0.7;
eng._dirLight.position.set(1, -1, 2).normalize();
this._solidFillLight = new THREE.DirectionalLight(0xffffff, 0.25);
this._solidFillLight.position.set(-1, 1, 0.5).normalize();
eng.scene.add(this._solidFillLight);
if (this.enclosureMesh) {
const box = new THREE.Box3().setFromObject(this.enclosureMesh);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const dist = Math.max(size.x, size.y, size.z) * 1.2;
eng.controls.target.copy(center);
eng.camera.position.set(center.x, center.y - dist * 0.5, center.z + dist * 0.6);
eng.camera.up.set(0, 0, 1);
eng.controls.update();
} else {
eng.resetView();
}
},
exitSolidView() {
const eng = this.engine;
eng.scene.background = new THREE.Color(0x000000);
eng.gridHelper.visible = eng.gridVisible;
eng._ambientLight.intensity = 0.9;
eng._dirLight.intensity = 0.3;
eng._dirLight.position.set(50, -50, 100);
if (this._solidFillLight) {
eng.scene.remove(this._solidFillLight);
this._solidFillLight = null;
}
if (this._renderedSTLLoaded) {
this._disposeEnclosureMeshes();
this._renderedSTLLoaded = false;
if (this._encData && this._dpi && this._minX !== undefined && this._maxY !== undefined) {
this.loadEnclosureGeometry(this._encData, this._dpi, this._minX, this._maxY);
}
} else {
if (this._savedEnclosurePos && this.enclosureMesh) {
this.enclosureMesh.position.copy(this._savedEnclosurePos);
this.enclosureMesh.quaternion.copy(this._savedEnclosureRot);
}
if (this._savedTrayPos && this.trayMesh) {
this.trayMesh.position.copy(this._savedTrayPos);
if (this._savedTrayRot) this.trayMesh.quaternion.copy(this._savedTrayRot);
}
}
this._savedEnclosurePos = null;
this._savedEnclosureRot = null;
this._savedTrayPos = null;
this._savedTrayRot = null;
for (let i = 0; i < eng.layers.length; i++) {
const mesh = eng.layerMeshes[i];
if (!mesh) continue;
const wasVisible = this._savedVisibility ? this._savedVisibility[i] : eng.layers[i].visible;
eng.layers[i].visible = wasVisible;
mesh.visible = wasVisible;
if (i === eng.enclosureLayerIndex || i === eng.trayLayerIndex) {
const baseOpacity = i === eng.enclosureLayerIndex ? 0.55 : 0.5;
mesh.traverse(c => {
if (c.material) {
c.material.opacity = baseOpacity;
c.material.transparent = true;
c.material.depthWrite = false;
c.material.side = THREE.DoubleSide;
c.material.needsUpdate = true;
}
});
}
}
if (this._cutoutVizGroup) this._cutoutVizGroup.visible = true;
if (this._cutoutOutlines) {
for (const ol of this._cutoutOutlines) ol.visible = true;
}
this._savedVisibility = null;
eng.resetView();
},
loadRenderedSTL(enclosureArrayBuffer, trayArrayBuffer) {
const eng = this.engine;
dbg('loadRenderedSTL: called, encBuf=', enclosureArrayBuffer?.byteLength || 0, 'trayBuf=', trayArrayBuffer?.byteLength || 0);
this._disposeEnclosureMeshes();
const loader = new STLLoader();
const s = (this._dpi || 600) / 25.4;
const minX = this._minX || 0;
const maxY = this._maxY || 0;
const encData = this._encData;
const totalH = encData ? encData.totalH : 0;
let centerX = 0, centerY = 0;
if (encData) {
if (encData.outlinePoints && encData.outlinePoints.length > 0) {
centerX = (minX + (minX + (eng._maxW || 500) / s)) / 2;
centerY = (maxY + (maxY - (eng._maxH || 500) / s)) / 2;
}
}
const originPx = (0 + centerX - minX) * s;
const originPy = -(maxY - (0 + centerY)) * s;
const createMeshFromSTL = (arrayBuffer, color, label) => {
const geometry = loader.parse(arrayBuffer);
geometry.scale(s, s, s);
geometry.scale(1, -1, 1);
const pos = geometry.attributes.position.array;
for (let i = 0; i < pos.length; i += 9) {
for (let j = 0; j < 3; j++) {
const tmp = pos[i + 3 + j];
pos[i + 3 + j] = pos[i + 6 + j];
pos[i + 6 + j] = tmp;
}
}
geometry.attributes.position.needsUpdate = true;
geometry.computeBoundingBox();
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.6,
metalness: 0.0,
side: THREE.FrontSide,
flatShading: true,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(originPx, originPy, 0);
return mesh;
};
if (enclosureArrayBuffer && enclosureArrayBuffer.byteLength > 84) {
const encGroup = new THREE.Group();
const encMesh = createMeshFromSTL(enclosureArrayBuffer, 0xffe090, 'enclosure');
encGroup.add(encMesh);
if (eng.enclosureLayerIndex >= 0) {
encGroup.scale.z = -1;
encGroup.position.z = eng.enclosureLayerIndex * Z_SPACING + totalH * s;
encGroup.userData = { layerIndex: eng.enclosureLayerIndex, isEnclosure: true };
const layer = eng.layers[eng.enclosureLayerIndex];
encGroup.visible = layer ? layer.visible : true;
this.enclosureMesh = encGroup;
eng.layerMeshes[eng.enclosureLayerIndex] = encGroup;
eng.layerGroup.add(encGroup);
}
}
if (trayArrayBuffer && trayArrayBuffer.byteLength > 84) {
const trayGroup = new THREE.Group();
const trayMesh = createMeshFromSTL(trayArrayBuffer, 0xa0d880, 'tray');
trayGroup.add(trayMesh);
if (eng.trayLayerIndex >= 0) {
trayGroup.scale.z = -1;
trayGroup.position.z = eng.trayLayerIndex * Z_SPACING + totalH * s;
trayGroup.userData = { layerIndex: eng.trayLayerIndex, isEnclosure: true };
const layer = eng.layers[eng.trayLayerIndex];
trayGroup.visible = layer ? layer.visible : false;
this.trayMesh = trayGroup;
eng.layerMeshes[eng.trayLayerIndex] = trayGroup;
eng.layerGroup.add(trayGroup);
}
}
this._renderedSTLLoaded = true;
},
highlightSide(sideNum) {
this.clearSideHighlight();
if (!this._encData) return;
const eng = this.engine;
const side = this._encData.sides.find(s => s.num === sideNum);
if (!side) return;
const s = this._s;
const [startPx, startPy] = this._toPixel(side.startX, side.startY);
const [endPx, endPy] = this._toPixel(side.endX, side.endY);
const dx = endPx - startPx;
const dy = endPy - startPy;
const len = Math.sqrt(dx * dx + dy * dy);
const totalH = this._encData.totalH * s;
const cl = this._encData.clearance;
const wt = this._encData.wallThickness;
const geo = new THREE.PlaneGeometry(len, totalH);
const sideColors = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0x8b5cf6, 0xec4899, 0x14b8a6, 0xf97316];
const color = sideColors[(sideNum - 1) % sideColors.length];
const mat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.4,
side: THREE.DoubleSide, depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
const midX = (startPx + endPx) / 2;
const midY = (startPy + endPy) / 2;
const offset = (cl + wt) * s;
const nx = Math.cos(side.angle);
const ny = -Math.sin(side.angle);
const wallAngle = Math.atan2(dy, dx);
const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0;
mesh.position.set(midX + nx * offset, midY + ny * offset, encZ + totalH / 2);
mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX'));
this._sideHighlightMesh = mesh;
eng.scene.add(mesh);
const canvas2d = document.createElement('canvas');
canvas2d.width = 64;
canvas2d.height = 64;
const ctx = canvas2d.getContext('2d');
ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`;
ctx.beginPath();
ctx.arc(32, 32, 28, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'white';
ctx.font = 'bold 32px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(sideNum.toString(), 32, 33);
const tex = new THREE.CanvasTexture(canvas2d);
const spriteMat = new THREE.SpriteMaterial({ map: tex, depthWrite: false });
const sprite = new THREE.Sprite(spriteMat);
const labelScale = Math.max(len, totalH) * 0.15;
sprite.scale.set(labelScale, labelScale, 1);
sprite.position.set(midX + nx * offset * 1.5, midY + ny * offset * 1.5, encZ + totalH / 2);
this._sideHighlightLabel = sprite;
eng.scene.add(sprite);
},
clearSideHighlight() {
const eng = this.engine;
if (!eng) return;
if (this._sideHighlightMesh) {
eng.scene.remove(this._sideHighlightMesh);
this._sideHighlightMesh.geometry.dispose();
this._sideHighlightMesh.material.dispose();
this._sideHighlightMesh = null;
}
if (this._sideHighlightLabel) {
eng.scene.remove(this._sideHighlightLabel);
this._sideHighlightLabel.material.map.dispose();
this._sideHighlightLabel.material.dispose();
this._sideHighlightLabel = null;
}
},
lookAtSide(sideNum) {
if (!this._encData) return;
const eng = this.engine;
const side = this._encData.sides.find(s => s.num === sideNum);
if (!side) return;
const s = this._s;
const [startPx, startPy] = this._toPixel(side.startX, side.startY);
const [endPx, endPy] = this._toPixel(side.endX, side.endY);
const midX = (startPx + endPx) / 2;
const midY = (startPy + endPy) / 2;
const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0;
const totalH = this._encData.totalH * s;
const midZ = encZ + totalH / 2;
const nx = Math.cos(side.angle);
const ny = -Math.sin(side.angle);
const dist = Math.max(eng._maxW || 500, eng._maxH || 500) * 0.5;
eng.camera.position.set(midX + nx * dist, midY + ny * dist, midZ);
eng.camera.up.set(0, 0, 1);
eng.controls.target.set(midX, midY, midZ);
eng.controls.update();
},
_makeCutoutGeo(w, h, r, geoShape) {
if (geoShape === 'circle') return new THREE.CircleGeometry(Math.max(w, h) / 2, 32);
if (geoShape === 'obround') {
const shape = new THREE.Shape();
const hw = w / 2, hh = h / 2;
const cr = Math.min(hw, hh);
if (w >= h) {
shape.absarc(hw - cr, 0, cr, -Math.PI / 2, Math.PI / 2, false);
shape.absarc(-hw + cr, 0, cr, Math.PI / 2, 3 * Math.PI / 2, false);
} else {
shape.absarc(0, hh - cr, cr, 0, Math.PI, false);
shape.absarc(0, -hh + cr, cr, Math.PI, 2 * Math.PI, false);
}
shape.closePath();
return new THREE.ShapeGeometry(shape);
}
if (!r || r <= 0) return new THREE.PlaneGeometry(w, h);
const cr = Math.min(r, w / 2, h / 2);
const hw = w / 2, hh = h / 2;
const shape = new THREE.Shape();
shape.moveTo(-hw + cr, -hh);
shape.lineTo(hw - cr, -hh);
shape.quadraticCurveTo(hw, -hh, hw, -hh + cr);
shape.lineTo(hw, hh - cr);
shape.quadraticCurveTo(hw, hh, hw - cr, hh);
shape.lineTo(-hw + cr, hh);
shape.quadraticCurveTo(-hw, hh, -hw, hh - cr);
shape.lineTo(-hw, -hh + cr);
shape.quadraticCurveTo(-hw, -hh, -hw + cr, -hh);
return new THREE.ShapeGeometry(shape);
},
refreshCutouts(cutouts, encData, dpi, minX, maxY) {
this._disposeCutoutViz();
if (!cutouts || cutouts.length === 0 || !encData) return;
const eng = this.engine;
this.storeEnclosureContext(encData, dpi, minX, maxY);
const s = this._s;
this._cutoutVizGroup = new THREE.Group();
this._cutoutVizMeshes = [];
const cl = encData.clearance;
const wt = encData.wallThickness;
const trayFloor = encData.trayFloor;
const pcbT = encData.pcbThickness;
const totalH = encData.totalH;
const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0;
for (const c of cutouts) {
let mesh;
const color = c.isDado ? 0xf9e2af : 0xa6e3a1;
const cr = (c.r || 0) * s;
if (c.surface === 'top') {
const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape);
const mat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.6,
side: THREE.DoubleSide, depthWrite: false,
});
mesh = new THREE.Mesh(geo, mat);
const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2);
mesh.position.set(px, py, encZ - 0.5);
} else if (c.surface === 'bottom') {
const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape);
const mat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.6,
side: THREE.DoubleSide, depthWrite: false,
});
mesh = new THREE.Mesh(geo, mat);
const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2);
mesh.position.set(px, py, encZ + totalH * s + 0.5);
} else if (c.surface === 'side') {
const side = encData.sides.find(sd => sd.num === c.sideNum);
if (!side) continue;
const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape);
const mat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.6,
side: THREE.DoubleSide, depthWrite: false,
});
mesh = new THREE.Mesh(geo, mat);
const sdx = side.endX - side.startX;
const sdy = side.endY - side.startY;
const sLen = Math.sqrt(sdx * sdx + sdy * sdy);
const ux = sdx / sLen, uy = sdy / sLen;
const midAlongSide = c.x + c.w / 2;
const mmX = side.startX + ux * midAlongSide;
const mmY = side.startY + uy * midAlongSide;
const [px, py] = this._toPixel(mmX, mmY);
const zMM = trayFloor + pcbT + c.y + c.h / 2;
const offset = (cl + wt) * s;
const nx = Math.cos(side.angle);
const ny = -Math.sin(side.angle);
mesh.position.set(px + nx * offset, py + ny * offset, encZ + (totalH - zMM) * s);
const wallAngle = Math.atan2(
this._toPixel(side.endX, side.endY)[1] - this._toPixel(side.startX, side.startY)[1],
this._toPixel(side.endX, side.endY)[0] - this._toPixel(side.startX, side.startY)[0]
);
mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX'));
}
if (mesh) {
mesh.userData.cutoutId = c.id;
mesh.userData.cutout = c;
this._cutoutVizGroup.add(mesh);
this._cutoutVizMeshes.push(mesh);
}
}
eng.scene.add(this._cutoutVizGroup);
},
_disposeCutoutViz() {
if (this._cutoutVizGroup) {
this.engine.disposeGroup(this._cutoutVizGroup);
this._cutoutVizGroup = null;
}
this._cutoutVizMeshes = [];
},
dispose() {
if (this._vizClickHandler) {
this.engine.unregisterClickHandler(this._vizClickHandler);
}
this.clearSideHighlight();
this._disposeCutoutViz();
this._disposeEnclosureMeshes();
},
};
return mode;
}

View File

@ -0,0 +1,12 @@
export function createStructuralMode() {
return {
name: 'structural',
engine: null,
install(engine) {
this.engine = engine;
},
dispose() {},
};
}

View File

@ -0,0 +1,798 @@
import * as THREE from 'three';
import { dbg } from '../former-engine.js';
export function createVectorWrapMode() {
const mode = {
name: 'vectorwrap',
engine: null,
_externalModelMesh: null,
_externalModelCenter: null,
_externalModelSize: null,
_projEncGroup: null,
_projEncLidMesh: null,
_projEncTrayMesh: null,
_projEncAssembled: true,
_vwGroup: null,
_vectorOverlayMesh: null,
_vwTexture: null,
_vwWidth: 0,
_vwHeight: 0,
_vwRestPositions: null,
_ffdCols: 0,
_ffdRows: 0,
_ffdGroup: null,
_ffdControlPoints: null,
_ffdCurrentPoints: null,
_ffdSpheres: null,
_ffdLines: null,
_ffdGridVisible: true,
_ffdDragging: null,
_ffdDragCleanup: null,
_cameraLocked: false,
_sleeveMesh: null,
_surfaceDragCleanup: null,
_dragStartUV: null,
install(engine) {
this.engine = engine;
},
loadExternalModel(stlArrayBuffer) {
this._disposeExternalModel();
const eng = this.engine;
const result = eng.loadSTLCentered(stlArrayBuffer, {
color: 0x888888,
side: THREE.DoubleSide,
flatShading: true,
});
this._externalModelMesh = result.mesh;
this._externalModelCenter = result.center;
this._externalModelSize = result.size;
eng.scene.add(result.mesh);
eng.fitCamera(result.size);
eng.gridHelper.position.set(0, 0, result.box.min.z - result.center.z - 0.5);
},
_disposeExternalModel() {
if (this._externalModelMesh) {
this.engine.scene.remove(this._externalModelMesh);
this._externalModelMesh.geometry.dispose();
this._externalModelMesh.material.dispose();
this._externalModelMesh = null;
}
},
loadProjectEnclosure(enclosureSTL, traySTL) {
dbg('vw-mode: loadProjectEnclosure called, encSTL=', enclosureSTL?.byteLength || 0, 'traySTL=', traySTL?.byteLength || 0);
this._disposeExternalModel();
this._disposeProjectEnclosure();
const eng = this.engine;
this._projEncGroup = new THREE.Group();
this._projEncAssembled = true;
if (enclosureSTL && enclosureSTL.byteLength > 84) {
dbg('vw-mode: parsing enclosure STL...');
try {
const loader = new (await_stl_loader())();
const geometry = loader.parse(enclosureSTL);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
dbg('vw-mode: enclosure geometry verts=', geometry.attributes.position.count, 'bbox=', JSON.stringify(geometry.boundingBox));
const mat = new THREE.MeshPhongMaterial({
color: 0xffe090, side: THREE.DoubleSide, flatShading: true,
});
this._projEncLidMesh = new THREE.Mesh(geometry, mat);
this._projEncGroup.add(this._projEncLidMesh);
dbg('vw-mode: enclosure lid mesh added');
} catch (e) {
dbg('vw-mode: enclosure STL parse FAILED:', e?.message || e);
}
} else {
dbg('vw-mode: skipping enclosure STL (empty or too small)');
}
if (traySTL && traySTL.byteLength > 84) {
dbg('vw-mode: parsing tray STL...');
try {
const loader = new (await_stl_loader())();
const geometry = loader.parse(traySTL);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
dbg('vw-mode: tray geometry verts=', geometry.attributes.position.count, 'bbox=', JSON.stringify(geometry.boundingBox));
const mat = new THREE.MeshPhongMaterial({
color: 0xa0d880, side: THREE.DoubleSide, flatShading: true,
});
this._projEncTrayMesh = new THREE.Mesh(geometry, mat);
this._projEncGroup.add(this._projEncTrayMesh);
dbg('vw-mode: tray mesh added');
} catch (e) {
dbg('vw-mode: tray STL parse FAILED:', e?.message || e);
}
} else {
dbg('vw-mode: skipping tray STL (empty or too small)');
}
dbg('vw-mode: projEncGroup children=', this._projEncGroup.children.length);
if (this._projEncGroup.children.length === 0) {
dbg('vw-mode: NO meshes loaded, returning early');
return;
}
const box = new THREE.Box3().setFromObject(this._projEncGroup);
const cx = (box.min.x + box.max.x) / 2;
const cy = (box.min.y + box.max.y) / 2;
const cz = (box.min.z + box.max.z) / 2;
this._projEncGroup.position.set(-cx, -cy, -cz);
this._externalModelCenter = new THREE.Vector3(cx, cy, cz);
this._externalModelSize = box.getSize(new THREE.Vector3());
dbg('vw-mode: group centered, size=', this._externalModelSize.x.toFixed(1), 'x', this._externalModelSize.y.toFixed(1), 'x', this._externalModelSize.z.toFixed(1));
eng.scene.add(this._projEncGroup);
eng.fitCamera(this._externalModelSize);
eng.gridHelper.position.set(0, 0, box.min.z - cz - 0.5);
dbg('vw-mode: loadProjectEnclosure complete');
},
toggleProjectEnclosurePart(part) {
const mesh = part === 'lid' ? this._projEncLidMesh : this._projEncTrayMesh;
if (!mesh) return true;
mesh.visible = !mesh.visible;
return mesh.visible;
},
toggleProjectEnclosureAssembly() {
if (!this._projEncLidMesh || !this._projEncGroup) return true;
this._projEncAssembled = !this._projEncAssembled;
if (this._projEncAssembled) {
this._projEncLidMesh.position.set(0, 0, 0);
} else {
const box = new THREE.Box3().setFromObject(this._projEncGroup);
const width = box.max.x - box.min.x;
this._projEncLidMesh.position.x = width * 1.2;
}
return this._projEncAssembled;
},
_disposeProjectEnclosure() {
if (this._projEncGroup) {
this.engine.disposeGroup(this._projEncGroup);
this._projEncGroup = null;
}
this._projEncLidMesh = null;
this._projEncTrayMesh = null;
this._projEncAssembled = true;
},
loadVectorOverlay(imageUrl, svgWidthMM, svgHeightMM) {
this._disposeVectorOverlay();
const eng = this.engine;
const loader = new THREE.TextureLoader();
loader.load(imageUrl, (tex) => {
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
const w = svgWidthMM || tex.image.width * (25.4 / 96);
const h = svgHeightMM || tex.image.height * (25.4 / 96);
this._vwWidth = w;
this._vwHeight = h;
this._vwTexture = tex;
const segsX = 32, segsY = 32;
const geo = new THREE.PlaneGeometry(w, h, segsX, segsY);
const mat = new THREE.MeshBasicMaterial({
map: tex,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
let zPos = 0;
const targetObj = this._externalModelMesh || this._projEncGroup;
if (targetObj) {
const box = new THREE.Box3().setFromObject(targetObj);
zPos = box.max.z + 2;
}
this._vwGroup = new THREE.Group();
this._vwGroup.position.set(0, 0, zPos);
this._vwGroup.add(mesh);
this._vectorOverlayMesh = mesh;
const pos = geo.attributes.position;
this._vwRestPositions = new Float32Array(pos.count * 3);
for (let i = 0; i < pos.count; i++) {
this._vwRestPositions[i * 3] = pos.getX(i);
this._vwRestPositions[i * 3 + 1] = pos.getY(i);
this._vwRestPositions[i * 3 + 2] = pos.getZ(i);
}
eng.scene.add(this._vwGroup);
this.initFFDGrid(4, 4);
});
},
initFFDGrid(cols, rows) {
this._disposeFFDGrid();
const eng = this.engine;
const w = this._vwWidth;
const h = this._vwHeight;
if (!w || !h) return;
this._ffdCols = cols;
this._ffdRows = rows;
this._ffdGroup = new THREE.Group();
this._ffdControlPoints = [];
this._ffdCurrentPoints = [];
this._ffdSpheres = [];
const halfW = w / 2, halfH = h / 2;
for (let iy = 0; iy <= rows; iy++) {
for (let ix = 0; ix <= cols; ix++) {
const px = -halfW + (ix / cols) * w;
const py = -halfH + (iy / rows) * h;
this._ffdControlPoints.push(new THREE.Vector2(px, py));
this._ffdCurrentPoints.push(new THREE.Vector2(px, py));
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(Math.max(w, h) * 0.012, 8, 8),
new THREE.MeshBasicMaterial({ color: 0x89b4fa, transparent: true, opacity: 0.8 })
);
sphere.position.set(px, py, 0.5);
sphere.userData.ffdIndex = iy * (cols + 1) + ix;
this._ffdSpheres.push(sphere);
this._ffdGroup.add(sphere);
}
}
this._ffdLines = [];
this._rebuildFFDLines();
this._vwGroup.add(this._ffdGroup);
this._ffdGridVisible = true;
this._ffdDragging = null;
this._setupFFDDrag();
},
_rebuildFFDLines() {
if (this._ffdLines) {
for (const line of this._ffdLines) {
this._ffdGroup.remove(line);
line.geometry.dispose();
line.material.dispose();
}
}
this._ffdLines = [];
const cols = this._ffdCols, rows = this._ffdRows;
const cp = this._ffdCurrentPoints;
const lineMat = new THREE.LineBasicMaterial({ color: 0x585b70, opacity: 0.5, transparent: true });
for (let iy = 0; iy <= rows; iy++) {
const pts = [];
for (let ix = 0; ix <= cols; ix++) {
const p = cp[iy * (cols + 1) + ix];
pts.push(new THREE.Vector3(p.x, p.y, 0.3));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const line = new THREE.Line(geo, lineMat.clone());
this._ffdLines.push(line);
this._ffdGroup.add(line);
}
for (let ix = 0; ix <= cols; ix++) {
const pts = [];
for (let iy = 0; iy <= rows; iy++) {
const p = cp[iy * (cols + 1) + ix];
pts.push(new THREE.Vector3(p.x, p.y, 0.3));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const line = new THREE.Line(geo, lineMat.clone());
this._ffdLines.push(line);
this._ffdGroup.add(line);
}
},
_applyFFD() {
if (!this._vectorOverlayMesh || !this._ffdCurrentPoints) return;
const pos = this._vectorOverlayMesh.geometry.attributes.position;
const rest = this._vwRestPositions;
const cols = this._ffdCols, rows = this._ffdRows;
const cp = this._ffdCurrentPoints;
const restCp = this._ffdControlPoints;
const w = this._vwWidth, h = this._vwHeight;
const halfW = w / 2, halfH = h / 2;
for (let i = 0; i < pos.count; i++) {
const rx = rest[i * 3];
const ry = rest[i * 3 + 1];
let u = (rx + halfW) / w;
let v = (ry + halfH) / h;
u = Math.max(0, Math.min(1, u));
v = Math.max(0, Math.min(1, v));
const cellX = Math.min(Math.floor(u * cols), cols - 1);
const cellY = Math.min(Math.floor(v * rows), rows - 1);
const lu = (u * cols) - cellX;
const lv = (v * rows) - cellY;
const i00 = cellY * (cols + 1) + cellX;
const i10 = i00 + 1;
const i01 = i00 + (cols + 1);
const i11 = i01 + 1;
const r00 = restCp[i00], r10 = restCp[i10];
const r01 = restCp[i01], r11 = restCp[i11];
const c00 = cp[i00], c10 = cp[i10];
const c01 = cp[i01], c11 = cp[i11];
const d00x = c00.x - r00.x, d00y = c00.y - r00.y;
const d10x = c10.x - r10.x, d10y = c10.y - r10.y;
const d01x = c01.x - r01.x, d01y = c01.y - r01.y;
const d11x = c11.x - r11.x, d11y = c11.y - r11.y;
const dx = (1 - lu) * (1 - lv) * d00x + lu * (1 - lv) * d10x
+ (1 - lu) * lv * d01x + lu * lv * d11x;
const dy = (1 - lu) * (1 - lv) * d00y + lu * (1 - lv) * d10y
+ (1 - lu) * lv * d01y + lu * lv * d11y;
pos.setXY(i, rx + dx, ry + dy);
}
pos.needsUpdate = true;
this._vectorOverlayMesh.geometry.computeBoundingBox();
this._vectorOverlayMesh.geometry.computeBoundingSphere();
},
_setupFFDDrag() {
const eng = this.engine;
const canvas = eng.renderer.domElement;
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let dragPlane = null;
let dragOffset = new THREE.Vector3();
const getWorldPos = (e) => {
const rect = canvas.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
return mouse;
};
const onDown = (e) => {
if (e.button !== 0 || !this._ffdGridVisible) return;
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
getWorldPos(e);
raycaster.setFromCamera(mouse, eng.camera);
const hits = raycaster.intersectObjects(this._ffdSpheres);
if (hits.length === 0) return;
e.stopImmediatePropagation();
eng.controls.enabled = false;
const sphere = hits[0].object;
this._ffdDragging = sphere.userData.ffdIndex;
const normal = eng.camera.getWorldDirection(new THREE.Vector3());
const worldPos = new THREE.Vector3();
sphere.getWorldPosition(worldPos);
dragPlane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, worldPos);
const intersection = new THREE.Vector3();
raycaster.ray.intersectPlane(dragPlane, intersection);
dragOffset.copy(worldPos).sub(intersection);
};
const onMove = (e) => {
if (this._ffdDragging === null) return;
getWorldPos(e);
raycaster.setFromCamera(mouse, eng.camera);
const intersection = new THREE.Vector3();
if (!raycaster.ray.intersectPlane(dragPlane, intersection)) return;
intersection.add(dragOffset);
const local = this._vwGroup.worldToLocal(intersection.clone());
const idx = this._ffdDragging;
this._ffdCurrentPoints[idx].set(local.x, local.y);
this._ffdSpheres[idx].position.set(local.x, local.y, 0.5);
this._applyFFD();
this._rebuildFFDLines();
};
const onUp = () => {
if (this._ffdDragging !== null) {
this._ffdDragging = null;
eng.controls.enabled = true;
}
};
canvas.addEventListener('pointerdown', onDown, { capture: true });
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
this._ffdDragCleanup = () => {
canvas.removeEventListener('pointerdown', onDown, { capture: true });
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onUp);
};
},
setFFDGridVisible(visible) {
this._ffdGridVisible = visible;
if (this._ffdGroup) this._ffdGroup.visible = visible;
},
resetFFDGrid() {
if (!this._ffdControlPoints || !this._ffdCurrentPoints) return;
for (let i = 0; i < this._ffdControlPoints.length; i++) {
this._ffdCurrentPoints[i].copy(this._ffdControlPoints[i]);
if (this._ffdSpheres[i]) {
this._ffdSpheres[i].position.set(
this._ffdControlPoints[i].x,
this._ffdControlPoints[i].y,
0.5
);
}
}
this._applyFFD();
this._rebuildFFDLines();
},
setFFDResolution(cols, rows) {
this.initFFDGrid(cols, rows);
},
getFFDState() {
if (!this._ffdCurrentPoints) return null;
return {
cols: this._ffdCols,
rows: this._ffdRows,
points: this._ffdCurrentPoints.map(p => [p.x, p.y]),
};
},
setFFDState(state) {
if (!state || !state.points) return;
this.initFFDGrid(state.cols, state.rows);
for (let i = 0; i < state.points.length && i < this._ffdCurrentPoints.length; i++) {
this._ffdCurrentPoints[i].set(state.points[i][0], state.points[i][1]);
if (this._ffdSpheres[i]) {
this._ffdSpheres[i].position.set(state.points[i][0], state.points[i][1], 0.5);
}
}
this._applyFFD();
this._rebuildFFDLines();
},
setVWTranslation(x, y) {
if (this._vwGroup) {
this._vwGroup.position.x = x;
this._vwGroup.position.y = y;
}
},
setVWRotation(degrees) {
if (this._vwGroup) {
this._vwGroup.rotation.z = degrees * Math.PI / 180;
}
},
setVWScale(sx, sy) {
if (this._vwGroup) {
this._vwGroup.scale.set(sx, sy, 1);
}
},
_disposeFFDGrid() {
if (this._ffdDragCleanup) {
this._ffdDragCleanup();
this._ffdDragCleanup = null;
}
if (this._ffdLines) {
for (const line of this._ffdLines) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
}
this._ffdLines = null;
}
if (this._ffdSpheres) {
for (const s of this._ffdSpheres) {
if (s.parent) s.parent.remove(s);
s.geometry.dispose();
s.material.dispose();
}
this._ffdSpheres = null;
}
if (this._ffdGroup && this._vwGroup) {
this._vwGroup.remove(this._ffdGroup);
}
this._ffdGroup = null;
this._ffdControlPoints = null;
this._ffdCurrentPoints = null;
this._ffdDragging = null;
},
_disposeVectorOverlay() {
this._disposeFFDGrid();
if (this._vwGroup) {
this.engine.scene.remove(this._vwGroup);
}
if (this._vectorOverlayMesh) {
this._vectorOverlayMesh.geometry.dispose();
if (this._vectorOverlayMesh.material.map) {
this._vectorOverlayMesh.material.map.dispose();
}
this._vectorOverlayMesh.material.dispose();
this._vectorOverlayMesh = null;
}
this._vwGroup = null;
this._vwTexture = null;
this._vwRestPositions = null;
},
setVWCameraLock(locked) {
this._cameraLocked = locked;
const eng = this.engine;
if (!eng) return;
if (locked) {
eng.controls.enabled = false;
this._buildSleeveMesh();
this._setupSurfaceDrag();
} else {
eng.controls.enabled = true;
this._disposeSleeveMesh();
if (this._surfaceDragCleanup) {
this._surfaceDragCleanup();
this._surfaceDragCleanup = null;
}
}
},
_buildSleeveMesh() {
this._disposeSleeveMesh();
if (!this._vwTexture || !this._vwGroup) return;
const targetObj = this._externalModelMesh || this._projEncGroup;
if (!targetObj) return;
const box = new THREE.Box3().setFromObject(targetObj);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
// Build a box-shaped sleeve around the enclosure
const hw = size.x / 2, hh = size.y / 2, hz = size.z / 2;
const perimeter = 2 * (size.x + size.y);
// Create a merged geometry wrapping around the box perimeter
// 4 side planes + top + bottom
const segments = [];
// Front face (Y = -hh)
segments.push({ pos: [0, -hh, 0], rot: [Math.PI/2, 0, 0], w: size.x, h: size.z, uStart: 0, uEnd: size.x / perimeter });
// Right face (X = hw)
segments.push({ pos: [hw, 0, 0], rot: [Math.PI/2, 0, -Math.PI/2], w: size.y, h: size.z, uStart: size.x / perimeter, uEnd: (size.x + size.y) / perimeter });
// Back face (Y = hh)
segments.push({ pos: [0, hh, 0], rot: [Math.PI/2, 0, Math.PI], w: size.x, h: size.z, uStart: (size.x + size.y) / perimeter, uEnd: (2 * size.x + size.y) / perimeter });
// Left face (X = -hw)
segments.push({ pos: [-hw, 0, 0], rot: [Math.PI/2, 0, Math.PI/2], w: size.y, h: size.z, uStart: (2 * size.x + size.y) / perimeter, uEnd: 1.0 });
const geometries = [];
for (const seg of segments) {
const geo = new THREE.PlaneGeometry(seg.w, seg.h, 8, 8);
const pos = geo.attributes.position;
const uv = geo.attributes.uv;
// Remap UVs for continuous wrapping
for (let i = 0; i < uv.count; i++) {
const u = uv.getX(i);
const v = uv.getY(i);
uv.setXY(i, seg.uStart + u * (seg.uEnd - seg.uStart), v);
}
uv.needsUpdate = true;
// Apply rotation and position
const euler = new THREE.Euler(seg.rot[0], seg.rot[1], seg.rot[2]);
const quat = new THREE.Quaternion().setFromEuler(euler);
const offset = new THREE.Vector3(seg.pos[0], seg.pos[1], seg.pos[2]);
for (let i = 0; i < pos.count; i++) {
const v3 = new THREE.Vector3(pos.getX(i), pos.getY(i), pos.getZ(i));
v3.applyQuaternion(quat);
v3.add(offset);
pos.setXYZ(i, v3.x, v3.y, v3.z);
}
pos.needsUpdate = true;
geometries.push(geo);
}
// Merge geometries
const merged = this._mergeGeometries(geometries);
if (!merged) return;
const tex = this._vwTexture.clone();
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.needsUpdate = true;
const mat = new THREE.MeshBasicMaterial({
map: tex,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
this._sleeveMesh = new THREE.Mesh(merged, mat);
// Position relative to the target object's local space
const eng = this.engine;
eng.scene.add(this._sleeveMesh);
// Hide the flat overlay while sleeve is active
if (this._vectorOverlayMesh) {
this._vectorOverlayMesh.visible = false;
}
if (this._ffdGroup) {
this._ffdGroup.visible = false;
}
},
_mergeGeometries(geometries) {
if (geometries.length === 0) return null;
let totalVerts = 0;
let totalIdx = 0;
for (const g of geometries) {
totalVerts += g.attributes.position.count;
totalIdx += g.index ? g.index.count : 0;
}
const pos = new Float32Array(totalVerts * 3);
const uv = new Float32Array(totalVerts * 2);
const idx = new Uint32Array(totalIdx);
let vOff = 0, iOff = 0, vBase = 0;
for (const g of geometries) {
const gPos = g.attributes.position;
const gUV = g.attributes.uv;
for (let i = 0; i < gPos.count; i++) {
pos[(vOff + i) * 3] = gPos.getX(i);
pos[(vOff + i) * 3 + 1] = gPos.getY(i);
pos[(vOff + i) * 3 + 2] = gPos.getZ(i);
uv[(vOff + i) * 2] = gUV.getX(i);
uv[(vOff + i) * 2 + 1] = gUV.getY(i);
}
if (g.index) {
for (let i = 0; i < g.index.count; i++) {
idx[iOff + i] = g.index.getX(i) + vBase;
}
iOff += g.index.count;
}
vBase += gPos.count;
vOff += gPos.count;
g.dispose();
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(pos, 3));
merged.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
merged.setIndex(new THREE.BufferAttribute(idx, 1));
merged.computeVertexNormals();
return merged;
},
_setupSurfaceDrag() {
if (this._surfaceDragCleanup) {
this._surfaceDragCleanup();
}
if (!this._sleeveMesh) return;
const canvas = this.engine.renderer.domElement;
const camera = this.engine.camera;
let dragging = false;
let lastX = 0, lastY = 0;
const onDown = (e) => {
if (e.button !== 0) return;
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
e.preventDefault();
};
const onMove = (e) => {
if (!dragging || !this._sleeveMesh) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
// Convert pixel delta to UV offset based on camera FOV and distance
const rect = canvas.getBoundingClientRect();
const fovRad = camera.fov * Math.PI / 180;
const dist = camera.position.length();
const viewH = 2 * dist * Math.tan(fovRad / 2);
const pxToMM = viewH / rect.height;
const tex = this._sleeveMesh.material.map;
if (tex) {
tex.offset.x -= (dx * pxToMM) / (tex.image?.width || 100);
tex.offset.y += (dy * pxToMM) / (tex.image?.height || 100);
}
};
const onUp = () => {
dragging = false;
};
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
this._surfaceDragCleanup = () => {
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onUp);
};
},
_disposeSleeveMesh() {
if (this._sleeveMesh) {
this.engine.scene.remove(this._sleeveMesh);
this._sleeveMesh.geometry.dispose();
if (this._sleeveMesh.material.map) {
this._sleeveMesh.material.map.dispose();
}
this._sleeveMesh.material.dispose();
this._sleeveMesh = null;
}
// Restore flat overlay visibility
if (this._vectorOverlayMesh) {
this._vectorOverlayMesh.visible = true;
}
if (this._ffdGroup && this._ffdGridVisible) {
this._ffdGroup.visible = true;
}
},
dispose() {
this._disposeExternalModel();
this._disposeProjectEnclosure();
this._disposeVectorOverlay();
this._disposeSleeveMesh();
if (this._surfaceDragCleanup) {
this._surfaceDragCleanup();
this._surfaceDragCleanup = null;
}
},
};
return mode;
}
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
function await_stl_loader() { return STLLoader; }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,13 @@ body {
user-select: none;
}
.nav-project-name {
font-size: 13px;
color: var(--text-subtle);
padding-left: 8px;
-webkit-app-region: no-drag;
}
.nav-btn {
-webkit-app-region: no-drag;
}
@ -260,6 +267,20 @@ body {
font-weight: 600;
}
/* Dashboard grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 640px) {
.dashboard-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Cards */
.card {
background: var(--bg-surface);
@ -822,3 +843,55 @@ select.form-input {
gap: 8px;
flex-wrap: wrap;
}
/* Unwrap page */
.unwrap-layout {
display: flex;
gap: 16px;
height: calc(100vh - 140px);
}
.unwrap-preview {
flex: 1;
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.unwrap-preview svg {
max-width: 100%;
max-height: 100%;
background: #fff;
border-radius: var(--radius-sm);
}
.unwrap-controls {
width: 220px;
flex-shrink: 0;
}
.unwrap-sidebar {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 16px;
}
/* VW camera lock button */
.sidebar-icon-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
.sidebar-icon-btn.locked {
background: var(--accent-dim);
border-color: var(--text-secondary);
color: var(--text-primary);
}

View File

@ -0,0 +1,48 @@
export function buildEnclosureSidebar(layers, esc) {
return `
<div class="former-sidebar-header">
<h3>Layers</h3>
<button class="btn btn-sm" onclick="navigate(state.project ? 'dashboard' : 'landing')">Close</button>
</div>
<div class="former-layers" id="former-layers">
${layers.map((l, i) => `
<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>
`).join('')}
</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="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>
</div>
</div>
<div id="former-cutout-tools" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(137,180,250,0.08)">
<div style="font-size:12px;color:var(--accent);margin-bottom:4px;font-weight:600">Cutout Selection Mode</div>
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:6px">Click elements to select/deselect. Esc to exit.</div>
<div id="former-cutout-status" style="font-size:11px;color:var(--text-subtle);margin-bottom:6px">0 elements selected</div>
<button class="btn btn-sm" onclick="formerExitCutoutMode()" style="width:100%">Done</button>
</div>
<div id="former-cutout-edit" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(137,180,250,0.06)"></div>
<div id="former-render-result" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(166,227,161,0.08)">
<div style="font-size:12px;color:var(--success);margin-bottom:4px;font-weight:600">Render Complete</div>
<div id="former-render-files" style="font-size:11px;color:var(--text-secondary);margin-bottom:6px;font-family:var(--font-mono);line-height:1.5"></div>
<div style="display:flex;gap:4px">
<button class="btn btn-sm" onclick="formerReturnToEditor()" style="flex:1">Return to Editor</button>
<button class="btn btn-sm" onclick="openOutputFolder()" style="flex:1;border-color:var(--accent);color:var(--accent)">Open Folder</button>
</div>
</div>
`;
}

View File

@ -0,0 +1,45 @@
export function buildStructuralSidebar(modeTitle, sInfo, esc) {
return `
<div class="former-sidebar-header">
<h3>${modeTitle}</h3>
<button class="btn btn-sm" onclick="navigate(state.project ? 'dashboard' : 'landing')">Close</button>
</div>
<div style="padding:12px 16px">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
${sInfo?.svgPath ? esc(sInfo.svgPath.split('/').pop()) : 'No SVG'} |
${sInfo?.svgWidth?.toFixed(1) || '?'} × ${sInfo?.svgHeight?.toFixed(1) || '?'} mm
</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:70px">Pattern</span>
<select class="form-input" id="sf-pattern" style="font-size:11px" onchange="updateStructuralLive()">
${['hexagon','triangle','diamond','voronoi','grid','gyroid'].map(p =>
`<option value="${p}" ${p === sInfo?.pattern ? 'selected' : ''}>${p}</option>`
).join('')}
</select>
</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:70px">Cell (mm)</span>
<input class="form-input" id="sf-cell" type="number" step="0.5" value="${sInfo?.cellSize || 10}" style="font-size:11px" onchange="updateStructuralLive()">
</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:70px">Wall (mm)</span>
<input class="form-input" id="sf-wall" type="number" step="0.1" value="${sInfo?.wallThick || 1.2}" style="font-size:11px" onchange="updateStructuralLive()">
</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:70px">Height (mm)</span>
<input class="form-input" id="sf-height" type="number" step="1" value="${sInfo?.height || 20}" style="font-size:11px" onchange="updateStructuralLive()">
</div>
<div style="margin-top:8px">
<button class="btn btn-sm btn-primary" onclick="renderStructuralPreview()" style="width:100%">Render Preview</button>
</div>
</div>
<div id="former-render-result" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(166,227,161,0.08)">
<div style="font-size:12px;color:var(--success);margin-bottom:4px;font-weight:600">Render Complete</div>
<div id="former-render-files" style="font-size:11px;color:var(--text-secondary);margin-bottom:6px;font-family:var(--font-mono);line-height:1.5"></div>
<div style="display:flex;gap:4px">
<button class="btn btn-sm" onclick="formerReturnToEditor()" style="flex:1">Return to Editor</button>
<button class="btn btn-sm" onclick="openOutputFolder()" style="flex:1;border-color:var(--accent);color:var(--accent)">Open Folder</button>
</div>
</div>
`;
}

View File

@ -0,0 +1,21 @@
export function buildUnwrapSidebar() {
return `
<div class="unwrap-sidebar">
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:0 0 8px;letter-spacing:0.5px">Unwrap Template</div>
<button class="btn btn-sm" onclick="generateUnwrap()" id="unwrap-gen-btn" style="width:100%;margin-bottom:6px">Generate Template</button>
<button class="btn btn-sm" onclick="exportUnwrapSVG()" id="unwrap-export-btn" style="width:100%;margin-bottom:6px" disabled>Export SVG</button>
<button class="btn btn-sm" onclick="importUnwrapArtwork()" id="unwrap-import-btn" style="width:100%;margin-bottom:12px">Import Artwork</button>
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:8px 0 4px;letter-spacing:0.5px">Display</div>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-secondary);cursor:pointer;margin:4px 0">
<input type="checkbox" checked id="unwrap-show-folds" onchange="toggleUnwrapFolds(this.checked)"> Fold lines
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-secondary);cursor:pointer;margin:4px 0">
<input type="checkbox" checked id="unwrap-show-labels" onchange="toggleUnwrapLabels(this.checked)"> Labels
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-secondary);cursor:pointer;margin:4px 0">
<input type="checkbox" checked id="unwrap-show-cutouts" onchange="toggleUnwrapCutouts(this.checked)"> Cutouts
</label>
</div>
`;
}

View File

@ -0,0 +1,75 @@
export function buildVectorWrapSidebar(modeTitle) {
return `
<div class="former-sidebar-header">
<h3>${modeTitle}</h3>
<button class="btn btn-sm" onclick="navigate(state.project ? 'dashboard' : 'landing')">Close</button>
</div>
<div style="padding:12px 16px">
<div style="font-size:11px;color:var(--text-subtle);margin-bottom:10px">Drag blue control points to deform the overlay.</div>
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:8px 0 4px;letter-spacing:0.5px">Display</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:60px">Opacity</span>
<input type="range" min="0" max="100" value="70" id="vw-opacity" oninput="setVWOverlayOpacity(this.value)" style="flex:1">
</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:60px">Z Offset</span>
<input type="range" min="-50" max="100" value="2" id="vw-zoffset" oninput="setVWOverlayZ(this.value)" style="flex:1">
</div>
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Transform</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:60px">X</span>
<input class="form-input" id="vw-tx" type="number" step="1" value="0" style="font-size:11px;width:60px" onchange="applyVWTransform()">
<span class="form-label" style="font-size:11px;min-width:20px;text-align:center">Y</span>
<input class="form-input" id="vw-ty" type="number" step="1" value="0" style="font-size:11px;width:60px" onchange="applyVWTransform()">
</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:60px">Rotate</span>
<input class="form-input" id="vw-rot" type="number" step="5" value="0" style="font-size:11px;flex:1" onchange="applyVWTransform()">
<span style="font-size:10px;color:var(--text-subtle);margin-left:4px">deg</span>
</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:60px">Scale X</span>
<input class="form-input" id="vw-sx" type="number" step="0.1" value="1" style="font-size:11px;width:60px" onchange="applyVWTransform()">
<span class="form-label" style="font-size:11px;min-width:20px;text-align:center">Y</span>
<input class="form-input" id="vw-sy" type="number" step="0.1" value="1" style="font-size:11px;width:60px" onchange="applyVWTransform()">
</div>
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Surface Wrap</div>
<div style="display:flex;gap:4px;margin-bottom:8px">
<button class="btn btn-sm sidebar-icon-btn" id="vw-lock-btn" onclick="toggleVWCameraLock()" title="Lock camera for surface wrapping" style="flex:1">
<span id="vw-lock-icon">&#x1F513;&#xFE0E;</span> Lock Camera
</button>
</div>
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Mesh Deform Grid</div>
<div class="form-row" style="margin:4px 0">
<span class="form-label" style="font-size:11px;min-width:60px">Cols</span>
<input class="form-input" id="vw-grid-cols" type="number" min="2" max="16" step="1" value="4" style="font-size:11px;width:50px">
<span class="form-label" style="font-size:11px;min-width:20px;text-align:center">x</span>
<input class="form-input" id="vw-grid-rows" type="number" min="2" max="16" step="1" value="4" style="font-size:11px;width:50px">
<button class="btn btn-sm" onclick="applyVWGridRes()" style="font-size:10px;padding:2px 6px;margin-left:4px">Set</button>
</div>
<div style="display:flex;gap:4px;margin-top:6px">
<button class="btn btn-sm" onclick="toggleVWGrid()" id="vw-grid-toggle" style="flex:1">Hide Grid</button>
<button class="btn btn-sm" onclick="resetVWGrid()" style="flex:1;border-color:var(--warning);color:var(--warning)">Reset</button>
</div>
<div id="vw-enclosure-parts" style="display:none">
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Enclosure Parts</div>
<div style="display:flex;gap:4px;flex-wrap:wrap">
<button class="btn btn-sm" id="vw-toggle-lid" onclick="toggleVWLid()" style="flex:1">Hide Lid</button>
<button class="btn btn-sm" id="vw-toggle-tray" onclick="toggleVWTray()" style="flex:1">Hide Tray</button>
</div>
<div style="margin-top:4px">
<button class="btn btn-sm" id="vw-toggle-assembly" onclick="toggleVWAssembly()" style="width:100%">Take Off Lid</button>
</div>
</div>
</div>
<div id="former-render-result" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(166,227,161,0.08)">
<div style="font-size:12px;color:var(--success);margin-bottom:4px;font-weight:600">Render Complete</div>
<div id="former-render-files" style="font-size:11px;color:var(--text-secondary);margin-bottom:6px;font-family:var(--font-mono);line-height:1.5"></div>
</div>
`;
}

View File

@ -79,6 +79,7 @@ func (inst *InstanceData) MigrateCutouts() []Cutout {
Height: lc.MaxY - lc.MinY,
IsDado: lc.IsDado,
Depth: lc.Depth,
Shape: lc.Shape,
})
}
return result

333
pattern.go Normal file
View File

@ -0,0 +1,333 @@
package main
import (
"fmt"
"math"
"strings"
)
// GenerateStructuralSCAD produces an OpenSCAD source string that extrudes
// the SVG outline filled with a structural pattern.
func GenerateStructuralSCADString(session *StructuralSession) (string, error) {
if session == nil || session.SVGDoc == nil {
return "", fmt.Errorf("no structural session")
}
outline := extractOutlinePolygon(session.SVGDoc)
if len(outline) < 3 {
return "", fmt.Errorf("SVG has no usable outline polygon (need at least 3 points)")
}
var b strings.Builder
b.WriteString("// Structural Fill — Generated by Former\n")
b.WriteString(fmt.Sprintf("// Pattern: %s, Cell: %.1fmm, Wall: %.1fmm, Height: %.1fmm\n\n",
session.Pattern, session.CellSize, session.WallThick, session.Height))
// Write outline polygon module
writePolygonModule(&b, "outline_shape", outline)
// Write pattern module
bbox := polygonBBox(outline)
switch session.Pattern {
case "hexagon":
writeHexPattern(&b, bbox, session.CellSize, session.WallThick)
case "triangle":
writeTrianglePattern(&b, bbox, session.CellSize, session.WallThick)
case "diamond":
writeDiamondPattern(&b, bbox, session.CellSize, session.WallThick)
case "grid":
writeGridPattern(&b, bbox, session.CellSize, session.WallThick)
case "gyroid":
writeGyroidPattern(&b, bbox, session.CellSize, session.WallThick)
default:
writeHexPattern(&b, bbox, session.CellSize, session.WallThick)
}
// Assembly: shell + infill, extruded
shellT := session.ShellThick
if shellT <= 0 {
shellT = session.WallThick
}
b.WriteString(fmt.Sprintf("\n// Assembly\nlinear_extrude(height = %.2f, convexity = 10) {\n", session.Height))
b.WriteString(fmt.Sprintf(" // Outer shell\n difference() {\n outline_shape();\n offset(r = -%.2f) outline_shape();\n }\n", shellT))
b.WriteString(" // Internal pattern clipped to outline\n intersection() {\n outline_shape();\n fill_pattern();\n }\n")
b.WriteString("}\n")
return b.String(), nil
}
// extractOutlinePolygon pulls the largest closed polygon from the SVG document.
// It flattens all elements' segments into point sequences and picks the one
// with the most points that forms a closed path.
func extractOutlinePolygon(doc *SVGDocument) [][2]float64 {
var best [][2]float64
for _, el := range doc.Elements {
pts := segmentsToPoints(el.Segments, el.Transform)
if len(pts) > len(best) {
best = pts
}
}
return best
}
// segmentsToPoints flattens path segments into a coordinate list,
// applying the element transform.
func segmentsToPoints(segs []PathSegment, xf [6]float64) [][2]float64 {
var pts [][2]float64
transform := func(x, y float64) (float64, float64) {
return xf[0]*x + xf[2]*y + xf[4], xf[1]*x + xf[3]*y + xf[5]
}
for _, seg := range segs {
switch seg.Command {
case 'M', 'L':
if len(seg.Args) >= 2 {
tx, ty := transform(seg.Args[0], seg.Args[1])
pts = append(pts, [2]float64{tx, ty})
}
case 'C':
if len(seg.Args) >= 6 {
// Sample cubic bezier at endpoints + midpoint
tx, ty := transform(seg.Args[4], seg.Args[5])
pts = append(pts, [2]float64{tx, ty})
}
case 'Q', 'S':
if len(seg.Args) >= 4 {
tx, ty := transform(seg.Args[2], seg.Args[3])
pts = append(pts, [2]float64{tx, ty})
}
case 'A':
if len(seg.Args) >= 7 {
tx, ty := transform(seg.Args[5], seg.Args[6])
pts = append(pts, [2]float64{tx, ty})
}
case 'T':
if len(seg.Args) >= 2 {
tx, ty := transform(seg.Args[0], seg.Args[1])
pts = append(pts, [2]float64{tx, ty})
}
case 'Z':
// Close path — no new point needed
}
}
return pts
}
func polygonBBox(poly [][2]float64) [4]float64 {
if len(poly) == 0 {
return [4]float64{}
}
minX, minY := poly[0][0], poly[0][1]
maxX, maxY := minX, minY
for _, p := range poly[1:] {
if p[0] < minX {
minX = p[0]
}
if p[0] > maxX {
maxX = p[0]
}
if p[1] < minY {
minY = p[1]
}
if p[1] > maxY {
maxY = p[1]
}
}
return [4]float64{minX, minY, maxX, maxY}
}
func writePolygonModule(b *strings.Builder, name string, poly [][2]float64) {
b.WriteString(fmt.Sprintf("module %s() {\n polygon(points=[\n", name))
for i, p := range poly {
comma := ","
if i == len(poly)-1 {
comma = ""
}
b.WriteString(fmt.Sprintf(" [%.4f, %.4f]%s\n", p[0], p[1], comma))
}
b.WriteString(" ]);\n}\n\n")
}
// Hexagonal honeycomb pattern
func writeHexPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) {
r := cellSize / 2.0
ri := r - wallThick/2.0
if ri < 0.1 {
ri = 0.1
}
dx := cellSize * 1.5
dy := cellSize * math.Sqrt(3) / 2.0
b.WriteString("module fill_pattern() {\n")
b.WriteString(fmt.Sprintf(" r = %.4f;\n", r))
b.WriteString(fmt.Sprintf(" ri = %.4f;\n", ri))
b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick))
b.WriteString(" difference() {\n")
b.WriteString(" union() {\n")
startX := bbox[0] - cellSize
startY := bbox[1] - cellSize
endX := bbox[2] + cellSize
endY := bbox[3] + cellSize
row := 0
for y := startY; y <= endY; y += dy {
offsetX := 0.0
if row%2 == 1 {
offsetX = cellSize * 0.75
}
for x := startX + offsetX; x <= endX; x += dx {
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) circle(r=r, $fn=6);\n", x, y))
}
row++
}
b.WriteString(" }\n")
b.WriteString(" // Subtract smaller hexagons to create walls\n")
b.WriteString(" union() {\n")
row = 0
for y := startY; y <= endY; y += dy {
offsetX := 0.0
if row%2 == 1 {
offsetX = cellSize * 0.75
}
for x := startX + offsetX; x <= endX; x += dx {
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) circle(r=ri, $fn=6);\n", x, y))
}
row++
}
b.WriteString(" }\n")
b.WriteString(" }\n")
b.WriteString("}\n\n")
}
// Triangle grid pattern
func writeTrianglePattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) {
b.WriteString("module fill_pattern() {\n")
b.WriteString(fmt.Sprintf(" cell = %.4f;\n", cellSize))
b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick))
// Horizontal lines
startY := bbox[1] - cellSize
endY := bbox[3] + cellSize
h := cellSize * math.Sqrt(3) / 2.0
b.WriteString(" union() {\n")
for y := startY; y <= endY; y += h {
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) square([%.4f, wall]);\n",
bbox[0]-cellSize, y-wallThick/2, bbox[2]-bbox[0]+2*cellSize))
}
// Diagonal lines (60 degrees)
for x := bbox[0] - (bbox[3]-bbox[1])*2; x <= bbox[2]+(bbox[3]-bbox[1])*2; x += cellSize {
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(60) square([wall, %.4f]);\n",
x, bbox[1]-cellSize, (bbox[3]-bbox[1]+2*cellSize)*2))
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(-60) square([wall, %.4f]);\n",
x, bbox[1]-cellSize, (bbox[3]-bbox[1]+2*cellSize)*2))
}
b.WriteString(" }\n")
b.WriteString("}\n\n")
}
// Diamond/rhombus lattice
func writeDiamondPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) {
b.WriteString("module fill_pattern() {\n")
b.WriteString(fmt.Sprintf(" cell = %.4f;\n", cellSize))
b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick))
extentX := bbox[2] - bbox[0] + 2*cellSize
extentY := bbox[3] - bbox[1] + 2*cellSize
diag := math.Sqrt(extentX*extentX + extentY*extentY)
b.WriteString(" union() {\n")
// 45-degree lines in both directions
for d := -diag; d <= diag; d += cellSize {
cx := (bbox[0] + bbox[2]) / 2.0
cy := (bbox[1] + bbox[3]) / 2.0
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(45) translate([0, %.4f]) square([wall, %.4f], center=true);\n",
cx, cy, d, diag*2))
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(-45) translate([0, %.4f]) square([wall, %.4f], center=true);\n",
cx, cy, d, diag*2))
}
b.WriteString(" }\n")
b.WriteString("}\n\n")
}
// Simple rectangular grid
func writeGridPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) {
b.WriteString("module fill_pattern() {\n")
b.WriteString(fmt.Sprintf(" cell = %.4f;\n", cellSize))
b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick))
b.WriteString(" union() {\n")
for x := bbox[0] - cellSize; x <= bbox[2]+cellSize; x += cellSize {
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) square([wall, %.4f]);\n",
x-wallThick/2, bbox[1]-cellSize, bbox[3]-bbox[1]+2*cellSize))
}
for y := bbox[1] - cellSize; y <= bbox[3]+cellSize; y += cellSize {
b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) square([%.4f, wall]);\n",
bbox[0]-cellSize, y-wallThick/2, bbox[2]-bbox[0]+2*cellSize))
}
b.WriteString(" }\n")
b.WriteString("}\n\n")
}
// Gyroid approximation (sinusoidal cross-section)
func writeGyroidPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) {
b.WriteString("module fill_pattern() {\n")
b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick))
// Approximate gyroid cross-section with a dense polygon path of sine waves
// Two perpendicular sets of sinusoidal walls
period := cellSize
amplitude := cellSize / 2.0
steps := 40
b.WriteString(" union() {\n")
// Horizontal sine waves
for baseY := bbox[1] - cellSize; baseY <= bbox[3]+cellSize; baseY += period {
b.WriteString(" polygon(points=[")
// Forward path (top edge)
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
x := bbox[0] - cellSize + t*(bbox[2]-bbox[0]+2*cellSize)
y := baseY + amplitude*math.Sin(2*math.Pi*t*(bbox[2]-bbox[0]+2*cellSize)/period) + wallThick/2
b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y))
}
// Reverse path (bottom edge)
for i := steps; i >= 0; i-- {
t := float64(i) / float64(steps)
x := bbox[0] - cellSize + t*(bbox[2]-bbox[0]+2*cellSize)
y := baseY + amplitude*math.Sin(2*math.Pi*t*(bbox[2]-bbox[0]+2*cellSize)/period) - wallThick/2
b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y))
}
b.WriteString("]);\n")
}
// Vertical sine waves
for baseX := bbox[0] - cellSize; baseX <= bbox[2]+cellSize; baseX += period {
b.WriteString(" polygon(points=[")
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
y := bbox[1] - cellSize + t*(bbox[3]-bbox[1]+2*cellSize)
x := baseX + amplitude*math.Sin(2*math.Pi*t*(bbox[3]-bbox[1]+2*cellSize)/period) + wallThick/2
b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y))
}
for i := steps; i >= 0; i-- {
t := float64(i) / float64(steps)
y := bbox[1] - cellSize + t*(bbox[3]-bbox[1]+2*cellSize)
x := baseX + amplitude*math.Sin(2*math.Pi*t*(bbox[3]-bbox[1]+2*cellSize)/period) - wallThick/2
b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y))
}
b.WriteString("]);\n")
}
b.WriteString(" }\n")
b.WriteString("}\n\n")
}

134
project.go Normal file
View File

@ -0,0 +1,134 @@
package main
import "time"
// ProjectData is the top-level structure serialized to project.json inside a .former directory.
type ProjectData struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
Version int `json:"version"`
Stencil *StencilData `json:"stencil,omitempty"`
Enclosure *EnclosureData `json:"enclosure,omitempty"`
VectorWrap *VectorWrapData `json:"vectorWrap,omitempty"`
Structural *StructuralData `json:"structural,omitempty"`
ScanHelper *ScanHelperData `json:"scanHelper,omitempty"`
Settings ProjectSettings `json:"settings"`
}
type ProjectSettings struct {
ShowGrid bool `json:"showGrid"`
TraditionalControls bool `json:"traditionalControls"`
}
type StencilData struct {
GerberFile string `json:"gerberFile,omitempty"`
OutlineFile string `json:"outlineFile,omitempty"`
StencilHeight float64 `json:"stencilHeight"`
WallHeight float64 `json:"wallHeight"`
WallThickness float64 `json:"wallThickness"`
DPI float64 `json:"dpi"`
Exports []string `json:"exports"`
}
type EnclosureData struct {
GerberFiles map[string]string `json:"gerberFiles"`
DrillPath string `json:"drillPath,omitempty"`
NPTHPath string `json:"npthPath,omitempty"`
EdgeCutsFile string `json:"edgeCutsFile"`
CourtyardFile string `json:"courtyardFile,omitempty"`
SoldermaskFile string `json:"soldermaskFile,omitempty"`
FabFile string `json:"fabFile,omitempty"`
Config EnclosureConfig `json:"config"`
Exports []string `json:"exports"`
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
ProjectName string `json:"projectName,omitempty"`
Cutouts []Cutout `json:"cutouts,omitempty"`
SideCutouts []SideCutout `json:"sideCutouts,omitempty"`
LidCutouts []LidCutout `json:"lidCutouts,omitempty"`
}
// MigrateCutouts returns the unified cutouts list, converting legacy fields if needed.
func (ed *EnclosureData) MigrateCutouts() []Cutout {
if len(ed.Cutouts) > 0 {
return ed.Cutouts
}
var result []Cutout
for _, sc := range ed.SideCutouts {
result = append(result, Cutout{
ID: randomID(),
Surface: "side",
SideNum: sc.Side,
X: sc.X,
Y: sc.Y,
Width: sc.Width,
Height: sc.Height,
CornerRadius: sc.CornerRadius,
SourceLayer: sc.Layer,
})
}
for _, lc := range ed.LidCutouts {
surface := "top"
if lc.Plane == "tray" {
surface = "bottom"
}
result = append(result, Cutout{
ID: randomID(),
Surface: surface,
X: lc.MinX,
Y: lc.MinY,
Width: lc.MaxX - lc.MinX,
Height: lc.MaxY - lc.MinY,
IsDado: lc.IsDado,
Depth: lc.Depth,
Shape: lc.Shape,
})
}
return result
}
type VectorWrapData struct {
SVGFile string `json:"svgFile,omitempty"`
ModelFile string `json:"modelFile,omitempty"`
ModelType string `json:"modelType,omitempty"`
SVGWidth float64 `json:"svgWidth"`
SVGHeight float64 `json:"svgHeight"`
GridCols int `json:"gridCols,omitempty"`
GridRows int `json:"gridRows,omitempty"`
GridPoints [][2]float64 `json:"gridPoints,omitempty"`
TranslateX float64 `json:"translateX"`
TranslateY float64 `json:"translateY"`
Rotation float64 `json:"rotation"`
ScaleX float64 `json:"scaleX"`
ScaleY float64 `json:"scaleY"`
Opacity float64 `json:"opacity"`
ZOffset float64 `json:"zOffset"`
}
type StructuralData struct {
SVGFile string `json:"svgFile,omitempty"`
Pattern string `json:"pattern"`
CellSize float64 `json:"cellSize"`
WallThick float64 `json:"wallThick"`
Height float64 `json:"height"`
ShellThick float64 `json:"shellThick"`
}
type ScanHelperData struct {
PageWidth float64 `json:"pageWidth"`
PageHeight float64 `json:"pageHeight"`
GridSpacing float64 `json:"gridSpacing"`
PagesWide int `json:"pagesWide"`
PagesTall int `json:"pagesTall"`
DPI float64 `json:"dpi"`
}
// RecentEntry tracks a recently-opened project for the landing page.
type RecentEntry struct {
Path string `json:"path"`
Name string `json:"name"`
LastOpened time.Time `json:"lastOpened"`
}

76
scad.go
View File

@ -85,7 +85,7 @@ func approximateArc(x1, y1, x2, y2, iVal, jVal float64, mode string) [][2]float6
// writeApertureFlash2D writes a 2D aperture shape centered at (x, y) into a SCAD file.
// gf is needed to resolve macro apertures. lw is the nozzle line width for snapping.
func writeApertureFlash2D(f *os.File, gf *GerberFile, ap Aperture, x, y, lw float64, indent string) {
func writeApertureFlash2D(f io.Writer, gf *GerberFile, ap Aperture, x, y, lw float64, indent string) {
switch ap.Type {
case "C":
if len(ap.Modifiers) > 0 {
@ -191,7 +191,7 @@ func writeApertureFlash2D(f *os.File, gf *GerberFile, ap Aperture, x, y, lw floa
}
// writeMacroPrimitive2D emits a single macro primitive as 2D SCAD geometry.
func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, indent string) {
func writeMacroPrimitive2D(f io.Writer, prim MacroPrimitive, params []float64, indent string) {
switch prim.Code {
case 1: // Circle: Exposure, Diameter, CenterX, CenterY
if len(prim.Modifiers) >= 4 {
@ -277,7 +277,7 @@ func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, in
}
// writeApertureLinearDraw2D writes a 2D stroke between two points using hull() of the aperture.
func writeApertureLinearDraw2D(f *os.File, ap Aperture, x1, y1, x2, y2 float64, indent string) {
func writeApertureLinearDraw2D(f io.Writer, ap Aperture, x1, y1, x2, y2 float64, indent string) {
switch ap.Type {
case "C":
if len(ap.Modifiers) > 0 {
@ -308,7 +308,7 @@ func writeApertureLinearDraw2D(f *os.File, ap Aperture, x1, y1, x2, y2 float64,
// writeGerberShapes2D writes a 2D SCAD union body representing all drawn shapes
// from the Gerber file. Call this inside a union() block.
func writeGerberShapes2D(f *os.File, gf *GerberFile, lw float64, indent string) {
func writeGerberShapes2D(f io.Writer, gf *GerberFile, lw float64, indent string) {
curX, curY := 0.0, 0.0
curDCode := 0
interpolationMode := "G01"
@ -789,29 +789,73 @@ func writeNativeSCADTo(f io.Writer, isTray bool, outlineVertices [][2]float64, c
if w < 0.01 || h < 0.01 {
continue
}
// Text engrave dado: use actual gerber shapes clipped to the selection region
if lc.IsDado && lc.GerberFile != nil {
var z, cutH float64
if lc.Plane == "lid" {
z = totalH - lc.Depth/2.0
cutH = lc.Depth + 0.1
} else {
z = lc.Depth/2.0 - 0.05
cutH = lc.Depth + 0.1
}
fmt.Fprintf(f, " // Text engrave dado (depth=%.2f)\n", lc.Depth)
fmt.Fprintf(f, " translate([0, 0, %f]) intersection() {\n", z-cutH/2.0)
fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f]);\n",
lc.MinX, lc.MinY, w, h, cutH)
fmt.Fprintf(f, " linear_extrude(height=%f) {\n", cutH)
writeGerberShapes2D(f, lc.GerberFile, 0, " ")
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " }\n")
continue
}
isCircle := lc.Shape == "circle"
isObround := lc.Shape == "obround"
r := w / 2.0
if h > w {
r = h / 2.0
}
// writeLidCut emits the appropriate 3D shape for a lid/tray cutout
writeLidCut := func(cx, cy, z, cutH float64) {
if isCircle {
fmt.Fprintf(f, " translate([%f, %f, %f]) cylinder(h=%f, r=%f, center=true);\n",
cx, cy, z, cutH, r)
} else if isObround {
or := math.Min(w, h) / 2.0
fmt.Fprintf(f, " translate([%f, %f, %f]) linear_extrude(height=%f, center=true) hull() {\n",
cx, cy, z, cutH)
if w >= h {
d := (w - h) / 2.0
fmt.Fprintf(f, " translate([%f, 0]) circle(r=%f);\n", d, or)
fmt.Fprintf(f, " translate([%f, 0]) circle(r=%f);\n", -d, or)
} else {
d := (h - w) / 2.0
fmt.Fprintf(f, " translate([0, %f]) circle(r=%f);\n", d, or)
fmt.Fprintf(f, " translate([0, %f]) circle(r=%f);\n", -d, or)
}
fmt.Fprintf(f, " }\n")
} else {
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
cx, cy, z, w, h, cutH)
}
}
if lc.Plane == "lid" {
if lc.IsDado && lc.Depth > 0 {
// Dado on lid: cut from top surface downward
fmt.Fprintf(f, " // Lid dado (depth=%.2f)\n", lc.Depth)
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
cx, cy, totalH-lc.Depth/2.0, w, h, lc.Depth+0.1)
writeLidCut(cx, cy, totalH-lc.Depth/2.0, lc.Depth+0.1)
} else {
// Through-cut on lid
fmt.Fprintf(f, " // Lid through-cut\n")
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
cx, cy, totalH-lidThick/2.0, w, h, lidThick+0.2)
writeLidCut(cx, cy, totalH-lidThick/2.0, lidThick+0.2)
}
} else if lc.Plane == "tray" {
if lc.IsDado && lc.Depth > 0 {
// Dado on tray: cut from bottom surface upward
fmt.Fprintf(f, " // Tray dado (depth=%.2f)\n", lc.Depth)
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
cx, cy, lc.Depth/2.0-0.05, w, h, lc.Depth+0.1)
writeLidCut(cx, cy, lc.Depth/2.0-0.05, lc.Depth+0.1)
} else {
// Through-cut on tray floor
fmt.Fprintf(f, " // Tray through-cut\n")
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
cx, cy, trayFloor/2.0, w, h, trayFloor+0.2)
writeLidCut(cx, cy, trayFloor/2.0, trayFloor+0.2)
}
}
}

183
scangrid.go Normal file
View File

@ -0,0 +1,183 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// GenerateScanGridSVG creates printable calibration grid SVG files.
// Returns the list of generated file paths.
func GenerateScanGridSVG(cfg *ScanHelperConfig, outputDir string) ([]string, error) {
if cfg == nil {
return nil, fmt.Errorf("no scan helper config")
}
os.MkdirAll(outputDir, 0755)
pageW := cfg.PageWidth
pageH := cfg.PageHeight
grid := cfg.GridSpacing
if grid <= 0 {
grid = 10
}
margin := 15.0 // mm margin for printer bleed
markerSize := 5.0
var files []string
for row := 0; row < cfg.PagesTall; row++ {
for col := 0; col < cfg.PagesWide; col++ {
filename := fmt.Sprintf("scan_grid_%dx%d.svg", col+1, row+1)
if cfg.PagesWide == 1 && cfg.PagesTall == 1 {
filename = "scan_grid.svg"
}
path := filepath.Join(outputDir, filename)
svg, err := renderGridPage(pageW, pageH, margin, grid, markerSize, col, row, cfg.PagesWide, cfg.PagesTall)
if err != nil {
return files, err
}
if err := os.WriteFile(path, []byte(svg), 0644); err != nil {
return files, fmt.Errorf("write %s: %v", filename, err)
}
files = append(files, path)
}
}
return files, nil
}
func renderGridPage(pageW, pageH, margin, gridSpacing, markerSize float64, col, row, totalCols, totalRows int) (string, error) {
var b strings.Builder
b.WriteString(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
width="%.1fmm" height="%.1fmm"
viewBox="0 0 %.1f %.1f">
`, pageW, pageH, pageW, pageH))
// White background
b.WriteString(fmt.Sprintf(`<rect x="0" y="0" width="%.1f" height="%.1f" fill="white"/>
`, pageW, pageH))
// Printable area
areaX := margin
areaY := margin
areaW := pageW - 2*margin
areaH := pageH - 2*margin
// Thin border around printable area
b.WriteString(fmt.Sprintf(`<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="none" stroke="#ccc" stroke-width="0.3"/>
`, areaX, areaY, areaW, areaH))
// Grid lines
b.WriteString(`<g stroke="#ddd" stroke-width="0.15">` + "\n")
for x := areaX; x <= areaX+areaW+0.01; x += gridSpacing {
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, x, areaY, x, areaY+areaH))
b.WriteString("\n")
}
for y := areaY; y <= areaY+areaH+0.01; y += gridSpacing {
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, areaX, y, areaX+areaW, y))
b.WriteString("\n")
}
b.WriteString("</g>\n")
// Major grid lines every 5 cells
majorSpacing := gridSpacing * 5
b.WriteString(`<g stroke="#aaa" stroke-width="0.3">` + "\n")
for x := areaX; x <= areaX+areaW+0.01; x += majorSpacing {
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, x, areaY, x, areaY+areaH))
b.WriteString("\n")
}
for y := areaY; y <= areaY+areaH+0.01; y += majorSpacing {
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, areaX, y, areaX+areaW, y))
b.WriteString("\n")
}
b.WriteString("</g>\n")
// Corner registration marks (L-shaped fiducials)
writeCornerMark(&b, areaX, areaY, markerSize, 1, 1)
writeCornerMark(&b, areaX+areaW, areaY, markerSize, -1, 1)
writeCornerMark(&b, areaX, areaY+areaH, markerSize, 1, -1)
writeCornerMark(&b, areaX+areaW, areaY+areaH, markerSize, -1, -1)
// Center crosshair
cx, cy := pageW/2, pageH/2
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.3"/>`,
cx-markerSize, cy, cx+markerSize, cy))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.3"/>`,
cx, cy-markerSize, cx, cy+markerSize))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(`<circle cx="%.2f" cy="%.2f" r="1" fill="none" stroke="black" stroke-width="0.3"/>`,
cx, cy))
b.WriteString("\n")
// Multi-page alignment markers (only if multiple pages)
if totalCols > 1 || totalRows > 1 {
writeAlignmentMarkers(&b, areaX, areaY, areaW, areaH, markerSize)
// Page coordinate label
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="3" fill="#999" text-anchor="end">Page %d,%d of %dx%d</text>`,
areaX+areaW, areaY-2, col+1, row+1, totalCols, totalRows))
b.WriteString("\n")
}
// Scale reference bar (10mm)
refY := areaY + areaH + 5
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
areaX, refY, areaX+10, refY))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
areaX, refY-1.5, areaX, refY+1.5))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
areaX+10, refY-1.5, areaX+10, refY+1.5))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="2.5" fill="black" text-anchor="middle">10mm</text>`,
areaX+5, refY+3.5))
b.WriteString("\n")
// Grid spacing label
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="2" fill="#999">Grid: %.0fmm</text>`,
areaX+15, refY+0.8, gridSpacing))
b.WriteString("\n")
b.WriteString("</svg>\n")
return b.String(), nil
}
func writeCornerMark(b *strings.Builder, x, y, size, dx, dy float64) {
// L-shaped corner mark
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
x, y, x+size*dx, y))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
x, y, x, y+size*dy))
b.WriteString("\n")
// Small filled circle at corner
b.WriteString(fmt.Sprintf(`<circle cx="%.2f" cy="%.2f" r="0.8" fill="black"/>`, x, y))
b.WriteString("\n")
}
func writeAlignmentMarkers(b *strings.Builder, x, y, w, h, size float64) {
// Edge midpoint crosses for stitching alignment
edges := [][2]float64{
{x + w/2, y}, // top center
{x + w/2, y + h}, // bottom center
{x, y + h/2}, // left center
{x + w, y + h/2}, // right center
}
for _, e := range edges {
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.4"/>`,
e[0]-size/2, e[1], e[0]+size/2, e[1]))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.4"/>`,
e[0], e[1]-size/2, e[0], e[1]+size/2))
b.WriteString("\n")
}
}

View File

@ -275,7 +275,7 @@ func GenerateEnclosureOutputs(session *EnclosureSession, cutouts []Cutout, outpu
os.MkdirAll(outputDir, 0755)
// Split unified cutouts into legacy types for STL/SCAD generation
sideCutouts, lidCutouts := SplitCutouts(cutouts)
sideCutouts, lidCutouts := SplitCutouts(cutouts, session.AllLayerGerbers)
id := randomID()
var generatedFiles []string

View File

@ -5,6 +5,7 @@ import (
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"sort"
@ -29,184 +30,262 @@ func formerProfilesDir() string {
return filepath.Join(formerBaseDir(), "profiles")
}
func formerProjectsDir() string {
return filepath.Join(formerBaseDir(), "projects")
}
func formerRecentPath() string {
return filepath.Join(formerBaseDir(), "recent.json")
}
func ensureFormerDirs() {
td := formerTempDir()
sd := formerSessionsDir()
pd := formerProfilesDir()
debugLog("ensureFormerDirs: temp=%s sessions=%s profiles=%s", td, sd, pd)
if err := os.MkdirAll(td, 0755); err != nil {
debugLog(" ERROR creating temp dir: %v", err)
}
if err := os.MkdirAll(sd, 0755); err != nil {
debugLog(" ERROR creating sessions dir: %v", err)
}
if err := os.MkdirAll(pd, 0755); err != nil {
debugLog(" ERROR creating profiles dir: %v", err)
for _, d := range []string{formerTempDir(), formerProjectsDir()} {
os.MkdirAll(d, 0755)
}
}
// ProjectEntry represents a saved project on disk
type ProjectEntry struct {
Path string
Type string // "session" or "profile"
Data InstanceData
ModTime time.Time
// ======== .former Project Persistence ========
// CreateProject creates a new .former directory at the given path with an empty project.json.
func CreateProject(path string) (*ProjectData, error) {
if !strings.HasSuffix(path, ".former") {
path += ".former"
}
if err := os.MkdirAll(path, 0755); err != nil {
return nil, fmt.Errorf("create project dir: %v", err)
}
for _, sub := range []string{"stencil", "enclosure", "vectorwrap", "structural", "scanhelper"} {
os.MkdirAll(filepath.Join(path, sub), 0755)
}
proj := &ProjectData{
ID: randomID(),
Name: strings.TrimSuffix(filepath.Base(path), ".former"),
CreatedAt: time.Now(),
Version: 1,
Settings: ProjectSettings{ShowGrid: true},
}
if err := SaveProject(path, proj); err != nil {
return nil, err
}
AddRecentProject(path, proj.Name)
return proj, nil
}
// SaveSession persists an enclosure session to ~/former/sessions/
func SaveSession(inst InstanceData, sourceDir string, thumbnail image.Image) (string, error) {
ensureFormerDirs()
name := sanitizeDirName(inst.ProjectName)
if name == "" {
name = "untitled"
}
id := inst.ID
if len(id) > 8 {
id = id[:8]
}
projectDir := filepath.Join(formerSessionsDir(), fmt.Sprintf("%s-%s", name, id))
if err := saveProject(projectDir, inst, sourceDir); err != nil {
return "", err
}
if thumbnail != nil {
SaveThumbnail(projectDir, thumbnail)
}
return projectDir, nil
}
// SaveProfile persists an enclosure session as a named profile to ~/former/profiles/
func SaveProfile(inst InstanceData, name string, sourceDir string, thumbnail image.Image) (string, error) {
ensureFormerDirs()
dirLabel := sanitizeDirName(name)
if dirLabel == "" {
dirLabel = "untitled"
}
id := inst.ID
if len(id) > 8 {
id = id[:8]
}
projectDir := filepath.Join(formerProfilesDir(), fmt.Sprintf("%s-%s", dirLabel, id))
inst.Name = name
if err := saveProject(projectDir, inst, sourceDir); err != nil {
return "", err
}
if thumbnail != nil {
SaveThumbnail(projectDir, thumbnail)
}
return projectDir, nil
}
func saveProject(projectDir string, inst InstanceData, sourceDir string) error {
os.MkdirAll(projectDir, 0755)
// Copy gerber files using original filenames
newGerberFiles := make(map[string]string)
for origName, savedBasename := range inst.GerberFiles {
srcPath := filepath.Join(sourceDir, savedBasename)
dstPath := filepath.Join(projectDir, origName)
if err := CopyFile(srcPath, dstPath); err != nil {
// Fallback: try using origName directly
srcPath = filepath.Join(sourceDir, origName)
if err2 := CopyFile(srcPath, dstPath); err2 != nil {
return fmt.Errorf("copy %s: %v", origName, err)
}
}
newGerberFiles[origName] = origName
}
inst.GerberFiles = newGerberFiles
// Copy drill files
if inst.DrillPath != "" {
srcPath := filepath.Join(sourceDir, inst.DrillPath)
ext := filepath.Ext(inst.DrillPath)
if ext == "" {
ext = ".drl"
}
dstName := "drill" + ext
dstPath := filepath.Join(projectDir, dstName)
if CopyFile(srcPath, dstPath) == nil {
inst.DrillPath = dstName
}
}
if inst.NPTHPath != "" {
srcPath := filepath.Join(sourceDir, inst.NPTHPath)
ext := filepath.Ext(inst.NPTHPath)
if ext == "" {
ext = ".drl"
}
dstName := "npth" + ext
dstPath := filepath.Join(projectDir, dstName)
if CopyFile(srcPath, dstPath) == nil {
inst.NPTHPath = dstName
}
}
// Write former.json
data, err := json.MarshalIndent(inst, "", " ")
// SaveProject atomically writes project.json inside a .former directory.
func SaveProject(path string, data *ProjectData) error {
raw, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644)
}
// ListProjects returns all saved projects sorted by modification time (newest first).
// Pass limit=0 for no limit.
func ListProjects(limit int) ([]ProjectEntry, error) {
ensureFormerDirs()
var entries []ProjectEntry
sessEntries, _ := listProjectsInDir(formerSessionsDir(), "session")
entries = append(entries, sessEntries...)
profEntries, _ := listProjectsInDir(formerProfilesDir(), "profile")
entries = append(entries, profEntries...)
sort.Slice(entries, func(i, j int) bool {
return entries[i].ModTime.After(entries[j].ModTime)
})
if limit > 0 && len(entries) > limit {
entries = entries[:limit]
tmpPath := filepath.Join(path, "project.json.tmp")
if err := os.WriteFile(tmpPath, raw, 0644); err != nil {
return err
}
return entries, nil
return os.Rename(tmpPath, filepath.Join(path, "project.json"))
}
func listProjectsInDir(dir, projType string) ([]ProjectEntry, error) {
dirEntries, err := os.ReadDir(dir)
// LoadProjectData reads project.json from a .former directory.
func LoadProjectData(path string) (*ProjectData, error) {
raw, err := os.ReadFile(filepath.Join(path, "project.json"))
if err != nil {
return nil, err
}
var proj ProjectData
if err := json.Unmarshal(raw, &proj); err != nil {
return nil, err
}
return &proj, nil
}
var entries []ProjectEntry
for _, de := range dirEntries {
if !de.IsDir() {
// ProjectSubdir returns the mode-specific subdirectory within a .former project.
func ProjectSubdir(projectPath, mode string) string {
return filepath.Join(projectPath, mode)
}
// ======== Recent Projects Tracking ========
// ListRecentProjects reads ~/former/recent.json and returns entries (newest first).
func ListRecentProjects() []RecentEntry {
raw, err := os.ReadFile(formerRecentPath())
if err != nil {
return nil
}
var entries []RecentEntry
json.Unmarshal(raw, &entries)
// Filter out entries whose paths no longer exist
var valid []RecentEntry
for _, e := range entries {
if _, err := os.Stat(filepath.Join(e.Path, "project.json")); err == nil {
valid = append(valid, e)
}
}
return valid
}
// AddRecentProject prepends a project to the recent list, deduplicates, caps at 20.
func AddRecentProject(path, name string) {
entries := ListRecentProjects()
entry := RecentEntry{Path: path, Name: name, LastOpened: time.Now()}
var deduped []RecentEntry
deduped = append(deduped, entry)
for _, e := range entries {
if e.Path != path {
deduped = append(deduped, e)
}
}
if len(deduped) > 20 {
deduped = deduped[:20]
}
raw, _ := json.MarshalIndent(deduped, "", " ")
os.WriteFile(formerRecentPath(), raw, 0644)
}
// ======== Migration from Old Sessions/Profiles ========
// MigrateOldProjects converts ~/former/sessions/* and ~/former/profiles/* into .former projects.
// Non-destructive: renames old dirs to .migrated suffix.
func MigrateOldProjects() {
for _, pair := range [][2]string{
{formerSessionsDir(), "session"},
{formerProfilesDir(), "profile"},
} {
dir := pair[0]
if _, err := os.Stat(dir); os.IsNotExist(err) {
continue
}
jsonPath := filepath.Join(dir, de.Name(), "former.json")
info, err := os.Stat(jsonPath)
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
migrated := 0
for _, de := range entries {
if !de.IsDir() {
continue
}
oldPath := filepath.Join(dir, de.Name())
jsonPath := filepath.Join(oldPath, "former.json")
raw, err := os.ReadFile(jsonPath)
if err != nil {
continue
}
var inst InstanceData
if err := json.Unmarshal(raw, &inst); err != nil {
if json.Unmarshal(raw, &inst) != nil {
continue
}
entries = append(entries, ProjectEntry{
Path: filepath.Join(dir, de.Name()),
Type: projType,
Data: inst,
ModTime: info.ModTime(),
})
name := inst.ProjectName
if inst.Name != "" {
name = inst.Name
}
if name == "" {
name = "Untitled"
}
safeName := sanitizeDirName(name)
if safeName == "" {
safeName = "untitled"
}
projPath := filepath.Join(formerProjectsDir(), safeName+".former")
// Avoid overwriting
if _, err := os.Stat(projPath); err == nil {
projPath = filepath.Join(formerProjectsDir(), safeName+"-"+inst.ID[:8]+".former")
}
proj := &ProjectData{
ID: inst.ID,
Name: name,
CreatedAt: inst.CreatedAt,
Version: 1,
Settings: ProjectSettings{ShowGrid: true},
Enclosure: &EnclosureData{
GerberFiles: inst.GerberFiles,
DrillPath: inst.DrillPath,
NPTHPath: inst.NPTHPath,
EdgeCutsFile: inst.EdgeCutsFile,
CourtyardFile: inst.CourtyardFile,
SoldermaskFile: inst.SoldermaskFile,
FabFile: inst.FabFile,
Config: inst.Config,
Exports: inst.Exports,
BoardW: inst.BoardW,
BoardH: inst.BoardH,
ProjectName: inst.ProjectName,
Cutouts: inst.MigrateCutouts(),
},
}
if err := os.MkdirAll(projPath, 0755); err != nil {
continue
}
encDir := filepath.Join(projPath, "enclosure")
os.MkdirAll(encDir, 0755)
// Copy gerber files into enclosure subdir
newGerberFiles := make(map[string]string)
for origName := range inst.GerberFiles {
srcPath := filepath.Join(oldPath, origName)
dstPath := filepath.Join(encDir, origName)
if CopyFile(srcPath, dstPath) == nil {
newGerberFiles[origName] = origName
}
}
proj.Enclosure.GerberFiles = newGerberFiles
// Copy drill files
if inst.DrillPath != "" {
src := filepath.Join(oldPath, inst.DrillPath)
dst := filepath.Join(encDir, inst.DrillPath)
CopyFile(src, dst)
}
if inst.NPTHPath != "" {
src := filepath.Join(oldPath, inst.NPTHPath)
dst := filepath.Join(encDir, inst.NPTHPath)
CopyFile(src, dst)
}
// Copy thumbnail
thumbSrc := filepath.Join(oldPath, "thumbnail.png")
thumbDst := filepath.Join(projPath, "thumbnail.png")
CopyFile(thumbSrc, thumbDst)
// Create other mode subdirs
for _, sub := range []string{"stencil", "vectorwrap", "structural", "scanhelper"} {
os.MkdirAll(filepath.Join(projPath, sub), 0755)
}
if SaveProject(projPath, proj) == nil {
AddRecentProject(projPath, name)
migrated++
}
}
if migrated > 0 {
migratedDir := dir + ".migrated"
if err := os.Rename(dir, migratedDir); err != nil {
log.Printf("migration: could not rename %s to %s: %v", dir, migratedDir, err)
} else {
log.Printf("Migrated %d old %ss from %s", migrated, pair[1], dir)
}
}
}
return entries, nil
}
// LoadProject reads former.json from a project directory
func LoadProject(projectDir string) (*InstanceData, error) {
// ======== Legacy Support (used during migration and by RestoreEnclosureProject) ========
// ProjectEntry represents a saved project on disk (legacy format)
type ProjectEntry struct {
Path string
Type string
Data InstanceData
ModTime time.Time
}
// LoadLegacyProject reads former.json from a legacy project directory
func LoadLegacyProject(projectDir string) (*InstanceData, error) {
raw, err := os.ReadFile(filepath.Join(projectDir, "former.json"))
if err != nil {
return nil, err
@ -218,31 +297,25 @@ func LoadProject(projectDir string) (*InstanceData, error) {
return &inst, nil
}
// UpdateProjectCutouts writes updated cutouts to an existing project's former.json
func UpdateProjectCutouts(projectDir string, cutouts []Cutout) error {
if projectDir == "" {
return nil
// RestoreEnclosureFromProject rebuilds an EnclosureSession from a .former project's enclosure data.
func RestoreEnclosureFromProject(projectPath string, encData *EnclosureData) (string, *EnclosureSession, error) {
encDir := filepath.Join(projectPath, "enclosure")
inst := &InstanceData{
ID: encData.ProjectName,
GerberFiles: encData.GerberFiles,
DrillPath: encData.DrillPath,
NPTHPath: encData.NPTHPath,
EdgeCutsFile: encData.EdgeCutsFile,
CourtyardFile: encData.CourtyardFile,
SoldermaskFile: encData.SoldermaskFile,
FabFile: encData.FabFile,
Config: encData.Config,
Exports: encData.Exports,
BoardW: encData.BoardW,
BoardH: encData.BoardH,
ProjectName: encData.ProjectName,
}
inst, err := LoadProject(projectDir)
if err != nil {
return err
}
inst.Cutouts = cutouts
// Clear legacy fields so they don't conflict
inst.SideCutouts = nil
inst.LidCutouts = nil
data, err := json.MarshalIndent(inst, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644)
}
// TouchProject updates the mtime of a project's former.json
func TouchProject(projectDir string) {
jsonPath := filepath.Join(projectDir, "former.json")
now := time.Now()
os.Chtimes(jsonPath, now, now)
return restoreSessionFromDir(inst, encDir)
}
// DeleteProject removes a project directory entirely
@ -250,20 +323,6 @@ func DeleteProject(projectDir string) error {
return os.RemoveAll(projectDir)
}
// RestoreProject loads and rebuilds a session from a project directory
func RestoreProject(projectDir string) (string, *EnclosureSession, *InstanceData, error) {
inst, err := LoadProject(projectDir)
if err != nil {
return "", nil, nil, err
}
sid, session, err := restoreSessionFromDir(inst, projectDir)
if err != nil {
return "", nil, nil, err
}
TouchProject(projectDir)
return sid, session, inst, nil
}
// SaveThumbnail saves a preview image to the project directory
func SaveThumbnail(projectDir string, img image.Image) error {
f, err := os.Create(filepath.Join(projectDir, "thumbnail.png"))
@ -274,6 +333,24 @@ func SaveThumbnail(projectDir string, img image.Image) error {
return png.Encode(f, img)
}
// ListProjectOutputFiles returns files in a mode's subdirectory.
func ListProjectOutputFiles(projectPath, mode string) []string {
dir := filepath.Join(projectPath, mode)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var files []string
for _, e := range entries {
if e.IsDir() {
continue
}
files = append(files, filepath.Join(dir, e.Name()))
}
sort.Strings(files)
return files
}
func sanitizeDirName(name string) string {
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {

878
svg_parse.go Normal file
View File

@ -0,0 +1,878 @@
package main
import (
"encoding/xml"
"fmt"
"math"
"os"
"regexp"
"strconv"
"strings"
"unicode"
)
type SVGDocument struct {
Width float64
Height float64
ViewBox [4]float64
Elements []SVGElement
Groups []SVGGroup
Warnings []string
RawSVG []byte
}
type SVGElement struct {
Type string
PathData string
Segments []PathSegment
Transform [6]float64
Fill string
Stroke string
StrokeW float64
GroupID int
BBox Bounds
}
type PathSegment struct {
Command byte
Args []float64
}
type SVGGroup struct {
ID string
Label string
Transform [6]float64
Children []int
Visible bool
}
func ParseSVG(path string) (*SVGDocument, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read SVG: %v", err)
}
doc := &SVGDocument{
RawSVG: data,
}
decoder := xml.NewDecoder(strings.NewReader(string(data)))
var groupStack []int
currentGroupID := -1
for {
tok, err := decoder.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "svg":
doc.parseSVGRoot(t)
case "g":
g := parseGroupAttrs(t)
gid := len(doc.Groups)
doc.Groups = append(doc.Groups, g)
if currentGroupID >= 0 {
doc.Groups[currentGroupID].Children = append(doc.Groups[currentGroupID].Children, gid)
}
groupStack = append(groupStack, currentGroupID)
currentGroupID = gid
case "path":
el := doc.parsePathElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "rect":
el := doc.parseRectElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "circle":
el := doc.parseCircleElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "ellipse":
el := doc.parseEllipseElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "line":
el := doc.parseLineElement(t, currentGroupID)
doc.Elements = append(doc.Elements, el)
case "polyline":
el := doc.parsePolyElement(t, currentGroupID, "polyline")
doc.Elements = append(doc.Elements, el)
case "polygon":
el := doc.parsePolyElement(t, currentGroupID, "polygon")
doc.Elements = append(doc.Elements, el)
case "text":
doc.Warnings = appendUnique(doc.Warnings, "Convert text to paths before importing")
decoder.Skip()
case "image":
doc.Warnings = appendUnique(doc.Warnings, "Embedded raster images (<image>) are not supported")
decoder.Skip()
}
case xml.EndElement:
if t.Name.Local == "g" && len(groupStack) > 0 {
currentGroupID = groupStack[len(groupStack)-1]
groupStack = groupStack[:len(groupStack)-1]
}
}
}
for i := range doc.Elements {
doc.Elements[i].BBox = computeElementBBox(&doc.Elements[i])
}
return doc, nil
}
// identityTransform returns [a,b,c,d,e,f] for the identity matrix
func identityTransform() [6]float64 {
return [6]float64{1, 0, 0, 1, 0, 0}
}
func (doc *SVGDocument) parseSVGRoot(el xml.StartElement) {
for _, a := range el.Attr {
switch a.Name.Local {
case "width":
doc.Width = parseLengthMM(a.Value)
case "height":
doc.Height = parseLengthMM(a.Value)
case "viewBox":
parts := splitFloats(a.Value)
if len(parts) >= 4 {
doc.ViewBox = [4]float64{parts[0], parts[1], parts[2], parts[3]}
}
case "version":
if a.Value != "" && a.Value != "1.1" {
doc.Warnings = appendUnique(doc.Warnings, "SVG version is "+a.Value+"; for best results re-export as plain SVG 1.1 from Inkscape")
}
}
}
if doc.Width == 0 && doc.ViewBox[2] > 0 {
doc.Width = doc.ViewBox[2] * (25.4 / 96.0)
}
if doc.Height == 0 && doc.ViewBox[3] > 0 {
doc.Height = doc.ViewBox[3] * (25.4 / 96.0)
}
}
func parseGroupAttrs(el xml.StartElement) SVGGroup {
g := SVGGroup{
Transform: identityTransform(),
Visible: true,
}
for _, a := range el.Attr {
switch {
case a.Name.Local == "id":
g.ID = a.Value
case a.Name.Local == "transform":
g.Transform = parseTransform(a.Value)
case a.Name.Local == "label" && a.Name.Space == "http://www.inkscape.org/namespaces/inkscape":
g.Label = a.Value
case a.Name.Local == "style":
if strings.Contains(a.Value, "display:none") || strings.Contains(a.Value, "display: none") {
g.Visible = false
}
case a.Name.Local == "display":
if a.Value == "none" {
g.Visible = false
}
}
}
return g
}
func (doc *SVGDocument) baseElement(el xml.StartElement, groupID int) SVGElement {
e := SVGElement{
Transform: identityTransform(),
GroupID: groupID,
}
for _, a := range el.Attr {
switch a.Name.Local {
case "transform":
e.Transform = parseTransform(a.Value)
case "fill":
e.Fill = a.Value
case "stroke":
e.Stroke = a.Value
case "stroke-width":
e.StrokeW, _ = strconv.ParseFloat(a.Value, 64)
case "style":
e.parseStyleAttr(a.Value)
}
}
return e
}
func (e *SVGElement) parseStyleAttr(style string) {
for _, part := range strings.Split(style, ";") {
kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
if len(kv) != 2 {
continue
}
k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
switch k {
case "fill":
e.Fill = v
case "stroke":
e.Stroke = v
case "stroke-width":
e.StrokeW, _ = strconv.ParseFloat(v, 64)
}
}
}
func (doc *SVGDocument) parsePathElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "path"
for _, a := range el.Attr {
if a.Name.Local == "d" {
e.PathData = a.Value
e.Segments = parsePath(a.Value)
}
}
return e
}
func (doc *SVGDocument) parseRectElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "rect"
var x, y, w, h, rx, ry float64
for _, a := range el.Attr {
switch a.Name.Local {
case "x":
x, _ = strconv.ParseFloat(a.Value, 64)
case "y":
y, _ = strconv.ParseFloat(a.Value, 64)
case "width":
w, _ = strconv.ParseFloat(a.Value, 64)
case "height":
h, _ = strconv.ParseFloat(a.Value, 64)
case "rx":
rx, _ = strconv.ParseFloat(a.Value, 64)
case "ry":
ry, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = rectToPath(x, y, w, h, rx, ry)
return e
}
func (doc *SVGDocument) parseCircleElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "circle"
var cx, cy, r float64
for _, a := range el.Attr {
switch a.Name.Local {
case "cx":
cx, _ = strconv.ParseFloat(a.Value, 64)
case "cy":
cy, _ = strconv.ParseFloat(a.Value, 64)
case "r":
r, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = circleToPath(cx, cy, r)
return e
}
func (doc *SVGDocument) parseEllipseElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "ellipse"
var cx, cy, rx, ry float64
for _, a := range el.Attr {
switch a.Name.Local {
case "cx":
cx, _ = strconv.ParseFloat(a.Value, 64)
case "cy":
cy, _ = strconv.ParseFloat(a.Value, 64)
case "rx":
rx, _ = strconv.ParseFloat(a.Value, 64)
case "ry":
ry, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = ellipseToPath(cx, cy, rx, ry)
return e
}
func (doc *SVGDocument) parseLineElement(el xml.StartElement, groupID int) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = "line"
var x1, y1, x2, y2 float64
for _, a := range el.Attr {
switch a.Name.Local {
case "x1":
x1, _ = strconv.ParseFloat(a.Value, 64)
case "y1":
y1, _ = strconv.ParseFloat(a.Value, 64)
case "x2":
x2, _ = strconv.ParseFloat(a.Value, 64)
case "y2":
y2, _ = strconv.ParseFloat(a.Value, 64)
}
}
e.Segments = lineToPath(x1, y1, x2, y2)
return e
}
func (doc *SVGDocument) parsePolyElement(el xml.StartElement, groupID int, typ string) SVGElement {
e := doc.baseElement(el, groupID)
e.Type = typ
for _, a := range el.Attr {
if a.Name.Local == "points" {
pts := splitFloats(a.Value)
if typ == "polygon" {
e.Segments = polygonToPath(pts)
} else {
e.Segments = polylineToPath(pts)
}
}
}
return e
}
// Shape-to-path converters
func rectToPath(x, y, w, h, rx, ry float64) []PathSegment {
if rx == 0 && ry == 0 {
return []PathSegment{
{Command: 'M', Args: []float64{x, y}},
{Command: 'L', Args: []float64{x + w, y}},
{Command: 'L', Args: []float64{x + w, y + h}},
{Command: 'L', Args: []float64{x, y + h}},
{Command: 'Z'},
}
}
if rx == 0 {
rx = ry
}
if ry == 0 {
ry = rx
}
if rx > w/2 {
rx = w / 2
}
if ry > h/2 {
ry = h / 2
}
return []PathSegment{
{Command: 'M', Args: []float64{x + rx, y}},
{Command: 'L', Args: []float64{x + w - rx, y}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + w, y + ry}},
{Command: 'L', Args: []float64{x + w, y + h - ry}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + w - rx, y + h}},
{Command: 'L', Args: []float64{x + rx, y + h}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x, y + h - ry}},
{Command: 'L', Args: []float64{x, y + ry}},
{Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + rx, y}},
{Command: 'Z'},
}
}
func circleToPath(cx, cy, r float64) []PathSegment {
return ellipseToPath(cx, cy, r, r)
}
func ellipseToPath(cx, cy, rx, ry float64) []PathSegment {
return []PathSegment{
{Command: 'M', Args: []float64{cx - rx, cy}},
{Command: 'A', Args: []float64{rx, ry, 0, 1, 0, cx + rx, cy}},
{Command: 'A', Args: []float64{rx, ry, 0, 1, 0, cx - rx, cy}},
{Command: 'Z'},
}
}
func lineToPath(x1, y1, x2, y2 float64) []PathSegment {
return []PathSegment{
{Command: 'M', Args: []float64{x1, y1}},
{Command: 'L', Args: []float64{x2, y2}},
}
}
func polylineToPath(coords []float64) []PathSegment {
if len(coords) < 4 {
return nil
}
segs := []PathSegment{
{Command: 'M', Args: []float64{coords[0], coords[1]}},
}
for i := 2; i+1 < len(coords); i += 2 {
segs = append(segs, PathSegment{Command: 'L', Args: []float64{coords[i], coords[i+1]}})
}
return segs
}
func polygonToPath(coords []float64) []PathSegment {
segs := polylineToPath(coords)
if len(segs) > 0 {
segs = append(segs, PathSegment{Command: 'Z'})
}
return segs
}
// Transform parsing
var transformFuncRe = regexp.MustCompile(`(\w+)\s*\(([^)]*)\)`)
func parseTransform(attr string) [6]float64 {
result := identityTransform()
matches := transformFuncRe.FindAllStringSubmatch(attr, -1)
for _, m := range matches {
fn := m[1]
args := splitFloats(m[2])
var t [6]float64
switch fn {
case "matrix":
if len(args) >= 6 {
t = [6]float64{args[0], args[1], args[2], args[3], args[4], args[5]}
} else {
continue
}
case "translate":
tx := 0.0
ty := 0.0
if len(args) >= 1 {
tx = args[0]
}
if len(args) >= 2 {
ty = args[1]
}
t = [6]float64{1, 0, 0, 1, tx, ty}
case "scale":
sx := 1.0
sy := 1.0
if len(args) >= 1 {
sx = args[0]
}
if len(args) >= 2 {
sy = args[1]
} else {
sy = sx
}
t = [6]float64{sx, 0, 0, sy, 0, 0}
case "rotate":
if len(args) < 1 {
continue
}
angle := args[0] * math.Pi / 180.0
c, s := math.Cos(angle), math.Sin(angle)
if len(args) >= 3 {
cx, cy := args[1], args[2]
t = [6]float64{c, s, -s, c, cx - c*cx + s*cy, cy - s*cx - c*cy}
} else {
t = [6]float64{c, s, -s, c, 0, 0}
}
case "skewX":
if len(args) < 1 {
continue
}
t = [6]float64{1, 0, math.Tan(args[0] * math.Pi / 180.0), 1, 0, 0}
case "skewY":
if len(args) < 1 {
continue
}
t = [6]float64{1, math.Tan(args[0] * math.Pi / 180.0), 0, 1, 0, 0}
default:
continue
}
result = composeTransforms(result, t)
}
return result
}
func composeTransforms(a, b [6]float64) [6]float64 {
return [6]float64{
a[0]*b[0] + a[2]*b[1],
a[1]*b[0] + a[3]*b[1],
a[0]*b[2] + a[2]*b[3],
a[1]*b[2] + a[3]*b[3],
a[0]*b[4] + a[2]*b[5] + a[4],
a[1]*b[4] + a[3]*b[5] + a[5],
}
}
// Path d attribute parser
func parsePath(d string) []PathSegment {
var segments []PathSegment
tokens := tokenizePath(d)
if len(tokens) == 0 {
return nil
}
var curX, curY float64
var startX, startY float64
var lastCmd byte
i := 0
for i < len(tokens) {
tok := tokens[i]
cmd := byte(0)
if len(tok) == 1 && isPathCommand(tok[0]) {
cmd = tok[0]
i++
} else if lastCmd != 0 {
cmd = lastCmd
if cmd == 'M' {
cmd = 'L'
} else if cmd == 'm' {
cmd = 'l'
}
} else {
break
}
rel := cmd >= 'a' && cmd <= 'z'
upper := cmd
if rel {
upper = cmd - 32
}
switch upper {
case 'M':
if i+1 >= len(tokens) {
break
}
x, y := parseF(tokens[i]), parseF(tokens[i+1])
i += 2
if rel {
x += curX
y += curY
}
curX, curY = x, y
startX, startY = x, y
segments = append(segments, PathSegment{Command: 'M', Args: []float64{x, y}})
case 'L':
if i+1 >= len(tokens) {
break
}
x, y := parseF(tokens[i]), parseF(tokens[i+1])
i += 2
if rel {
x += curX
y += curY
}
curX, curY = x, y
segments = append(segments, PathSegment{Command: 'L', Args: []float64{x, y}})
case 'H':
if i >= len(tokens) {
break
}
x := parseF(tokens[i])
i++
if rel {
x += curX
}
curX = x
segments = append(segments, PathSegment{Command: 'L', Args: []float64{curX, curY}})
case 'V':
if i >= len(tokens) {
break
}
y := parseF(tokens[i])
i++
if rel {
y += curY
}
curY = y
segments = append(segments, PathSegment{Command: 'L', Args: []float64{curX, curY}})
case 'C':
if i+5 >= len(tokens) {
break
}
args := make([]float64, 6)
for j := 0; j < 6; j++ {
args[j] = parseF(tokens[i+j])
}
i += 6
if rel {
for j := 0; j < 6; j += 2 {
args[j] += curX
args[j+1] += curY
}
}
curX, curY = args[4], args[5]
segments = append(segments, PathSegment{Command: 'C', Args: args})
case 'S':
if i+3 >= len(tokens) {
break
}
args := make([]float64, 4)
for j := 0; j < 4; j++ {
args[j] = parseF(tokens[i+j])
}
i += 4
if rel {
for j := 0; j < 4; j += 2 {
args[j] += curX
args[j+1] += curY
}
}
curX, curY = args[2], args[3]
segments = append(segments, PathSegment{Command: 'S', Args: args})
case 'Q':
if i+3 >= len(tokens) {
break
}
args := make([]float64, 4)
for j := 0; j < 4; j++ {
args[j] = parseF(tokens[i+j])
}
i += 4
if rel {
for j := 0; j < 4; j += 2 {
args[j] += curX
args[j+1] += curY
}
}
curX, curY = args[2], args[3]
segments = append(segments, PathSegment{Command: 'Q', Args: args})
case 'T':
if i+1 >= len(tokens) {
break
}
x, y := parseF(tokens[i]), parseF(tokens[i+1])
i += 2
if rel {
x += curX
y += curY
}
curX, curY = x, y
segments = append(segments, PathSegment{Command: 'T', Args: []float64{x, y}})
case 'A':
if i+6 >= len(tokens) {
break
}
rx := parseF(tokens[i])
ry := parseF(tokens[i+1])
rot := parseF(tokens[i+2])
largeArc := parseF(tokens[i+3])
sweep := parseF(tokens[i+4])
x := parseF(tokens[i+5])
y := parseF(tokens[i+6])
i += 7
if rel {
x += curX
y += curY
}
curX, curY = x, y
segments = append(segments, PathSegment{Command: 'A', Args: []float64{rx, ry, rot, largeArc, sweep, x, y}})
case 'Z':
curX, curY = startX, startY
segments = append(segments, PathSegment{Command: 'Z'})
default:
i++
}
lastCmd = cmd
}
return segments
}
func isPathCommand(c byte) bool {
return strings.ContainsRune("MmLlHhVvCcSsQqTtAaZz", rune(c))
}
func tokenizePath(d string) []string {
var tokens []string
var buf strings.Builder
flush := func() {
s := buf.String()
if s != "" {
tokens = append(tokens, s)
buf.Reset()
}
}
for i := 0; i < len(d); i++ {
c := d[i]
if isPathCommand(c) {
flush()
tokens = append(tokens, string(c))
} else if c == ',' || c == ' ' || c == '\t' || c == '\n' || c == '\r' {
flush()
} else if c == '-' && buf.Len() > 0 {
// Negative sign starts new number unless after 'e'/'E' (exponent)
s := buf.String()
lastChar := s[len(s)-1]
if lastChar != 'e' && lastChar != 'E' {
flush()
}
buf.WriteByte(c)
} else if c == '.' && strings.Contains(buf.String(), ".") {
flush()
buf.WriteByte(c)
} else {
buf.WriteByte(c)
}
}
flush()
return tokens
}
func parseF(s string) float64 {
v, _ := strconv.ParseFloat(s, 64)
return v
}
// Unit conversion
func parseLengthMM(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
unit := ""
numStr := s
for _, u := range []string{"mm", "cm", "in", "pt", "px"} {
if strings.HasSuffix(s, u) {
unit = u
numStr = strings.TrimSpace(s[:len(s)-len(u)])
break
}
}
// Strip non-numeric trailing chars
numStr = strings.TrimRightFunc(numStr, func(r rune) bool {
return !unicode.IsDigit(r) && r != '.' && r != '-' && r != '+'
})
v, _ := strconv.ParseFloat(numStr, 64)
switch unit {
case "mm":
return v
case "cm":
return v * 10.0
case "in":
return v * 25.4
case "pt":
return v * (25.4 / 72.0)
case "px", "":
return v * (25.4 / 96.0)
}
return v * (25.4 / 96.0)
}
// Bounding box computation (approximate, from segments)
func computeElementBBox(el *SVGElement) Bounds {
b := Bounds{MinX: math.MaxFloat64, MinY: math.MaxFloat64, MaxX: -math.MaxFloat64, MaxY: -math.MaxFloat64}
expandPt := func(x, y float64) {
tx := el.Transform[0]*x + el.Transform[2]*y + el.Transform[4]
ty := el.Transform[1]*x + el.Transform[3]*y + el.Transform[5]
if tx < b.MinX {
b.MinX = tx
}
if tx > b.MaxX {
b.MaxX = tx
}
if ty < b.MinY {
b.MinY = ty
}
if ty > b.MaxY {
b.MaxY = ty
}
}
for _, seg := range el.Segments {
switch seg.Command {
case 'M', 'L', 'T':
if len(seg.Args) >= 2 {
expandPt(seg.Args[0], seg.Args[1])
}
case 'C':
if len(seg.Args) >= 6 {
expandPt(seg.Args[0], seg.Args[1])
expandPt(seg.Args[2], seg.Args[3])
expandPt(seg.Args[4], seg.Args[5])
}
case 'S', 'Q':
if len(seg.Args) >= 4 {
expandPt(seg.Args[0], seg.Args[1])
expandPt(seg.Args[2], seg.Args[3])
}
case 'A':
if len(seg.Args) >= 7 {
expandPt(seg.Args[5], seg.Args[6])
expandPt(seg.Args[5]-seg.Args[0], seg.Args[6]-seg.Args[1])
expandPt(seg.Args[5]+seg.Args[0], seg.Args[6]+seg.Args[1])
}
}
}
if b.MinX == math.MaxFloat64 {
return Bounds{}
}
return b
}
// Helpers
func splitFloats(s string) []float64 {
s = strings.Map(func(r rune) rune {
if r == ',' {
return ' '
}
return r
}, s)
var result []float64
for _, part := range strings.Fields(s) {
v, err := strconv.ParseFloat(part, 64)
if err == nil {
result = append(result, v)
}
}
return result
}
func appendUnique(warnings []string, msg string) []string {
for _, w := range warnings {
if w == msg {
return warnings
}
}
return append(warnings, msg)
}
func (doc *SVGDocument) LayerCount() int {
count := 0
for _, g := range doc.Groups {
if g.Label != "" {
count++
}
}
return count
}
func (doc *SVGDocument) VisibleLayerNames() []string {
var names []string
for _, g := range doc.Groups {
if g.Label != "" && g.Visible {
names = append(names, g.Label)
}
}
return names
}

44
test_shapes_test.go Normal file
View File

@ -0,0 +1,44 @@
package main
import (
"fmt"
"os"
"testing"
)
func TestShapeExtraction(t *testing.T) {
files := []string{
"temp/3efe010e39240608501455e800058a3f_EIS4-F_Cu.gbr",
"temp/3efe010e39240608501455e800058a3f_EIS4-F_Silkscreen.gbr",
"temp/3efe010e39240608501455e800058a3f_EIS4-F_Paste.gbr",
"temp/3efe010e39240608501455e800058a3f_EIS4-F_Fab.gbr",
"temp/3efe010e39240608501455e800058a3f_EIS4-User_Drawings.gbr",
}
for _, f := range files {
if _, err := os.Stat(f); err != nil {
continue
}
gf, err := ParseGerber(f)
if err != nil {
fmt.Printf("%s: parse error: %v\n", f, err)
continue
}
bounds := gf.CalculateBounds()
elems := ExtractElementBBoxes(gf, 508, &bounds)
shapes := map[string]int{}
types := map[string]int{}
for _, e := range elems {
shapes[e.Shape]++
types[e.Type]++
}
fmt.Printf("%s: %d elements, shapes=%v types=%v\n", f, len(elems), shapes, types)
count := 0
for _, e := range elems {
if e.Type == "pad" && count < 5 {
fmt.Printf(" pad id=%d shape=%s footprint=%s w=%.1f h=%.1f\n",
e.ID, e.Shape, e.Footprint, e.MaxX-e.MinX, e.MaxY-e.MinY)
count++
}
}
}
}

387
unwrap.go Normal file
View File

@ -0,0 +1,387 @@
package main
import (
"fmt"
"math"
"strings"
)
type TabSlot struct {
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
H float64 `json:"h"`
}
type UnwrapPanel struct {
Label string `json:"label"`
FaceType string `json:"faceType"` // "lid", "tray", "side"
SideNum int `json:"sideNum"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Angle float64 `json:"angle"`
Cutouts []Cutout `json:"cutouts"`
TabSlots []TabSlot `json:"tabSlots"`
// Polygon outline for non-rectangular panels (lid/tray)
Polygon [][2]float64 `json:"polygon,omitempty"`
}
type UnwrapLayout struct {
Panels []UnwrapPanel `json:"panels"`
TotalW float64 `json:"totalW"`
TotalH float64 `json:"totalH"`
WallThick float64 `json:"wallThick"`
}
// offsetPolygon moves each vertex outward along the average of its two adjacent edge normals.
func offsetPolygon(poly [][2]float64, dist float64) [][2]float64 {
n := len(poly)
if n < 3 {
return poly
}
// Remove closing duplicate if present
last := poly[n-1]
first := poly[0]
closed := math.Abs(last[0]-first[0]) < 0.01 && math.Abs(last[1]-first[1]) < 0.01
if closed {
n--
}
// Compute centroid to determine winding
cx, cy := 0.0, 0.0
for i := 0; i < n; i++ {
cx += poly[i][0]
cy += poly[i][1]
}
cx /= float64(n)
cy /= float64(n)
// Compute signed area to detect winding direction
area := 0.0
for i := 0; i < n; i++ {
j := (i + 1) % n
area += poly[i][0]*poly[j][1] - poly[j][0]*poly[i][1]
}
sign := 1.0
if area > 0 {
sign = -1.0
}
out := make([][2]float64, n)
for i := 0; i < n; i++ {
prev := (i - 1 + n) % n
next := (i + 1) % n
// Edge vectors
e1x := poly[i][0] - poly[prev][0]
e1y := poly[i][1] - poly[prev][1]
e2x := poly[next][0] - poly[i][0]
e2y := poly[next][1] - poly[i][1]
// Outward normals (perpendicular, pointing away from center)
n1x := -e1y * sign
n1y := e1x * sign
n2x := -e2y * sign
n2y := e2x * sign
// Normalize
l1 := math.Sqrt(n1x*n1x + n1y*n1y)
l2 := math.Sqrt(n2x*n2x + n2y*n2y)
if l1 > 0 {
n1x /= l1
n1y /= l1
}
if l2 > 0 {
n2x /= l2
n2y /= l2
}
// Average normal
ax := (n1x + n2x) / 2
ay := (n1y + n2y) / 2
al := math.Sqrt(ax*ax + ay*ay)
if al < 1e-9 {
out[i] = poly[i]
continue
}
ax /= al
ay /= al
// Half-angle cosine
dot := n1x*n2x + n1y*n2y
halfAngle := math.Acos(math.Max(-1, math.Min(1, dot))) / 2
cosHalf := math.Cos(halfAngle)
if cosHalf < 0.1 {
cosHalf = 0.1
}
scale := dist / cosHalf
out[i] = [2]float64{poly[i][0] + ax*scale, poly[i][1] + ay*scale}
}
// Close the polygon
out = append(out, out[0])
return out
}
// ComputeUnwrapLayout generates a flat unfolded net of the enclosure's outer surface.
func ComputeUnwrapLayout(session *EnclosureSession, cutouts []Cutout) *UnwrapLayout {
cfg := session.Config
wt := cfg.WallThickness
clearance := cfg.Clearance
offset := clearance + 2*wt
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
if len(outlinePoly) < 3 {
return nil
}
lidPoly := offsetPolygon(outlinePoly, offset)
// Bounding box of the lid polygon
lidMinX, lidMinY := math.Inf(1), math.Inf(1)
lidMaxX, lidMaxY := math.Inf(-1), math.Inf(-1)
for _, p := range lidPoly {
if p[0] < lidMinX { lidMinX = p[0] }
if p[1] < lidMinY { lidMinY = p[1] }
if p[0] > lidMaxX { lidMaxX = p[0] }
if p[1] > lidMaxY { lidMaxY = p[1] }
}
lidW := lidMaxX - lidMinX
lidH := lidMaxY - lidMinY
wallH := cfg.WallHeight + cfg.PCBThickness + 1.0
sides := session.Sides
if len(sides) == 0 {
return nil
}
// Normalize lid polygon to origin for the SVG layout
normLidPoly := make([][2]float64, len(lidPoly))
for i, p := range lidPoly {
normLidPoly[i] = [2]float64{p[0] - lidMinX, p[1] - lidMinY}
}
// Classify cutouts by surface
sideCutouts, lidCutouts := SplitCutouts(cutouts, nil)
_ = lidCutouts
// Pry tab positions
pryW := 8.0
pryH := 1.5
// Find longest side for tray attachment
longestIdx := 0
longestLen := 0.0
for i, s := range sides {
if s.Length > longestLen {
longestLen = s.Length
longestIdx = i
}
}
// Layout: lid at center, sides folded out from lid edges
// We place lid at a starting position, then attach side panels along each edge.
// Simple cross layout: lid centered, sides extending outward.
margin := 5.0
layoutX := margin
layoutY := margin
var panels []UnwrapPanel
// Lid panel
lidPanel := UnwrapPanel{
Label: "L",
FaceType: "lid",
X: layoutX,
Y: layoutY + wallH, // sides above will fold up
Width: lidW,
Height: lidH,
Polygon: normLidPoly,
}
// Map lid cutouts
for _, c := range cutouts {
if c.Surface == "top" {
mapped := c
mapped.X = c.X - lidMinX + lidPanel.X
mapped.Y = c.Y - lidMinY + lidPanel.Y
lidPanel.Cutouts = append(lidPanel.Cutouts, mapped)
}
}
panels = append(panels, lidPanel)
// Side panels: attach along each edge of the lid
// For simplicity, we place sides in a strip below the lid, left to right
sideX := margin
sideY := lidPanel.Y + lidH // below the lid
for i, s := range sides {
sp := UnwrapPanel{
Label: fmt.Sprintf("S%d", s.Num),
FaceType: "side",
SideNum: s.Num,
X: sideX,
Y: sideY,
Width: s.Length,
Height: wallH,
Angle: s.Angle,
}
// Map side cutouts to this panel
for _, sc := range sideCutouts {
if sc.Side == s.Num {
mapped := Cutout{
ID: fmt.Sprintf("unwrap-s%d", sc.Side),
Surface: "side",
SideNum: sc.Side,
X: sc.X + sp.X,
Y: sc.Y + sp.Y,
Width: sc.Width,
Height: sc.Height,
}
sp.Cutouts = append(sp.Cutouts, mapped)
}
}
// Tab slots on pry tab sides
if s.Num == 1 || s.Num == len(sides) {
slot := TabSlot{
X: sp.Width/2 - pryW/2,
Y: 0,
W: pryW,
H: pryH,
}
sp.TabSlots = append(sp.TabSlots, slot)
}
panels = append(panels, sp)
// If this is the longest side, attach tray below it
if i == longestIdx {
trayPanel := UnwrapPanel{
Label: "T",
FaceType: "tray",
X: sideX,
Y: sideY + wallH,
Width: lidW,
Height: lidH,
Polygon: normLidPoly,
}
for _, c := range cutouts {
if c.Surface == "bottom" {
mapped := c
mapped.X = c.X - lidMinX + trayPanel.X
mapped.Y = c.Y - lidMinY + trayPanel.Y
trayPanel.Cutouts = append(trayPanel.Cutouts, mapped)
}
}
panels = append(panels, trayPanel)
}
sideX += s.Length + 2.0
}
// Compute total layout bounds
totalW := margin
totalH := margin
for _, p := range panels {
right := p.X + p.Width
bottom := p.Y + p.Height
if right > totalW { totalW = right }
if bottom > totalH { totalH = bottom }
}
totalW += margin
totalH += margin
return &UnwrapLayout{
Panels: panels,
TotalW: totalW,
TotalH: totalH,
WallThick: wt,
}
}
// GenerateUnwrapSVG produces an SVG string of the unfolded enclosure net.
func GenerateUnwrapSVG(layout *UnwrapLayout) string {
if layout == nil {
return ""
}
var b strings.Builder
b.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.2f %.2f" width="%.2fmm" height="%.2fmm">`+"\n",
layout.TotalW, layout.TotalH, layout.TotalW, layout.TotalH))
b.WriteString(`<style>`)
b.WriteString(`.panel { fill: none; stroke: #333; stroke-width: 0.3; }`)
b.WriteString(`.cutout { fill: #e0e0e0; stroke: #999; stroke-width: 0.2; }`)
b.WriteString(`.tab-slot { fill: #ccc; stroke: #999; stroke-width: 0.15; }`)
b.WriteString(`.fold-line { stroke: #aaa; stroke-width: 0.15; stroke-dasharray: 2,1; }`)
b.WriteString(`.label { font-family: sans-serif; font-size: 4px; fill: #666; text-anchor: middle; dominant-baseline: central; }`)
b.WriteString("</style>\n")
for _, p := range layout.Panels {
b.WriteString(fmt.Sprintf(`<g id="panel-%s" data-face="%s" data-side="%d" data-x="%.2f" data-y="%.2f" data-w="%.2f" data-h="%.2f">`+"\n",
p.Label, p.FaceType, p.SideNum, p.X, p.Y, p.Width, p.Height))
// Panel outline
if len(p.Polygon) > 2 {
b.WriteString(`<path class="panel" d="`)
for i, pt := range p.Polygon {
cmd := "L"
if i == 0 {
cmd = "M"
}
b.WriteString(fmt.Sprintf("%s%.3f,%.3f ", cmd, pt[0]+p.X, pt[1]+p.Y))
}
b.WriteString("Z\" />\n")
} else {
b.WriteString(fmt.Sprintf(`<rect class="panel" x="%.3f" y="%.3f" width="%.3f" height="%.3f" />`+"\n",
p.X, p.Y, p.Width, p.Height))
}
// Cutouts
for _, c := range p.Cutouts {
if c.Shape == "circle" {
r := c.Width / 2
b.WriteString(fmt.Sprintf(`<circle class="cutout" cx="%.3f" cy="%.3f" r="%.3f" />`+"\n",
c.X+r, c.Y+r, r))
} else {
b.WriteString(fmt.Sprintf(`<rect class="cutout" x="%.3f" y="%.3f" width="%.3f" height="%.3f" />`+"\n",
c.X, c.Y, c.Width, c.Height))
}
}
// Tab slots
for _, t := range p.TabSlots {
b.WriteString(fmt.Sprintf(`<rect class="tab-slot" x="%.3f" y="%.3f" width="%.3f" height="%.3f" />`+"\n",
p.X+t.X, p.Y+t.Y, t.W, t.H))
}
// Label
cx := p.X + p.Width/2
cy := p.Y + p.Height/2
b.WriteString(fmt.Sprintf(`<text class="label" x="%.2f" y="%.2f">%s</text>`+"\n", cx, cy, p.Label))
b.WriteString("</g>\n")
}
// Fold lines between lid and side panels
for _, p := range layout.Panels {
if p.FaceType == "side" {
b.WriteString(fmt.Sprintf(`<line class="fold-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" />`+"\n",
p.X, p.Y, p.X+p.Width, p.Y))
}
if p.FaceType == "tray" {
b.WriteString(fmt.Sprintf(`<line class="fold-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" />`+"\n",
p.X, p.Y, p.X+p.Width, p.Y))
}
}
b.WriteString("</svg>")
return b.String()
}

38
vectorwrap.go Normal file
View File

@ -0,0 +1,38 @@
package main
import "image"
// VectorWrapSession holds state for the Vector Wrap workflow:
// wrapping 2D vector art onto 3D surfaces.
type VectorWrapSession struct {
SVGDoc *SVGDocument
SVGPath string
SVGImage image.Image
ModelPath string
ModelType string // "stl", "scad", or "project-enclosure"
ModelSTL []byte // raw binary STL data
EnclosureSCAD string // populated when ModelType == "project-enclosure"
TraySCAD string
}
// StructuralSession holds state for the Structural Procedures workflow:
// procedural pattern infill of edge-cut shapes for 3D printing.
type StructuralSession struct {
SVGDoc *SVGDocument
SVGPath string
Pattern string // e.g. "hexagon", "triangle", "diamond", "voronoi"
CellSize float64 // mm
WallThick float64 // mm
Height float64 // extrusion height mm
ShellThick float64 // outer shell thickness mm
}
// ScanHelperConfig holds configuration for generating printable scan grids.
type ScanHelperConfig struct {
PageWidth float64 // mm (e.g. 210 for A4)
PageHeight float64 // mm (e.g. 297 for A4)
GridSpacing float64 // mm between calibration markers
PagesWide int // number of pages horizontally when stitching
PagesTall int // number of pages vertically when stitching
DPI float64 // target scan DPI
}