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