Initial commit: Former — PCB Stencil & Enclosure Generator

Desktop application (Wails v2) for generating 3D-printable solder paste
stencils and snap-fit enclosures from KiCad/Gerber files. Features include
native OpenSCAD export, interactive 3D layer viewer, visual cutout placement,
automatic USB port detection, and CLI mode for batch workflows.

Based on pcb-to-stencil by Nikolai Danylchyk (https://github.com/kennycoder/pcb-to-stencil).
This commit is contained in:
pszsh 2026-02-26 15:27:05 -08:00
commit 5d09f56e00
37 changed files with 11055 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Build output
build/
bin/
# Dependencies
frontend/node_modules/
frontend/dist/
frontend/wailsjs/
frontend/package-lock.json
# Temp / working files
temp/
data/
.DS_Store
*.DS_Store
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
# IDE
.vscode/
.idea/
*.swp
*.swo
*~

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "static/vectors"]
path = static/vectors
url = git@ssh-git.else-if.org:jess/FormerStaticVectors.git

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

95
README.md Normal file
View File

@ -0,0 +1,95 @@
# Former
A desktop application for generating 3D-printable solder paste stencils and snap-fit enclosures from KiCad/Gerber files.
![Former](static/showcase.png)
## Features
**Stencil Generation**
- Parses RS-274X Gerber solder paste layers
- Supports standard apertures (Circle, Rectangle, Obround) and Aperture Macros with rotation
- Generates optimized STL meshes for 3D printing
- Automatic cropping to PCB bounds
**Enclosure Generation**
- Generates snap-fit enclosures with lids and trays from KiCad `.gbrjob` projects
- Native OpenSCAD `.scad` export for parametric editing
- Interactive 3D layer viewer with per-layer colorization (KiCad color scheme)
- Visual cutout placement on any surface (top, bottom, sides) with live 3D preview
- Automatic USB port detection and cutout alignment from F.Fab/B.Fab layers
- Tray clip system with vertical relief slots
- Dado/engrave mode for surface text and logos
**Desktop App**
- Native macOS and Windows application (Wails v2)
- Project saving and loading with persistent cutout state
- CLI mode for scripted/batch workflows
## Install
### Requirements
- [Go](https://go.dev/dl/) 1.21+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation): `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- [Node.js](https://nodejs.org/) 18+ (for frontend build)
- macOS: Xcode Command Line Tools (`xcode-select --install`)
### Build
**macOS:**
```bash
./build.sh
```
**Windows (native):**
```bat
build-windows.bat
```
**Cross-compile for Windows (from macOS/Linux):**
```bash
./build-windows.sh
```
The built application will be at `build/bin/Former.app` (macOS) or `build/bin/Former.exe` (Windows).
## CLI Usage
Former can also run headless from the command line:
```bash
./Former [options] <paste_layer.gbr> [outline.gbr]
```
### Options
| Flag | Default | Description |
|------|---------|-------------|
| `--height` | 0.16 | Stencil height in mm |
| `--wall-height` | 2.0 | Wall height in mm |
| `--wall-thickness` | 1.0 | Wall thickness in mm |
| `--dpi` | 1016 | Rendering DPI |
| `--keep-png` | false | Save intermediate PNG |
### Example
```bash
./Former --height=0.16 --keep-png my_board-F_Paste.gbr my_board-Edge_Cuts.gbr
```
## 3D Printing Tips
For fine-pitch SMD stencils (TSSOP, 0402, etc.):
- **Nozzle**: 0.2mm recommended
- **Layer height**: 0.16mm total (0.10mm first layer + 0.06mm second)
- **Build surface**: Smooth PEI sheet for flat stencil bottom
## Acknowledgments
Former began as a fork of [pcb-to-stencil](https://github.com/kennycoder/pcb-to-stencil) by [Nikolai Danylchyk](https://github.com/kennycoder), a Go tool for converting Gerber paste layers to STL stencils. The original Gerber parser and STL mesh generation formed the foundation that Former builds upon.
## License
MIT License — see [LICENSE](LICENSE) for details.

981
app.go Normal file
View File

@ -0,0 +1,981 @@
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
projectDir string // path to the current project directory (for auto-saving)
formerLayers []*FormerLayer
stencilFiles []string
}
func NewApp(imageServer *ImageServer) *App {
return &App{
imageServer: imageServer,
}
}
// autosaveCutouts persists the current cutouts to the project directory's former.json
func (a *App) autosaveCutouts() {
a.mu.RLock()
dir := a.projectDir
cutouts := make([]Cutout, len(a.cutouts))
copy(cutouts, a.cutouts)
a.mu.RUnlock()
if dir == "" {
return
}
if err := UpdateProjectCutouts(dir, cutouts); err != nil {
log.Printf("autosave cutouts failed: %v", err)
}
}
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()
a.cutouts = append(a.cutouts, Cutout{
ID: randomID(),
Surface: "side",
SideNum: side,
X: x,
Y: y,
Width: w,
Height: h,
CornerRadius: radius,
SourceLayer: layer,
})
a.mu.Unlock()
a.autosaveCutouts()
}
func (a *App) RemoveSideCutout(index int) {
a.mu.Lock()
// 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:]...)
break
}
count++
}
}
a.mu.Unlock()
a.autosaveCutouts()
}
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()
if c.ID == "" {
c.ID = randomID()
}
a.cutouts = append(a.cutouts, c)
a.mu.Unlock()
a.autosaveCutouts()
return c.ID
}
func (a *App) UpdateCutout(c Cutout) {
a.mu.Lock()
for i, existing := range a.cutouts {
if existing.ID == c.ID {
a.cutouts[i] = c
break
}
}
a.mu.Unlock()
a.autosaveCutouts()
}
func (a *App) RemoveCutout(id string) {
a.mu.Lock()
for i, c := range a.cutouts {
if c.ID == id {
a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...)
break
}
}
a.mu.Unlock()
a.autosaveCutouts()
}
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()
var dupID string
for _, c := range a.cutouts {
if c.ID == id {
dup := c
dup.ID = randomID()
if dup.Surface == "side" {
dup.X += 1.0
} else {
dup.X += 1.0
dup.Y += 1.0
}
a.cutouts = append(a.cutouts, dup)
dupID = dup.ID
break
}
}
a.mu.Unlock()
a.autosaveCutouts()
return dupID
}
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()
if a.enclosureSession == nil {
a.mu.Unlock()
return
}
bounds := a.enclosureSession.OutlineBounds
dpi := a.enclosureSession.Config.DPI
surface := "top"
if plane == "tray" {
surface = "bottom"
}
for _, el := range elements {
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,
})
}
a.mu.Unlock()
a.autosaveCutouts()
}
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()
var kept []Cutout
for _, c := range a.cutouts {
if c.Surface == "side" {
kept = append(kept, c)
}
}
a.cutouts = kept
a.mu.Unlock()
a.autosaveCutouts()
}
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 savedDir, saveErr := SaveSession(inst, filepath.Join(".", "temp"), session.OutlineImg); saveErr != nil {
log.Printf("Warning: could not save session: %v", saveErr)
} else {
a.mu.Lock()
a.projectDir = savedDir
a.mu.Unlock()
}
// 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")
}
savedDir, err := SaveProfile(inst, name, sourceDir, session.OutlineImg)
if err == nil && savedDir != "" {
a.mu.Lock()
a.projectDir = savedDir
a.mu.Unlock()
}
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.projectDir = projectPath
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()
}

