Ill clean up the working space soon. It is now a desktop application in the loosest of terms. Its still just a webapp, but a nicely packed one

This commit is contained in:
pszsh 2026-02-26 00:28:22 -08:00
parent 66ea1db755
commit 0f93fcdf86
37 changed files with 24079 additions and 1446 deletions

8
.gitignore vendored
View File

@ -1,2 +1,8 @@
./temp/*
temp/
temp/
build/bin
frontend/node_modules/
frontend/dist/
data/
frontend/wailsjs/
bin/

View File

@ -9,6 +9,10 @@ A Go tool to convert Gerber files (specifically solder paste layers) into 3D pri
- Supports Aperture Macros (AM) with rotation (e.g., rounded rectangles).
- Automatically crops the output to the PCB bounds.
- Generates a 3D STL mesh optimized for 3D printing.
- **Enclosure Generation**: Automatically generates a snap-fit enclosure and tray based on the PCB outline.
- **Native OpenSCAD Support**: Exports native `.scad` scripts for parametric editing and customization.
- **Smart Cutouts**: Interactive side-cutout placement with automatic USB port alignment.
- **Tray Snap System**: Robust tray clips with vertical relief slots for secure enclosure latching.
## Usage
@ -35,6 +39,15 @@ go run main.go gerber.go -height=0.16 -keep-png my_board_paste_top.gbr my_board_
This will generate `my_board_paste_top.stl` in the same directory.
### Enclosure Generation
To generate an enclosure, use the web interface. Upload a `.gbrjob` file and the associated Gerber layers. The tool will auto-discover the board thickness and outline.
1. **Upload**: Provide the Gerber job and layout files.
2. **Configure**: Adjust wall thickness, clearance, and mounting hole parameters in the UI.
3. **Preview**: Use the interactive preview to place and align side cutouts for connectors.
4. **Export**: Generate STLs or OpenSCAD scripts for both the enclosure top and the tray.
### Web Interface
To start the web interface:
@ -59,10 +72,11 @@ These settings assume you run the tool with `-height=0.16` (the default).
## How it Works
1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws).
2. **Rendering**: It renders the PCB layer into a high-resolution internal image.
3. **Meshing**: It converts the image into a 3D mesh using a run-length encoding approach to optimize the triangle count.
4. **Export**: The mesh is saved as a binary STL file.
1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws). For enclosures, it parses the `.gbrjob` to identify board layers.
2. **Rendering**: It renders the PCB outline and layers into a high-resolution internal image.
3. **Path Extraction**: Board edges are traced and simplified to generate 2.5D geometry.
4. **Meshing**: It converts the image into a 3D mesh (STL) or generates CSG primitives (SCAD) using the board topology.
5. **Export**: The resulting files are saved for 3D printing or further CAD refinement.
## License

946
app.go Normal file
View File

@ -0,0 +1,946 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image/png"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// ======== Image Server ========
// ImageServer serves dynamically-generated images at /api/* paths.
// It implements http.Handler and is used as the Wails AssetServer fallback handler.
type ImageServer struct {
mu sync.RWMutex
images map[string][]byte
}
func NewImageServer() *ImageServer {
return &ImageServer{images: make(map[string][]byte)}
}
func (s *ImageServer) Store(key string, data []byte) {
s.mu.Lock()
defer s.mu.Unlock()
s.images[key] = data
}
func (s *ImageServer) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.images = make(map[string][]byte)
}
func (s *ImageServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
s.mu.RLock()
data, ok := s.images[path]
s.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
if strings.HasSuffix(path, ".png") {
w.Header().Set("Content-Type", "image/png")
} else if strings.HasSuffix(path, ".svg") {
w.Header().Set("Content-Type", "image/svg+xml")
}
w.Header().Set("Cache-Control", "no-cache")
w.Write(data)
}
// ======== Frontend-facing Types ========
type ProjectInfoJS struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
}
type SessionInfoJS struct {
ProjectName string `json:"projectName"`
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
Sides []BoardSide `json:"sides"`
TotalH float64 `json:"totalH"`
Cutouts []Cutout `json:"cutouts"`
HasSession bool `json:"hasSession"`
MinX float64 `json:"minX"`
MaxY float64 `json:"maxY"`
DPI float64 `json:"dpi"`
}
type LayerInfoJS struct {
Index int `json:"index"`
Name string `json:"name"`
ColorHex string `json:"colorHex"`
Visible bool `json:"visible"`
Highlight bool `json:"highlight"`
BaseAlpha float64 `json:"baseAlpha"`
}
type GenerateResultJS struct {
Files []string `json:"files"`
}
type StencilResultJS struct {
Files []string `json:"files"`
}
// ======== App ========
type App struct {
ctx context.Context
imageServer *ImageServer
mu sync.RWMutex
enclosureSession *EnclosureSession
cutouts []Cutout
formerLayers []*FormerLayer
stencilFiles []string
}
func NewApp(imageServer *ImageServer) *App {
return &App{
imageServer: imageServer,
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Render and cache the logo (PNG for favicon, SVG for background art)
logoImg := renderSVGNative(formerLogoSVG, 512, 512)
if logoImg != nil {
var buf bytes.Buffer
png.Encode(&buf, logoImg)
a.imageServer.Store("/api/logo.png", buf.Bytes())
}
a.imageServer.Store("/api/logo.svg", formerLogoSVG)
}
// ======== Landing Page ========
func (a *App) GetRecentProjects() []ProjectInfoJS {
entries, err := ListProjects(20)
if err != nil {
return nil
}
var result []ProjectInfoJS
for _, e := range entries {
name := e.Data.ProjectName
if e.Data.Name != "" {
name = e.Data.Name
}
if name == "" {
name = "Untitled"
}
result = append(result, ProjectInfoJS{
ID: e.Data.ID,
Name: name,
Path: e.Path,
Type: e.Type,
CreatedAt: e.ModTime.Format(time.RFC3339),
BoardW: e.Data.BoardW,
BoardH: e.Data.BoardH,
})
}
return result
}
func (a *App) GetLogoDataURL() string {
logoImg := renderSVGNative(formerLogoSVG, 256, 256)
if logoImg == nil {
return ""
}
var buf bytes.Buffer
png.Encode(&buf, logoImg)
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
// ======== File Dialogs ========
func (a *App) SelectFile(title string, patterns string) (string, error) {
var filters []wailsRuntime.FileFilter
if patterns != "" {
filters = append(filters, wailsRuntime.FileFilter{
DisplayName: "Files",
Pattern: patterns,
})
}
return wailsRuntime.OpenFileDialog(a.ctx, wailsRuntime.OpenDialogOptions{
Title: title,
Filters: filters,
})
}
func (a *App) SelectFolder(title string) (string, error) {
return wailsRuntime.OpenDirectoryDialog(a.ctx, wailsRuntime.OpenDialogOptions{
Title: title,
})
}
func (a *App) SelectMultipleFiles(title string, patterns string) ([]string, error) {
var filters []wailsRuntime.FileFilter
if patterns != "" {
filters = append(filters, wailsRuntime.FileFilter{
DisplayName: "Files",
Pattern: patterns,
})
}
return wailsRuntime.OpenMultipleFilesDialog(a.ctx, wailsRuntime.OpenDialogOptions{
Title: title,
Filters: filters,
})
}
// ======== Stencil Workflow ========
func (a *App) GenerateStencil(gerberPath, outlinePath string, height, wallHeight, wallThickness, dpi float64, exports []string) (*StencilResultJS, error) {
if gerberPath == "" {
return nil, fmt.Errorf("no solder paste gerber file selected")
}
cfg := Config{
StencilHeight: height,
WallHeight: wallHeight,
WallThickness: wallThickness,
DPI: dpi,
}
if cfg.StencilHeight == 0 {
cfg.StencilHeight = DefaultStencilHeight
}
if cfg.WallHeight == 0 {
cfg.WallHeight = DefaultWallHeight
}
if cfg.WallThickness == 0 {
cfg.WallThickness = DefaultWallThickness
}
if cfg.DPI == 0 {
cfg.DPI = DefaultDPI
}
if len(exports) == 0 {
exports = []string{"stl"}
}
files, pasteImg, outlineImg, err := processPCB(gerberPath, outlinePath, cfg, exports)
if err != nil {
return nil, err
}
// Store layers for The Former
a.mu.Lock()
a.formerLayers = buildStencilLayers(pasteImg, outlineImg)
a.stencilFiles = files
a.mu.Unlock()
a.prepareFormerImages()
return &StencilResultJS{Files: files}, nil
}
// ======== Enclosure Workflow ========
func (a *App) DiscoverGerberFiles(folderPath string) ([]string, error) {
entries, err := os.ReadDir(folderPath)
if err != nil {
return nil, fmt.Errorf("could not read folder: %v", err)
}
var paths []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.ToLower(entry.Name())
if strings.HasSuffix(name, ".gbr") || strings.HasSuffix(name, ".gbrjob") ||
strings.HasSuffix(name, ".gtp") || strings.HasSuffix(name, ".gbp") ||
strings.HasSuffix(name, ".gko") || strings.HasSuffix(name, ".gm1") ||
strings.HasSuffix(name, ".gtl") || strings.HasSuffix(name, ".gbl") ||
strings.HasSuffix(name, ".gts") || strings.HasSuffix(name, ".gbs") {
paths = append(paths, filepath.Join(folderPath, entry.Name()))
}
}
return paths, nil
}
func (a *App) BuildEnclosureSession(gbrjobPath string, gerberPaths []string, drillPath, npthPath string, wallThickness, wallHeight, clearance, dpi float64, exports []string) error {
if gbrjobPath == "" {
return fmt.Errorf("no gerber job file selected")
}
if len(gerberPaths) == 0 {
return fmt.Errorf("no gerber files selected")
}
ecfg := EnclosureConfig{
WallThickness: wallThickness,
WallHeight: wallHeight,
Clearance: clearance,
DPI: dpi,
}
if ecfg.WallThickness == 0 {
ecfg.WallThickness = DefaultEncWallThick
}
if ecfg.WallHeight == 0 {
ecfg.WallHeight = DefaultEncWallHeight
}
if ecfg.Clearance == 0 {
ecfg.Clearance = DefaultClearance
}
if ecfg.DPI == 0 {
ecfg.DPI = 600
}
if len(exports) == 0 {
exports = []string{"stl", "scad"}
}
os.MkdirAll("temp", 0755)
uuid := randomID()
// Copy gbrjob
gbrjobDst := filepath.Join("temp", uuid+"_"+filepath.Base(gbrjobPath))
if err := CopyFile(gbrjobPath, gbrjobDst); err != nil {
return fmt.Errorf("failed to copy gbrjob: %v", err)
}
// Copy gerbers
savedGerbers := make(map[string]string)
var sourceDir string
for _, src := range gerberPaths {
baseName := filepath.Base(src)
dst := filepath.Join("temp", uuid+"_"+baseName)
if err := CopyFile(src, dst); err != nil {
return fmt.Errorf("failed to copy %s: %v", baseName, err)
}
savedGerbers[baseName] = dst
if sourceDir == "" {
sourceDir = filepath.Dir(src)
}
}
// Copy drill files
var drillDst, npthDst string
if drillPath != "" {
drillDst = filepath.Join("temp", uuid+"_drill"+filepath.Ext(drillPath))
if err := CopyFile(drillPath, drillDst); err != nil {
return fmt.Errorf("failed to copy PTH drill: %v", err)
}
}
if npthPath != "" {
npthDst = filepath.Join("temp", uuid+"_npth"+filepath.Ext(npthPath))
if err := CopyFile(npthPath, npthDst); err != nil {
return fmt.Errorf("failed to copy NPTH drill: %v", err)
}
}
_, session, err := BuildEnclosureSession(gbrjobDst, savedGerbers, drillDst, npthDst, ecfg, exports)
if err != nil {
return fmt.Errorf("session build failed: %v", err)
}
session.SourceDir = sourceDir
a.mu.Lock()
a.enclosureSession = session
a.cutouts = nil
a.mu.Unlock()
// Render board preview
if session.OutlineImg != nil {
var buf bytes.Buffer
png.Encode(&buf, session.OutlineImg)
a.imageServer.Store("/api/board-preview.png", buf.Bytes())
}
return nil
}
func (a *App) GetSessionInfo() *SessionInfoJS {
a.mu.RLock()
defer a.mu.RUnlock()
if a.enclosureSession == nil {
return &SessionInfoJS{HasSession: false}
}
s := a.enclosureSession
result := make([]Cutout, len(a.cutouts))
copy(result, a.cutouts)
return &SessionInfoJS{
ProjectName: s.ProjectName,
BoardW: s.BoardW,
BoardH: s.BoardH,
Sides: s.Sides,
TotalH: s.TotalH,
Cutouts: result,
HasSession: true,
MinX: s.OutlineBounds.MinX,
MaxY: s.OutlineBounds.MaxY,
DPI: s.Config.DPI,
}
}
func (a *App) AddSideCutout(side int, x, y, w, h, radius float64, layer string) {
a.mu.Lock()
defer a.mu.Unlock()
a.cutouts = append(a.cutouts, Cutout{
ID: randomID(),
Surface: "side",
SideNum: side,
X: x,
Y: y,
Width: w,
Height: h,
CornerRadius: radius,
SourceLayer: layer,
})
}
func (a *App) RemoveSideCutout(index int) {
a.mu.Lock()
defer a.mu.Unlock()
// Find the Nth side cutout
count := 0
for i, c := range a.cutouts {
if c.Surface == "side" {
if count == index {
a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...)
return
}
count++
}
}
}
func (a *App) GetSideCutouts() []SideCutout {
a.mu.RLock()
defer a.mu.RUnlock()
var result []SideCutout
for _, c := range a.cutouts {
if c.Surface == "side" {
result = append(result, CutoutToSideCutout(c))
}
}
return result
}
// ======== Unified Cutout CRUD ========
func (a *App) AddCutout(c Cutout) string {
a.mu.Lock()
defer a.mu.Unlock()
if c.ID == "" {
c.ID = randomID()
}
a.cutouts = append(a.cutouts, c)
return c.ID
}
func (a *App) UpdateCutout(c Cutout) {
a.mu.Lock()
defer a.mu.Unlock()
for i, existing := range a.cutouts {
if existing.ID == c.ID {
a.cutouts[i] = c
return
}
}
}
func (a *App) RemoveCutout(id string) {
a.mu.Lock()
defer a.mu.Unlock()
for i, c := range a.cutouts {
if c.ID == id {
a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...)
return
}
}
}
func (a *App) GetCutouts() []Cutout {
a.mu.RLock()
defer a.mu.RUnlock()
result := make([]Cutout, len(a.cutouts))
copy(result, a.cutouts)
return result
}
func (a *App) DuplicateCutout(id string) string {
a.mu.Lock()
defer a.mu.Unlock()
for _, c := range a.cutouts {
if c.ID == id {
dup := c
dup.ID = randomID()
// Offset slightly so it's visible
if dup.Surface == "side" {
dup.X += 1.0
} else {
dup.X += 1.0
dup.Y += 1.0
}
a.cutouts = append(a.cutouts, dup)
return dup.ID
}
}
return ""
}
func (a *App) GetSideLength(sideNum int) float64 {
a.mu.RLock()
defer a.mu.RUnlock()
if a.enclosureSession == nil {
return 0
}
for _, s := range a.enclosureSession.Sides {
if s.Num == sideNum {
return s.Length
}
}
return 0
}
// AddLidCutouts converts element pixel bboxes to mm coordinates and stores them as unified cutouts.
func (a *App) AddLidCutouts(elements []ElementBBox, plane string, isDado bool, depth float64) {
a.mu.Lock()
defer a.mu.Unlock()
if a.enclosureSession == nil {
return
}
bounds := a.enclosureSession.OutlineBounds
dpi := a.enclosureSession.Config.DPI
surface := "top"
if plane == "tray" {
surface = "bottom"
}
for _, el := range elements {
// Convert pixel coordinates to gerber mm coordinates
mmMinX := float64(el.MinX)*(25.4/dpi) + bounds.MinX
mmMaxX := float64(el.MaxX)*(25.4/dpi) + bounds.MinX
mmMinY := bounds.MaxY - float64(el.MaxY)*(25.4/dpi)
mmMaxY := bounds.MaxY - float64(el.MinY)*(25.4/dpi)
a.cutouts = append(a.cutouts, Cutout{
ID: randomID(),
Surface: surface,
X: mmMinX,
Y: mmMinY,
Width: mmMaxX - mmMinX,
Height: mmMaxY - mmMinY,
IsDado: isDado,
Depth: depth,
})
}
}
func (a *App) GetLidCutouts() []LidCutout {
a.mu.RLock()
defer a.mu.RUnlock()
var result []LidCutout
for _, c := range a.cutouts {
if c.Surface == "top" || c.Surface == "bottom" {
result = append(result, CutoutToLidCutout(c))
}
}
return result
}
func (a *App) ClearLidCutouts() {
a.mu.Lock()
defer a.mu.Unlock()
// Remove only top/bottom cutouts, keep side cutouts
var kept []Cutout
for _, c := range a.cutouts {
if c.Surface == "side" {
kept = append(kept, c)
}
}
a.cutouts = kept
}
func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) {
a.mu.RLock()
session := a.enclosureSession
allCutouts := make([]Cutout, len(a.cutouts))
copy(allCutouts, a.cutouts)
a.mu.RUnlock()
if session == nil {
return nil, fmt.Errorf("no enclosure session active")
}
outputDir := session.SourceDir
if outputDir == "" {
outputDir = filepath.Join(".", "temp")
}
files, err := GenerateEnclosureOutputs(session, allCutouts, outputDir)
if err != nil {
return nil, err
}
// Auto-save session
inst := InstanceData{
ID: randomID(),
CreatedAt: time.Now(),
GerberFiles: session.GerberFiles,
DrillPath: session.DrillPath,
NPTHPath: session.NPTHPath,
EdgeCutsFile: session.EdgeCutsFile,
CourtyardFile: session.CourtyardFile,
SoldermaskFile: session.SoldermaskFile,
FabFile: session.FabFile,
Config: session.Config,
Exports: session.Exports,
BoardW: session.BoardW,
BoardH: session.BoardH,
ProjectName: session.ProjectName,
Cutouts: allCutouts,
}
if _, saveErr := SaveSession(inst, filepath.Join(".", "temp"), session.OutlineImg); saveErr != nil {
log.Printf("Warning: could not save session: %v", saveErr)
}
// Prepare Former layers
a.mu.Lock()
a.formerLayers = buildEnclosureLayers(session)
a.mu.Unlock()
a.prepareFormerImages()
return &GenerateResultJS{Files: files}, nil
}
func (a *App) SaveEnclosureProfile(name string) error {
a.mu.RLock()
session := a.enclosureSession
allCutouts := make([]Cutout, len(a.cutouts))
copy(allCutouts, a.cutouts)
a.mu.RUnlock()
if session == nil {
return fmt.Errorf("no enclosure session active")
}
if name == "" {
name = session.ProjectName
}
if name == "" {
name = "Untitled"
}
inst := InstanceData{
ID: randomID(),
Name: name,
CreatedAt: time.Now(),
GerberFiles: session.GerberFiles,
DrillPath: session.DrillPath,
NPTHPath: session.NPTHPath,
EdgeCutsFile: session.EdgeCutsFile,
CourtyardFile: session.CourtyardFile,
SoldermaskFile: session.SoldermaskFile,
FabFile: session.FabFile,
Config: session.Config,
Exports: session.Exports,
BoardW: session.BoardW,
BoardH: session.BoardH,
ProjectName: session.ProjectName,
Cutouts: allCutouts,
}
sourceDir := session.SourceDir
if sourceDir == "" {
sourceDir = filepath.Join(".", "temp")
}
_, err := SaveProfile(inst, name, sourceDir, session.OutlineImg)
return err
}
func (a *App) OpenProject(projectPath string) error {
_, session, inst, err := RestoreProject(projectPath)
if err != nil {
return err
}
a.mu.Lock()
a.enclosureSession = session
a.cutouts = inst.MigrateCutouts()
a.mu.Unlock()
// Render board preview
if session.OutlineImg != nil {
var buf bytes.Buffer
png.Encode(&buf, session.OutlineImg)
a.imageServer.Store("/api/board-preview.png", buf.Bytes())
}
return nil
}
func (a *App) DeleteProject(projectPath string) error {
return DeleteProject(projectPath)
}
// ======== Auto-Detect USB Port ========
type AutoDetectResultJS struct {
Footprints []Footprint `json:"footprints"`
FabImageURL string `json:"fabImageURL"`
}
func (a *App) UploadAndDetectFootprints(fabPaths []string) (*AutoDetectResultJS, error) {
a.mu.RLock()
session := a.enclosureSession
a.mu.RUnlock()
if session == nil {
return nil, fmt.Errorf("no enclosure session active")
}
if len(fabPaths) == 0 {
return nil, fmt.Errorf("no fab gerber files selected")
}
footprints, fabImg := UploadFabAndExtractFootprints(session, fabPaths)
if fabImg != nil {
var buf bytes.Buffer
png.Encode(&buf, fabImg)
a.imageServer.Store("/api/fab-overlay.png", buf.Bytes())
}
return &AutoDetectResultJS{
Footprints: footprints,
FabImageURL: "/api/fab-overlay.png?t=" + fmt.Sprint(time.Now().UnixMilli()),
}, nil
}
// ======== Former ========
type MountingHoleJS struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Diameter float64 `json:"diameter"`
}
type Enclosure3DDataJS struct {
OutlinePoints [][2]float64 `json:"outlinePoints"`
WallThickness float64 `json:"wallThickness"`
Clearance float64 `json:"clearance"`
WallHeight float64 `json:"wallHeight"`
PCBThickness float64 `json:"pcbThickness"`
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
TrayFloor float64 `json:"trayFloor"`
SnapHeight float64 `json:"snapHeight"`
LidThick float64 `json:"lidThick"`
TotalH float64 `json:"totalH"`
MountingHoles []MountingHoleJS `json:"mountingHoles"`
Sides []BoardSide `json:"sides"`
Cutouts []Cutout `json:"cutouts"`
MinBX float64 `json:"minBX"`
MaxBX float64 `json:"maxBX"`
BoardCenterY float64 `json:"boardCenterY"`
}
func (a *App) GetEnclosure3DData() *Enclosure3DDataJS {
a.mu.RLock()
s := a.enclosureSession
allCutouts := make([]Cutout, len(a.cutouts))
copy(allCutouts, a.cutouts)
a.mu.RUnlock()
if s == nil {
return nil
}
poly := ExtractPolygonFromGerber(s.OutlineGf)
if poly == nil {
return nil
}
wt := s.Config.WallThickness
trayFloor := 1.5
pcbT := s.Config.PCBThickness
totalH := s.Config.WallHeight + pcbT + trayFloor
var mountingHoles []MountingHoleJS
for _, h := range s.DrillHoles {
if h.Type == DrillTypeMounting {
mountingHoles = append(mountingHoles, MountingHoleJS{X: h.X, Y: h.Y, Diameter: h.Diameter})
}
}
return &Enclosure3DDataJS{
OutlinePoints: poly,
WallThickness: wt,
Clearance: s.Config.Clearance,
WallHeight: s.Config.WallHeight,
PCBThickness: pcbT,
BoardW: s.BoardW,
BoardH: s.BoardH,
TrayFloor: trayFloor,
SnapHeight: 2.5,
LidThick: wt,
TotalH: totalH,
MountingHoles: mountingHoles,
Sides: s.Sides,
Cutouts: allCutouts,
MinBX: s.MinBX,
MaxBX: s.MaxBX,
BoardCenterY: s.BoardCenterY,
}
}
func (a *App) GetFormerLayers() []LayerInfoJS {
a.mu.RLock()
defer a.mu.RUnlock()
var result []LayerInfoJS
for i, l := range a.formerLayers {
result = append(result, LayerInfoJS{
Index: i,
Name: l.Name,
ColorHex: fmt.Sprintf("#%02x%02x%02x", l.Color.R, l.Color.G, l.Color.B),
Visible: l.Visible,
Highlight: l.Highlight,
BaseAlpha: l.BaseAlpha,
})
}
return result
}
func (a *App) SetLayerVisibility(index int, visible bool) {
a.mu.Lock()
defer a.mu.Unlock()
if index >= 0 && index < len(a.formerLayers) {
a.formerLayers[index].Visible = visible
if !visible {
a.formerLayers[index].Highlight = false
}
}
}
func (a *App) ToggleHighlight(index int) {
a.mu.Lock()
defer a.mu.Unlock()
if index < 0 || index >= len(a.formerLayers) {
return
}
if a.formerLayers[index].Highlight {
a.formerLayers[index].Highlight = false
} else {
for i := range a.formerLayers {
a.formerLayers[i].Highlight = false
}
a.formerLayers[index].Highlight = true
a.formerLayers[index].Visible = true
}
}
func (a *App) GetLayerElements(layerIndex int) ([]ElementBBox, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if layerIndex < 0 || layerIndex >= len(a.formerLayers) {
return nil, fmt.Errorf("layer index %d out of range", layerIndex)
}
layer := a.formerLayers[layerIndex]
if layer.SourceFile == "" {
return nil, fmt.Errorf("no source file for layer %q", layer.Name)
}
if a.enclosureSession == nil {
return nil, fmt.Errorf("no enclosure session active")
}
gf, ok := a.enclosureSession.AllLayerGerbers[layer.SourceFile]
if !ok || gf == nil {
return nil, fmt.Errorf("parsed gerber not available for %q", layer.SourceFile)
}
bounds := a.enclosureSession.OutlineBounds
dpi := a.enclosureSession.Config.DPI
elements := ExtractElementBBoxes(gf, dpi, &bounds)
return elements, nil
}
func (a *App) OpenFormerEnclosure() {
a.mu.Lock()
if a.enclosureSession != nil {
a.formerLayers = buildEnclosureLayers(a.enclosureSession)
}
a.mu.Unlock()
a.prepareFormerImages()
}
func (a *App) prepareFormerImages() {
a.mu.RLock()
layers := a.formerLayers
a.mu.RUnlock()
for i, layer := range layers {
if layer.Source == nil {
continue
}
// Colorize at full alpha — frontend controls opacity via canvas globalAlpha
colored := colorizeLayer(layer.Source, layer.Color, 1.0)
var buf bytes.Buffer
png.Encode(&buf, colored)
a.imageServer.Store(fmt.Sprintf("/api/layers/%d.png", i), buf.Bytes())
}
}
// RenderFromFormer generates output files (SCAD, SVG, etc.) from the current session.
// Called from the Former 3D editor's "Render & View" button.
func (a *App) RenderFromFormer() (*GenerateResultJS, error) {
return a.GenerateEnclosureOutputs()
}
// GetOutputDir returns the output directory path for the current session.
func (a *App) GetOutputDir() (string, error) {
a.mu.RLock()
session := a.enclosureSession
a.mu.RUnlock()
if session == nil {
return "", fmt.Errorf("no enclosure session active")
}
outputDir := session.SourceDir
if outputDir == "" {
outputDir = filepath.Join(".", "temp")
}
absDir, err := filepath.Abs(outputDir)
if err != nil {
return outputDir, nil
}
return absDir, nil
}
// OpenOutputFolder opens the output directory in the OS file manager.
func (a *App) OpenOutputFolder() error {
dir, err := a.GetOutputDir()
if err != nil {
return err
}
return exec.Command("open", dir).Start()
}

Binary file not shown.

12
build.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
pkill -f Former || true
export SDKROOT=$(xcrun --show-sdk-path)
export CC=/usr/bin/clang
export CGO_ENABLED=1
# Generate app icon from Former.svg
echo "Generating app icon..."
go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped."
~/go/bin/wails build -skipbindings
open build/bin/Former.app

130
cmd/genicon/main.go Normal file
View File

@ -0,0 +1,130 @@
package main
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
#import <AppKit/AppKit.h>
#import <stdlib.h>
unsigned char* renderSVGToPixels(const void* svgBytes, int svgLen, int targetW, int targetH) {
@autoreleasepool {
NSData *data = [NSData dataWithBytesNoCopy:(void*)svgBytes length:svgLen freeWhenDone:NO];
NSImage *svgImage = [[NSImage alloc] initWithData:data];
if (!svgImage) return NULL;
int w = targetW;
int h = targetH;
int rowBytes = w * 4;
int totalBytes = rowBytes * h;
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc]
initWithBitmapDataPlanes:NULL
pixelsWide:w
pixelsHigh:h
bitsPerSample:8
samplesPerPixel:4
hasAlpha:YES
isPlanar:NO
colorSpaceName:NSDeviceRGBColorSpace
bytesPerRow:rowBytes
bitsPerPixel:32];
[NSGraphicsContext saveGraphicsState];
NSGraphicsContext *ctx = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep];
[NSGraphicsContext setCurrentContext:ctx];
[[NSColor clearColor] set];
NSRectFill(NSMakeRect(0, 0, w, h));
[svgImage drawInRect:NSMakeRect(0, 0, w, h)
fromRect:NSZeroRect
operation:NSCompositingOperationSourceOver
fraction:1.0];
[NSGraphicsContext restoreGraphicsState];
unsigned char* result = (unsigned char*)malloc(totalBytes);
if (result) {
memcpy(result, [rep bitmapData], totalBytes);
}
return result;
}
}
*/
import "C"
import (
"fmt"
"image"
"image/color"
"image/png"
"os"
"unsafe"
)
func main() {
svgPath := "static/Former.svg"
outPath := "build/appicon.png"
size := 1024
svgData, err := os.ReadFile(svgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to read SVG: %v\n", err)
os.Exit(1)
}
pixels := C.renderSVGToPixels(
unsafe.Pointer(&svgData[0]),
C.int(len(svgData)),
C.int(size),
C.int(size),
)
if pixels == nil {
fmt.Fprintln(os.Stderr, "SVG rendering failed")
os.Exit(1)
}
defer C.free(unsafe.Pointer(pixels))
rawLen := size * size * 4
raw := unsafe.Slice((*byte)(unsafe.Pointer(pixels)), rawLen)
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
i := (y*size + x) * 4
r, g, b, a := raw[i], raw[i+1], raw[i+2], raw[i+3]
if a > 0 && a < 255 {
scale := 255.0 / float64(a)
r = clamp(float64(r) * scale)
g = clamp(float64(g) * scale)
b = clamp(float64(b) * scale)
}
img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
}
}
f, err := os.Create(outPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create output: %v\n", err)
os.Exit(1)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
fmt.Fprintf(os.Stderr, "PNG encode failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated %s (%dx%d) from %s\n", outPath, size, size, svgPath)
}
func clamp(v float64) uint8 {
if v > 255 {
return 255
}
if v < 0 {
return 0
}
return uint8(v)
}

