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() }