16
build-windows.bat Normal file
View File

@ -0,0 +1,16 @@
@echo off
REM Build Former for Windows — run this on a Windows machine with Go and Wails installed
REM Prerequisites: Go 1.21+, Wails CLI (go install github.com/wailsapp/wails/v2/cmd/wails@latest)
echo Generating app icon...
go run ./cmd/genicon 2>nul && echo Icon generated. || echo Icon generation skipped.
echo Building Former for Windows...
wails build -skipbindings
if %ERRORLEVEL% NEQ 0 (
echo Build failed.
exit /b 1
)
echo.
echo Done: build\bin\Former.exe

20
build-windows.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
# Cross-compile Former for Windows (amd64) from macOS/Linux
set -e
# Generate app icon (needs CGO on macOS for native SVG rendering)
echo "Generating app icon..."
if [[ "$OSTYPE" == darwin* ]]; then
SDKROOT=$(xcrun --show-sdk-path) CC=/usr/bin/clang CGO_ENABLED=1 \
go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped."
else
CGO_ENABLED=0 go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped."
fi
WAILS=$(command -v wails || echo "$HOME/go/bin/wails")
echo "Building Former for Windows (amd64)..."
CGO_ENABLED=0 "$WAILS" build -skipbindings -platform windows/amd64
echo ""
ls -lh build/bin/Former.exe 2>/dev/null && echo "Done." || echo "Build failed."