View File

@ -9,12 +9,12 @@ import (
// EnclosureConfig holds parameters for enclosure generation
type EnclosureConfig struct {
PCBThickness float64 // mm
WallThickness float64 // mm
WallHeight float64 // mm (height of walls above PCB)
Clearance float64 // mm (gap between PCB and enclosure wall)
DPI float64
OutlineBounds *Bounds // gerber coordinate bounds for drill mapping
PCBThickness float64 `json:"pcbThickness"`
WallThickness float64 `json:"wallThickness"`
WallHeight float64 `json:"wallHeight"`
Clearance float64 `json:"clearance"`
DPI float64 `json:"dpi"`
OutlineBounds *Bounds `json:"-"`
}
// Default enclosure values
@ -33,11 +33,85 @@ type EnclosureResult struct {
// SideCutout defines a cutout on a side wall face
type SideCutout struct {
Side int // 1-indexed side number (matches BoardSide.Num)
X, Y float64 // Position on the face in mm (from StartX/StartY, from bottom)
Width float64 // Width in mm
Height float64 // Height in mm
CornerRadius float64 // Corner radius in mm (0 for square)
Side int `json:"side"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"w"`
Height float64 `json:"h"`
CornerRadius float64 `json:"r"`
Layer string `json:"l"`
}
// LidCutout defines a cutout on the lid or tray plane (top/bottom flat surfaces)
type LidCutout struct {
ID int `json:"id"`
Plane string `json:"plane"` // "lid" or "tray"
MinX float64 `json:"minX"` // gerber mm coordinates
MinY float64 `json:"minY"`
MaxX float64 `json:"maxX"`
MaxY float64 `json:"maxY"`
IsDado bool `json:"isDado"`
Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut
}
// Cutout is the unified cutout type — replaces separate SideCutout/LidCutout.
type Cutout struct {
ID string `json:"id"`
Surface string `json:"surface"` // "top", "bottom", "side"
SideNum int `json:"sideNum"` // only when Surface="side"
X float64 `json:"x"` // side: mm along side; top/bottom: gerber mm minX
Y float64 `json:"y"` // side: mm height from PCB; top/bottom: gerber mm minY
Width float64 `json:"w"`
Height float64 `json:"h"`
CornerRadius float64 `json:"r"`
IsDado bool `json:"isDado"`
Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut
SourceLayer string `json:"sourceLayer"` // "F" or "B" for side cutouts
}
// CutoutToSideCutout converts a unified Cutout (surface="side") to legacy SideCutout
func CutoutToSideCutout(c Cutout) SideCutout {
return SideCutout{
Side: c.SideNum,
X: c.X,
Y: c.Y,
Width: c.Width,
Height: c.Height,
CornerRadius: c.CornerRadius,
Layer: c.SourceLayer,
}
}
// CutoutToLidCutout converts a unified Cutout (surface="top"/"bottom") to legacy LidCutout
func CutoutToLidCutout(c Cutout) LidCutout {
plane := "lid"
if c.Surface == "bottom" {
plane = "tray"
}
return LidCutout{
Plane: plane,
MinX: c.X,
MinY: c.Y,
MaxX: c.X + c.Width,
MaxY: c.Y + c.Height,
IsDado: c.IsDado,
Depth: c.Depth,
}
}
// SplitCutouts partitions unified cutouts into side and lid slices for SCAD/STL generation.
func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) {
var sides []SideCutout
var lids []LidCutout
for _, c := range cutouts {
switch c.Surface {
case "side":
sides = append(sides, CutoutToSideCutout(c))
case "top", "bottom":
lids = append(lids, CutoutToLidCutout(c))
}
}
return sides, lids
}
// BoardSide represents a physical straight edge of the board outline
@ -657,9 +731,9 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
minZ += trayFloor + pcbT
maxZ += trayFloor + pcbT
// Wall below cutout: from 0 to minZ
if minZ > 0.05 {
addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, minZ)
// Wall below cutout: from trayFloor to minZ (preserve enclosure floor)
if minZ > trayFloor+0.3 {
addBoxAtZ(&cutoutEncTris, bx2, by2, trayFloor, bw, bh, minZ-trayFloor)
}
// Wall above cutout: from maxZ to totalH
if maxZ < totalH-0.05 {
@ -697,7 +771,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
by2 := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
AddBox(&newEncTris, bx, by2, bw, bh, totalH)
addBoxAtZ(&newEncTris, bx, by2, trayFloor, bw, bh, totalH-trayFloor)
runStart = -1
}
}

412
former.go Normal file
View File

@ -0,0 +1,412 @@
package main
import (
"image"
"image/color"
"strings"
)
// KiCad-standard layer colors (NRGBA, no alpha — alpha is controlled by BaseAlpha)
var (
ColorFCu = color.NRGBA{R: 200, G: 52, B: 52, A: 255}
ColorBCu = color.NRGBA{R: 77, G: 127, B: 196, A: 255}
ColorFPaste = color.NRGBA{R: 200, G: 52, B: 52, A: 255}
ColorBPaste = color.NRGBA{R: 0, G: 194, B: 194, A: 255}
ColorFMask = color.NRGBA{R: 132, G: 0, B: 132, A: 255}
ColorBMask = color.NRGBA{R: 0, G: 132, B: 132, A: 255}
ColorFSilkS = color.NRGBA{R: 232, G: 232, B: 232, A: 255}
ColorBSilkS = color.NRGBA{R: 200, G: 200, B: 200, A: 255}
ColorFab = color.NRGBA{R: 132, G: 132, B: 132, A: 255}
ColorEdgeCuts = color.NRGBA{R: 200, G: 200, B: 0, A: 255}
ColorCrtYd = color.NRGBA{R: 194, G: 194, B: 194, A: 255}
ColorEnclosure = color.NRGBA{R: 255, G: 253, B: 230, A: 255}
ColorStencil = color.NRGBA{R: 180, G: 180, B: 180, A: 255}
)
// FormerLayer represents a single displayable layer in The Former.
type FormerLayer struct {
Name string
Color color.NRGBA
Source image.Image // raw white-on-black gerber render
Visible bool
Highlight bool
BaseAlpha float64 // default opacity 0.01.0 (all layers slightly transparent)
SourceFile string // original gerber filename (key into AllLayerGerbers)
}
// ElementBBox describes a selectable graphic element's bounding box on a layer.
type ElementBBox struct {
ID int `json:"id"`
MinX float64 `json:"minX"`
MinY float64 `json:"minY"`
MaxX float64 `json:"maxX"`
MaxY float64 `json:"maxY"`
Type string `json:"type"`
Footprint string `json:"footprint"`
}
// ExtractElementBBoxes walks gerber commands and returns bounding boxes in image pixel coordinates.
func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []ElementBBox {
if gf == nil {
return nil
}
mmToPx := func(mmX, mmY float64) (float64, float64) {
px := (mmX - bounds.MinX) * dpi / 25.4
py := (bounds.MaxY - mmY) * dpi / 25.4
return px, py
}
apertureRadius := func(dcode int) float64 {
if ap, ok := gf.State.Apertures[dcode]; ok && len(ap.Modifiers) > 0 {
return ap.Modifiers[0] / 2.0
}
return 0.25
}
var elements []ElementBBox
id := 0
curX, curY := 0.0, 0.0
curDCode := 0
for _, cmd := range gf.Commands {
switch cmd.Type {
case "APERTURE":
if cmd.D != nil {
curDCode = *cmd.D
}
continue
case "G01", "G02", "G03", "G36", "G37":
continue
}
prevX, prevY := curX, curY
if cmd.X != nil {
curX = *cmd.X
}
if cmd.Y != nil {
curY = *cmd.Y
}
switch cmd.Type {
case "FLASH": // D03
r := apertureRadius(curDCode)
px, py := mmToPx(curX, curY)
rpx := r * dpi / 25.4
elements = append(elements, ElementBBox{
ID: id,
MinX: px - rpx,
MinY: py - rpx,
MaxX: px + rpx,
MaxY: py + rpx,
Type: "pad",
Footprint: cmd.Footprint,
})
id++
case "DRAW": // D01
r := apertureRadius(curDCode)
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
}
elements = append(elements, ElementBBox{
ID: id,
MinX: minPx - rpx,
MinY: minPy - rpx,
MaxX: maxPx + rpx,
MaxY: maxPy + rpx,
Type: "trace",
Footprint: cmd.Footprint,
})
id++
}
}
return elements
}
// colorizeLayer converts a white-on-black source image into a colored NRGBA image.
// Bright pixels become the layer color at the given alpha; dark pixels become transparent.
func colorizeLayer(src image.Image, col color.NRGBA, alpha float64) *image.NRGBA {
bounds := src.Bounds()
w := bounds.Dx()
h := bounds.Dy()
dst := image.NewNRGBA(image.Rect(0, 0, w, h))
a := uint8(alpha * 255)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, _, _, _ := src.At(x+bounds.Min.X, y+bounds.Min.Y).RGBA()
// r is 065535; treat anything above ~10% as "active"
if r > 6500 {
dst.SetNRGBA(x, y, color.NRGBA{R: col.R, G: col.G, B: col.B, A: a})
}
// else: stays transparent (zero value)
}
}
return dst
}
// composeLayers blends all visible layers into a single image.
// Background matches the app theme. If any layer has Highlight=true,
// that layer renders at full BaseAlpha while others are dimmed.
func composeLayers(layers []*FormerLayer, width, height int) *image.NRGBA {
dst := image.NewNRGBA(image.Rect(0, 0, width, height))
// Fill with theme background
bg := color.NRGBA{R: 52, G: 53, B: 60, A: 255}
for i := 0; i < width*height*4; i += 4 {
dst.Pix[i+0] = bg.R
dst.Pix[i+1] = bg.G
dst.Pix[i+2] = bg.B
dst.Pix[i+3] = bg.A
}
// Check if any layer is highlighted
hasHighlight := false
for _, l := range layers {
if l.Highlight && l.Visible {
hasHighlight = true
break
}
}
for _, l := range layers {
if !l.Visible || l.Source == nil {
continue
}
alpha := l.BaseAlpha
if hasHighlight && !l.Highlight {
alpha *= 0.3 // dim non-highlighted layers
}
colored := colorizeLayer(l.Source, l.Color, alpha)
// Alpha-blend colored layer onto dst
srcBounds := colored.Bounds()
for y := 0; y < height && y < srcBounds.Dy(); y++ {
for x := 0; x < width && x < srcBounds.Dx(); x++ {
si := (y*srcBounds.Dx() + x) * 4
di := (y*width + x) * 4
sa := float64(colored.Pix[si+3]) / 255.0
if sa == 0 {
continue
}
sr := float64(colored.Pix[si+0])
sg := float64(colored.Pix[si+1])
sb := float64(colored.Pix[si+2])
dr := float64(dst.Pix[di+0])
dg := float64(dst.Pix[di+1])
db := float64(dst.Pix[di+2])
inv := 1.0 - sa
dst.Pix[di+0] = uint8(sr*sa + dr*inv)
dst.Pix[di+1] = uint8(sg*sa + dg*inv)
dst.Pix[di+2] = uint8(sb*sa + db*inv)
dst.Pix[di+3] = 255
}
}
}
return dst
}
// layerInfo holds the display name and color for a KiCad layer.
type layerInfo struct {
Name string
Color color.NRGBA
DefaultOn bool // visible by default
Alpha float64 // base alpha
SortOrder int // lower = drawn first (bottom)
}
// inferLayer maps a gerber filename to its KiCad layer name, color, and defaults.
func inferLayer(filename string) layerInfo {
lf := strings.ToLower(filename)
switch {
case strings.Contains(lf, "edge_cuts") || strings.Contains(lf, "edge.cuts"):
return layerInfo{"Edge Cuts", ColorEdgeCuts, true, 0.7, 10}
case strings.Contains(lf, "f_cu") || strings.Contains(lf, "f.cu") || strings.Contains(lf, "-gtl"):
return layerInfo{"F.Cu", ColorFCu, false, 0.7, 20}
case strings.Contains(lf, "b_cu") || strings.Contains(lf, "b.cu") || strings.Contains(lf, "-gbl"):
return layerInfo{"B.Cu", ColorBCu, false, 0.7, 21}
case (strings.Contains(lf, "in1") || strings.Contains(lf, "in_1")) && strings.Contains(lf, "cu"):
return layerInfo{"In1.Cu", color.NRGBA{R: 200, G: 160, B: 52, A: 255}, false, 0.7, 22}
case (strings.Contains(lf, "in2") || strings.Contains(lf, "in_2")) && strings.Contains(lf, "cu"):
return layerInfo{"In2.Cu", color.NRGBA{R: 200, G: 52, B: 200, A: 255}, false, 0.7, 23}
case strings.Contains(lf, "f_paste") || strings.Contains(lf, "f.paste") || strings.Contains(lf, "-gtp"):
return layerInfo{"F.Paste", ColorFPaste, false, 0.7, 30}
case strings.Contains(lf, "b_paste") || strings.Contains(lf, "b.paste") || strings.Contains(lf, "-gbp"):
return layerInfo{"B.Paste", ColorBPaste, false, 0.7, 31}
case strings.Contains(lf, "f_silks") || strings.Contains(lf, "f.silks") || strings.Contains(lf, "f_silk"):
return layerInfo{"F.SilkS", ColorFSilkS, false, 0.7, 40}
case strings.Contains(lf, "b_silks") || strings.Contains(lf, "b.silks") || strings.Contains(lf, "b_silk"):
return layerInfo{"B.SilkS", ColorBSilkS, false, 0.7, 41}
case strings.Contains(lf, "f_mask") || strings.Contains(lf, "f.mask") || strings.Contains(lf, "-gts"):
return layerInfo{"F.Mask", ColorFMask, false, 0.6, 50}
case strings.Contains(lf, "b_mask") || strings.Contains(lf, "b.mask") || strings.Contains(lf, "-gbs"):
return layerInfo{"B.Mask", ColorBMask, false, 0.6, 51}
case strings.Contains(lf, "f_courtyard") || strings.Contains(lf, "f_crtyd") || strings.Contains(lf, "f.crtyd"):
return layerInfo{"F.CrtYd", ColorCrtYd, false, 0.6, 60}
case strings.Contains(lf, "b_courtyard") || strings.Contains(lf, "b_crtyd") || strings.Contains(lf, "b.crtyd"):
return layerInfo{"B.CrtYd", ColorCrtYd, false, 0.6, 61}
case strings.Contains(lf, "f_fab") || strings.Contains(lf, "f.fab"):
return layerInfo{"F.Fab", ColorFab, false, 0.6, 70}
case strings.Contains(lf, "b_fab") || strings.Contains(lf, "b.fab"):
return layerInfo{"B.Fab", ColorFab, false, 0.6, 71}
case strings.Contains(lf, ".gbrjob"):
return layerInfo{} // skip job files
default:
return layerInfo{filename, color.NRGBA{R: 160, G: 160, B: 160, A: 255}, false, 0.6, 100}
}
}
// renderEnclosureWallImage generates a 2D top-down image of the enclosure walls
// from the outline image and config. White pixels = wall area.
func renderEnclosureWallImage(outlineImg image.Image, cfg EnclosureConfig) image.Image {
bounds := outlineImg.Bounds()
w := bounds.Dx()
h := bounds.Dy()
pixelToMM := 25.4 / cfg.DPI
wallDist, boardMask := ComputeWallMask(outlineImg, cfg.WallThickness+cfg.Clearance, pixelToMM)
wallThickPx := int(cfg.WallThickness / pixelToMM)
dst := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
idx := y*w + x
// Wall area: outside the board, within wall thickness distance
if !boardMask[idx] && wallDist[idx] > 0 && wallDist[idx] <= wallThickPx {
dst.Pix[idx*4+0] = 255
dst.Pix[idx*4+1] = 255
dst.Pix[idx*4+2] = 255
dst.Pix[idx*4+3] = 255
}
}
}
return dst
}
// buildStencilLayers creates FormerLayer slice for the stencil workflow.
func buildStencilLayers(pasteImg, outlineImg image.Image) []*FormerLayer {
var layers []*FormerLayer
if outlineImg != nil {
layers = append(layers, &FormerLayer{
Name: "Edge Cuts",
Color: ColorEdgeCuts,
Source: outlineImg,
Visible: true,
BaseAlpha: 0.7,
})
}
if pasteImg != nil {
layers = append(layers, &FormerLayer{
Name: "Solder Paste",
Color: ColorFPaste,
Source: pasteImg,
Visible: true,
BaseAlpha: 0.8,
})
}
return layers
}
// buildEnclosureLayers creates FormerLayer slice for the enclosure workflow
// using ALL uploaded gerber layers, not just the ones with special roles.
func buildEnclosureLayers(session *EnclosureSession) []*FormerLayer {
type sortedLayer struct {
layer *FormerLayer
order int
}
var sorted []sortedLayer
// Tray — hidden by default, rendered as 3D geometry in the Former
if session.EnclosureWallImg != nil {
sorted = append(sorted, sortedLayer{
layer: &FormerLayer{
Name: "Tray",
Color: color.NRGBA{R: 180, G: 200, B: 160, A: 255},
Source: session.EnclosureWallImg, // placeholder image
Visible: false,
BaseAlpha: 0.4,
},
order: -1,
})
}
// Enclosure walls — bottom layer, very transparent
if session.EnclosureWallImg != nil {
sorted = append(sorted, sortedLayer{
layer: &FormerLayer{
Name: "Enclosure",
Color: ColorEnclosure,
Source: session.EnclosureWallImg,
Visible: true,
BaseAlpha: 0.35,
},
order: 0,
})
}
// All gerber layers from uploaded files
for origName, img := range session.AllLayerImages {
if img == nil {
continue
}
info := inferLayer(origName)
if info.Name == "" {
continue
}
sorted = append(sorted, sortedLayer{
layer: &FormerLayer{
Name: info.Name,
Color: info.Color,
Source: img,
Visible: info.DefaultOn,
BaseAlpha: info.Alpha,
SourceFile: origName,
},
order: info.SortOrder,
})
}
// Sort by order (lower = bottom)
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[j].order < sorted[i].order {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
layers := make([]*FormerLayer, len(sorted))
for i, s := range sorted {
layers[i] = s.layer
}
return layers
}

33
frontend/index.html Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Former</title>
<link rel="stylesheet" href="/src/style.css">
</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>
<div class="nav-spacer"></div>
<button class="nav-btn" onclick="navigate('stencil')">Stencil</button>
<button class="nav-btn" onclick="navigate('enclosure')">Enclosure</button>
<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-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-former" class="page page-former"></section>
</main>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1113
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
frontend/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "former-frontend",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^6.0.0"
},
"dependencies": {
"three": "^0.183.1"
}
}

1
frontend/package.json.md5 Executable file
View File

@ -0,0 +1 @@
98e9f2b9e6d5bad224e73bd97622e3b9

873
frontend/src/former3d.js Normal file
View File

