1926 lines
48 KiB
Go
1926 lines
48 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"`
|
|
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,
|
|
}
|
|
}
|