19
build.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Build Former for macOS
set -e
pkill -f "Former.app" || true
sleep 0.5
export SDKROOT=$(xcrun --show-sdk-path)
export CC=/usr/bin/clang
export CGO_ENABLED=1
WAILS=$(command -v wails || echo "$HOME/go/bin/wails")
# Generate app icon from Former.svg
echo "Generating app icon..."
go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped."
"$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/vectors/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)
}

189
drill.go Normal file
View File

@ -0,0 +1,189 @@
package main
import (
"bufio"
"math"
"os"
"regexp"
"strconv"
"strings"
)
// DrillHoleType classifies a drill hole by function
type DrillHoleType int
const (
DrillTypeUnknown DrillHoleType = iota
DrillTypeVia // ViaDrill — ignore for enclosure
DrillTypeComponent // ComponentDrill — component leads
DrillTypeMounting // Mounting holes (from NPTH)
)
// DrillHole represents a single drill hole with position, diameter, and type
type DrillHole struct {
X, Y float64 // Position in mm
Diameter float64 // Diameter in mm
Type DrillHoleType // Classified by TA.AperFunction
ToolNum int // Tool number (T1, T2, etc.)
}
// ParseDrill parses an Excellon drill file and returns hole positions
func ParseDrill(filename string) ([]DrillHole, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
var holes []DrillHole
type toolInfo struct {
diameter float64
holeType DrillHoleType
}
tools := make(map[int]toolInfo)
currentTool := 0
inHeader := true
units := "MM"
isNPTH := false
// Format spec
formatDec := 0
// Pending aperture function for the next tool definition
pendingType := DrillTypeUnknown
scanner := bufio.NewScanner(file)
reToolDef := regexp.MustCompile(`^T(\d+)C([\d.]+)`)
reToolSelect := regexp.MustCompile(`^T(\d+)$`)
reCoord := regexp.MustCompile(`^X([+-]?[\d.]+)Y([+-]?[\d.]+)`)
reAperFunc := regexp.MustCompile(`TA\.AperFunction,(\w+),(\w+),(\w+)`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Check file function for NPTH
if strings.Contains(line, "TF.FileFunction") {
if strings.Contains(line, "NonPlated") || strings.Contains(line, "NPTH") {
isNPTH = true
}
}
// Parse TA.AperFunction comments (appears before tool definition)
if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "G04") {
m := reAperFunc.FindStringSubmatch(line)
if len(m) >= 4 {
funcType := m[3]
switch funcType {
case "ViaDrill":
pendingType = DrillTypeVia
case "ComponentDrill":
pendingType = DrillTypeComponent
default:
pendingType = DrillTypeUnknown
}
}
// Also check for format spec
if strings.HasPrefix(line, ";FORMAT=") {
re := regexp.MustCompile(`\{(\d+):(\d+)\}`)
fm := re.FindStringSubmatch(line)
if len(fm) == 3 {
formatDec, _ = strconv.Atoi(fm[2])
}
}
continue
}
// Detect header end
if line == "%" || line == "M95" {
inHeader = false
continue
}
// Units
if strings.Contains(line, "METRIC") || line == "M71" {
units = "MM"
continue
}
if strings.Contains(line, "INCH") || line == "M72" {
units = "IN"
continue
}
// Tool definitions (in header): T01C0.300
if inHeader {
m := reToolDef.FindStringSubmatch(line)
if len(m) == 3 {
toolNum, _ := strconv.Atoi(m[1])
dia, _ := strconv.ParseFloat(m[2], 64)
ht := pendingType
// If this is an NPTH file and type is unknown, classify as mounting
if isNPTH && ht == DrillTypeUnknown {
ht = DrillTypeMounting
}
tools[toolNum] = toolInfo{diameter: dia, holeType: ht}
pendingType = DrillTypeUnknown // Reset
continue
}
}
// Tool selection: T01
m := reToolSelect.FindStringSubmatch(line)
if len(m) == 2 {
toolNum, _ := strconv.Atoi(m[1])
currentTool = toolNum
continue
}
// End of file
if line == "M30" || line == "M00" {
break
}
// Coordinate: X123456Y789012
mc := reCoord.FindStringSubmatch(line)
if len(mc) == 3 && currentTool != 0 {
x := parseExcellonCoord(mc[1], formatDec)
y := parseExcellonCoord(mc[2], formatDec)
ti := tools[currentTool]
dia := ti.diameter
// Convert inches to mm if needed
if units == "IN" {
x *= 25.4
y *= 25.4
if dia < 1.0 {
dia *= 25.4
}
}
holes = append(holes, DrillHole{
X: x,
Y: y,
Diameter: dia,
Type: ti.holeType,
ToolNum: currentTool,
})
}
}
return holes, nil
}
func parseExcellonCoord(s string, fmtDec int) float64 {
if strings.Contains(s, ".") {
val, _ := strconv.ParseFloat(s, 64)
return val
}
val, _ := strconv.ParseFloat(s, 64)
if fmtDec > 0 {
return val / math.Pow(10, float64(fmtDec))
}
return val / 1000.0
}