@ -0,0 +1,873 @@
// Former 3D — Three.js layer viewer with orbit controls, layer selection, cutout tools, and grid
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const Z_SPACING = 3;
export class Former3D {
constructor(container) {
this.container = container;
this.layers = [];
this.layerMeshes = [];
this.selectedLayerIndex = -1;
this.cutoutMode = false;
this.elements = [];
this.elementMeshes = [];
this.hoveredElement = -1;
this.cutouts = [];
this.enclosureMesh = null;
this.trayMesh = null;
this.enclosureLayerIndex = -1;
this.trayLayerIndex = -1;
this._onLayerSelect = null;
this._onCutoutSelect = null;
this._onCutoutHover = null;
this._initScene();
this._initControls();
this._initGrid();
this._initRaycasting();
this._animate();
}
_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.scene.add(new THREE.AmbientLight(0xffffff, 0.9));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight.position.set(50, -50, 100);
this.scene.add(dirLight);
this.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.selectionOutline = null;
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.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.DOLLY
};
this.controls.target.set(0, 0, 0);
this.controls.update();
}
_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 };
// Start rectangle selection in dado mode (left button only)
if (this.cutoutMode && this.isDadoMode && e.button === 0) {
this._rectSelecting = true;
this._rectStart = { x: e.clientX, y: e.clientY };
// Create overlay div
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);
}
}
});
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;
}
// 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';
}
if (this.cutoutMode && this.elementMeshes.length > 0) {
this._updateMouse(e);
this.raycaster.setFromCamera(this.mouse, this.camera);
const hits = this.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 (this._onCutoutHover) this._onCutoutHover(newHover);
}
}
});
canvas.addEventListener('mouseup', e => {
if (this._rectSelecting && this._rectStart && this._isDragging) {
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) {
// Select all elements whose projected center falls within the rectangle
for (let i = 0; i < this.elementMeshes.length; i++) {
const m = this.elementMeshes[i];
if (m.userData.selected) continue;
// Project mesh center to screen
const pos = m.position.clone();
pos.project(this.camera);
const rect = this.renderer.domElement.getBoundingClientRect();
const sx = (pos.x * 0.5 + 0.5) * rect.width + rect.left;
const sy = (-pos.y * 0.5 + 0.5) * rect.height + rect.top;
if (sx >= x1 && sx <= x2 && sy >= y1 && sy <= y2) {
m.userData.selected = true;
m.material.color.setHex(0xf9e2af);
m.material.opacity = 0.7;
this.cutouts.push(this.elements[i]);
if (this._onCutoutSelect) this._onCutoutSelect(this.elements[i], true);
}
}
}
}
this._rectSelecting = false;
this._rectStart = null;
if (this._rectOverlay) this._rectOverlay.style.display = 'none';
});
canvas.addEventListener('click', e => {
if (this._isDragging) return;
this._updateMouse(e);
this.raycaster.setFromCamera(this.mouse, this.camera);
if (this.cutoutMode) {
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 (this._onCutoutSelect) this._onCutoutSelect(el, m.userData.selected);
}
} else {
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) {
// For enclosure mesh, check ancestors
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.enclosureMesh = null;
this.trayMesh = null;
this.enclosureLayerIndex = -1;
this.trayLayerIndex = -1;
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); // placeholder, replaced by loadEnclosureGeometry
continue;
}
if (layer.name === 'Tray') {
this.trayLayerIndex = i;
this.layerMeshes.push(null); // placeholder, replaced by loadEnclosureGeometry
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);
}
}
// ===== 3D Enclosure geometry =====
// Creates full enclosure + tray geometry from the same parameters as the SCAD output.
loadEnclosureGeometry(encData, dpi, minX, maxY) {
if (!encData || !encData.outlinePoints || encData.outlinePoints.length < 3) return;
// Remove previous meshes
this._disposeEnclosureMeshes();
const s = dpi / 25.4; // mm to pixels
// Convert mm to 3D pixel-space coordinates (Y inverted for image space)
const toPixel = (mmX, mmY) => [
(mmX - minX) * s,
-(maxY - mmY) * s
];
const pts = encData.outlinePoints.map(p => toPixel(p[0], p[1]));
// Compute winding and offset function
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; // outward offset sign
const offsetPoly = (points, dist) => {
const n = points.length;
const result = [];
const maxMiter = Math.abs(dist) * 2; // clamp miter to 2x offset (bevel-style at sharp corners)
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;
// Clamp: if corner is too sharp, insert bevel (two points)
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 };
};
// Key dimensions from SCAD (converted to pixels for XY, raw mm for Z which we scale)
const cl = encData.clearance;
const wt = encData.wallThickness;
const trayFloor = encData.trayFloor;
const snapH = encData.snapHeight;
const lidThick = encData.lidThick;
const totalH = encData.totalH;
// Pre-compute offset polygons in pixel space
const polyInner = offsetPoly(pts, sign * cl * s); // offset(clearance)
const polyTrayWall = offsetPoly(pts, sign * (cl + wt) * s); // offset(clearance + wt)
const polyOuter = offsetPoly(pts, sign * (cl + 2 * wt) * s); // offset(clearance + 2*wt)
const enclosureParts = [];
const trayParts = [];
// ===== ENCLOSURE (lid-on-top piece) =====
// Small epsilon to prevent Z-fighting at shared boundaries
const eps = 0.05 * s;
// 1. Lid plate: solid from totalH-lidThick to totalH
enclosureParts.push(makeSolid(polyOuter, lidThick * s, (totalH - lidThick) * s + eps));
// 2. Upper wall ring: outer to inner, from trayFloor+snapH to totalH-lidThick
const upperWallH = totalH - lidThick - (trayFloor + snapH);
if (upperWallH > 0.1) {
enclosureParts.push(makeRing(polyOuter, polyInner, upperWallH * s - eps, (trayFloor + snapH) * s + eps));
}
// 3. Lower wall ring: outer to trayWall, from trayFloor to trayFloor+snapH
// (wider inner cavity for tray snap-fit recess)
if (snapH > 0.1) {
enclosureParts.push(makeRing(polyOuter, polyTrayWall, snapH * s - eps, trayFloor * s));
}
// 4. Mounting pegs (cylinders)
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); // align cylinder with Z axis
enclosureParts.push({ geo: cylGeo, zPos: pegH / 2, cx: px, cy: py, isCyl: true });
}
}
// ===== TRAY =====
// 1. Tray floor: solid, from 0 to trayFloor
trayParts.push(makeSolid(polyOuter, trayFloor * s, 0));
// 2. Tray inner wall: ring from trayWall to inner, from trayFloor to trayFloor+snapH
if (snapH > 0.1) {
trayParts.push(makeRing(polyTrayWall, polyInner, snapH * s - eps, trayFloor * s + eps));
}
// Build enclosure group mesh
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 (this.enclosureLayerIndex >= 0) {
encGroup.position.z = this.enclosureLayerIndex * Z_SPACING;
encGroup.userData = { layerIndex: this.enclosureLayerIndex, isEnclosure: true };
const layer = this.layers[this.enclosureLayerIndex];
encGroup.visible = layer ? layer.visible : true;
this.enclosureMesh = encGroup;
this.layerMeshes[this.enclosureLayerIndex] = encGroup;
this.layerGroup.add(encGroup);
}
// Build tray group mesh
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 (this.trayLayerIndex >= 0) {
trayGroup.position.z = this.trayLayerIndex * Z_SPACING;
trayGroup.userData = { layerIndex: this.trayLayerIndex, isEnclosure: true };
const layer = this.layers[this.trayLayerIndex];
trayGroup.visible = layer ? layer.visible : false;
this.trayMesh = trayGroup;
this.layerMeshes[this.trayLayerIndex] = trayGroup;
this.layerGroup.add(trayGroup);
}
}
_disposeEnclosureMeshes() {
for (const mesh of [this.enclosureMesh, this.trayMesh]) {
if (mesh) {
this.layerGroup.remove(mesh);
mesh.traverse(c => {
if (c.geometry) c.geometry.dispose();
if (c.material) c.material.dispose();
});
}
}
this.enclosureMesh = null;
this.trayMesh = null;
}
// ===== 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) return;
// Selection outline (skip for enclosure — too complex)
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);
}
// Z-axis arrows
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);
}
}
// ===== Cutout mode =====
enterCutoutMode(elements, layerIndex, isDado = false) {
this.cutoutMode = true;
this.isDadoMode = isDado;
this.elements = elements;
this.hoveredElement = -1;
this.cutouts = [];
this._rectSelecting = false;
this._rectStart = null;
this._rectOverlay = null;
while (this.elementGroup.children.length) {
const c = this.elementGroup.children[0];
c.geometry?.dispose();
c.material?.dispose();
this.elementGroup.remove(c);
}
this.elementMeshes = [];
const layerMesh = this.layerMeshes[layerIndex];
const layerZ = layerMesh ? layerMesh.position.z : 0;
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 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 };
this.elementGroup.add(mesh);
this.elementMeshes.push(mesh);
}
this.elementGroup.visible = true;
this._homeTopDown(layerIndex);
}
exitCutoutMode() {
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;
}
this.elementGroup.visible = false;
while (this.elementGroup.children.length) {
const c = this.elementGroup.children[0];
c.geometry?.dispose();
c.material?.dispose();
this.elementGroup.remove(c);
}
this.elementMeshes = [];
}
// ===== 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, pos.z + dist);
this.camera.up.set(0, 1, 0);
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, cz + dist);
this.camera.up.set(0, 1, 0);
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();
}
// Switch to solid render preview: show only enclosure+tray opaque, hide all other layers
enterSolidView() {
this._savedVisibility = this.layers.map(l => l.visible);
for (let i = 0; i < this.layers.length; i++) {
const mesh = this.layerMeshes[i];
if (!mesh) continue;
if (i === this.enclosureLayerIndex || i === this.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;
}
}
this.selectLayer(-1);
this.gridHelper.visible = false;
this.scene.background = new THREE.Color(0x1e1e2e);
this.resetView();
}
// Return from solid view to normal editor
exitSolidView() {
this.scene.background = new THREE.Color(0x000000);
this.gridHelper.visible = this.gridVisible;
for (let i = 0; i < this.layers.length; i++) {
const mesh = this.layerMeshes[i];
if (!mesh) continue;
const wasVisible = this._savedVisibility ? this._savedVisibility[i] : this.layers[i].visible;
this.layers[i].visible = wasVisible;
mesh.visible = wasVisible;
if (i === this.enclosureLayerIndex || i === this.trayLayerIndex) {
const baseOpacity = i === this.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;
}
});
}
}
this._savedVisibility = null;
this.resetView();
}
// Callbacks
onLayerSelect(cb) { this._onLayerSelect = cb; }
onCutoutSelect(cb) { this._onCutoutSelect = cb; }
onCutoutHover(cb) { this._onCutoutHover = cb; }
dispose() {
if (this._animId) cancelAnimationFrame(this._animId);
if (this._resizeObserver) this._resizeObserver.disconnect();
if (this._rectOverlay) {
this._rectOverlay.remove();
this._rectOverlay = null;
}
this.controls.dispose();
this.renderer.dispose();
if (this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
}
}

1441
frontend/src/main.js Normal file

File diff suppressed because it is too large Load Diff

700
frontend/src/style.css Normal file
View File

@ -0,0 +1,700 @@
/* Former — Dark Theme */
:root {
--bg-base: #1e1e2e;
--bg-surface: #313244;
--bg-overlay: #45475a;
--bg-input: #1e1e2e;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-subtle: #6c7086;
--accent: #89b4fa;
--accent-hover: #74c7ec;
--accent-dim: rgba(137, 180, 250, 0.15);
--success: #a6e3a1;
--error: #f38ba8;
--warning: #fab387;
--border: #585b70;
--border-light: #45475a;
--radius: 8px;
--radius-sm: 4px;
--transition: 150ms ease;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font);
background: var(--bg-base);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
-webkit-font-smoothing: antialiased;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Navigation (includes macOS titlebar drag region) */
#nav {
display: flex;
align-items: center;
padding: 0 16px 0 76px; /* left padding for macOS traffic lights */
height: 38px;
flex-shrink: 0;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-light);
gap: 4px;
-webkit-app-region: drag;
-webkit-user-select: none;
user-select: none;
}
.nav-btn {
background: none;
border: none;
color: var(--text-secondary);
font: inherit;
font-size: 13px;
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.nav-btn:hover {
background: var(--bg-overlay);
color: var(--text-primary);
}
.nav-brand {
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
-webkit-app-region: no-drag;
-webkit-user-select: none;
user-select: none;
}
.nav-btn {
-webkit-app-region: no-drag;
}
.nav-spacer { flex: 1; }
/* Main content */
#main {
flex: 1;
overflow-y: auto;
position: relative;
}
.page {
display: none;
padding: 24px;
max-width: 720px;
margin: 0 auto;
animation: fadeIn 200ms ease;
}
.page.active {
display: block;
}
.page-former.active {
display: flex;
max-width: none;
padding: 0;
height: 100%;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Landing page */
#page-landing {
position: relative;
overflow: hidden;
}
.landing-bg-art {
position: relative;
width: 100%;
display: flex;
justify-content: center;
margin-top: -8px;
margin-bottom: -60px;
pointer-events: none;
z-index: 0;
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.1) 100%);
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.1) 100%);
}
.landing-bg-logo {
width: 50%;
height: auto;
opacity: 0.8;
filter: brightness(1.4);
}
.landing-hero {
text-align: center;
padding: 0 0 28px;
position: relative;
z-index: 1;
}
.landing-hero h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 4px;
}
.landing-hero p {
color: var(--text-secondary);
font-size: 14px;
}
.landing-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 32px;
position: relative;
z-index: 1;
}
.action-card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
transition: all var(--transition);
text-align: left;
}
.action-card:hover {
border-color: var(--accent);
background: var(--accent-dim);
}
.action-card h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.action-card p {
font-size: 13px;
color: var(--text-secondary);
}
/* Section titles */
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
/* Recent projects */
.project-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.project-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.project-item:hover {
border-color: var(--accent);
background: var(--accent-dim);
}
.project-name {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.project-meta {
font-size: 12px;
color: var(--text-subtle);
}
.badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: var(--bg-overlay);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* Cards */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
}
.card-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
/* Form elements */
.form-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-label {
font-size: 13px;
color: var(--text-secondary);
min-width: 140px;
}
.form-input {
flex: 1;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font: inherit;
font-size: 13px;
padding: 6px 10px;
outline: none;
transition: border-color var(--transition);
}
.form-input:focus {
border-color: var(--accent);
}
.form-input::placeholder {
color: var(--text-subtle);
}
select.form-input {
cursor: pointer;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn:hover {
background: var(--bg-overlay);
border-color: var(--text-subtle);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #1e1e2e;
font-weight: 600;
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn-danger {
color: var(--error);
border-color: var(--error);
}
.btn-danger:hover {
background: rgba(243, 139, 168, 0.1);
}
/* File picker row */
.file-row {
display: flex;
align-items: center;
gap: 8px;
}
.file-name {
flex: 1;
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-name.has-file {
color: var(--text-primary);
}
/* Checkbox row */
.check-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.check-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
cursor: pointer;
}
.check-label input[type="checkbox"] {
accent-color: var(--accent);
}
/* Action bar */
.action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
}
.action-bar .spacer {
flex: 1;
}
/* Page header */
.page-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.page-header h2 {
font-size: 18px;
font-weight: 600;
}
/* Board preview canvas */
.board-canvas-wrap {
background: #34353c;
border-radius: var(--radius);
padding: 0;
text-align: center;
}
.board-canvas-wrap canvas {
max-width: 100%;
border-radius: var(--radius);
}
/* Side buttons */
.side-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.side-tab {
padding: 4px 10px;
font-size: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
}
.side-tab.active {
background: var(--accent);
border-color: var(--accent);
color: #1e1e2e;
}
/* Cutout list */
.cutout-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.cutout-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-base);
border-radius: var(--radius-sm);
font-size: 12px;
font-family: var(--font-mono);
}
.cutout-item .cutout-text {
flex: 1;
color: var(--text-secondary);
}
/* Result page */
.result-files {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
background: var(--bg-base);
padding: 12px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
/* Loading spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(30, 30, 46, 0.8);
z-index: 100;
flex-direction: column;
gap: 12px;
}
.loading-overlay .spinner {
width: 32px;
height: 32px;
border-width: 3px;
}
.loading-text {
font-size: 14px;
color: var(--text-secondary);
}
/* ===== THE FORMER ===== */
.former-container {
display: flex;
width: 100%;
height: 100%;
}
.former-canvas-wrap {
flex: 1;
background: #000000;
position: relative;
overflow: hidden;
}
.former-canvas-wrap canvas {
position: absolute;
top: 0;
left: 0;
}
.former-sidebar {
width: 260px;
background: var(--bg-surface);
border-left: 1px solid var(--border-light);
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.former-sidebar-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 8px;
}
.former-sidebar-header h3 {
flex: 1;
font-size: 14px;
font-weight: 600;
}
.former-layers {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.former-layer-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.former-layer-row:hover {
background: var(--bg-overlay);
}
.former-layer-row.highlighted {
background: var(--accent-dim);
}
.former-layer-row.selected {
background: rgba(137, 180, 250, 0.25);
border-left: 2px solid var(--accent);
padding-left: 6px;
}
.layer-vis-btn,
.layer-hl-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-subtle);
cursor: pointer;
font-size: 14px;
transition: all var(--transition);
}
.layer-vis-btn:hover,
.layer-hl-btn:hover {
background: var(--bg-overlay);
color: var(--text-primary);
}
.layer-vis-btn.active {
color: var(--text-primary);
}
.layer-hl-btn.active {
color: var(--warning);
}
.layer-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.layer-name {
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layer-name.dimmed {
color: var(--text-subtle);
}
.former-actions {
padding: 12px 16px;
border-top: 1px solid var(--border-light);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-overlay);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}
/* Empty state */
.empty-state {
text-align: center;
padding: 40px 0;
color: var(--text-subtle);
font-size: 14px;
}
/* Preset buttons */
.preset-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}

7
frontend/vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'dist',
},
});

33
go.mod
View File

@ -1,3 +1,36 @@
module pcb-to-stencil
go 1.23.0
require github.com/wailsapp/wails/v2 v2.11.0
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

81
go.sum Normal file
View File

@ -0,0 +1,81 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

225
instance.go Normal file
View File

@ -0,0 +1,225 @@
package main
import (
"fmt"
"image"
"log"
"os"
"path/filepath"
"strings"
"time"
)
// InstanceData holds the serializable state for a saved enclosure instance.
type InstanceData struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
CreatedAt time.Time `json:"createdAt"`
// Source files (basenames relative to the project directory)
GerberFiles map[string]string `json:"gerberFiles"`
DrillPath string `json:"drillPath,omitempty"`
NPTHPath string `json:"npthPath,omitempty"`
// Discovered layer filenames (keys into GerberFiles)
EdgeCutsFile string `json:"edgeCutsFile"`
CourtyardFile string `json:"courtyardFile,omitempty"`
SoldermaskFile string `json:"soldermaskFile,omitempty"`
FabFile string `json:"fabFile,omitempty"`
// Configuration
Config EnclosureConfig `json:"config"`
Exports []string `json:"exports"`
// Board display info
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
ProjectName string `json:"projectName,omitempty"`
// Unified cutouts (new format)
Cutouts []Cutout `json:"cutouts,omitempty"`
// Legacy cutout fields — kept for backward compatibility when loading old projects
SideCutouts []SideCutout `json:"sideCutouts,omitempty"`
LidCutouts []LidCutout `json:"lidCutouts,omitempty"`
}
// MigrateCutouts returns the unified cutouts list, converting legacy fields if needed.
func (inst *InstanceData) MigrateCutouts() []Cutout {
if len(inst.Cutouts) > 0 {
return inst.Cutouts
}
// Migrate legacy side cutouts
var result []Cutout
for _, sc := range inst.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,
})
}
// Migrate legacy lid cutouts
for _, lc := range inst.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,
})
}
return result
}
// restoreSessionFromDir rebuilds an EnclosureSession from an InstanceData,
// resolving all file paths relative to baseDir.
func restoreSessionFromDir(inst *InstanceData, baseDir string) (string, *EnclosureSession, error) {
outlineBasename, ok := inst.GerberFiles[inst.EdgeCutsFile]
if !ok {
return "", nil, fmt.Errorf("edge cuts file not found: %s", inst.EdgeCutsFile)
}
outlinePath := filepath.Join(baseDir, outlineBasename)
if _, err := os.Stat(outlinePath); err != nil {
return "", nil, fmt.Errorf("source files no longer available: %v", err)
}
outlineGf, err := ParseGerber(outlinePath)
if err != nil {
return "", nil, fmt.Errorf("parse outline: %v", err)
}
outlineBounds := outlineGf.CalculateBounds()
actualBoardW := outlineBounds.MaxX - outlineBounds.MinX
actualBoardH := outlineBounds.MaxY - outlineBounds.MinY
ecfg := inst.Config
margin := ecfg.WallThickness + ecfg.Clearance + 5.0
outlineBounds.MinX -= margin
outlineBounds.MinY -= margin
outlineBounds.MaxX += margin
outlineBounds.MaxY += margin
ecfg.OutlineBounds = &outlineBounds
outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds)
minBX, maxBX := outlineImg.Bounds().Max.X, -1
var boardCenterY float64
var boardCount int
_, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4)
imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y
for y := 0; y < imgH; y++ {
for x := 0; x < imgW; x++ {
if boardMask[y*imgW+x] {
if x < minBX {
minBX = x
}
if x > maxBX {
maxBX = x
}
boardCenterY += float64(y)
boardCount++
}
}
}
if boardCount > 0 {
boardCenterY /= float64(boardCount)
}
// Resolve gerber paths relative to baseDir and render ALL layers
resolvedGerbers := make(map[string]string)
allLayers := make(map[string]image.Image)
allGerbers := make(map[string]*GerberFile)
for origName, basename := range inst.GerberFiles {
fullPath := filepath.Join(baseDir, basename)
resolvedGerbers[origName] = fullPath
if strings.HasSuffix(strings.ToLower(origName), ".gbrjob") {
continue
}
if origName == inst.EdgeCutsFile {
allLayers[origName] = outlineImg
allGerbers[origName] = outlineGf
continue
}
if gf, err := ParseGerber(fullPath); err == nil {
allLayers[origName] = gf.Render(ecfg.DPI, &outlineBounds)
allGerbers[origName] = gf
}
}
var courtyardImg, soldermaskImg image.Image
if inst.CourtyardFile != "" {
courtyardImg = allLayers[inst.CourtyardFile]
}
if inst.SoldermaskFile != "" {
soldermaskImg = allLayers[inst.SoldermaskFile]
}
if courtyardImg == nil && inst.FabFile != "" {
courtyardImg = allLayers[inst.FabFile]
}
var drillHoles []DrillHole
if inst.DrillPath != "" {
if holes, err := ParseDrill(filepath.Join(baseDir, inst.DrillPath)); err == nil {
drillHoles = append(drillHoles, holes...)
}
}
if inst.NPTHPath != "" {
if holes, err := ParseDrill(filepath.Join(baseDir, inst.NPTHPath)); err == nil {
drillHoles = append(drillHoles, holes...)
}
}
var filteredHoles []DrillHole
for _, h := range drillHoles {
if h.Type != DrillTypeVia {
filteredHoles = append(filteredHoles, h)
}
}
pixelToMM := 25.4 / ecfg.DPI
sessionID := randomID()
session := &EnclosureSession{
Exports: inst.Exports,
OutlineGf: outlineGf,
OutlineImg: outlineImg,
CourtyardImg: courtyardImg,
SoldermaskImg: soldermaskImg,
DrillHoles: filteredHoles,
Config: ecfg,
OutlineBounds: outlineBounds,
BoardW: actualBoardW,
BoardH: actualBoardH,
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
MinBX: float64(minBX)*pixelToMM + outlineBounds.MinX,
MaxBX: float64(maxBX)*pixelToMM + outlineBounds.MinX,
BoardCenterY: outlineBounds.MaxY - boardCenterY*pixelToMM,
Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, pixelToMM, &outlineBounds),
GerberFiles: inst.GerberFiles,
DrillPath: inst.DrillPath,
NPTHPath: inst.NPTHPath,
ProjectName: inst.ProjectName,
EdgeCutsFile: inst.EdgeCutsFile,
CourtyardFile: inst.CourtyardFile,
SoldermaskFile: inst.SoldermaskFile,
FabFile: inst.FabFile,
EnclosureWallImg: renderEnclosureWallImage(outlineImg, ecfg),
AllLayerImages: allLayers,
AllLayerGerbers: allGerbers,
SourceDir: baseDir,
}
log.Printf("Restored session %s from %s (%s)", sessionID, baseDir, inst.ProjectName)
return sessionID, session, nil
}

