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