Former/app.go

1079 lines
27 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
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) {
debugLog("app.startup() called")
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)
debugLog("app.startup() done, logo stored (svg=%d bytes)", len(formerLogoSVG))
}
// ======== Landing Page ========
func (a *App) GetRecentProjects() []ProjectInfoJS {
debugLog("GetRecentProjects() called")
entries, err := ListProjects(20)
if err != nil {
debugLog("GetRecentProjects() error: %v", err)
return nil
}
debugLog("GetRecentProjects() found %d projects", len(entries))
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())
}
func (a *App) GetLogoSVGDataURL() string {
if len(formerLogoSVG) == 0 {
return ""
}
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(formerLogoSVG)
}
// ======== 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 {
debugLog("BuildEnclosureSession() called: gbrjob=%s gerbers=%d drill=%q npth=%q", gbrjobPath, len(gerberPaths), drillPath, npthPath)
debugLog(" params: wallThick=%.2f wallH=%.2f clearance=%.2f dpi=%.0f exports=%v", wallThickness, wallHeight, clearance, dpi, exports)
if gbrjobPath == "" {
debugLog(" ERROR: no gerber job file selected")
return fmt.Errorf("no gerber job file selected")
}
if len(gerberPaths) == 0 {
debugLog(" ERROR: no gerber files selected")
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"}
}
tempDir := formerTempDir()
cwd, _ := os.Getwd()
debugLog(" CWD=%s, tempDir=%s", cwd, tempDir)
if err := os.MkdirAll(tempDir, 0755); err != nil {
debugLog(" ERROR creating temp dir: %v", err)
return fmt.Errorf("failed to create temp dir: %v", err)
}
uuid := randomID()
debugLog(" session uuid=%s", uuid)
// Copy gbrjob
gbrjobDst := filepath.Join(tempDir, uuid+"_"+filepath.Base(gbrjobPath))
debugLog(" copying gbrjob %s -> %s", gbrjobPath, gbrjobDst)
if err := CopyFile(gbrjobPath, gbrjobDst); err != nil {
debugLog(" ERROR copy gbrjob: %v", err)
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(tempDir, uuid+"_"+baseName)
debugLog(" copying gerber %s -> %s", src, dst)
if err := CopyFile(src, dst); err != nil {
debugLog(" ERROR copy gerber %s: %v", baseName, err)
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(tempDir, uuid+"_drill"+filepath.Ext(drillPath))
debugLog(" copying drill %s -> %s", drillPath, drillDst)
if err := CopyFile(drillPath, drillDst); err != nil {
debugLog(" ERROR copy drill: %v", err)
return fmt.Errorf("failed to copy PTH drill: %v", err)
}
}
if npthPath != "" {
npthDst = filepath.Join(tempDir, uuid+"_npth"+filepath.Ext(npthPath))
debugLog(" copying npth %s -> %s", npthPath, npthDst)
if err := CopyFile(npthPath, npthDst); err != nil {
debugLog(" ERROR copy npth: %v", err)
return fmt.Errorf("failed to copy NPTH drill: %v", err)
}
}
debugLog(" calling core BuildEnclosureSession...")
_, session, err := BuildEnclosureSession(gbrjobDst, savedGerbers, drillDst, npthDst, ecfg, exports)
if err != nil {
debugLog(" ERROR session build: %v", err)
return fmt.Errorf("session build failed: %v", err)
}
debugLog(" session built OK: project=%s board=%.1fx%.1fmm", session.ProjectName, session.BoardW, session.BoardH)
session.SourceDir = sourceDir
a.mu.Lock()
a.enclosureSession = session
a.cutouts = nil
a.mu.Unlock()
debugLog(" session stored in app state")
// 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())
debugLog(" board preview rendered (%d bytes)", buf.Len())
}
debugLog("BuildEnclosureSession() returning nil (success)")
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) {
debugLog("GenerateEnclosureOutputs() called")
a.mu.RLock()
session := a.enclosureSession
allCutouts := make([]Cutout, len(a.cutouts))
copy(allCutouts, a.cutouts)
a.mu.RUnlock()
if session == nil {
debugLog(" ERROR: no enclosure session active")
return nil, fmt.Errorf("no enclosure session active")
}
outputDir := session.SourceDir
if outputDir == "" {
outputDir = formerTempDir()
}
debugLog(" outputDir=%s cutouts=%d", outputDir, len(allCutouts))
files, err := GenerateEnclosureOutputs(session, allCutouts, outputDir)
if err != nil {
debugLog(" ERROR generate: %v", err)
return nil, err
}
debugLog(" generated %d files", len(files))
// 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, formerTempDir(), 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 = formerTempDir()
}
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()
}
// SCADResult holds generated SCAD source code for both enclosure and tray.
type SCADResult struct {
EnclosureSCAD string `json:"enclosureSCAD"`
TraySCAD string `json:"traySCAD"`
}
// GetEnclosureSCAD generates enclosure and tray SCAD source strings for WASM rendering.
func (a *App) GetEnclosureSCAD() (*SCADResult, error) {
debugLog("GetEnclosureSCAD() called")
a.mu.RLock()
session := a.enclosureSession
allCutouts := make([]Cutout, len(a.cutouts))
copy(allCutouts, a.cutouts)
a.mu.RUnlock()
if session == nil {
debugLog(" ERROR: no enclosure session active")
return nil, fmt.Errorf("no enclosure session active")
}
debugLog(" extracting outline polygon...")
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
if outlinePoly == nil {
debugLog(" ERROR: could not extract board outline polygon")
return nil, fmt.Errorf("could not extract board outline polygon")
}
debugLog(" outline polygon: %d vertices", len(outlinePoly))
sideCutouts, lidCutouts := SplitCutouts(allCutouts)
debugLog(" cutouts: %d side, %d lid", len(sideCutouts), len(lidCutouts))
debugLog(" generating enclosure SCAD string...")
encSCAD, err := GenerateNativeSCADString(false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
if err != nil {
debugLog(" ERROR enclosure SCAD: %v", err)
return nil, fmt.Errorf("generate enclosure SCAD: %v", err)
}
debugLog(" enclosure SCAD: %d chars", len(encSCAD))
debugLog(" generating tray SCAD string...")
traySCAD, err := GenerateNativeSCADString(true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
if err != nil {
debugLog(" ERROR tray SCAD: %v", err)
return nil, fmt.Errorf("generate tray SCAD: %v", err)
}
debugLog(" tray SCAD: %d chars", len(traySCAD))
debugLog("GetEnclosureSCAD() returning OK")
return &SCADResult{
EnclosureSCAD: encSCAD,
TraySCAD: traySCAD,
}, nil
}
// 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 = formerTempDir()
}
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()
}