1449
main.go

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
#!/bin/bash
pkill -f ./bin/pcb-to-stencil\ -server
go clean && go build -o bin/pcb-to-stencil .
./bin/pcb-to-stencil -server

694
scad.go
View File

@ -6,6 +6,16 @@ import (
"os"
)
// snapToLine rounds a dimension to the nearest quarter-multiple of lineWidth.
// If lineWidth is 0, the value is returned unchanged.
func snapToLine(v, lineWidth float64) float64 {
if lineWidth <= 0 {
return v
}
unit := lineWidth / 4.0
return math.Round(v/unit) * unit
}
func WriteSCAD(filename string, triangles [][3]Point) error {
// Fallback/legacy mesh WriteSCAD
f, err := os.Create(filename)
@ -37,6 +47,440 @@ func WriteSCAD(filename string, triangles [][3]Point) error {
return nil
}
// approximateArc returns intermediate arc points from (x1,y1) to (x2,y2),
// excluding the start point, including the end point.
func approximateArc(x1, y1, x2, y2, iVal, jVal float64, mode string) [][2]float64 {
centerX := x1 + iVal
centerY := y1 + jVal
radius := math.Sqrt(iVal*iVal + jVal*jVal)
startAngle := math.Atan2(y1-centerY, x1-centerX)
endAngle := math.Atan2(y2-centerY, x2-centerX)
if mode == "G03" {
if endAngle <= startAngle {
endAngle += 2 * math.Pi
}
} else {
if startAngle <= endAngle {
startAngle += 2 * math.Pi
}
}
arcLen := math.Abs(endAngle-startAngle) * radius
steps := int(arcLen * 8)
if steps < 4 {
steps = 4
}
if steps > 128 {
steps = 128
}
pts := make([][2]float64, steps)
for s := 0; s < steps; s++ {
t := float64(s+1) / float64(steps)
a := startAngle + t*(endAngle-startAngle)
pts[s] = [2]float64{centerX + radius*math.Cos(a), centerY + radius*math.Sin(a)}
}
return pts
}
// 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) {
switch ap.Type {
case "C":
if len(ap.Modifiers) > 0 {
r := snapToLine(ap.Modifiers[0]/2, lw)
fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, x, y, r)
}
case "R":
if len(ap.Modifiers) >= 2 {
w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw)
fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n", indent, x, y, w, h)
}
case "O":
if len(ap.Modifiers) >= 2 {
w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw)
r := math.Min(w, h) / 2
fmt.Fprintf(f, "%stranslate([%f, %f]) hull() {\n", indent, x, y)
if w >= h {
d := (w - h) / 2
fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, d, r)
fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, -d, r)
} else {
d := (h - w) / 2
fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, d, r)
fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, -d, r)
}
fmt.Fprintf(f, "%s}\n", indent)
}
case "P":
if len(ap.Modifiers) >= 2 {
dia, numV := ap.Modifiers[0], int(ap.Modifiers[1])
r := snapToLine(dia/2, lw)
rot := 0.0
if len(ap.Modifiers) >= 3 {
rot = ap.Modifiers[2]
}
fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n",
indent, x, y, rot, r, numV)
}
default:
// Macro aperture compute bounding box from primitives and emit a simple square.
if gf == nil {
return
}
macro, ok := gf.State.Macros[ap.Type]
if !ok {
return
}
minX, minY := math.Inf(1), math.Inf(1)
maxX, maxY := math.Inf(-1), math.Inf(-1)
trackPt := func(px, py, radius float64) {
if px-radius < minX { minX = px - radius }
if px+radius > maxX { maxX = px + radius }
if py-radius < minY { minY = py - radius }
if py+radius > maxY { maxY = py + radius }
}
for _, prim := range macro.Primitives {
switch prim.Code {
case 1: // Circle
if len(prim.Modifiers) >= 4 {
dia := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)
cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers)
cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers)
trackPt(cx, cy, dia/2)
}
case 4: // Outline polygon
if len(prim.Modifiers) >= 3 {
numV := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers))
for i := 0; i < numV && 2+i*2+1 < len(prim.Modifiers); i++ {
vx := evaluateMacroExpression(prim.Modifiers[2+i*2], ap.Modifiers)
vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], ap.Modifiers)
trackPt(vx, vy, 0)
}
}
case 20: // Vector line
if len(prim.Modifiers) >= 7 {
w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)
sx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers)
sy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers)
ex := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers)
ey := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers)
trackPt(sx, sy, w/2)
trackPt(ex, ey, w/2)
}
case 21: // Center line rect
if len(prim.Modifiers) >= 6 {
w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)
h := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers)
cx := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers)
cy := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers)
trackPt(cx, cy, math.Max(w, h)/2)
}
}
}
if !math.IsInf(minX, 1) {
w := snapToLine(maxX-minX, lw)
h := snapToLine(maxY-minY, lw)
cx := (minX + maxX) / 2
cy := (minY + maxY) / 2
fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n",
indent, x+cx, y+cy, w, h)
}
}
}
// writeMacroPrimitive2D emits a single macro primitive as 2D SCAD geometry.
func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, indent string) {
switch prim.Code {
case 1: // Circle: Exposure, Diameter, CenterX, CenterY
if len(prim.Modifiers) >= 4 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 {
return
}
dia := evaluateMacroExpression(prim.Modifiers[1], params)
cx := evaluateMacroExpression(prim.Modifiers[2], params)
cy := evaluateMacroExpression(prim.Modifiers[3], params)
fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, cx, cy, dia/2)
}
case 4: // Outline (Polygon): Exposure, NumVertices, X1,Y1,...,Xn,Yn, Rotation
if len(prim.Modifiers) >= 3 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 {
return
}
numV := int(evaluateMacroExpression(prim.Modifiers[1], params))
if len(prim.Modifiers) < 2+numV*2+1 {
return
}
rot := evaluateMacroExpression(prim.Modifiers[2+numV*2], params)
fmt.Fprintf(f, "%srotate([0, 0, %f]) polygon(points=[\n", indent, rot)
for i := 0; i < numV; i++ {
vx := evaluateMacroExpression(prim.Modifiers[2+i*2], params)
vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], params)
comma := ","
if i == numV-1 {
comma = ""
}
fmt.Fprintf(f, "%s [%f, %f]%s\n", indent, vx, vy, comma)
}
fmt.Fprintf(f, "%s]);\n", indent)
}
case 5: // Regular Polygon: Exposure, NumVertices, CenterX, CenterY, Diameter, Rotation
if len(prim.Modifiers) >= 6 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 {
return
}
numV := int(evaluateMacroExpression(prim.Modifiers[1], params))
cx := evaluateMacroExpression(prim.Modifiers[2], params)
cy := evaluateMacroExpression(prim.Modifiers[3], params)
dia := evaluateMacroExpression(prim.Modifiers[4], params)
rot := evaluateMacroExpression(prim.Modifiers[5], params)
fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n",
indent, cx, cy, rot, dia/2, numV)
}
case 20: // Vector Line: Exposure, Width, StartX, StartY, EndX, EndY, Rotation
if len(prim.Modifiers) >= 7 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 {
return
}
width := 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)
rot := evaluateMacroExpression(prim.Modifiers[6], params)
// hull() of two squares at start/end for a rectangle with the given width
fmt.Fprintf(f, "%srotate([0, 0, %f]) hull() {\n", indent, rot)
fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, sx, sy, width)
fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, ex, ey, width)
fmt.Fprintf(f, "%s}\n", indent)
}
case 21: // Center Line (Rect): Exposure, Width, Height, CenterX, CenterY, Rotation
if len(prim.Modifiers) >= 6 {
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
if exposure == 0 {
return
}
w := evaluateMacroExpression(prim.Modifiers[1], params)
h := evaluateMacroExpression(prim.Modifiers[2], params)
cx := evaluateMacroExpression(prim.Modifiers[3], params)
cy := evaluateMacroExpression(prim.Modifiers[4], params)
rot := evaluateMacroExpression(prim.Modifiers[5], params)
fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) square([%f, %f], center=true);\n",
indent, cx, cy, rot, w, h)
}
}
}
// 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) {
switch ap.Type {
case "C":
if len(ap.Modifiers) > 0 {
r := ap.Modifiers[0] / 2
fmt.Fprintf(f, "%shull() {\n", indent)
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r)
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r)
fmt.Fprintf(f, "%s}\n", indent)
}
case "R":
if len(ap.Modifiers) >= 2 {
w, h := ap.Modifiers[0], ap.Modifiers[1]
fmt.Fprintf(f, "%shull() {\n", indent)
fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x1, y1, w, h)
fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x2, y2, w, h)
fmt.Fprintf(f, "%s}\n", indent)
}
default:
if len(ap.Modifiers) > 0 {
r := ap.Modifiers[0] / 2
fmt.Fprintf(f, "%shull() {\n", indent)
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r)
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r)
fmt.Fprintf(f, "%s}\n", indent)
}
}
}
// 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) {
curX, curY := 0.0, 0.0
curDCode := 0
interpolationMode := "G01"
inRegion := false
var regionPts [][2]float64
for _, cmd := range gf.Commands {
if cmd.Type == "APERTURE" {
if cmd.D != nil {
curDCode = *cmd.D
}
continue
}
if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" {
interpolationMode = cmd.Type
continue
}
if cmd.Type == "G36" {
inRegion = true
regionPts = nil
continue
}
if cmd.Type == "G37" {
if len(regionPts) >= 3 {
fmt.Fprintf(f, "%spolygon(points=[\n", indent)
for i, pt := range regionPts {
fmt.Fprintf(f, "%s [%f, %f]", indent, pt[0], pt[1])
if i < len(regionPts)-1 {
fmt.Fprintf(f, ",")
}
fmt.Fprintf(f, "\n")
}
fmt.Fprintf(f, "%s]);\n", indent)
}
inRegion = false
regionPts = nil
continue
}
prevX, prevY := curX, curY
if cmd.X != nil {
curX = *cmd.X
}
if cmd.Y != nil {
curY = *cmd.Y
}
if inRegion {
switch cmd.Type {
case "MOVE":
regionPts = append(regionPts, [2]float64{curX, curY})
case "DRAW":
if interpolationMode == "G01" {
regionPts = append(regionPts, [2]float64{curX, curY})
} else {
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
}
if cmd.J != nil {
jVal = *cmd.J
}
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
regionPts = append(regionPts, arcPts...)
}
}
continue
}
ap, ok := gf.State.Apertures[curDCode]
if !ok {
continue
}
switch cmd.Type {
case "FLASH":
writeApertureFlash2D(f, gf, ap, curX, curY, lw, indent)
case "DRAW":
if interpolationMode == "G01" {
writeApertureLinearDraw2D(f, ap, prevX, prevY, curX, curY, indent)
} else {
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
}
if cmd.J != nil {
jVal = *cmd.J
}
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
all := append([][2]float64{{prevX, prevY}}, arcPts...)
for i := 0; i < len(all)-1; i++ {
writeApertureLinearDraw2D(f, ap, all[i][0], all[i][1], all[i+1][0], all[i+1][1], indent)
}
}
}
}
}
// WriteStencilSCAD generates native parametric OpenSCAD for a solder paste stencil.
// Instead of a rasterised mesh, it uses CSG primitives (circles, squares, hulls,
// polygons) so the result prints cleanly at any nozzle size.
func WriteStencilSCAD(filename string, gf *GerberFile, outlineGf *GerberFile, cfg Config, bounds *Bounds) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n")
fmt.Fprintf(f, "$fn = 60;\n\n")
lw := cfg.LineWidth
fmt.Fprintf(f, "stencil_height = %f; // mm solder paste layer thickness\n", snapToLine(cfg.StencilHeight, lw))
fmt.Fprintf(f, "wall_height = %f; // mm alignment frame height\n", snapToLine(cfg.WallHeight, lw))
fmt.Fprintf(f, "wall_thickness = %f; // mm alignment frame wall thickness\n", snapToLine(cfg.WallThickness, lw))
if lw > 0 {
fmt.Fprintf(f, "// line_width = %f; // mm all dimensions snapped to multiples/fractions of this\n", lw)
}
fmt.Fprintf(f, "\n")
var outlineVerts [][2]float64
if outlineGf != nil {
outlineVerts = ExtractPolygonFromGerber(outlineGf)
}
centerX := (bounds.MinX + bounds.MaxX) / 2.0
centerY := (bounds.MinY + bounds.MaxY) / 2.0
// Board outline module (2D)
if len(outlineVerts) > 0 {
fmt.Fprintf(f, "module board_outline() {\n polygon(points=[\n")
for i, v := range outlineVerts {
fmt.Fprintf(f, " [%f, %f]", v[0], v[1])
if i < len(outlineVerts)-1 {
fmt.Fprintf(f, ",")
}
fmt.Fprintf(f, "\n")
}
fmt.Fprintf(f, " ]);\n}\n\n")
} else {
// Fallback: bounding rectangle
fmt.Fprintf(f, "module board_outline() {\n")
fmt.Fprintf(f, " translate([%f, %f]) square([%f, %f]);\n",
bounds.MinX, bounds.MinY, bounds.MaxX-bounds.MinX, bounds.MaxY-bounds.MinY)
fmt.Fprintf(f, "}\n\n")
}
// Paste pad openings module (2D union of all aperture shapes)
fmt.Fprintf(f, "module paste_pads() {\n union() {\n")
writeGerberShapes2D(f, gf, cfg.LineWidth, " ")
fmt.Fprintf(f, " }\n}\n\n")
// Main body centred at origin for easy placement on the print bed
fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY)
fmt.Fprintf(f, " difference() {\n")
fmt.Fprintf(f, " union() {\n")
fmt.Fprintf(f, " // Thin stencil plate\n")
fmt.Fprintf(f, " linear_extrude(height=stencil_height)\n")
fmt.Fprintf(f, " board_outline();\n")
fmt.Fprintf(f, " // Alignment wall keeps stencil registered to the PCB edge\n")
fmt.Fprintf(f, " linear_extrude(height=wall_height)\n")
fmt.Fprintf(f, " difference() {\n")
fmt.Fprintf(f, " offset(r=wall_thickness) board_outline();\n")
fmt.Fprintf(f, " board_outline();\n")
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " // Paste pad cutouts (punched through the stencil plate)\n")
fmt.Fprintf(f, " translate([0, 0, -0.1])\n")
fmt.Fprintf(f, " linear_extrude(height=stencil_height + 0.2)\n")
fmt.Fprintf(f, " paste_pads();\n")
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, "}\n")
return nil
}
// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon
func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
var strokes [][][2]float64
@ -210,7 +654,7 @@ func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
}
// WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code
func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error {
func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error {
f, err := os.Create(filename)
if err != nil {
return err
@ -272,7 +716,8 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
// Cutouts are relative to board. UI specifies c.Y from bottom, so c.Y adds to Z.
z := c.Height/2 + trayFloor + pcbT + c.Y
w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls
wallDepth := 2*(clearance+2*wt) + 2.0 // just enough to cut through walls
w, d, h := c.Width, wallDepth, c.Height
dx := bs.EndX - bs.StartX
dy := bs.EndY - bs.StartY
@ -319,6 +764,67 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt+0.5, boardCenterY, clipZ, 1.0, pryW, clipH)
fmt.Fprintf(f, "}\n\n")
// Lid/Tray Cutouts Module
fmt.Fprintf(f, "module lid_cutouts() {\n")
for _, lc := range lidCutouts {
cx := (lc.MinX + lc.MaxX) / 2.0
cy := (lc.MinY + lc.MaxY) / 2.0
w := lc.MaxX - lc.MinX
h := lc.MaxY - lc.MinY
if w < 0.01 || h < 0.01 {
continue
}
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)
} 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)
}
} 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)
} 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)
}
}
}
fmt.Fprintf(f, "}\n\n")
// cutoutMid returns the midpoint XY and rotation angle for a side cutout,
// matching the geometry used in side_cutouts().
cutoutMid := func(c SideCutout) (midX, midY, rotDeg float64, ok bool) {
for i := range sides {
if sides[i].Num != c.Side {
continue
}
bs := &sides[i]
dx := bs.EndX - bs.StartX
dy := bs.EndY - bs.StartY
if l := math.Sqrt(dx*dx + dy*dy); l > 0 {
dx /= l
dy /= l
}
midX = bs.StartX + dx*(c.X+c.Width/2)
midY = bs.StartY + dy*(c.X+c.Width/2)
rotDeg = (bs.Angle*180.0/math.Pi) - 90.0
ok = true
return
}
return
}
centerX := cfg.OutlineBounds.MinX + (cfg.OutlineBounds.MaxX-cfg.OutlineBounds.MinX)/2.0
centerY := cfg.OutlineBounds.MinY + (cfg.OutlineBounds.MaxY-cfg.OutlineBounds.MinY)/2.0
fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY)
@ -352,6 +858,69 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
fmt.Fprintf(f, " translate([%f,%f,-1]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, trayFloor+2, socketRadius)
}
fmt.Fprintf(f, " side_cutouts();\n")
fmt.Fprintf(f, " lid_cutouts();\n")
// Board dado on tray: layer-aware groove on each side with port cutouts.
{
trayWallDepth := 2*(clearance+wt) + 2.0
type trayDadoInfo struct {
hasF bool
hasB bool
fPortTop float64
bPortBot float64
}
trayDadoSides := make(map[int]*trayDadoInfo)
for _, c := range cutouts {
di, ok := trayDadoSides[c.Side]
if !ok {
di = &trayDadoInfo{fPortTop: 0, bPortBot: 1e9}
trayDadoSides[c.Side] = di
}
portBot := trayFloor + pcbT + c.Y
portTop := portBot + c.Height
if c.Layer == "F" {
di.hasF = true
if portTop > di.fPortTop {
di.fPortTop = portTop
}
} else {
di.hasB = true
if portBot < di.bPortBot {
di.bPortBot = portBot
}
}
}
trayH := trayFloor + snapHeight + wt + pcbT + 2.0
for _, bs := range sides {
di, ok := trayDadoSides[bs.Num]
if !ok {
continue
}
midX := (bs.StartX + bs.EndX) / 2.0
midY := (bs.StartY + bs.EndY) / 2.0
rotDeg := (bs.Angle*180.0/math.Pi) - 90.0
dadoLen := bs.Length + 1.0
if di.hasF {
// F-layer: dado above ports (toward lid), same direction as enclosure
dadoBot := di.fPortTop
dadoH := trayH - dadoBot
if dadoH > 0.1 {
fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num)
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH)
}
}
if di.hasB {
// B-layer: dado below ports (toward floor)
dadoBot := trayFloor + 0.3
dadoH := di.bPortBot - dadoBot
if dadoH > 0.1 {
fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num)
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH)
}
}
}
}
fmt.Fprintf(f, "}\n")
fmt.Fprintf(f, "pry_clips();\n\n")
@ -369,12 +938,127 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor+snapHeight+0.2, clearance+wt+0.15)
fmt.Fprintf(f, " // Vertical relief slots for the tray clips to slide into\n")
fmt.Fprintf(f, " clipZ = %f;\n", trayFloor+snapHeight)
fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, pryW+1.0)
fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, pryW+1.0)
reliefClipZ := trayFloor + snapHeight
reliefH := reliefClipZ + 1.0
reliefZ := trayFloor - 1.0
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, reliefZ, pryW+1.0, reliefH)
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, reliefZ, pryW+1.0, reliefH)
fmt.Fprintf(f, " pry_slots();\n")
// Port cutouts only these go through the full wall to the outside
fmt.Fprintf(f, " side_cutouts();\n")
fmt.Fprintf(f, " lid_cutouts();\n")
wallDepth := 2*(clearance+2*wt) + 2.0
lidBottom := totalH - lidThick
// Inner wall ring helper used to limit slots and dado to the
// inner rim only (outer wall stays solid, only ports break through).
// Inner wall spans from offset(clearance) to offset(clearance+wt).
fmt.Fprintf(f, " // --- Entry slots & board dado (inner wall only) ---\n")
fmt.Fprintf(f, " intersection() {\n")
fmt.Fprintf(f, " // Clamp to inner wall ring\n")
fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor-1, totalH+2)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.5)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance-0.5)
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " union() {\n")
// Port entry slots vertical channel from port to lid/floor,
// only in the inner wall so the outer wall stays solid.
for _, c := range cutouts {
mX, mY, mRot, ok := cutoutMid(c)
if !ok {
continue
}
zTopCut := trayFloor + pcbT + c.Y + c.Height
if c.Layer == "F" {
// F-layer: ports on top of board, slot from port top toward lid (plate)
slotH := lidBottom - zTopCut
if slotH > 0.1 {
fmt.Fprintf(f, " // Port entry slot (F-layer, open toward plate)\n")
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
mX, mY, zTopCut+slotH/2.0, mRot, c.Width, wallDepth, slotH)
}
} else {
// B-layer: ports under board, slot from floor up to port bottom
zBotCut := trayFloor + pcbT + c.Y
slotH := zBotCut - (trayFloor + 0.3)
if slotH > 0.1 {
slotBot := trayFloor + 0.3
fmt.Fprintf(f, " // Port entry slot (B-layer, open toward rim)\n")
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
mX, mY, slotBot+slotH/2.0, mRot, c.Width, wallDepth, slotH)
}
}
}
// Board dado full-length groove at PCB height, inner wall only.
// For F-layer: dado sits below ports (board under ports), from tray floor to port bottom.
// For B-layer: dado sits above ports (board over ports), from port top to lid.
// Collect per-side: lowest port bottom (F) or highest port top (B).
type dadoInfo struct {
hasF bool
hasB bool
fPortTop float64 // highest port-top on this side (F-layer)
bPortBot float64 // lowest port-bottom on this side (B-layer)
}
dadoSides := make(map[int]*dadoInfo)
for _, c := range cutouts {
di, ok := dadoSides[c.Side]
if !ok {
di = &dadoInfo{fPortTop: 0, bPortBot: 1e9}
dadoSides[c.Side] = di
}
portBot := trayFloor + pcbT + c.Y
portTop := portBot + c.Height
if c.Layer == "F" {
di.hasF = true
if portTop > di.fPortTop {
di.fPortTop = portTop
}
} else {
di.hasB = true
if portBot < di.bPortBot {
di.bPortBot = portBot
}
}
}
for _, bs := range sides {
di, ok := dadoSides[bs.Num]
if !ok {
continue
}
midX := (bs.StartX + bs.EndX) / 2.0
midY := (bs.StartY + bs.EndY) / 2.0
rotDeg := (bs.Angle*180.0/math.Pi) - 90.0
dadoLen := bs.Length + 1.0
if di.hasF {
// F-layer: ports on top of board, dado above ports (toward lid/plate)
dadoBot := di.fPortTop
dadoH := lidBottom - dadoBot
if dadoH > 0.1 {
fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num)
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH)
}
}
if di.hasB {
// B-layer: ports under board, dado below ports (toward open rim)
dadoBot := trayFloor + 0.3
dadoH := di.bPortBot - dadoBot
if dadoH > 0.1 {
fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num)
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH)
}
}
}
fmt.Fprintf(f, " } // end union\n")
fmt.Fprintf(f, " } // end intersection\n")
fmt.Fprintf(f, "}\n")
fmt.Fprintf(f, "mounting_pegs(false);\n")
}

