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"` Type string `json:"type"` Path string `json:"path"` CreatedAt string `json:"createdAt"` HasStencil bool `json:"hasStencil"` HasEnclosure bool `json:"hasEnclosure"` HasVectorWrap bool `json:"hasVectorWrap"` HasStructural bool `json:"hasStructural"` HasScanHelper bool `json:"hasScanHelper"` HasObject bool `json:"hasObject"` BoardW float64 `json:"boardW"` BoardH float64 `json:"boardH"` ShowGrid bool `json:"showGrid"` TraditionalControls bool `json:"traditionalControls"` } type EnclosureSetupJS struct { GbrjobFile string `json:"gbrjobFile"` GerberFiles []string `json:"gerberFiles"` DrillPath string `json:"drillPath"` NPTHPath string `json:"npthPath"` WallThick float64 `json:"wallThick"` WallHeight float64 `json:"wallHeight"` Clearance float64 `json:"clearance"` DPI float64 `json:"dpi"` SourceDir string `json:"sourceDir"` } 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"` SourceFile string `json:"sourceFile"` } 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 project *ProjectData projectPath string enclosureSession *EnclosureSession vectorWrapSession *VectorWrapSession structuralSession *StructuralSession scanHelperConfig *ScanHelperConfig cutouts []Cutout formerLayers []*FormerLayer stencilFiles []string } func NewApp(imageServer *ImageServer) *App { return &App{ imageServer: imageServer, } } // autosaveProject persists the current project state to project.json func (a *App) autosaveProject() { a.mu.RLock() proj := a.project projPath := a.projectPath cutouts := make([]Cutout, len(a.cutouts)) copy(cutouts, a.cutouts) a.mu.RUnlock() if proj == nil || projPath == "" { return } if proj.Enclosure != nil { proj.Enclosure.Cutouts = cutouts } if err := SaveProject(projPath, proj); err != nil { log.Printf("autosave project failed: %v", err) } } func (a *App) startup(ctx context.Context) { debugLog("app.startup() called") a.ctx = ctx MigrateOldProjects() 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 { entries := ListRecentProjects() var result []ProjectInfoJS for _, e := range entries { proj, err := LoadProjectData(e.Path) if err != nil { continue } pType := proj.Type if pType == "" { pType = "pcb" } info := ProjectInfoJS{ ID: proj.ID, Name: proj.Name, Type: pType, Path: e.Path, CreatedAt: e.LastOpened.Format(time.RFC3339), ShowGrid: proj.Settings.ShowGrid, TraditionalControls: proj.Settings.TraditionalControls, } if proj.Stencil != nil { info.HasStencil = true } if proj.Enclosure != nil { info.HasEnclosure = true info.BoardW = proj.Enclosure.BoardW info.BoardH = proj.Enclosure.BoardH } if proj.VectorWrap != nil { info.HasVectorWrap = true } if proj.Structural != nil { info.HasStructural = true } if proj.ScanHelper != nil { info.HasScanHelper = true } if proj.Object != nil { info.HasObject = true } result = append(result, info) } 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 // Populate project enclosure data if a project is open if a.project != nil { a.project.Enclosure = &EnclosureData{ 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, } // Copy gerber files into project's enclosure subdir if a.projectPath != "" { encDir := filepath.Join(a.projectPath, "enclosure") os.MkdirAll(encDir, 0755) newGerbers := make(map[string]string) for origName, fullPath := range savedGerbers { dst := filepath.Join(encDir, origName) CopyFile(fullPath, dst) newGerbers[origName] = origName } a.project.Enclosure.GerberFiles = newGerbers if drillDst != "" { dstName := "drill" + filepath.Ext(drillPath) CopyFile(drillDst, filepath.Join(encDir, dstName)) a.project.Enclosure.DrillPath = dstName } if npthDst != "" { dstName := "npth" + filepath.Ext(npthPath) CopyFile(npthDst, filepath.Join(encDir, dstName)) a.project.Enclosure.NPTHPath = dstName } } } a.mu.Unlock() if session.OutlineImg != nil { var buf bytes.Buffer png.Encode(&buf, session.OutlineImg) a.imageServer.Store("/api/board-preview.png", buf.Bytes()) } a.autosaveProject() 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.autosaveProject() } 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.autosaveProject() } 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.autosaveProject() 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.autosaveProject() } 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.autosaveProject() } 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.autosaveProject() 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, gerberSource string) { 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) shape := el.Shape if shape == "" { shape = "rect" } a.cutouts = append(a.cutouts, Cutout{ ID: randomID(), Surface: surface, X: mmMinX, Y: mmMinY, Width: mmMaxX - mmMinX, Height: mmMaxY - mmMinY, IsDado: isDado, Depth: depth, Shape: shape, GerberSource: gerberSource, }) } a.mu.Unlock() a.autosaveProject() } 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.autosaveProject() } 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) projPath := a.projectPath a.mu.RUnlock() if session == nil { return nil, fmt.Errorf("no enclosure session active") } outputDir := session.SourceDir if projPath != "" { outputDir = filepath.Join(projPath, "enclosure") os.MkdirAll(outputDir, 0755) } else if outputDir == "" { outputDir = formerTempDir() } files, err := GenerateEnclosureOutputs(session, allCutouts, outputDir) if err != nil { return nil, err } a.autosaveProject() a.mu.Lock() a.formerLayers = buildEnclosureLayers(session) a.mu.Unlock() a.prepareFormerImages() return &GenerateResultJS{Files: files}, nil } // ======== Project Lifecycle ======== func (a *App) CreateNewProject(name, projectType string) (string, error) { if name == "" { name = "Untitled" } if projectType == "" { projectType = "pcb" } safeName := sanitizeDirName(name) if safeName == "" { safeName = "untitled" } path := filepath.Join(formerProjectsDir(), safeName+".former") // Avoid overwriting existing project if _, err := os.Stat(path); err == nil { path = filepath.Join(formerProjectsDir(), safeName+"-"+randomID()[:8]+".former") } proj, err := CreateProject(path) if err != nil { return "", err } proj.Name = name proj.Type = projectType SaveProject(path, proj) a.mu.Lock() a.project = proj a.projectPath = path a.enclosureSession = nil a.vectorWrapSession = nil a.structuralSession = nil a.scanHelperConfig = nil a.cutouts = nil a.formerLayers = nil a.stencilFiles = nil a.mu.Unlock() return path, nil } func (a *App) OpenProjectDialog() (string, error) { path, err := wailsRuntime.OpenDirectoryDialog(a.ctx, wailsRuntime.OpenDialogOptions{ Title: "Open Former Project", DefaultDirectory: formerProjectsDir(), }) if err != nil || path == "" { return "", err } if err := a.OpenProjectByPath(path); err != nil { return "", err } return path, nil } func (a *App) OpenProjectByPath(path string) error { proj, err := LoadProjectData(path) if err != nil { return fmt.Errorf("load project: %v", err) } a.mu.Lock() a.project = proj a.projectPath = path a.enclosureSession = nil a.vectorWrapSession = nil a.structuralSession = nil a.scanHelperConfig = nil a.cutouts = nil a.formerLayers = nil a.stencilFiles = nil a.mu.Unlock() // Restore enclosure session if data exists if proj.Enclosure != nil && proj.Enclosure.EdgeCutsFile != "" { sid, session, err := RestoreEnclosureFromProject(path, proj.Enclosure) if err == nil { a.mu.Lock() a.enclosureSession = session a.cutouts = proj.Enclosure.MigrateCutouts() a.mu.Unlock() debugLog("Restored enclosure session %s from project", sid) if session.OutlineImg != nil { var buf bytes.Buffer png.Encode(&buf, session.OutlineImg) a.imageServer.Store("/api/board-preview.png", buf.Bytes()) } } else { debugLog("Could not restore enclosure from project: %v", err) } } // Restore scan helper config if proj.ScanHelper != nil { a.mu.Lock() a.scanHelperConfig = &ScanHelperConfig{ PageWidth: proj.ScanHelper.PageWidth, PageHeight: proj.ScanHelper.PageHeight, GridSpacing: proj.ScanHelper.GridSpacing, PagesWide: proj.ScanHelper.PagesWide, PagesTall: proj.ScanHelper.PagesTall, DPI: proj.ScanHelper.DPI, } a.mu.Unlock() } AddRecentProject(path, proj.Name) return nil } func (a *App) CloseProject() { a.autosaveProject() a.mu.Lock() a.project = nil a.projectPath = "" a.enclosureSession = nil a.vectorWrapSession = nil a.structuralSession = nil a.scanHelperConfig = nil a.cutouts = nil a.formerLayers = nil a.stencilFiles = nil a.mu.Unlock() a.imageServer.Clear() } func (a *App) GetProjectInfo() *ProjectInfoJS { a.mu.RLock() defer a.mu.RUnlock() if a.project == nil { return nil } p := a.project projType := p.Type if projType == "" { projType = "pcb" } info := &ProjectInfoJS{ ID: p.ID, Name: p.Name, Type: projType, Path: a.projectPath, CreatedAt: p.CreatedAt.Format(time.RFC3339), ShowGrid: p.Settings.ShowGrid, TraditionalControls: p.Settings.TraditionalControls, } if p.Stencil != nil { info.HasStencil = true } if p.Enclosure != nil { info.HasEnclosure = true info.BoardW = p.Enclosure.BoardW info.BoardH = p.Enclosure.BoardH } if p.VectorWrap != nil { info.HasVectorWrap = true } if p.Structural != nil { info.HasStructural = true } if p.ScanHelper != nil { info.HasScanHelper = true } if p.Object != nil { info.HasObject = true } return info } func (a *App) SaveProjectSettings(showGrid, traditionalControls bool) { a.mu.Lock() if a.project != nil { a.project.Settings.ShowGrid = showGrid a.project.Settings.TraditionalControls = traditionalControls } a.mu.Unlock() a.autosaveProject() } func (a *App) GetProjectOutputFiles(mode string) []string { a.mu.RLock() projPath := a.projectPath a.mu.RUnlock() if projPath == "" { return nil } return ListProjectOutputFiles(projPath, mode) } // OpenProject opens an old-style project or new .former project (backward compat for frontend) func (a *App) OpenProject(projectPath string) error { return a.OpenProjectByPath(projectPath) } 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, SourceFile: l.SourceFile, }) } 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, session.AllLayerGerbers) 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 project/session. func (a *App) GetOutputDir() (string, error) { a.mu.RLock() projPath := a.projectPath session := a.enclosureSession a.mu.RUnlock() if projPath != "" { return projPath, nil } if session != nil && session.SourceDir != "" { return filepath.Abs(session.SourceDir) } return formerTempDir(), 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() } // ======== Active Mode Detection ======== func (a *App) GetActiveMode() string { a.mu.RLock() defer a.mu.RUnlock() if a.vectorWrapSession != nil && (a.vectorWrapSession.SVGPath != "" || a.vectorWrapSession.ModelPath != "") { return "vectorwrap" } if a.structuralSession != nil && a.structuralSession.SVGPath != "" { return "structural" } if a.enclosureSession != nil { return "enclosure" } if len(a.formerLayers) > 0 { return "stencil" } return "" } // ======== Enclosure Reconfigure ======== func (a *App) GetEnclosureSetupData() *EnclosureSetupJS { a.mu.RLock() defer a.mu.RUnlock() if a.project == nil || a.project.Enclosure == nil { return nil } enc := a.project.Enclosure var gerberFiles []string for name := range enc.GerberFiles { gerberFiles = append(gerberFiles, name) } // Reconstruct gbrjob file from gerber files map var gbrjob string for name := range enc.GerberFiles { if strings.HasSuffix(strings.ToLower(name), ".gbrjob") { gbrjob = name break } } return &EnclosureSetupJS{ GbrjobFile: gbrjob, GerberFiles: gerberFiles, DrillPath: enc.DrillPath, NPTHPath: enc.NPTHPath, WallThick: enc.Config.WallThickness, WallHeight: enc.Config.WallHeight, Clearance: enc.Config.Clearance, DPI: enc.Config.DPI, } } // ======== 3D Object Setup (Surface Wrap projects) ======== func (a *App) ConfigureProjectObject(modelPath string) error { if modelPath == "" { return fmt.Errorf("no model file selected") } a.mu.Lock() if a.project == nil { a.mu.Unlock() return fmt.Errorf("no project open") } projPath := a.projectPath a.mu.Unlock() baseName := filepath.Base(modelPath) dstDir := filepath.Join(projPath, "vectorwrap") os.MkdirAll(dstDir, 0755) dstPath := filepath.Join(dstDir, baseName) if err := CopyFile(modelPath, dstPath); err != nil { return fmt.Errorf("copy model: %v", err) } modelType := "stl" if strings.HasSuffix(strings.ToLower(modelPath), ".scad") { modelType = "scad" } a.mu.Lock() a.project.Object = &ObjectData{ ModelFile: baseName, ModelType: modelType, } a.mu.Unlock() a.autosaveProject() return nil } // ======== Vector Wrap ======== type SVGValidationResultJS struct { Width float64 `json:"width"` Height float64 `json:"height"` Elements int `json:"elements"` Layers int `json:"layers"` Warnings []string `json:"warnings"` LayerNames []string `json:"layerNames"` } type SVGLayerInfoJS struct { ID string `json:"id"` Label string `json:"label"` Visible bool `json:"visible"` } type VectorWrapInfoJS struct { HasSession bool `json:"hasSession"` SVGPath string `json:"svgPath"` SVGWidth float64 `json:"svgWidth"` SVGHeight float64 `json:"svgHeight"` ModelPath string `json:"modelPath"` ModelType string `json:"modelType"` HasProjectEnclosure bool `json:"hasProjectEnclosure"` } func (a *App) ImportSVGForVectorWrap(svgPath string) (*SVGValidationResultJS, error) { doc, err := ParseSVG(svgPath) if err != nil { return nil, fmt.Errorf("parse SVG: %v", err) } // Render SVG to image via native renderer renderW, renderH := 1024, 1024 if doc.Width > 0 && doc.Height > 0 { aspect := doc.Width / doc.Height if aspect > 1 { renderH = int(float64(renderW) / aspect) } else { renderW = int(float64(renderH) * aspect) } } svgImg := renderSVGNative(doc.RawSVG, renderW, renderH) if svgImg != nil { var buf bytes.Buffer png.Encode(&buf, svgImg) a.imageServer.Store("/api/vectorwrap-svg.png", buf.Bytes()) } a.mu.Lock() if a.vectorWrapSession == nil { a.vectorWrapSession = &VectorWrapSession{} } a.vectorWrapSession.SVGDoc = doc a.vectorWrapSession.SVGPath = svgPath a.vectorWrapSession.SVGImage = svgImg a.mu.Unlock() return &SVGValidationResultJS{ Width: doc.Width, Height: doc.Height, Elements: len(doc.Elements), Layers: doc.LayerCount(), Warnings: doc.Warnings, LayerNames: doc.VisibleLayerNames(), }, nil } func (a *App) ImportModelForVectorWrap(modelPath string) error { ext := strings.ToLower(filepath.Ext(modelPath)) var modelType string var stlData []byte switch ext { case ".stl": modelType = "stl" data, err := os.ReadFile(modelPath) if err != nil { return fmt.Errorf("read STL: %v", err) } stlData = data case ".scad": modelType = "scad" default: return fmt.Errorf("unsupported model format: %s (use .stl or .scad)", ext) } a.mu.Lock() if a.vectorWrapSession == nil { a.vectorWrapSession = &VectorWrapSession{} } a.vectorWrapSession.ModelPath = modelPath a.vectorWrapSession.ModelType = modelType a.vectorWrapSession.ModelSTL = stlData a.mu.Unlock() return nil } func (a *App) GetVectorWrapInfo() *VectorWrapInfoJS { a.mu.RLock() defer a.mu.RUnlock() hasEnc := a.enclosureSession != nil if a.vectorWrapSession == nil { return &VectorWrapInfoJS{HasSession: false, HasProjectEnclosure: hasEnc} } s := a.vectorWrapSession info := &VectorWrapInfoJS{ HasSession: true, SVGPath: s.SVGPath, ModelPath: s.ModelPath, ModelType: s.ModelType, HasProjectEnclosure: hasEnc, } if s.SVGDoc != nil { info.SVGWidth = s.SVGDoc.Width info.SVGHeight = s.SVGDoc.Height } return info } func (a *App) GetVectorWrapModelSTL() ([]byte, error) { a.mu.RLock() defer a.mu.RUnlock() if a.vectorWrapSession == nil || len(a.vectorWrapSession.ModelSTL) == 0 { return nil, fmt.Errorf("no STL model loaded") } return a.vectorWrapSession.ModelSTL, nil } func (a *App) GetVectorWrapSCADSource() (string, error) { a.mu.RLock() defer a.mu.RUnlock() if a.vectorWrapSession == nil || a.vectorWrapSession.ModelType != "scad" { return "", fmt.Errorf("no SCAD model loaded") } data, err := os.ReadFile(a.vectorWrapSession.ModelPath) if err != nil { return "", fmt.Errorf("read SCAD: %v", err) } return string(data), nil } func (a *App) UseProjectEnclosureForVectorWrap() error { debugLog("UseProjectEnclosureForVectorWrap() 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 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 fmt.Errorf("could not extract board outline polygon") } debugLog(" outline polygon: %d vertices", len(outlinePoly)) sideCutouts, lidCutouts := SplitCutouts(allCutouts, session.AllLayerGerbers) 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 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 fmt.Errorf("generate tray SCAD: %v", err) } debugLog(" tray SCAD: %d chars", len(traySCAD)) a.mu.Lock() if a.vectorWrapSession == nil { a.vectorWrapSession = &VectorWrapSession{} } a.vectorWrapSession.ModelType = "project-enclosure" a.vectorWrapSession.ModelPath = "" a.vectorWrapSession.ModelSTL = nil a.vectorWrapSession.EnclosureSCAD = encSCAD a.vectorWrapSession.TraySCAD = traySCAD a.mu.Unlock() debugLog("UseProjectEnclosureForVectorWrap() returning OK") return nil } type VectorWrapProjectSCADJS struct { EnclosureSCAD string `json:"enclosureSCAD"` TraySCAD string `json:"traySCAD"` } func (a *App) GetVectorWrapProjectSCAD() (*VectorWrapProjectSCADJS, error) { debugLog("GetVectorWrapProjectSCAD() called") a.mu.RLock() vws := a.vectorWrapSession projPath := a.projectPath a.mu.RUnlock() if vws == nil || vws.ModelType != "project-enclosure" { debugLog(" ERROR: no project enclosure loaded (session=%v, type=%q)", vws != nil, func() string { if vws != nil { return vws.ModelType } return "" }()) return nil, fmt.Errorf("no project enclosure loaded for vector wrap") } // Return in-memory SCAD if available if vws.EnclosureSCAD != "" { debugLog(" returning in-memory enc=%d chars, tray=%d chars", len(vws.EnclosureSCAD), len(vws.TraySCAD)) return &VectorWrapProjectSCADJS{ EnclosureSCAD: vws.EnclosureSCAD, TraySCAD: vws.TraySCAD, }, nil } // Fall back to reading SCAD files from disk if projPath == "" { return nil, fmt.Errorf("no in-memory SCAD and no project path") } encDir := filepath.Join(projPath, "enclosure") encSCAD, traySCAD, err := findLatestSCADFiles(encDir) if err != nil { debugLog(" disk fallback failed: %v", err) return nil, fmt.Errorf("no SCAD available: %v", err) } debugLog(" returning disk enc=%d chars, tray=%d chars", len(encSCAD), len(traySCAD)) // Cache in session for subsequent calls a.mu.Lock() if a.vectorWrapSession != nil { a.vectorWrapSession.EnclosureSCAD = encSCAD a.vectorWrapSession.TraySCAD = traySCAD } a.mu.Unlock() return &VectorWrapProjectSCADJS{ EnclosureSCAD: encSCAD, TraySCAD: traySCAD, }, nil } // findLatestSCADFiles scans dir for *_enclosure.scad and *_tray.scad, // returning the contents of the most recently modified pair. func findLatestSCADFiles(dir string) (encSCAD, traySCAD string, err error) { entries, err := os.ReadDir(dir) if err != nil { return "", "", err } var bestEncFile, bestTrayFile string var bestEncTime, bestTrayTime time.Time for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".scad") { continue } info, err := e.Info() if err != nil { continue } name := e.Name() if strings.HasSuffix(name, "_enclosure.scad") && info.ModTime().After(bestEncTime) { bestEncFile = filepath.Join(dir, name) bestEncTime = info.ModTime() } else if strings.HasSuffix(name, "_tray.scad") && info.ModTime().After(bestTrayTime) { bestTrayFile = filepath.Join(dir, name) bestTrayTime = info.ModTime() } } if bestEncFile == "" { return "", "", fmt.Errorf("no *_enclosure.scad found in %s", dir) } encData, err := os.ReadFile(bestEncFile) if err != nil { return "", "", fmt.Errorf("read enclosure SCAD: %v", err) } encSCAD = string(encData) debugLog(" found enclosure SCAD: %s (%d chars)", filepath.Base(bestEncFile), len(encSCAD)) if bestTrayFile != "" { trayData, err := os.ReadFile(bestTrayFile) if err == nil { traySCAD = string(trayData) debugLog(" found tray SCAD: %s (%d chars)", filepath.Base(bestTrayFile), len(traySCAD)) } } return encSCAD, traySCAD, nil } func (a *App) GetVectorWrapSVGLayers() []SVGLayerInfoJS { a.mu.RLock() defer a.mu.RUnlock() if a.vectorWrapSession == nil || a.vectorWrapSession.SVGDoc == nil { return nil } var layers []SVGLayerInfoJS for _, g := range a.vectorWrapSession.SVGDoc.Groups { if g.Label != "" { layers = append(layers, SVGLayerInfoJS{ ID: g.ID, Label: g.Label, Visible: g.Visible, }) } } return layers } // ======== Enclosure Unwrap ======== func (a *App) GetUnwrapSVG() (string, error) { a.mu.RLock() session := a.enclosureSession cutouts := a.cutouts a.mu.RUnlock() if session == nil { return "", fmt.Errorf("no enclosure session active") } if len(session.Sides) == 0 { return "", fmt.Errorf("no board sides detected") } layout := ComputeUnwrapLayout(session, cutouts) if layout == nil { return "", fmt.Errorf("failed to compute unwrap layout") } return GenerateUnwrapSVG(layout), nil } func (a *App) GetUnwrapLayout() (*UnwrapLayout, error) { a.mu.RLock() session := a.enclosureSession cutouts := a.cutouts a.mu.RUnlock() if session == nil { return nil, fmt.Errorf("no enclosure session active") } if len(session.Sides) == 0 { return nil, fmt.Errorf("no board sides detected") } layout := ComputeUnwrapLayout(session, cutouts) if layout == nil { return nil, fmt.Errorf("failed to compute unwrap layout") } return layout, nil } func (a *App) SaveUnwrapSVG() (string, error) { svg, err := a.GetUnwrapSVG() if err != nil { return "", err } outDir, err := a.GetOutputDir() if err != nil { return "", err } os.MkdirAll(outDir, 0755) outPath := filepath.Join(outDir, "enclosure-unwrap.svg") if err := os.WriteFile(outPath, []byte(svg), 0644); err != nil { return "", fmt.Errorf("write SVG: %v", err) } return outPath, nil } func (a *App) OpenFileExternal(path string) error { return exec.Command("open", path).Start() } // ======== Structural Procedures ======== type StructuralInfoJS struct { HasSession bool `json:"hasSession"` SVGPath string `json:"svgPath"` SVGWidth float64 `json:"svgWidth"` SVGHeight float64 `json:"svgHeight"` Pattern string `json:"pattern"` CellSize float64 `json:"cellSize"` WallThick float64 `json:"wallThick"` Height float64 `json:"height"` } func (a *App) ImportSVGForStructural(svgPath string) (*SVGValidationResultJS, error) { doc, err := ParseSVG(svgPath) if err != nil { return nil, fmt.Errorf("parse SVG: %v", err) } a.mu.Lock() a.structuralSession = &StructuralSession{ SVGDoc: doc, SVGPath: svgPath, Pattern: "hexagon", CellSize: 10.0, WallThick: 1.2, Height: 20.0, ShellThick: 1.6, } a.mu.Unlock() return &SVGValidationResultJS{ Width: doc.Width, Height: doc.Height, Elements: len(doc.Elements), Layers: doc.LayerCount(), Warnings: doc.Warnings, LayerNames: doc.VisibleLayerNames(), }, nil } func (a *App) UpdateStructuralParams(pattern string, cellSize, wallThick, height, shellThick float64) error { a.mu.Lock() defer a.mu.Unlock() if a.structuralSession == nil { return fmt.Errorf("no structural session active") } if pattern != "" { a.structuralSession.Pattern = pattern } if cellSize > 0 { a.structuralSession.CellSize = cellSize } if wallThick > 0 { a.structuralSession.WallThick = wallThick } if height > 0 { a.structuralSession.Height = height } if shellThick > 0 { a.structuralSession.ShellThick = shellThick } return nil } func (a *App) GenerateStructuralSCAD() (string, error) { a.mu.RLock() session := a.structuralSession a.mu.RUnlock() if session == nil { return "", fmt.Errorf("no structural session active") } return GenerateStructuralSCADString(session) } func (a *App) GetStructuralInfo() *StructuralInfoJS { a.mu.RLock() defer a.mu.RUnlock() if a.structuralSession == nil { return &StructuralInfoJS{HasSession: false} } s := a.structuralSession info := &StructuralInfoJS{ HasSession: true, SVGPath: s.SVGPath, Pattern: s.Pattern, CellSize: s.CellSize, WallThick: s.WallThick, Height: s.Height, } if s.SVGDoc != nil { info.SVGWidth = s.SVGDoc.Width info.SVGHeight = s.SVGDoc.Height } return info } // ======== Scan Helper ======== type ScanHelperInfoJS struct { PageWidth float64 `json:"pageWidth"` PageHeight float64 `json:"pageHeight"` GridSpacing float64 `json:"gridSpacing"` PagesWide int `json:"pagesWide"` PagesTall int `json:"pagesTall"` DPI float64 `json:"dpi"` } func (a *App) UpdateScanHelperConfig(pageW, pageH, gridSpacing, dpi float64, pagesWide, pagesTall int) { a.mu.Lock() a.scanHelperConfig = &ScanHelperConfig{ PageWidth: pageW, PageHeight: pageH, GridSpacing: gridSpacing, PagesWide: pagesWide, PagesTall: pagesTall, DPI: dpi, } if a.project != nil { a.project.ScanHelper = &ScanHelperData{ PageWidth: pageW, PageHeight: pageH, GridSpacing: gridSpacing, PagesWide: pagesWide, PagesTall: pagesTall, DPI: dpi, } } a.mu.Unlock() a.autosaveProject() } func (a *App) GenerateScanGrid() ([]string, error) { a.mu.RLock() cfg := a.scanHelperConfig projPath := a.projectPath a.mu.RUnlock() if cfg == nil { cfg = &ScanHelperConfig{ PageWidth: 210, PageHeight: 297, GridSpacing: 10, PagesWide: 1, PagesTall: 1, DPI: 300, } } outDir := formerTempDir() if projPath != "" { outDir = filepath.Join(projPath, "scanhelper") os.MkdirAll(outDir, 0755) } return GenerateScanGridSVG(cfg, outDir) } func (a *App) GetScanHelperInfo() *ScanHelperInfoJS { a.mu.RLock() defer a.mu.RUnlock() if a.scanHelperConfig == nil { return &ScanHelperInfoJS{ PageWidth: 210, PageHeight: 297, GridSpacing: 10, PagesWide: 1, PagesTall: 1, DPI: 300, } } c := a.scanHelperConfig return &ScanHelperInfoJS{ PageWidth: c.PageWidth, PageHeight: c.PageHeight, GridSpacing: c.GridSpacing, PagesWide: c.PagesWide, PagesTall: c.PagesTall, DPI: c.DPI, } }