1217
enclosure.go Normal file

File diff suppressed because it is too large Load Diff

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>

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

1344
frontend/src/former3d.js Normal file

File diff suppressed because it is too large Load Diff

1714
frontend/src/main.js Normal file

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,713 @@
/* Former — Dark Theme (Google AI Studio Match) */
:root {
--bg-base: #131314;
--bg-surface: #1e1f20;
--bg-overlay: #282a2c;
--bg-input: #1e1f20;
--text-primary: #e3e3e3;
--text-secondary: #c4c7c5;
--text-subtle: #8e918f;
--accent: #e3e3e3;
--accent-hover: #ffffff;
--accent-dim: rgba(227, 227, 227, 0.1);
--success: #81c995;
--error: #f28b82;
--warning: #fdd663;
--border: #444746;
--border-light: #333638;
--radius: 12px;
--radius-sm: 8px;
--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: 44px;
flex-shrink: 0;
background: var(--bg-base);
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;
font-weight: 500;
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: 500;
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: 32px 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.5) 100%);
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.2) 100%);
}
.landing-bg-logo {
width: 50%;
height: auto;
opacity: 0.8;
filter: brightness(1.4);
}
.landing-hero {
text-align: center;
padding: 0 0 32px;
position: relative;
z-index: 1;
}
.landing-hero h1 {
font-size: 32px;
font-weight: 500;
letter-spacing: -0.5px;
margin-bottom: 8px;
}
.landing-hero p {
color: var(--text-secondary);
font-size: 15px;
}
.landing-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 40px;
position: relative;
z-index: 1;
}
.action-card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 24px;
cursor: pointer;
transition: all var(--transition);
text-align: left;
}
.action-card:hover {
border-color: var(--border);
background: var(--bg-overlay);
}
.action-card h3 {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-primary);
}
.action-card p {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
/* Section titles */
.section-title {
font-size: 12px;
font-weight: 500;
color: var(--text-subtle);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 16px;
}
/* Recent projects */
.project-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.project-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
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(--border);
background: var(--bg-overlay);
}
.project-name {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.project-meta {
font-size: 12px;
color: var(--text-subtle);
}
.badge {
font-size: 10px;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-base);
border: 1px solid var(--border-light);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Cards */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 16px;
}
/* Form elements */
.form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.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: 8px 12px;
outline: none;
transition: all var(--transition);
}
.form-input:focus {
border-color: var(--text-secondary);
outline: 1px solid var(--text-secondary);
}
.form-input::placeholder {
color: var(--text-subtle);
}
select.form-input {
cursor: pointer;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn:hover {
background: var(--bg-overlay);
border-color: var(--text-secondary);
}
.btn-primary {
background: var(--text-primary);
border-color: var(--text-primary);
color: var(--bg-base);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-danger {
color: var(--error);
border-color: var(--border);
}
.btn-danger:hover {
background: rgba(242, 139, 130, 0.1);
border-color: var(--error);
}
/* File picker row */
.file-row {
display: flex;
align-items: center;
gap: 12px;
}
.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: 6px;
font-size: 13px;
cursor: pointer;
}
.check-label input[type="checkbox"] {
accent-color: var(--text-primary);
}
/* Action bar */
.action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px;
}
.action-bar .spacer {
flex: 1;
}
/* Page header */
.page-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.page-header h2 {
font-size: 18px;
font-weight: 500;
}
/* Board preview canvas */
.board-canvas-wrap {
background: var(--bg-base);
border: 1px solid var(--border-light);
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: 6px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.side-tab {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
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(--text-primary);
border-color: var(--text-primary);
color: var(--bg-base);
}
/* Cutout list */
.cutout-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.cutout-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-base);
border: 1px solid var(--border-light);
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);
border: 1px solid var(--border-light);
padding: 16px;
border-radius: var(--radius-sm);
margin-bottom: 20px;
}
/* Loading spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--text-primary);
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(19, 19, 20, 0.8);
z-index: 100;
flex-direction: column;
gap: 16px;
}
.loading-overlay .spinner {
width: 32px;
height: 32px;
border-width: 3px;
}
.loading-text {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
/* ===== 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: 280px;
background: var(--bg-base);
border-left: 1px solid var(--border-light);
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.former-sidebar-header {
padding: 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: 500;
}
.former-layers {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.former-layer-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.former-layer-row:hover {
background: var(--bg-surface);
}
.former-layer-row.highlighted {
background: var(--bg-overlay);
}
.former-layer-row.selected {
background: var(--bg-surface);
border-left: 2px solid var(--text-primary);
padding-left: 8px;
}
.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: 16px;
border-top: 1px solid var(--border-light);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-light);
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: 8px;
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',
},
});

108
gbrjob.go Normal file
View File

@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// GerberJob represents a KiCad .gbrjob file
type GerberJob struct {
Header struct {
GenerationSoftware struct {
Vendor string `json:"Vendor"`
Application string `json:"Application"`
Version string `json:"Version"`
} `json:"GenerationSoftware"`
} `json:"Header"`
GeneralSpecs struct {
ProjectId struct {
Name string `json:"Name"`
} `json:"ProjectId"`
Size struct {
X float64 `json:"X"`
Y float64 `json:"Y"`
} `json:"Size"`
BoardThickness float64 `json:"BoardThickness"`
} `json:"GeneralSpecs"`
FilesAttributes []struct {
Path string `json:"Path"`
FileFunction string `json:"FileFunction"`
FilePolarity string `json:"FilePolarity"`
} `json:"FilesAttributes"`
}
// GerberJobResult contains the auto-discovered file assignments
type GerberJobResult struct {
ProjectName string
BoardWidth float64 // mm
BoardHeight float64 // mm
BoardThickness float64 // mm
EdgeCutsFile string // Profile
FabFile string // AssemblyDrawing,Top
CourtyardFile string // matches courtyard naming
SoldermaskFile string // matches mask naming
}
// ParseGerberJob parses a .gbrjob file and returns auto-discovered file mappings
func ParseGerberJob(filename string) (*GerberJobResult, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read gbrjob: %w", err)
}
var job GerberJob
if err := json.Unmarshal(data, &job); err != nil {
return nil, fmt.Errorf("parse gbrjob JSON: %w", err)
}
result := &GerberJobResult{
ProjectName: job.GeneralSpecs.ProjectId.Name,
BoardWidth: job.GeneralSpecs.Size.X,
BoardHeight: job.GeneralSpecs.Size.Y,
BoardThickness: job.GeneralSpecs.BoardThickness,
}
// Map FileFunction to our layer types
for _, f := range job.FilesAttributes {
fn := strings.ToLower(f.FileFunction)
path := f.Path
switch {
case fn == "profile":
result.EdgeCutsFile = path
case strings.HasPrefix(fn, "assemblydrawing"):
// F.Fab = AssemblyDrawing,Top
if strings.Contains(fn, "top") {
result.FabFile = path
}
}
// Also match by filename patterns for courtyard/mask
lp := strings.ToLower(path)
switch {
case strings.Contains(lp, "courtyard") || strings.Contains(lp, "courty"):
if strings.Contains(lp, "f_courty") || strings.Contains(lp, "f.courty") || strings.Contains(lp, "-f_courty") {
result.CourtyardFile = path
}
case strings.Contains(lp, "mask") || strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask"):
if strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask") || strings.Contains(lp, "-f_mask") {
result.SoldermaskFile = path
}
}
}
fmt.Printf("GerberJob: project=%s, board=%.1fx%.1fmm, thickness=%.1fmm\n",
result.ProjectName, result.BoardWidth, result.BoardHeight, result.BoardThickness)
fmt.Printf(" EdgeCuts: %s\n", result.EdgeCutsFile)
fmt.Printf(" F.Fab: %s\n", result.FabFile)
fmt.Printf(" F.Courtyard: %s\n", result.CourtyardFile)
fmt.Printf(" F.Mask: %s\n", result.SoldermaskFile)
if result.EdgeCutsFile == "" {
return nil, fmt.Errorf("no Edge.Cuts (Profile) layer found in gbrjob")
}
return result, nil
}

1004
gerber.go Normal file

File diff suppressed because it is too large Load Diff

36
go.mod Normal file
View File

@ -0,0 +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
}

113
main.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"context"
"embed"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
)
//go:embed all:frontend/dist
var assets embed.FS
//go:embed static/vectors/Former.svg
var formerLogoSVG []byte
func main() {
// CLI flags
flagHeight := flag.Float64("height", DefaultStencilHeight, "Stencil height in mm")
flagWallHeight := flag.Float64("wall-height", DefaultWallHeight, "Wall height in mm")
flagWallThickness := flag.Float64("wall-thickness", DefaultWallThickness, "Wall thickness in mm")
flagDPI := flag.Float64("dpi", DefaultDPI, "DPI for rendering")
flagKeepPNG := flag.Bool("keep-png", false, "Save intermediate PNG file")
flag.Parse()
// If files are passed as arguments, run in CLI mode
if flag.NArg() > 0 {
cfg := Config{
StencilHeight: *flagHeight,
WallHeight: *flagWallHeight,
WallThickness: *flagWallThickness,
DPI: *flagDPI,
KeepPNG: *flagKeepPNG,
}
runCLI(cfg, flag.Args())
return
}
// Ensure working directories exist
os.MkdirAll("temp", 0755)
ensureFormerDirs()
runGUI()
}
func runCLI(cfg Config, args []string) {
if len(args) < 1 {
fmt.Println("Usage: former [options] <gerber_file> [outline_file]")
fmt.Println(" former (no args = launch GUI)")
flag.PrintDefaults()
os.Exit(1)
}
gerberPath := args[0]
var outlinePath string
if len(args) > 1 {
outlinePath = args[1]
}
_, _, _, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"})
if err != nil {
log.Fatalf("Error: %v", err)
}
fmt.Println("Success! Happy printing.")
}
func runGUI() {
imageServer := NewImageServer()
app := NewApp(imageServer)
err := wails.Run(&options.App{
Title: "Former",
Width: 960,
Height: 720,
MinWidth: 640,
MinHeight: 480,
AssetServer: &assetserver.Options{
Assets: assets,
Handler: imageServer,
},
OnStartup: app.startup,
OnBeforeClose: func(ctx context.Context) (prevent bool) {
// Force-exit after brief grace period to prevent ghost PIDs on macOS
go func() {
time.Sleep(500 * time.Millisecond)
os.Exit(0)
}()
return false
},
Bind: []interface{}{
app,
},
Mac: &mac.Options{
TitleBar: mac.TitleBarHiddenInset(),
About: &mac.AboutInfo{
Title: "Former",
Message: "PCB Stencil & Enclosure Generator",
},
WebviewIsTransparent: true,
WindowIsTranslucent: false,
},
})
if err != nil {
log.Fatal(err)
}
}

1069
scad.go Normal file

File diff suppressed because it is too large Load Diff

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
}

BIN
static/showcase.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

1
static/vectors Submodule

@ -0,0 +1 @@
Subproject commit d1fdfca1265d2c295318beec03966301cc5a4d5a

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
}

274
storage.go Normal file
View File

@ -0,0 +1,274 @@
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
}
// UpdateProjectCutouts writes updated cutouts to an existing project's former.json
func UpdateProjectCutouts(projectDir string, cutouts []Cutout) error {
if projectDir == "" {
return nil
}
inst, err := LoadProject(projectDir)
if err != nil {
return err
}
inst.Cutouts = cutouts
// Clear legacy fields so they don't conflict
inst.SideCutouts = nil
inst.LidCutouts = nil
data, err := json.MarshalIndent(inst, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644)
}
// TouchProject updates the mtime of a project's former.json
func TouchProject(projectDir string) {
jsonPath := filepath.Join(projectDir, "former.json")
now := time.Now()
os.Chtimes(jsonPath, now, now)
}
// 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
}

182
svg.go Normal file
View File

@ -0,0 +1,182 @@
package main
import (
"fmt"
"math"
"os"
)
func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
b := *bounds
widthMM := b.MaxX - b.MinX
heightMM := b.MaxY - b.MinY
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
// Use mm directly for SVG
fmt.Fprintf(f, `<svg xmlns="http://www.w3.org/2000/svg" width="%fmm" height="%fmm" viewBox="0 0 %f %f">`,
widthMM, heightMM, widthMM, heightMM)
fmt.Fprintf(f, "\n<g fill=\"black\" stroke=\"black\">\n")
// Note: SVG Y-axis points down. We need to invert Y: (heightMM - (y - b.MinY))
toSVGX := func(x float64) float64 { return x - b.MinX }
toSVGY := func(y float64) float64 { return heightMM - (y - b.MinY) }
curX, curY := 0.0, 0.0
curDCode := 0
interpolationMode := "G01" // Default linear
inRegion := false
var regionVertices [][2]float64
for _, cmd := range gf.Commands {
if cmd.Type == "APERTURE" {
curDCode = *cmd.D
continue
}
if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" {
interpolationMode = cmd.Type
continue
}
if cmd.Type == "G36" {
inRegion = true
regionVertices = nil
continue
}
if cmd.Type == "G37" {
if len(regionVertices) >= 3 {
fmt.Fprintf(f, `<polygon points="`)
for _, v := range regionVertices {
fmt.Fprintf(f, "%f,%f ", toSVGX(v[0]), toSVGY(v[1]))
}
fmt.Fprintf(f, "\" fill=\"black\" stroke=\"none\"/>\n")
}
inRegion = false
regionVertices = nil
continue
}
prevX, prevY := curX, curY
if cmd.X != nil {
curX = *cmd.X
}
if cmd.Y != nil {
curY = *cmd.Y
}
if inRegion {
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") {
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)
for _, pt := range arcPts {
regionVertices = append(regionVertices, pt)
}
}
continue
}
if cmd.Type == "FLASH" {
ap, ok := gf.State.Apertures[curDCode]
if ok {
writeSVGAperture(f, toSVGX(curX), toSVGY(curY), ap, false)
}
} else if cmd.Type == "DRAW" {
ap, ok := gf.State.Apertures[curDCode]
if ok {
// Basic stroke representation for lines
w := 0.1 // default
if len(ap.Modifiers) > 0 {
w = ap.Modifiers[0]
}
if interpolationMode == "G01" {
fmt.Fprintf(f, `<line x1="%f" y1="%f" x2="%f" y2="%f" stroke-width="%f" stroke-linecap="round"/>`+"\n",
toSVGX(prevX), toSVGY(prevY), toSVGX(curX), toSVGY(curY), w)
} else {
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
}
if cmd.J != nil {
jVal = *cmd.J
}
// 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 %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)
}
}
}
}
fmt.Fprintf(f, "</g>\n</svg>\n")
return nil
}
func writeSVGAperture(f *os.File, cx, cy float64, ap Aperture, isMacro bool) {
switch ap.Type {
case "C":
if len(ap.Modifiers) > 0 {
r := ap.Modifiers[0] / 2
fmt.Fprintf(f, `<circle cx="%f" cy="%f" r="%f" />`+"\n", cx, cy, r)
}
case "R":
if len(ap.Modifiers) >= 2 {
w, h := ap.Modifiers[0], ap.Modifiers[1]
fmt.Fprintf(f, `<rect x="%f" y="%f" width="%f" height="%f" />`+"\n", cx-w/2, cy-h/2, w, h)
}
case "O":
if len(ap.Modifiers) >= 2 {
w, h := ap.Modifiers[0], ap.Modifiers[1]
r := w / 2
if h < w {
r = h / 2
}
fmt.Fprintf(f, `<rect x="%f" y="%f" width="%f" height="%f" rx="%f" ry="%f" />`+"\n", cx-w/2, cy-h/2, w, h, r, r)
}
case "P":
if len(ap.Modifiers) >= 2 {
dia, numV := ap.Modifiers[0], int(ap.Modifiers[1])
r := dia / 2
rot := 0.0
if len(ap.Modifiers) >= 3 {
rot = ap.Modifiers[2]
}
fmt.Fprintf(f, `<polygon points="`)
for i := 0; i < numV; i++ {
a := (rot + float64(i)*360.0/float64(numV)) * math.Pi / 180.0
// SVG inverted Y means we might need minus for Sin
fmt.Fprintf(f, "%f,%f ", cx+r*math.Cos(a), cy-r*math.Sin(a))
}
fmt.Fprintf(f, `" />`+"\n")
}
}
}

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": ""
}
}