362
session.go Normal file
View File

@ -0,0 +1,362 @@
package main
import (
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"log"
"os"
"path/filepath"
"strings"
)
// EnclosureSession holds all state for an active enclosure editing session
type EnclosureSession struct {
Exports []string
OutlineGf *GerberFile
OutlineImg image.Image
CourtyardImg image.Image
SoldermaskImg image.Image
DrillHoles []DrillHole
Config EnclosureConfig
OutlineBounds Bounds
BoardW float64
BoardH float64
TotalH float64
MinBX float64
MaxBX float64
BoardCenterY float64
Sides []BoardSide
FabImg image.Image
EnclosureWallImg image.Image // 2D top-down view of enclosure walls
AllLayerImages map[string]image.Image // all rendered gerber layers keyed by original filename
AllLayerGerbers map[string]*GerberFile // parsed gerber files keyed by original filename
SourceDir string // original directory of the gerber files
// Persistence metadata
GerberFiles map[string]string
DrillPath string
NPTHPath string
ProjectName string
EdgeCutsFile string
CourtyardFile string
SoldermaskFile string
FabFile string
}
// BuildEnclosureSession creates a session from uploaded files and configuration.
// This is used by both the initial upload and by instance restore.
func BuildEnclosureSession(
gbrjobPath string,
gerberPaths map[string]string, // original filename -> saved path
drillPath, npthPath string,
ecfg EnclosureConfig,
exports []string,
) (string, *EnclosureSession, error) {
// Parse gbrjob
jobResult, err := ParseGerberJob(gbrjobPath)
if err != nil {
return "", nil, fmt.Errorf("parse gbrjob: %v", err)
}
pcbThickness := jobResult.BoardThickness
if pcbThickness == 0 {
pcbThickness = DefaultPCBThickness
}
ecfg.PCBThickness = pcbThickness
// Find outline
outlinePath, ok := gerberPaths[jobResult.EdgeCutsFile]
if !ok {
return "", nil, fmt.Errorf("Edge.Cuts file '%s' not found in uploaded gerbers", jobResult.EdgeCutsFile)
}
// Parse outline
outlineGf, err := ParseGerber(outlinePath)
if err != nil {
return "", nil, fmt.Errorf("parse outline: %v", err)
}
outlineBounds := outlineGf.CalculateBounds()
actualBoardW := outlineBounds.MaxX - outlineBounds.MinX
actualBoardH := outlineBounds.MaxY - outlineBounds.MinY
margin := ecfg.WallThickness + ecfg.Clearance + 5.0
outlineBounds.MinX -= margin
outlineBounds.MinY -= margin
outlineBounds.MaxX += margin
outlineBounds.MaxY += margin
ecfg.OutlineBounds = &outlineBounds
outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds)
// Compute board mask
minBX, _, maxBX, _ := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y, -1, -1
var boardCenterY float64
var boardCount int
_, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4)
imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y
for y := 0; y < imgH; y++ {
for x := 0; x < imgW; x++ {
if boardMask[y*imgW+x] {
if x < minBX {
minBX = x
}
if x > maxBX {
maxBX = x
}
boardCenterY += float64(y)
boardCount++
}
}
}
if boardCount > 0 {
boardCenterY /= float64(boardCount)
}
// Parse drill files
var drillHoles []DrillHole
if drillPath != "" {
if holes, err := ParseDrill(drillPath); err == nil {
drillHoles = append(drillHoles, holes...)
}
}
if npthPath != "" {
if holes, err := ParseDrill(npthPath); err == nil {
drillHoles = append(drillHoles, holes...)
}
}
var filteredHoles []DrillHole
for _, h := range drillHoles {
if h.Type != DrillTypeVia {
filteredHoles = append(filteredHoles, h)
}
}
// Render layer images
var courtyardImg image.Image
if courtPath, ok := gerberPaths[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" {
if courtGf, err := ParseGerber(courtPath); err == nil {
courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds)
}
}
var soldermaskImg image.Image
if maskPath, ok := gerberPaths[jobResult.SoldermaskFile]; ok && jobResult.SoldermaskFile != "" {
if maskGf, err := ParseGerber(maskPath); err == nil {
soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds)
}
}
// Fab fallback for courtyard
if courtyardImg == nil && jobResult.FabFile != "" {
if fabPath, ok := gerberPaths[jobResult.FabFile]; ok {
if fabGf, err := ParseGerber(fabPath); err == nil {
courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds)
}
}
}
pixelToMM := 25.4 / ecfg.DPI
// Render ALL uploaded gerber layers
allLayers := make(map[string]image.Image)
allGerbers := make(map[string]*GerberFile)
for origName, fullPath := range gerberPaths {
if strings.HasSuffix(strings.ToLower(origName), ".gbrjob") {
continue
}
// Skip edge cuts — already rendered as outlineImg
if origName == jobResult.EdgeCutsFile {
allLayers[origName] = outlineImg
allGerbers[origName] = outlineGf
continue
}
gf, err := ParseGerber(fullPath)
if err != nil {
log.Printf("Warning: could not parse %s: %v", origName, err)
continue
}
allLayers[origName] = gf.Render(ecfg.DPI, &outlineBounds)
allGerbers[origName] = gf
}
// Build basenames map for persistence
gerberBasenames := make(map[string]string)
for origName, fullPath := range gerberPaths {
gerberBasenames[origName] = filepath.Base(fullPath)
}
sessionID := randomID()
session := &EnclosureSession{
Exports: exports,
OutlineGf: outlineGf,
OutlineImg: outlineImg,
CourtyardImg: courtyardImg,
SoldermaskImg: soldermaskImg,
DrillHoles: filteredHoles,
Config: ecfg,
OutlineBounds: outlineBounds,
BoardW: actualBoardW,
BoardH: actualBoardH,
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
MinBX: float64(minBX)*pixelToMM + outlineBounds.MinX,
MaxBX: float64(maxBX)*pixelToMM + outlineBounds.MinX,
BoardCenterY: outlineBounds.MaxY - boardCenterY*pixelToMM,
Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, pixelToMM, &outlineBounds),
GerberFiles: gerberBasenames,
DrillPath: filepath.Base(drillPath),
NPTHPath: filepath.Base(npthPath),
ProjectName: jobResult.ProjectName,
EdgeCutsFile: jobResult.EdgeCutsFile,
CourtyardFile: jobResult.CourtyardFile,
SoldermaskFile: jobResult.SoldermaskFile,
FabFile: jobResult.FabFile,
EnclosureWallImg: renderEnclosureWallImage(outlineImg, ecfg),
AllLayerImages: allLayers,
AllLayerGerbers: allGerbers,
}
log.Printf("Created session %s for project %s (%.1f x %.1f mm)", sessionID, jobResult.ProjectName, actualBoardW, actualBoardH)
return sessionID, session, nil
}
// UploadFabAndExtractFootprints processes fab gerber files and returns footprint data
func UploadFabAndExtractFootprints(session *EnclosureSession, fabPaths []string) ([]Footprint, image.Image) {
var allFootprints []Footprint
var fabGfList []*GerberFile
for _, path := range fabPaths {
gf, err := ParseGerber(path)
if err == nil {
allFootprints = append(allFootprints, ExtractFootprints(gf)...)
fabGfList = append(fabGfList, gf)
}
}
// Composite fab images
var fabImg image.Image
if len(fabGfList) > 0 {
bounds := session.OutlineBounds
imgW := int((bounds.MaxX - bounds.MinX) * session.Config.DPI / 25.4)
imgH := int((bounds.MaxY - bounds.MinY) * session.Config.DPI / 25.4)
if imgW > 0 && imgH > 0 {
composite := image.NewRGBA(image.Rect(0, 0, imgW, imgH))
draw.Draw(composite, composite.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
for _, gf := range fabGfList {
layerImg := gf.Render(session.Config.DPI, &bounds)
if rgba, ok := layerImg.(*image.RGBA); ok {
for y := 0; y < imgH; y++ {
for x := 0; x < imgW; x++ {
if r, _, _, _ := rgba.At(x, y).RGBA(); r > 0x7FFF {
composite.Set(x, y, color.RGBA{0, 255, 255, 180})
}
}
}
}
}
fabImg = composite
}
}
return allFootprints, fabImg
}
// GenerateEnclosureOutputs produces all requested output files for the enclosure
func GenerateEnclosureOutputs(session *EnclosureSession, cutouts []Cutout, outputDir string) ([]string, error) {
os.MkdirAll(outputDir, 0755)
// Split unified cutouts into legacy types for STL/SCAD generation
sideCutouts, lidCutouts := SplitCutouts(cutouts)
id := randomID()
var generatedFiles []string
wantsType := func(t string) bool {
for _, e := range session.Exports {
if e == t {
return true
}
}
return false
}
if len(session.Exports) == 0 {
session.Exports = []string{"stl"}
}
// STL
if wantsType("stl") {
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts, session.Sides)
encPath := filepath.Join(outputDir, id+"_enclosure.stl")
trayPath := filepath.Join(outputDir, id+"_tray.stl")
WriteSTL(encPath, result.EnclosureTriangles)
WriteSTL(trayPath, result.TrayTriangles)
generatedFiles = append(generatedFiles, encPath, trayPath)
}
// SCAD
if wantsType("scad") {
scadPathEnc := filepath.Join(outputDir, id+"_enclosure.scad")
scadPathTray := filepath.Join(outputDir, id+"_tray.scad")
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
generatedFiles = append(generatedFiles, scadPathEnc, scadPathTray)
}
// SVG
if wantsType("svg") && session.OutlineGf != nil {
svgPath := filepath.Join(outputDir, id+"_outline.svg")
WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds)
generatedFiles = append(generatedFiles, svgPath)
}
// PNG
if wantsType("png") && session.OutlineImg != nil {
pngPath := filepath.Join(outputDir, id+"_outline.png")
if f, err := os.Create(pngPath); err == nil {
png.Encode(f, session.OutlineImg)
f.Close()
generatedFiles = append(generatedFiles, pngPath)
}
}
return generatedFiles, nil
}
// SaveOutlineImage saves the outline image as PNG to a file
func SaveOutlineImage(session *EnclosureSession, path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, session.OutlineImg)
}
// CopyFile copies a file from src to dst
func CopyFile(src, dst string) error {
s, err := os.Open(src)
if err != nil {
return err
}
defer s.Close()
d, err := os.Create(dst)
if err != nil {
return err
}
defer d.Close()
_, err = io.Copy(d, s)
return err
}

15427
static/Former.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 996 KiB

217
static/Former_Alt.svg Normal file
View File

