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:
commit
5d09f56e00
|
|
@ -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
|
||||||
|
*~
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "static/vectors"]
|
||||||
|
path = static/vectors
|
||||||
|
url = git@ssh-git.else-if.org:jess/FormerStaticVectors.git
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Former
|
||||||
|
|
||||||
|
A desktop application for generating 3D-printable solder paste stencils and snap-fit enclosures from KiCad/Gerber files.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.0–1.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 0–65535; 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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
98e9f2b9e6d5bad224e73bd97622e3b9
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 680 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit d1fdfca1265d2c295318beec03966301cc5a4d5a
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue