Former/app.go

1810 lines
46 KiB
Go

package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image/png"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// ======== Image Server ========
// ImageServer serves dynamically-generated images at /api/* paths.
// It implements http.Handler and is used as the Wails AssetServer fallback handler.
type ImageServer struct {
mu sync.RWMutex
images map[string][]byte
}
func NewImageServer() *ImageServer {
return &ImageServer{images: make(map[string][]byte)}
}
func (s *ImageServer) Store(key string, data []byte) {
s.mu.Lock()
defer s.mu.Unlock()
s.images[key] = data
}
func (s *ImageServer) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.images = make(map[string][]byte)
}
func (s *ImageServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
s.mu.RLock()
data, ok := s.images[path]
s.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
if strings.HasSuffix(path, ".png") {
w.Header().Set("Content-Type", "image/png")
} else if strings.HasSuffix(path, ".svg") {
w.Header().Set("Content-Type", "image/svg+xml")
}
w.Header().Set("Cache-Control", "no-cache")
w.Write(data)
}
// ======== Frontend-facing Types ========
type ProjectInfoJS struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
HasStencil bool `json:"hasStencil"`
HasEnclosure bool `json:"hasEnclosure"`
HasVectorWrap bool `json:"hasVectorWrap"`
HasStructural bool `json:"hasStructural"`
HasScanHelper bool `json:"hasScanHelper"`
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
ShowGrid bool `json:"showGrid"`
TraditionalControls bool `json:"traditionalControls"`
}
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
}
info := ProjectInfoJS{
ID: proj.ID,
Name: proj.Name,
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
}
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 string) (string, error) {
if name == "" {
name = "Untitled"
}
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
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
info := &ProjectInfoJS{
ID: p.ID,
Name: p.Name,
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
}
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 ""
}
// ======== 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) ImportUnwrapArtwork(svgContent string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.vectorWrapSession == nil {
a.vectorWrapSession = &VectorWrapSession{}
}
// Store the artwork SVG for use with surface wrapping
a.vectorWrapSession.EnclosureSCAD = svgContent
return nil
}
// ======== 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,
}
}