@ -0,0 +1,217 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100.34781mm"
height="100.34781mm"
viewBox="0 0 100.34781 100.34781"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
sodipodi:docname="Former.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="0.96756968"
inkscape:cx="159.67842"
inkscape:cy="203.60291"
inkscape:window-width="1920"
inkscape:window-height="1147"
inkscape:window-x="1800"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg1"
showgrid="false" />
<defs
id="defs1">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="-0.27345145 : -77.411507 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="209.72656 : -77.411507 : 1"
inkscape:persp3d-origin="104.72656 : -126.9115 : 1"
id="perspective43" />
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="501.67766 : 36.518513 : 1"
inkscape:vp_y="0 : -181.14384 : 0"
inkscape:vp_z="-620.91289 : 36.518513 : 1"
inkscape:persp3d-origin="-59.61763 : 45.485133 : 1"
id="perspective37" />
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="-301.47692 : 93.432223 : 1"
inkscape:vp_y="0 : -124.00936 : 0"
inkscape:vp_z="-331.61284 : 48.296543 : 1"
inkscape:persp3d-origin="313.00181 : 54.434983 : 1"
id="perspective1" />
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="-30.15488 : 45.957953 : 1"
inkscape:vp_y="0 : -32.81081 : 0"
inkscape:vp_z="-38.12834 : 34.015803 : 1"
inkscape:persp3d-origin="132.42596 : 35.639933 : 1"
id="perspective1-0" />
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="-28.701185 : 45.524883 : 1"
inkscape:vp_y="0 : -32.81081 : 0"
inkscape:vp_z="-36.674645 : 33.582733 : 1"
inkscape:persp3d-origin="133.87965 : 35.206863 : 1"
id="perspective1-2" />
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="212.76167 : -7.7584967 : 1"
inkscape:vp_y="0 : -47.927641 : 0"
inkscape:vp_z="-112.56885 : -7.7584967 : 1"
inkscape:persp3d-origin="50.0964 : -5.3860767 : 1"
id="perspective37-4" />
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="210.52305 : -19.135577 : 1"
inkscape:vp_y="0 : -47.927641 : 0"
inkscape:vp_z="-86.495696 : -19.135577 : 1"
inkscape:persp3d-origin="62.013671 : -16.763157 : 1"
id="perspective37-5" />
</defs>
<g
inkscape:groupmode="layer"
id="layer7"
inkscape:label="Layer 1"
transform="translate(-44.713747,-64.128802)" />
<g
id="g1"
transform="translate(9.844254,-9.8413531)">
<g
id="layer5"
inkscape:label="Layer 4"
style="stroke:#24db72;stroke-opacity:1"
transform="matrix(1.3883972,0,0,1.289212,-91.924665,-108.45675)">
<path
d="m 89.791895,138.26313 -23.31906,3.62375 v -8.13995 l 23.31906,-2.64885 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path1-1" />
<path
d="M 124.04082,141.44239 V 133.422 l -34.248925,-2.32392 v 7.16505 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path2-5" />
<path
d="m 124.04082,141.44239 -21.23125,4.61552 -36.336735,-4.17103 23.31906,-3.62375 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path3-80" />
<path
d="m 124.04082,133.422 -21.23125,3.3738 -36.336735,-3.04887 23.31906,-2.64885 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path4-9" />
<path
d="m 102.80957,146.05791 v -9.26211 l -36.336735,-3.04887 v 8.13995 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path5-2" />
<path
d="m 124.04082,141.44239 -21.23125,4.61552 v -9.26211 l 21.23125,-3.3738 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path6-4" />
</g>
<g
id="layer4"
inkscape:label="Layer 3"
style="stroke:#e51b8f;stroke-opacity:1"
transform="matrix(1.3883972,0,0,1.289212,-91.923901,-67.614303)">
<path
d="m 89.791351,122.21329 -23.319059,3.62374 v -8.13996 l 23.319059,-2.64884 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path1-0" />
<path
d="m 124.04027,125.39253 v -8.02036 l -34.248919,-2.32394 v 7.16506 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path2-3" />
<path
d="m 124.04027,125.39253 -21.23125,4.61554 -36.336728,-4.17104 23.319059,-3.62374 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path3-9" />
<path
d="m 124.04027,117.37217 -21.23125,3.37378 -36.336728,-3.04888 23.319059,-2.64884 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path4-4" />
<path
d="m 102.80902,130.00807 v -9.26212 l -36.336728,-3.04888 v 8.13996 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path5-9" />
<path
d="m 124.04027,125.39253 -21.23125,4.61554 v -9.26212 l 21.23125,-3.37378 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path6-65" />
</g>
<g
id="layer3"
inkscape:label="Layer 2"
transform="matrix(1.3883972,0,0,1.289212,-109.94614,-6.279169)"
style="stroke:#ecea47;stroke-opacity:1">
<path
d="M 102.77195,43.376986 79.452902,47.000733 V 38.860775 L 102.77195,36.21194 Z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path1-7" />
<path
d="M 137.02088,46.55624 V 38.535865 L 102.77195,36.21194 v 7.165046 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path2-6" />
<path
d="M 137.02088,46.55624 115.78964,51.171765 79.452902,47.000733 102.77195,43.376986 Z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path3-8" />
<path
d="m 137.02088,38.535865 -21.23124,3.37379 -36.336738,-3.04888 23.319048,-2.648835 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path4-3" />
<path
d="m 115.78964,51.171765 v -9.26211 l -36.336738,-3.04888 v 8.139958 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path5-8" />
<path
d="m 137.02088,46.55624 -21.23124,4.615525 v -9.26211 l 21.23124,-3.37379 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path6-6" />
</g>
<g
inkscape:label="Layer 1"
id="layer1"
style="fill:#424242;fill-opacity:1;stroke:#220eff;stroke-opacity:1"
transform="matrix(1.3883972,0,0,1.289212,-91.923146,-87.705827)">
<path
d="M 89.790811,90.737556 66.471752,94.3613 v -8.139957 l 23.319059,-2.648834 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path1" />
<path
d="M 124.03973,93.916808 V 85.896434 L 89.790811,83.572509 v 7.165047 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path2" />
<path
d="M 124.03973,93.916808 102.80849,98.532333 66.471752,94.3613 89.790811,90.737556 Z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path3" />
<path
d="m 124.03973,85.896434 -21.23124,3.37379 -36.336738,-3.048881 23.319059,-2.648834 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path4" />
<path
d="M 102.80849,98.532333 V 89.270224 L 66.471752,86.221343 V 94.3613 Z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path5" />
<path
d="m 124.03973,93.916808 -21.23124,4.615525 v -9.262109 l 21.23124,-3.37379 z"
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path6" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -64,10 +64,18 @@
<label for="height">Stencil Height (mm)</label>
<input type="number" id="height" name="height" value="0.16" step="0.01">
</div>
<div class="form-group">
<label for="lineWidth">Nozzle Line Width (mm)</label>
<input type="number" id="lineWidth" name="lineWidth" value="0.42" step="0.01">
<div class="hint">Pad sizes snap to multiples of this.</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="dpi">DPI</label>
<input type="number" id="dpi" name="dpi" value="1000" step="100">
</div>
<div class="form-group"></div>
</div>
<div class="form-row">
@ -150,6 +158,18 @@
<div class="spinner"></div>
<div>Processing... This may take 10-20 seconds.</div>
</div>
<!-- Instance Management Panel -->
<div id="instancePanel" style="display:none; margin-top: 1.5rem;">
<div id="profilesSection" style="display:none;">
<h3 style="font-size: 0.9rem; font-weight: 600; color: #374151; margin: 0 0 0.5rem 0;">Saved Profiles</h3>
<div id="profilesList" class="instance-list"></div>
</div>
<div id="recentsSection" style="display:none;">
<h3 style="font-size: 0.9rem; font-weight: 600; color: #6b7280; margin: 1rem 0 0.5rem 0;">Recent Enclosures</h3>
<div id="recentsList2" class="instance-list"></div>
</div>
</div>
</div>
<script>
@ -195,6 +215,7 @@
var config = {
stencilHeight: document.getElementById('height').value,
stencilDpi: document.getElementById('dpi').value,
stencilLineWidth: document.getElementById('lineWidth').value,
stencilWallHeight: document.getElementById('wallHeight').value,
stencilWallThickness: document.getElementById('wallThickness').value,
@ -219,6 +240,7 @@
if (config) {
if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight;
if (config.stencilDpi) document.getElementById('dpi').value = config.stencilDpi;
if (config.stencilLineWidth) document.getElementById('lineWidth').value = config.stencilLineWidth;
if (config.stencilWallHeight) document.getElementById('wallHeight').value = config.stencilWallHeight;
if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness;
@ -253,6 +275,108 @@
document.addEventListener('DOMContentLoaded', updateRecents);
// Removed old triggerExport since we now use multi-select checkboxes
// --- Instance Management ---
function loadServerInstances() {
fetch('/api/instances').then(function(r) { return r.json(); }).then(function(data) {
var panel = document.getElementById('instancePanel');
var hasContent = false;
// Profiles
if (data.profiles && data.profiles.length > 0) {
document.getElementById('profilesSection').style.display = 'block';
var list = document.getElementById('profilesList');
list.innerHTML = '';
data.profiles.forEach(function(inst) {
list.appendChild(createInstanceCard(inst, 'profile'));
});
hasContent = true;
}
// Recents
if (data.recents && data.recents.length > 0) {
document.getElementById('recentsSection').style.display = 'block';
var list2 = document.getElementById('recentsList2');
list2.innerHTML = '';
data.recents.forEach(function(inst) {
list2.appendChild(createInstanceCard(inst, 'recent'));
});
hasContent = true;
}
if (hasContent) panel.style.display = 'block';
}).catch(function(e) { console.log('No instances:', e); });
}
function createInstanceCard(inst, type) {
var card = document.createElement('div');
card.className = 'instance-card';
var title = inst.name || inst.projectName || 'Unnamed';
var dims = (inst.boardW || 0).toFixed(1) + ' x ' + (inst.boardH || 0).toFixed(1) + ' mm';
var cutouts = (inst.sideCutouts || []).length;
var date = inst.createdAt ? new Date(inst.createdAt).toLocaleDateString() : '';
card.innerHTML =
'<div class="instance-card-info" onclick="restoreInstance(\'' + inst.id + '\')">' +
'<div class="instance-card-title">' + escapeHtml(title) + '</div>' +
'<div class="instance-card-meta">' + dims + ' &middot; ' + cutouts + ' cutout' + (cutouts !== 1 ? 's' : '') + ' &middot; ' + date + '</div>' +
'</div>' +
'<div class="instance-card-actions">' +
(type === 'recent' ? '<button class="inst-btn inst-save" onclick="saveAsProfile(\'' + inst.id + '\')">Save</button>' : '') +
'<button class="inst-btn inst-del" onclick="deleteInstance(\'' + inst.id + '\',\'' + type + '\')">Del</button>' +
'</div>';
return card;
}
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function restoreInstance(id) {
document.getElementById('loading').style.display = 'block';
fetch('/api/instances/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id })
}).then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.json();
}).then(function(data) {
if (data.sideCutouts && data.sideCutouts.length > 0) {
sessionStorage.setItem('restoredCutouts', JSON.stringify(data.sideCutouts));
}
window.location.href = '/preview?id=' + data.sessionId;
}).catch(function(e) {
document.getElementById('loading').style.display = 'none';
alert('Restore failed: ' + e.message);
});
}
function saveAsProfile(id) {
var name = prompt('Save profile as:');
if (!name) return;
fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id, name: name })
}).then(function(r) {
if (!r.ok) throw new Error('Save failed');
loadServerInstances();
}).catch(function(e) { alert(e.message); });
}
function deleteInstance(id, type) {
if (!confirm('Delete this ' + type + '?')) return;
var url = type === 'profile' ? '/api/profiles/' + id : '/api/recents/' + id;
fetch(url, { method: 'DELETE' }).then(function() {
loadServerInstances();
});
}
document.addEventListener('DOMContentLoaded', loadServerInstances);
</script>
</body>

View File

@ -359,7 +359,13 @@
<label for="cutR">Corner Radius (mm)</label>
<input type="number" id="cutR" value="1.3" step="0.01">
</div>
<div class="form-group"></div>
<div class="form-group">
<label for="cutLayer">Board Layer</label>
<select id="cutLayer" style="font-size:0.85rem; padding:0.3rem;">
<option value="F">F (top)</option>
<option value="B">B (bottom)</option>
</select>
</div>
<div class="form-group"></div>
<div class="form-group">
<div class="unit-note">All values in mm (0.01mm precision)</div>
@ -388,6 +394,8 @@
<a href="/" class="btn secondary"
style="flex: 1; text-align: center; text-decoration: none; padding: 10px; border-radius: 4px; border: 1px solid var(--border-color); color: var(--text-color);">Go
Back</a>
<button type="button" id="btnSaveProfile" class="btn secondary"
style="flex: 0; padding: 10px 16px; border-radius: 4px; font-size: 0.85rem; cursor: pointer; white-space: nowrap;">Save</button>
<button type="submit" class="submit-btn" style="flex: 1; margin-top: 0;">Generate</button>
</div>
</form>
@ -824,7 +832,8 @@
y: parseFloat(document.getElementById('cutY').value) || 0,
w: parseFloat(document.getElementById('cutW').value) || 9,
h: parseFloat(document.getElementById('cutH').value) || 3.5,
r: parseFloat(document.getElementById('cutR').value) || 1.3
r: parseFloat(document.getElementById('cutR').value) || 1.3,
l: document.getElementById('cutLayer').value || 'F'
};
sideCutouts.push(c);
updateCutoutList();
@ -838,7 +847,9 @@
var div = document.createElement('div');
div.className = 'cutout-item';
var color = sideColors[(c.side - 1) % sideColors.length];
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span>&nbsp; ' +
var layerLabel = (c.l === 'B') ? 'B' : 'F';
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span> ' +
'<span style="background:#555;color:#fff;padding:0 3px;border-radius:2px;font-size:0.75rem;">' + layerLabel + '</span>&nbsp; ' +
c.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' +
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
'<button onclick="removeCutout(' + i + ')"></button>';
@ -973,6 +984,47 @@
}
});
// --- Restore cutouts from sessionStorage (instance restore) ---
(function() {
var stored = sessionStorage.getItem('restoredCutouts');
if (stored) {
sessionStorage.removeItem('restoredCutouts');
try {
var restored = JSON.parse(stored);
if (restored && restored.length > 0) {
sideCutouts = restored;
// Enable side editor
document.getElementById('optSideCutout').checked = true;
document.getElementById('sideEditor').classList.add('active');
updateCutoutList();
drawSideFace();
}
} catch(e) { console.log('Could not restore cutouts:', e); }
}
})();
// --- Save Profile Button ---
document.getElementById('btnSaveProfile').addEventListener('click', function() {
var name = prompt('Save profile as:');
if (!name) return;
fetch('/api/profiles/from-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: sessionId,
name: name,
sideCutouts: sideCutouts
})
}).then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.json();
}).then(function() {
alert('Profile saved!');
}).catch(function(e) {
alert('Save failed: ' + e.message);
});
});
function applyAlignment(fp, lip) {
var bx = fp.centerX;
var by = fp.centerY;
@ -1017,6 +1069,7 @@
var cutX = bestPosX - (9.0 / 2);
document.getElementById('cutX').value = cutX.toFixed(2);
document.getElementById('cutY').value = '0.00';
// Default to F layer for auto-aligned cutouts (user can change before adding)
currentSide = closestSide.num;
document.getElementById('btnAddCutout').click();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

View File

@ -390,4 +390,86 @@ input[type="file"] {
.help-popup-close:hover {
color: white;
}
/* Instance cards */
.instance-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.instance-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.75rem;
background: #f9fafb;
border: 1px solid var(--border);
border-radius: 6px;
transition: border-color 0.15s;
}
.instance-card:hover {
border-color: var(--primary);
}
.instance-card-info {
flex: 1;
cursor: pointer;
min-width: 0;
}
.instance-card-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.instance-card-meta {
font-size: 0.72rem;
color: #6b7280;
margin-top: 0.15rem;
}
.instance-card-actions {
display: flex;
gap: 0.3rem;
flex-shrink: 0;
margin-left: 0.5rem;
}
.inst-btn {
padding: 0.2rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: white;
font-size: 0.72rem;
cursor: pointer;
color: var(--text);
}
.inst-btn:hover {
background: #f3f4f6;
}
.inst-btn.inst-save {
color: #059669;
border-color: #a7f3d0;
}
.inst-btn.inst-save:hover {
background: #ecfdf5;
}
.inst-btn.inst-del {
color: #dc2626;
border-color: #fecaca;
}
.inst-btn.inst-del:hover {
background: #fef2f2;
}

403
stencil_process.go Normal file
View File

@ -0,0 +1,403 @@
package main
import (
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"strings"
)
// Config holds stencil generation parameters
type Config struct {
StencilHeight float64
WallHeight float64
WallThickness float64
LineWidth float64
DPI float64
KeepPNG bool
}
// Default values
const (
DefaultStencilHeight = 0.16
DefaultWallHeight = 2.0
DefaultWallThickness = 1.0
DefaultDPI = 1000.0
)
// ComputeWallMask generates a mask for the wall based on the outline image.
func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]int, []bool) {
bounds := img.Bounds()
w := bounds.Max.X
h := bounds.Max.Y
size := w * h
dx := []int{0, 0, 1, -1}
dy := []int{1, -1, 0, 0}
isOutline := make([]bool, size)
outlineQueue := []int{}
for i := 0; i < size; i++ {
cx := i % w
cy := i / w
c := img.At(cx, cy)
r, _, _, _ := c.RGBA()
if r > 10000 {
isOutline[i] = true
outlineQueue = append(outlineQueue, i)
}
}
gapClosingMM := 0.5
gapClosingPixels := int(gapClosingMM / pixelToMM)
if gapClosingPixels < 1 {
gapClosingPixels = 1
}
dist := make([]int, size)
for i := 0; i < size; i++ {
if isOutline[i] {
dist[i] = 0
} else {
dist[i] = -1
}
}
dilatedOutline := make([]bool, size)
copy(dilatedOutline, isOutline)
dQueue := make([]int, len(outlineQueue))
copy(dQueue, outlineQueue)
for len(dQueue) > 0 {
idx := dQueue[0]
dQueue = dQueue[1:]
d := dist[idx]
if d >= gapClosingPixels {
continue
}
cx := idx % w
cy := idx / w
for i := 0; i < 4; i++ {
nx, ny := cx+dx[i], cy+dy[i]
if nx >= 0 && nx < w && ny >= 0 && ny < h {
nIdx := ny*w + nx
if dist[nIdx] == -1 {
dist[nIdx] = d + 1
dilatedOutline[nIdx] = true
dQueue = append(dQueue, nIdx)
}
}
}
}
isOutside := make([]bool, size)
if !dilatedOutline[0] {
isOutside[0] = true
fQueue := []int{0}
for len(fQueue) > 0 {
idx := fQueue[0]
fQueue = fQueue[1:]
cx := idx % w
cy := idx / w
for i := 0; i < 4; i++ {
nx, ny := cx+dx[i], cy+dy[i]
if nx >= 0 && nx < w && ny >= 0 && ny < h {
nIdx := ny*w + nx
if !isOutside[nIdx] && !dilatedOutline[nIdx] {
isOutside[nIdx] = true
fQueue = append(fQueue, nIdx)
}
}
}
}
}
for i := 0; i < size; i++ {
if isOutside[i] {
dist[i] = 0
} else {
dist[i] = -1
}
}
oQueue := []int{}
for i := 0; i < size; i++ {
if isOutside[i] {
oQueue = append(oQueue, i)
}
}
isOutsideExpanded := make([]bool, size)
copy(isOutsideExpanded, isOutside)
for len(oQueue) > 0 {
idx := oQueue[0]
oQueue = oQueue[1:]
d := dist[idx]
if d >= gapClosingPixels {
continue
}
cx := idx % w
cy := idx / w
for i := 0; i < 4; i++ {
nx, ny := cx+dx[i], cy+dy[i]
if nx >= 0 && nx < w && ny >= 0 && ny < h {
nIdx := ny*w + nx
if dist[nIdx] == -1 {
dist[nIdx] = d + 1
isOutsideExpanded[nIdx] = true
oQueue = append(oQueue, nIdx)
}
}
}
}
isBoard := make([]bool, size)
for i := 0; i < size; i++ {
isBoard[i] = !isOutsideExpanded[i]
}
thicknessPixels := int(thicknessMM / pixelToMM)
if thicknessPixels < 1 {
thicknessPixels = 1
}
for i := 0; i < size; i++ {
if isBoard[i] {
dist[i] = 0
} else {
dist[i] = -1
}
}
wQueue := []int{}
for i := 0; i < size; i++ {
if isBoard[i] {
wQueue = append(wQueue, i)
}
}
wallDist := make([]int, size)
for i := range wallDist {
wallDist[i] = -1
}
for len(wQueue) > 0 {
idx := wQueue[0]
wQueue = wQueue[1:]
d := dist[idx]
if d >= thicknessPixels {
continue
}
cx := idx % w
cy := idx / w
for i := 0; i < 4; i++ {
nx, ny := cx+dx[i], cy+dy[i]
if nx >= 0 && nx < w && ny >= 0 && ny < h {
nIdx := ny*w + nx
if dist[nIdx] == -1 {
dist[nIdx] = d + 1
wallDist[nIdx] = d + 1
wQueue = append(wQueue, nIdx)
}
}
}
}
return wallDist, isBoard
}
func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3]Point {
pixelToMM := 25.4 / cfg.DPI
bounds := stencilImg.Bounds()
width := bounds.Max.X
height := bounds.Max.Y
var triangles [][3]Point
var wallDist []int
var boardMask []bool
if outlineImg != nil {
wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM)
}
for y := 0; y < height; y++ {
var startX = -1
var currentHeight = 0.0
for x := 0; x < width; x++ {
sc := stencilImg.At(x, y)
sr, sg, sb, _ := sc.RGBA()
isStencilSolid := sr < 10000 && sg < 10000 && sb < 10000
isWall := false
isInsideBoard := true
if wallDist != nil {
idx := y*width + x
isWall = wallDist[idx] >= 0
if boardMask != nil {
isInsideBoard = boardMask[idx]
}
}
h := 0.0
if isWall {
h = cfg.WallHeight
} else if isStencilSolid {
if isInsideBoard {
h = cfg.WallHeight
}
}
if h > 0 {
if startX == -1 {
startX = x
currentHeight = h
} else if h != currentHeight {
stripLen := x - startX
AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM,
float64(stripLen)*pixelToMM, pixelToMM, currentHeight)
startX = x
currentHeight = h
}
} else {
if startX != -1 {
stripLen := x - startX
AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM,
float64(stripLen)*pixelToMM, pixelToMM, currentHeight)
startX = -1
currentHeight = 0.0
}
}
}
if startX != -1 {
stripLen := width - startX
AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM,
float64(stripLen)*pixelToMM, pixelToMM, currentHeight)
}
}
return triangles
}
// processPCB handles stencil generation from gerber files
func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, image.Image, image.Image, error) {
baseName := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath))
var generatedFiles []string
wantsType := func(t string) bool {
for _, e := range exports {
if e == t {
return true
}
}
return false
}
if len(exports) == 0 {
exports = []string{"stl"}
}
fmt.Printf("Parsing %s...\n", gerberPath)
gf, err := ParseGerber(gerberPath)
if err != nil {
return nil, nil, nil, fmt.Errorf("error parsing gerber: %v", err)
}
var outlineGf *GerberFile
if outlinePath != "" {
fmt.Printf("Parsing outline %s...\n", outlinePath)
outlineGf, err = ParseGerber(outlinePath)
if err != nil {
return nil, nil, nil, fmt.Errorf("error parsing outline gerber: %v", err)
}
}
bounds := gf.CalculateBounds()
if outlineGf != nil {
outlineBounds := outlineGf.CalculateBounds()
if outlineBounds.MinX < bounds.MinX {
bounds.MinX = outlineBounds.MinX
}
if outlineBounds.MinY < bounds.MinY {
bounds.MinY = outlineBounds.MinY
}
if outlineBounds.MaxX > bounds.MaxX {
bounds.MaxX = outlineBounds.MaxX
}
if outlineBounds.MaxY > bounds.MaxY {
bounds.MaxY = outlineBounds.MaxY
}
}
margin := cfg.WallThickness + 5.0
bounds.MinX -= margin
bounds.MinY -= margin
bounds.MaxX += margin
bounds.MaxY += margin
fmt.Println("Rendering to internal image...")
img := gf.Render(cfg.DPI, &bounds)
var outlineImg image.Image
if outlineGf != nil {
outlineImg = outlineGf.Render(cfg.DPI, &bounds)
}
if cfg.KeepPNG {
pngPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".png"
f, err := os.Create(pngPath)
if err != nil {
log.Printf("Warning: Could not create PNG file: %v", err)
} else {
if err := png.Encode(f, img); err != nil {
log.Printf("Warning: Could not encode PNG: %v", err)
}
f.Close()
}
}
var triangles [][3]Point
if wantsType("stl") {
fmt.Println("Generating mesh...")
triangles = GenerateMeshFromImages(img, outlineImg, cfg)
}
if wantsType("stl") {
outputFilename := baseName + ".stl"
fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles))
if err := WriteSTL(outputFilename, triangles); err != nil {
return nil, nil, nil, fmt.Errorf("error writing stl: %v", err)
}
generatedFiles = append(generatedFiles, outputFilename)
}
if wantsType("svg") {
outputFilename := baseName + ".svg"
if err := WriteSVG(outputFilename, gf, &bounds); err != nil {
return nil, nil, nil, fmt.Errorf("error writing svg: %v", err)
}
generatedFiles = append(generatedFiles, outputFilename)
}
if wantsType("png") {
outputFilename := baseName + ".png"
if f, err := os.Create(outputFilename); err == nil {
png.Encode(f, img)
f.Close()
generatedFiles = append(generatedFiles, outputFilename)
}
}
if wantsType("scad") {
outputFilename := baseName + ".scad"
if err := WriteStencilSCAD(outputFilename, gf, outlineGf, cfg, &bounds); err != nil {
return nil, nil, nil, fmt.Errorf("error writing scad: %v", err)
}
generatedFiles = append(generatedFiles, outputFilename)
}
return generatedFiles, img, outlineImg, nil
}

85
stl.go Normal file
View File

@ -0,0 +1,85 @@
package main
import (
"encoding/binary"
"math"
"os"
)
// --- STL Types and Helpers ---
type Point struct {
X, Y, Z float64
}
func WriteSTL(filename string, triangles [][3]Point) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
header := make([]byte, 80)
copy(header, "Generated by pcb-to-stencil")
if _, err := f.Write(header); err != nil {
return err
}
count := uint32(len(triangles))
if err := binary.Write(f, binary.LittleEndian, count); err != nil {
return err
}
buf := make([]byte, 50)
for _, t := range triangles {
binary.LittleEndian.PutUint32(buf[0:4], math.Float32bits(0))
binary.LittleEndian.PutUint32(buf[4:8], math.Float32bits(0))
binary.LittleEndian.PutUint32(buf[8:12], math.Float32bits(0))
binary.LittleEndian.PutUint32(buf[12:16], math.Float32bits(float32(t[0].X)))
binary.LittleEndian.PutUint32(buf[16:20], math.Float32bits(float32(t[0].Y)))
binary.LittleEndian.PutUint32(buf[20:24], math.Float32bits(float32(t[0].Z)))
binary.LittleEndian.PutUint32(buf[24:28], math.Float32bits(float32(t[1].X)))
binary.LittleEndian.PutUint32(buf[28:32], math.Float32bits(float32(t[1].Y)))
binary.LittleEndian.PutUint32(buf[32:36], math.Float32bits(float32(t[1].Z)))
binary.LittleEndian.PutUint32(buf[36:40], math.Float32bits(float32(t[2].X)))
binary.LittleEndian.PutUint32(buf[40:44], math.Float32bits(float32(t[2].Y)))
binary.LittleEndian.PutUint32(buf[44:48], math.Float32bits(float32(t[2].Z)))
binary.LittleEndian.PutUint16(buf[48:50], 0)
if _, err := f.Write(buf); err != nil {
return err
}
}
return nil
}
func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) {
x0, y0 := x, y
x1, y1 := x+w, y+h
z0, z1 := 0.0, zHeight
p000 := Point{x0, y0, z0}
p100 := Point{x1, y0, z0}
p110 := Point{x1, y1, z0}
p010 := Point{x0, y1, z0}
p001 := Point{x0, y0, z1}
p101 := Point{x1, y0, z1}
p111 := Point{x1, y1, z1}
p011 := Point{x0, y1, z1}
addQuad := func(a, b, c, d Point) {
*triangles = append(*triangles, [3]Point{a, b, c})
*triangles = append(*triangles, [3]Point{c, d, a})
}
addQuad(p000, p010, p110, p100) // Bottom
addQuad(p101, p111, p011, p001) // Top
addQuad(p000, p100, p101, p001) // Front
addQuad(p100, p110, p111, p101) // Right
addQuad(p110, p010, p011, p111) // Back
addQuad(p010, p000, p001, p011) // Left
}

254
storage.go Normal file
View File

@ -0,0 +1,254 @@
package main
import (
"encoding/json"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func formerBaseDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "former")
}
func formerSessionsDir() string {
return filepath.Join(formerBaseDir(), "sessions")
}
func formerProfilesDir() string {
return filepath.Join(formerBaseDir(), "profiles")
}
func ensureFormerDirs() {
os.MkdirAll(formerSessionsDir(), 0755)
os.MkdirAll(formerProfilesDir(), 0755)
}
// ProjectEntry represents a saved project on disk
type ProjectEntry struct {
Path string
Type string // "session" or "profile"
Data InstanceData
ModTime time.Time
}
// 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, "", " ")
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]
}
return entries, nil
}
func listProjectsInDir(dir, projType string) ([]ProjectEntry, error) {
dirEntries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var entries []ProjectEntry
for _, de := range dirEntries {
if !de.IsDir() {
continue
}
jsonPath := filepath.Join(dir, de.Name(), "former.json")
info, err := os.Stat(jsonPath)
if err != nil {
continue
}
raw, err := os.ReadFile(jsonPath)
if err != nil {
continue
}
var inst InstanceData
if err := json.Unmarshal(raw, &inst); err != nil {
continue
}
entries = append(entries, ProjectEntry{
Path: filepath.Join(dir, de.Name()),
Type: projType,
Data: inst,
ModTime: info.ModTime(),
})
}
return entries, nil
}
// LoadProject reads former.json from a project directory
func LoadProject(projectDir string) (*InstanceData, error) {
raw, err := os.ReadFile(filepath.Join(projectDir, "former.json"))
if err != nil {
return nil, err
}
var inst InstanceData
if err := json.Unmarshal(raw, &inst); err != nil {
return nil, err
}
return &inst, nil
}
// 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)
}
// DeleteProject removes a project directory entirely
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"))
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, img)
}
func sanitizeDirName(name string) string {
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
return '-'
}
return r
}, name)
name = strings.TrimSpace(name)
if len(name) > 50 {
name = name[:50]
}
return name
}

56
svg.go
View File

@ -69,12 +69,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
}
if inRegion {
if cmd.Type == "MOVE" || cmd.Type == "DRAW" && interpolationMode == "G01" {
if cmd.Type == "MOVE" || (cmd.Type == "DRAW" && interpolationMode == "G01") {
regionVertices = append(regionVertices, [2]float64{curX, curY})
} else if cmd.Type == "DRAW" && (interpolationMode == "G02" || interpolationMode == "G03") {
// We don't have perfect analytic translation to SVG path for region arcs yet.
// We can just output the line for now, or approximate it as before.
// For SVG, we can just output line segments just like we did for image processing.
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
@ -82,29 +79,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
if cmd.J != nil {
jVal = *cmd.J
}
centerX, centerY := prevX+iVal, prevY+jVal
radius := math.Sqrt(iVal*iVal + jVal*jVal)
startAngle := math.Atan2(prevY-centerY, prevX-centerX)
endAngle := math.Atan2(curY-centerY, curX-centerX)
if interpolationMode == "G03" {
if endAngle <= startAngle {
endAngle += 2 * math.Pi
}
} else {
if startAngle <= endAngle {
startAngle += 2 * math.Pi
}
}
arcLen := math.Abs(endAngle-startAngle) * radius
steps := int(arcLen * 10) // 10 segments per mm
if steps < 10 {
steps = 10
}
for s := 1; s <= steps; s++ {
t := float64(s) / float64(steps)
a := startAngle + t*(endAngle-startAngle)
ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a)
regionVertices = append(regionVertices, [2]float64{ax, ay})
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
for _, pt := range arcPts {
regionVertices = append(regionVertices, pt)
}
}
continue
@ -136,15 +113,26 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
jVal = *cmd.J
}
// SVG path Arc
rx, ry := math.Sqrt(iVal*iVal+jVal*jVal), math.Sqrt(iVal*iVal+jVal*jVal)
sweep := 1 // G03 CCW -> SVG path sweep up due to inverted Y
if interpolationMode == "G02" {
sweep = 0
// SVG path arc (Y-axis inverted: G02 CW -> CCW in SVG, G03 CCW -> CW in SVG)
r := math.Sqrt(iVal*iVal + jVal*jVal)
acx, acy := prevX+iVal, prevY+jVal
sa := math.Atan2(prevY-acy, prevX-acx)
ea := math.Atan2(curY-acy, curX-acx)
var arcSpan float64
if interpolationMode == "G03" {
if ea <= sa { ea += 2 * math.Pi }
arcSpan = ea - sa
} else {
if sa <= ea { sa += 2 * math.Pi }
arcSpan = sa - ea
}
largeArc := 0
if arcSpan > math.Pi { largeArc = 1 }
sweep := 1 // G03 CCW Gerber -> CW SVG
if interpolationMode == "G02" { sweep = 0 }
fmt.Fprintf(f, `<path d="M %f %f A %f %f 0 0 %d %f %f" stroke-width="%f" fill="none" stroke-linecap="round"/>`+"\n",
toSVGX(prevX), toSVGY(prevY), rx, ry, sweep, toSVGX(curX), toSVGY(curY), w)
fmt.Fprintf(f, `<path d="M %f %f A %f %f 0 %d %d %f %f" stroke-width="%f" fill="none" stroke-linecap="round"/>` + "\n",
toSVGX(prevX), toSVGY(prevY), r, r, largeArc, sweep, toSVGX(curX), toSVGY(curY), w)
}
}
}

117
svg_render_darwin.go Normal file
View File

@ -0,0 +1,117 @@
package main
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
#import <AppKit/AppKit.h>
#import <stdlib.h>
// Renders SVG data to raw RGBA pixels using macOS native NSImage.
// Returns NULL on failure. Caller must free() the returned pixels.
unsigned char* nativeRenderSVG(const void* svgBytes, int svgLen, int targetW, int targetH) {
@autoreleasepool {
NSData *data = [NSData dataWithBytesNoCopy:(void*)svgBytes length:svgLen freeWhenDone:NO];
NSImage *svgImage = [[NSImage alloc] initWithData:data];
if (!svgImage) return NULL;
int w = targetW;
int h = targetH;
int rowBytes = w * 4;
int totalBytes = rowBytes * h;
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc]
initWithBitmapDataPlanes:NULL
pixelsWide:w
pixelsHigh:h
bitsPerSample:8
samplesPerPixel:4
hasAlpha:YES
isPlanar:NO
colorSpaceName:NSDeviceRGBColorSpace
bytesPerRow:rowBytes
bitsPerPixel:32];
[NSGraphicsContext saveGraphicsState];
NSGraphicsContext *ctx = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep];
[NSGraphicsContext setCurrentContext:ctx];
// Start with fully transparent background
[[NSColor clearColor] set];
NSRectFill(NSMakeRect(0, 0, w, h));
// Draw SVG, preserving alpha
[svgImage drawInRect:NSMakeRect(0, 0, w, h)
fromRect:NSZeroRect
operation:NSCompositingOperationSourceOver
fraction:1.0];
[NSGraphicsContext restoreGraphicsState];
unsigned char* result = (unsigned char*)malloc(totalBytes);
if (result) {
memcpy(result, [rep bitmapData], totalBytes);
}
return result;
}
}
*/
import "C"
import (
"image"
"image/color"
"unsafe"
)
// renderSVGNative uses macOS NSImage to render SVG data to an image.Image
// with full transparency support.
func renderSVGNative(svgData []byte, width, height int) image.Image {
if len(svgData) == 0 {
return nil
}
pixels := C.nativeRenderSVG(
unsafe.Pointer(&svgData[0]),
C.int(len(svgData)),
C.int(width),
C.int(height),
)
if pixels == nil {
return nil
}
defer C.free(unsafe.Pointer(pixels))
rawLen := width * height * 4
raw := unsafe.Slice((*byte)(unsafe.Pointer(pixels)), rawLen)
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
i := (y*width + x) * 4
r, g, b, a := raw[i], raw[i+1], raw[i+2], raw[i+3]
// NSImage gives premultiplied alpha — convert to straight
if a > 0 && a < 255 {
scale := 255.0 / float64(a)
r = clampByte(float64(r) * scale)
g = clampByte(float64(g) * scale)
b = clampByte(float64(b) * scale)
}
img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
}
}
return img
}
func clampByte(v float64) uint8 {
if v > 255 {
return 255
}
if v < 0 {
return 0
}
return uint8(v)
}

11
svg_render_other.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !darwin
package main
import "image"
// renderSVGNative is a no-op on non-macOS platforms.
// Returns nil, causing callers to fall back to Fyne's built-in renderer.
func renderSVGNative(svgData []byte, width, height int) image.Image {
return nil
}

12
util.go Normal file
View File

@ -0,0 +1,12 @@
package main
import (
"crypto/rand"
"encoding/hex"
)
func randomID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}

12
wails.json Normal file
View File

@ -0,0 +1,12 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "Former",
"outputfilename": "Former",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": ""
}
}