commit 5d09f56e009ffb80a1ebb5c0c3293137fc8723cc Author: pszsh Date: Thu Feb 26 15:27:05 2026 -0800 Initial commit: Former — PCB Stencil & Enclosure Generator Desktop application (Wails v2) for generating 3D-printable solder paste stencils and snap-fit enclosures from KiCad/Gerber files. Features include native OpenSCAD export, interactive 3D layer viewer, visual cutout placement, automatic USB port detection, and CLI mode for batch workflows. Based on pcb-to-stencil by Nikolai Danylchyk (https://github.com/kennycoder/pcb-to-stencil). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..636c2e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Build output +build/ +bin/ + +# Dependencies +frontend/node_modules/ +frontend/dist/ +frontend/wailsjs/ +frontend/package-lock.json + +# Temp / working files +temp/ +data/ +.DS_Store +*.DS_Store + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..779f736 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "static/vectors"] + path = static/vectors + url = git@ssh-git.else-if.org:jess/FormerStaticVectors.git diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e4c433 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f0aaab --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Former + +A desktop application for generating 3D-printable solder paste stencils and snap-fit enclosures from KiCad/Gerber files. + +![Former](static/showcase.png) + +## Features + +**Stencil Generation** +- Parses RS-274X Gerber solder paste layers +- Supports standard apertures (Circle, Rectangle, Obround) and Aperture Macros with rotation +- Generates optimized STL meshes for 3D printing +- Automatic cropping to PCB bounds + +**Enclosure Generation** +- Generates snap-fit enclosures with lids and trays from KiCad `.gbrjob` projects +- Native OpenSCAD `.scad` export for parametric editing +- Interactive 3D layer viewer with per-layer colorization (KiCad color scheme) +- Visual cutout placement on any surface (top, bottom, sides) with live 3D preview +- Automatic USB port detection and cutout alignment from F.Fab/B.Fab layers +- Tray clip system with vertical relief slots +- Dado/engrave mode for surface text and logos + +**Desktop App** +- Native macOS and Windows application (Wails v2) +- Project saving and loading with persistent cutout state +- CLI mode for scripted/batch workflows + +## Install + +### Requirements + +- [Go](https://go.dev/dl/) 1.21+ +- [Wails CLI](https://wails.io/docs/gettingstarted/installation): `go install github.com/wailsapp/wails/v2/cmd/wails@latest` +- [Node.js](https://nodejs.org/) 18+ (for frontend build) +- macOS: Xcode Command Line Tools (`xcode-select --install`) + +### Build + +**macOS:** +```bash +./build.sh +``` + +**Windows (native):** +```bat +build-windows.bat +``` + +**Cross-compile for Windows (from macOS/Linux):** +```bash +./build-windows.sh +``` + +The built application will be at `build/bin/Former.app` (macOS) or `build/bin/Former.exe` (Windows). + +## CLI Usage + +Former can also run headless from the command line: + +```bash +./Former [options] [outline.gbr] +``` + +### Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--height` | 0.16 | Stencil height in mm | +| `--wall-height` | 2.0 | Wall height in mm | +| `--wall-thickness` | 1.0 | Wall thickness in mm | +| `--dpi` | 1016 | Rendering DPI | +| `--keep-png` | false | Save intermediate PNG | + +### Example + +```bash +./Former --height=0.16 --keep-png my_board-F_Paste.gbr my_board-Edge_Cuts.gbr +``` + +## 3D Printing Tips + +For fine-pitch SMD stencils (TSSOP, 0402, etc.): + +- **Nozzle**: 0.2mm recommended +- **Layer height**: 0.16mm total (0.10mm first layer + 0.06mm second) +- **Build surface**: Smooth PEI sheet for flat stencil bottom + +## Acknowledgments + +Former began as a fork of [pcb-to-stencil](https://github.com/kennycoder/pcb-to-stencil) by [Nikolai Danylchyk](https://github.com/kennycoder), a Go tool for converting Gerber paste layers to STL stencils. The original Gerber parser and STL mesh generation formed the foundation that Former builds upon. + +## License + +MIT License — see [LICENSE](LICENSE) for details. diff --git a/app.go b/app.go new file mode 100644 index 0000000..b15fd0b --- /dev/null +++ b/app.go @@ -0,0 +1,981 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "image/png" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// ======== Image Server ======== + +// ImageServer serves dynamically-generated images at /api/* paths. +// It implements http.Handler and is used as the Wails AssetServer fallback handler. +type ImageServer struct { + mu sync.RWMutex + images map[string][]byte +} + +func NewImageServer() *ImageServer { + return &ImageServer{images: make(map[string][]byte)} +} + +func (s *ImageServer) Store(key string, data []byte) { + s.mu.Lock() + defer s.mu.Unlock() + s.images[key] = data +} + +func (s *ImageServer) Clear() { + s.mu.Lock() + defer s.mu.Unlock() + s.images = make(map[string][]byte) +} + +func (s *ImageServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + s.mu.RLock() + data, ok := s.images[path] + s.mu.RUnlock() + + if !ok { + http.NotFound(w, r) + return + } + + if strings.HasSuffix(path, ".png") { + w.Header().Set("Content-Type", "image/png") + } else if strings.HasSuffix(path, ".svg") { + w.Header().Set("Content-Type", "image/svg+xml") + } + w.Header().Set("Cache-Control", "no-cache") + w.Write(data) +} + +// ======== Frontend-facing Types ======== + +type ProjectInfoJS struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + CreatedAt string `json:"createdAt"` + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` +} + +type SessionInfoJS struct { + ProjectName string `json:"projectName"` + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` + Sides []BoardSide `json:"sides"` + TotalH float64 `json:"totalH"` + Cutouts []Cutout `json:"cutouts"` + HasSession bool `json:"hasSession"` + MinX float64 `json:"minX"` + MaxY float64 `json:"maxY"` + DPI float64 `json:"dpi"` +} + +type LayerInfoJS struct { + Index int `json:"index"` + Name string `json:"name"` + ColorHex string `json:"colorHex"` + Visible bool `json:"visible"` + Highlight bool `json:"highlight"` + BaseAlpha float64 `json:"baseAlpha"` +} + +type GenerateResultJS struct { + Files []string `json:"files"` +} + +type StencilResultJS struct { + Files []string `json:"files"` +} + +// ======== App ======== + +type App struct { + ctx context.Context + imageServer *ImageServer + + mu sync.RWMutex + enclosureSession *EnclosureSession + cutouts []Cutout + projectDir string // path to the current project directory (for auto-saving) + formerLayers []*FormerLayer + stencilFiles []string +} + +func NewApp(imageServer *ImageServer) *App { + return &App{ + imageServer: imageServer, + } +} + +// autosaveCutouts persists the current cutouts to the project directory's former.json +func (a *App) autosaveCutouts() { + a.mu.RLock() + dir := a.projectDir + cutouts := make([]Cutout, len(a.cutouts)) + copy(cutouts, a.cutouts) + a.mu.RUnlock() + + if dir == "" { + return + } + if err := UpdateProjectCutouts(dir, cutouts); err != nil { + log.Printf("autosave cutouts failed: %v", err) + } +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + + // Render and cache the logo (PNG for favicon, SVG for background art) + logoImg := renderSVGNative(formerLogoSVG, 512, 512) + if logoImg != nil { + var buf bytes.Buffer + png.Encode(&buf, logoImg) + a.imageServer.Store("/api/logo.png", buf.Bytes()) + } + a.imageServer.Store("/api/logo.svg", formerLogoSVG) +} + +// ======== Landing Page ======== + +func (a *App) GetRecentProjects() []ProjectInfoJS { + entries, err := ListProjects(20) + if err != nil { + return nil + } + var result []ProjectInfoJS + for _, e := range entries { + name := e.Data.ProjectName + if e.Data.Name != "" { + name = e.Data.Name + } + if name == "" { + name = "Untitled" + } + result = append(result, ProjectInfoJS{ + ID: e.Data.ID, + Name: name, + Path: e.Path, + Type: e.Type, + CreatedAt: e.ModTime.Format(time.RFC3339), + BoardW: e.Data.BoardW, + BoardH: e.Data.BoardH, + }) + } + return result +} + +func (a *App) GetLogoDataURL() string { + logoImg := renderSVGNative(formerLogoSVG, 256, 256) + if logoImg == nil { + return "" + } + var buf bytes.Buffer + png.Encode(&buf, logoImg) + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// ======== 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 { + if gbrjobPath == "" { + return fmt.Errorf("no gerber job file selected") + } + if len(gerberPaths) == 0 { + 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"} + } + + os.MkdirAll("temp", 0755) + uuid := randomID() + + // Copy gbrjob + gbrjobDst := filepath.Join("temp", uuid+"_"+filepath.Base(gbrjobPath)) + if err := CopyFile(gbrjobPath, gbrjobDst); err != nil { + 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("temp", uuid+"_"+baseName) + if err := CopyFile(src, dst); err != nil { + 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("temp", uuid+"_drill"+filepath.Ext(drillPath)) + if err := CopyFile(drillPath, drillDst); err != nil { + return fmt.Errorf("failed to copy PTH drill: %v", err) + } + } + if npthPath != "" { + npthDst = filepath.Join("temp", uuid+"_npth"+filepath.Ext(npthPath)) + if err := CopyFile(npthPath, npthDst); err != nil { + return fmt.Errorf("failed to copy NPTH drill: %v", err) + } + } + + _, session, err := BuildEnclosureSession(gbrjobDst, savedGerbers, drillDst, npthDst, ecfg, exports) + if err != nil { + return fmt.Errorf("session build failed: %v", err) + } + session.SourceDir = sourceDir + + a.mu.Lock() + a.enclosureSession = session + a.cutouts = nil + a.mu.Unlock() + + // Render board preview + if session.OutlineImg != nil { + var buf bytes.Buffer + png.Encode(&buf, session.OutlineImg) + a.imageServer.Store("/api/board-preview.png", buf.Bytes()) + } + + return nil +} + +func (a *App) GetSessionInfo() *SessionInfoJS { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.enclosureSession == nil { + return &SessionInfoJS{HasSession: false} + } + s := a.enclosureSession + result := make([]Cutout, len(a.cutouts)) + copy(result, a.cutouts) + return &SessionInfoJS{ + ProjectName: s.ProjectName, + BoardW: s.BoardW, + BoardH: s.BoardH, + Sides: s.Sides, + TotalH: s.TotalH, + Cutouts: result, + HasSession: true, + MinX: s.OutlineBounds.MinX, + MaxY: s.OutlineBounds.MaxY, + DPI: s.Config.DPI, + } +} + +func (a *App) AddSideCutout(side int, x, y, w, h, radius float64, layer string) { + a.mu.Lock() + a.cutouts = append(a.cutouts, Cutout{ + ID: randomID(), + Surface: "side", + SideNum: side, + X: x, + Y: y, + Width: w, + Height: h, + CornerRadius: radius, + SourceLayer: layer, + }) + a.mu.Unlock() + a.autosaveCutouts() +} + +func (a *App) RemoveSideCutout(index int) { + a.mu.Lock() + // Find the Nth side cutout + count := 0 + for i, c := range a.cutouts { + if c.Surface == "side" { + if count == index { + a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...) + break + } + count++ + } + } + a.mu.Unlock() + a.autosaveCutouts() +} + +func (a *App) GetSideCutouts() []SideCutout { + a.mu.RLock() + defer a.mu.RUnlock() + var result []SideCutout + for _, c := range a.cutouts { + if c.Surface == "side" { + result = append(result, CutoutToSideCutout(c)) + } + } + return result +} + +// ======== Unified Cutout CRUD ======== + +func (a *App) AddCutout(c Cutout) string { + a.mu.Lock() + if c.ID == "" { + c.ID = randomID() + } + a.cutouts = append(a.cutouts, c) + a.mu.Unlock() + a.autosaveCutouts() + return c.ID +} + +func (a *App) UpdateCutout(c Cutout) { + a.mu.Lock() + for i, existing := range a.cutouts { + if existing.ID == c.ID { + a.cutouts[i] = c + break + } + } + a.mu.Unlock() + a.autosaveCutouts() +} + +func (a *App) RemoveCutout(id string) { + a.mu.Lock() + for i, c := range a.cutouts { + if c.ID == id { + a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...) + break + } + } + a.mu.Unlock() + a.autosaveCutouts() +} + +func (a *App) GetCutouts() []Cutout { + a.mu.RLock() + defer a.mu.RUnlock() + result := make([]Cutout, len(a.cutouts)) + copy(result, a.cutouts) + return result +} + +func (a *App) DuplicateCutout(id string) string { + a.mu.Lock() + var dupID string + for _, c := range a.cutouts { + if c.ID == id { + dup := c + dup.ID = randomID() + if dup.Surface == "side" { + dup.X += 1.0 + } else { + dup.X += 1.0 + dup.Y += 1.0 + } + a.cutouts = append(a.cutouts, dup) + dupID = dup.ID + break + } + } + a.mu.Unlock() + a.autosaveCutouts() + return dupID +} + +func (a *App) GetSideLength(sideNum int) float64 { + a.mu.RLock() + defer a.mu.RUnlock() + if a.enclosureSession == nil { + return 0 + } + for _, s := range a.enclosureSession.Sides { + if s.Num == sideNum { + return s.Length + } + } + return 0 +} + +// AddLidCutouts converts element pixel bboxes to mm coordinates and stores them as unified cutouts. +func (a *App) AddLidCutouts(elements []ElementBBox, plane string, isDado bool, depth float64) { + a.mu.Lock() + + if a.enclosureSession == nil { + a.mu.Unlock() + return + } + + bounds := a.enclosureSession.OutlineBounds + dpi := a.enclosureSession.Config.DPI + + surface := "top" + if plane == "tray" { + surface = "bottom" + } + + for _, el := range elements { + mmMinX := float64(el.MinX)*(25.4/dpi) + bounds.MinX + mmMaxX := float64(el.MaxX)*(25.4/dpi) + bounds.MinX + mmMinY := bounds.MaxY - float64(el.MaxY)*(25.4/dpi) + mmMaxY := bounds.MaxY - float64(el.MinY)*(25.4/dpi) + + a.cutouts = append(a.cutouts, Cutout{ + ID: randomID(), + Surface: surface, + X: mmMinX, + Y: mmMinY, + Width: mmMaxX - mmMinX, + Height: mmMaxY - mmMinY, + IsDado: isDado, + Depth: depth, + }) + } + a.mu.Unlock() + a.autosaveCutouts() +} + +func (a *App) GetLidCutouts() []LidCutout { + a.mu.RLock() + defer a.mu.RUnlock() + var result []LidCutout + for _, c := range a.cutouts { + if c.Surface == "top" || c.Surface == "bottom" { + result = append(result, CutoutToLidCutout(c)) + } + } + return result +} + +func (a *App) ClearLidCutouts() { + a.mu.Lock() + var kept []Cutout + for _, c := range a.cutouts { + if c.Surface == "side" { + kept = append(kept, c) + } + } + a.cutouts = kept + a.mu.Unlock() + a.autosaveCutouts() +} + +func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) { + a.mu.RLock() + session := a.enclosureSession + allCutouts := make([]Cutout, len(a.cutouts)) + copy(allCutouts, a.cutouts) + a.mu.RUnlock() + + if session == nil { + return nil, fmt.Errorf("no enclosure session active") + } + + outputDir := session.SourceDir + if outputDir == "" { + outputDir = filepath.Join(".", "temp") + } + + files, err := GenerateEnclosureOutputs(session, allCutouts, outputDir) + if err != nil { + return nil, err + } + + // Auto-save session + inst := InstanceData{ + ID: randomID(), + CreatedAt: time.Now(), + GerberFiles: session.GerberFiles, + DrillPath: session.DrillPath, + NPTHPath: session.NPTHPath, + EdgeCutsFile: session.EdgeCutsFile, + CourtyardFile: session.CourtyardFile, + SoldermaskFile: session.SoldermaskFile, + FabFile: session.FabFile, + Config: session.Config, + Exports: session.Exports, + BoardW: session.BoardW, + BoardH: session.BoardH, + ProjectName: session.ProjectName, + Cutouts: allCutouts, + } + if savedDir, saveErr := SaveSession(inst, filepath.Join(".", "temp"), session.OutlineImg); saveErr != nil { + log.Printf("Warning: could not save session: %v", saveErr) + } else { + a.mu.Lock() + a.projectDir = savedDir + a.mu.Unlock() + } + + // Prepare Former layers + a.mu.Lock() + a.formerLayers = buildEnclosureLayers(session) + a.mu.Unlock() + a.prepareFormerImages() + + return &GenerateResultJS{Files: files}, nil +} + +func (a *App) SaveEnclosureProfile(name string) error { + a.mu.RLock() + session := a.enclosureSession + allCutouts := make([]Cutout, len(a.cutouts)) + copy(allCutouts, a.cutouts) + a.mu.RUnlock() + + if session == nil { + return fmt.Errorf("no enclosure session active") + } + if name == "" { + name = session.ProjectName + } + if name == "" { + name = "Untitled" + } + + inst := InstanceData{ + ID: randomID(), + Name: name, + CreatedAt: time.Now(), + GerberFiles: session.GerberFiles, + DrillPath: session.DrillPath, + NPTHPath: session.NPTHPath, + EdgeCutsFile: session.EdgeCutsFile, + CourtyardFile: session.CourtyardFile, + SoldermaskFile: session.SoldermaskFile, + FabFile: session.FabFile, + Config: session.Config, + Exports: session.Exports, + BoardW: session.BoardW, + BoardH: session.BoardH, + ProjectName: session.ProjectName, + Cutouts: allCutouts, + } + sourceDir := session.SourceDir + if sourceDir == "" { + sourceDir = filepath.Join(".", "temp") + } + savedDir, err := SaveProfile(inst, name, sourceDir, session.OutlineImg) + if err == nil && savedDir != "" { + a.mu.Lock() + a.projectDir = savedDir + a.mu.Unlock() + } + return err +} + +func (a *App) OpenProject(projectPath string) error { + _, session, inst, err := RestoreProject(projectPath) + if err != nil { + return err + } + + a.mu.Lock() + a.enclosureSession = session + a.cutouts = inst.MigrateCutouts() + a.projectDir = projectPath + a.mu.Unlock() + + // Render board preview + if session.OutlineImg != nil { + var buf bytes.Buffer + png.Encode(&buf, session.OutlineImg) + a.imageServer.Store("/api/board-preview.png", buf.Bytes()) + } + + return nil +} + +func (a *App) DeleteProject(projectPath string) error { + return DeleteProject(projectPath) +} + +// ======== Auto-Detect USB Port ======== + +type AutoDetectResultJS struct { + Footprints []Footprint `json:"footprints"` + FabImageURL string `json:"fabImageURL"` +} + +func (a *App) UploadAndDetectFootprints(fabPaths []string) (*AutoDetectResultJS, error) { + a.mu.RLock() + session := a.enclosureSession + a.mu.RUnlock() + + if session == nil { + return nil, fmt.Errorf("no enclosure session active") + } + if len(fabPaths) == 0 { + return nil, fmt.Errorf("no fab gerber files selected") + } + + footprints, fabImg := UploadFabAndExtractFootprints(session, fabPaths) + + if fabImg != nil { + var buf bytes.Buffer + png.Encode(&buf, fabImg) + a.imageServer.Store("/api/fab-overlay.png", buf.Bytes()) + } + + return &AutoDetectResultJS{ + Footprints: footprints, + FabImageURL: "/api/fab-overlay.png?t=" + fmt.Sprint(time.Now().UnixMilli()), + }, nil +} + +// ======== Former ======== + +type MountingHoleJS struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Diameter float64 `json:"diameter"` +} + +type Enclosure3DDataJS struct { + OutlinePoints [][2]float64 `json:"outlinePoints"` + WallThickness float64 `json:"wallThickness"` + Clearance float64 `json:"clearance"` + WallHeight float64 `json:"wallHeight"` + PCBThickness float64 `json:"pcbThickness"` + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` + TrayFloor float64 `json:"trayFloor"` + SnapHeight float64 `json:"snapHeight"` + LidThick float64 `json:"lidThick"` + TotalH float64 `json:"totalH"` + MountingHoles []MountingHoleJS `json:"mountingHoles"` + Sides []BoardSide `json:"sides"` + Cutouts []Cutout `json:"cutouts"` + MinBX float64 `json:"minBX"` + MaxBX float64 `json:"maxBX"` + BoardCenterY float64 `json:"boardCenterY"` +} + +func (a *App) GetEnclosure3DData() *Enclosure3DDataJS { + a.mu.RLock() + s := a.enclosureSession + allCutouts := make([]Cutout, len(a.cutouts)) + copy(allCutouts, a.cutouts) + a.mu.RUnlock() + + if s == nil { + return nil + } + poly := ExtractPolygonFromGerber(s.OutlineGf) + if poly == nil { + return nil + } + + wt := s.Config.WallThickness + trayFloor := 1.5 + pcbT := s.Config.PCBThickness + totalH := s.Config.WallHeight + pcbT + trayFloor + + var mountingHoles []MountingHoleJS + for _, h := range s.DrillHoles { + if h.Type == DrillTypeMounting { + mountingHoles = append(mountingHoles, MountingHoleJS{X: h.X, Y: h.Y, Diameter: h.Diameter}) + } + } + + return &Enclosure3DDataJS{ + OutlinePoints: poly, + WallThickness: wt, + Clearance: s.Config.Clearance, + WallHeight: s.Config.WallHeight, + PCBThickness: pcbT, + BoardW: s.BoardW, + BoardH: s.BoardH, + TrayFloor: trayFloor, + SnapHeight: 2.5, + LidThick: wt, + TotalH: totalH, + MountingHoles: mountingHoles, + Sides: s.Sides, + Cutouts: allCutouts, + MinBX: s.MinBX, + MaxBX: s.MaxBX, + BoardCenterY: s.BoardCenterY, + } +} + +func (a *App) GetFormerLayers() []LayerInfoJS { + a.mu.RLock() + defer a.mu.RUnlock() + + var result []LayerInfoJS + for i, l := range a.formerLayers { + result = append(result, LayerInfoJS{ + Index: i, + Name: l.Name, + ColorHex: fmt.Sprintf("#%02x%02x%02x", l.Color.R, l.Color.G, l.Color.B), + Visible: l.Visible, + Highlight: l.Highlight, + BaseAlpha: l.BaseAlpha, + }) + } + return result +} + +func (a *App) SetLayerVisibility(index int, visible bool) { + a.mu.Lock() + defer a.mu.Unlock() + if index >= 0 && index < len(a.formerLayers) { + a.formerLayers[index].Visible = visible + if !visible { + a.formerLayers[index].Highlight = false + } + } +} + +func (a *App) ToggleHighlight(index int) { + a.mu.Lock() + defer a.mu.Unlock() + if index < 0 || index >= len(a.formerLayers) { + return + } + if a.formerLayers[index].Highlight { + a.formerLayers[index].Highlight = false + } else { + for i := range a.formerLayers { + a.formerLayers[i].Highlight = false + } + a.formerLayers[index].Highlight = true + a.formerLayers[index].Visible = true + } +} + +func (a *App) GetLayerElements(layerIndex int) ([]ElementBBox, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + if layerIndex < 0 || layerIndex >= len(a.formerLayers) { + return nil, fmt.Errorf("layer index %d out of range", layerIndex) + } + + layer := a.formerLayers[layerIndex] + if layer.SourceFile == "" { + return nil, fmt.Errorf("no source file for layer %q", layer.Name) + } + + if a.enclosureSession == nil { + return nil, fmt.Errorf("no enclosure session active") + } + + gf, ok := a.enclosureSession.AllLayerGerbers[layer.SourceFile] + if !ok || gf == nil { + return nil, fmt.Errorf("parsed gerber not available for %q", layer.SourceFile) + } + + bounds := a.enclosureSession.OutlineBounds + dpi := a.enclosureSession.Config.DPI + elements := ExtractElementBBoxes(gf, dpi, &bounds) + return elements, nil +} + +func (a *App) OpenFormerEnclosure() { + a.mu.Lock() + if a.enclosureSession != nil { + a.formerLayers = buildEnclosureLayers(a.enclosureSession) + } + a.mu.Unlock() + a.prepareFormerImages() +} + +func (a *App) prepareFormerImages() { + a.mu.RLock() + layers := a.formerLayers + a.mu.RUnlock() + + for i, layer := range layers { + if layer.Source == nil { + continue + } + // Colorize at full alpha — frontend controls opacity via canvas globalAlpha + colored := colorizeLayer(layer.Source, layer.Color, 1.0) + var buf bytes.Buffer + png.Encode(&buf, colored) + a.imageServer.Store(fmt.Sprintf("/api/layers/%d.png", i), buf.Bytes()) + } +} + +// RenderFromFormer generates output files (SCAD, SVG, etc.) from the current session. +// Called from the Former 3D editor's "Render & View" button. +func (a *App) RenderFromFormer() (*GenerateResultJS, error) { + return a.GenerateEnclosureOutputs() +} + +// GetOutputDir returns the output directory path for the current session. +func (a *App) GetOutputDir() (string, error) { + a.mu.RLock() + session := a.enclosureSession + a.mu.RUnlock() + + if session == nil { + return "", fmt.Errorf("no enclosure session active") + } + + outputDir := session.SourceDir + if outputDir == "" { + outputDir = filepath.Join(".", "temp") + } + + absDir, err := filepath.Abs(outputDir) + if err != nil { + return outputDir, nil + } + return absDir, nil +} + +// OpenOutputFolder opens the output directory in the OS file manager. +func (a *App) OpenOutputFolder() error { + dir, err := a.GetOutputDir() + if err != nil { + return err + } + return exec.Command("open", dir).Start() +} diff --git a/build-windows.bat b/build-windows.bat new file mode 100644 index 0000000..64abb57 --- /dev/null +++ b/build-windows.bat @@ -0,0 +1,16 @@ +@echo off +REM Build Former for Windows — run this on a Windows machine with Go and Wails installed +REM Prerequisites: Go 1.21+, Wails CLI (go install github.com/wailsapp/wails/v2/cmd/wails@latest) + +echo Generating app icon... +go run ./cmd/genicon 2>nul && echo Icon generated. || echo Icon generation skipped. + +echo Building Former for Windows... +wails build -skipbindings +if %ERRORLEVEL% NEQ 0 ( + echo Build failed. + exit /b 1 +) + +echo. +echo Done: build\bin\Former.exe diff --git a/build-windows.sh b/build-windows.sh new file mode 100755 index 0000000..496ef0d --- /dev/null +++ b/build-windows.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Cross-compile Former for Windows (amd64) from macOS/Linux +set -e + +# Generate app icon (needs CGO on macOS for native SVG rendering) +echo "Generating app icon..." +if [[ "$OSTYPE" == darwin* ]]; then + SDKROOT=$(xcrun --show-sdk-path) CC=/usr/bin/clang CGO_ENABLED=1 \ + go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped." +else + CGO_ENABLED=0 go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped." +fi + +WAILS=$(command -v wails || echo "$HOME/go/bin/wails") + +echo "Building Former for Windows (amd64)..." +CGO_ENABLED=0 "$WAILS" build -skipbindings -platform windows/amd64 + +echo "" +ls -lh build/bin/Former.exe 2>/dev/null && echo "Done." || echo "Build failed." diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..674add4 --- /dev/null +++ b/build.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Build Former for macOS +set -e +pkill -f "Former.app" || true +sleep 0.5 + +export SDKROOT=$(xcrun --show-sdk-path) +export CC=/usr/bin/clang +export CGO_ENABLED=1 + +WAILS=$(command -v wails || echo "$HOME/go/bin/wails") + +# Generate app icon from Former.svg +echo "Generating app icon..." +go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped." + +"$WAILS" build -skipbindings + +open build/bin/Former.app diff --git a/cmd/genicon/main.go b/cmd/genicon/main.go new file mode 100644 index 0000000..656fd35 --- /dev/null +++ b/cmd/genicon/main.go @@ -0,0 +1,130 @@ +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework AppKit -framework CoreGraphics + +#import +#import + +unsigned char* renderSVGToPixels(const void* svgBytes, int svgLen, int targetW, int targetH) { + @autoreleasepool { + NSData *data = [NSData dataWithBytesNoCopy:(void*)svgBytes length:svgLen freeWhenDone:NO]; + NSImage *svgImage = [[NSImage alloc] initWithData:data]; + if (!svgImage) return NULL; + + int w = targetW; + int h = targetH; + int rowBytes = w * 4; + int totalBytes = rowBytes * h; + + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:w + pixelsHigh:h + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSDeviceRGBColorSpace + bytesPerRow:rowBytes + bitsPerPixel:32]; + + [NSGraphicsContext saveGraphicsState]; + NSGraphicsContext *ctx = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep]; + [NSGraphicsContext setCurrentContext:ctx]; + + [[NSColor clearColor] set]; + NSRectFill(NSMakeRect(0, 0, w, h)); + + [svgImage drawInRect:NSMakeRect(0, 0, w, h) + fromRect:NSZeroRect + operation:NSCompositingOperationSourceOver + fraction:1.0]; + + [NSGraphicsContext restoreGraphicsState]; + + unsigned char* result = (unsigned char*)malloc(totalBytes); + if (result) { + memcpy(result, [rep bitmapData], totalBytes); + } + return result; + } +} +*/ +import "C" + +import ( + "fmt" + "image" + "image/color" + "image/png" + "os" + "unsafe" +) + +func main() { + svgPath := "static/vectors/Former.svg" + outPath := "build/appicon.png" + size := 1024 + + svgData, err := os.ReadFile(svgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read SVG: %v\n", err) + os.Exit(1) + } + + pixels := C.renderSVGToPixels( + unsafe.Pointer(&svgData[0]), + C.int(len(svgData)), + C.int(size), + C.int(size), + ) + if pixels == nil { + fmt.Fprintln(os.Stderr, "SVG rendering failed") + os.Exit(1) + } + defer C.free(unsafe.Pointer(pixels)) + + rawLen := size * size * 4 + raw := unsafe.Slice((*byte)(unsafe.Pointer(pixels)), rawLen) + + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + i := (y*size + x) * 4 + r, g, b, a := raw[i], raw[i+1], raw[i+2], raw[i+3] + if a > 0 && a < 255 { + scale := 255.0 / float64(a) + r = clamp(float64(r) * scale) + g = clamp(float64(g) * scale) + b = clamp(float64(b) * scale) + } + img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a}) + } + } + + f, err := os.Create(outPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create output: %v\n", err) + os.Exit(1) + } + defer f.Close() + + if err := png.Encode(f, img); err != nil { + fmt.Fprintf(os.Stderr, "PNG encode failed: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Generated %s (%dx%d) from %s\n", outPath, size, size, svgPath) +} + +func clamp(v float64) uint8 { + if v > 255 { + return 255 + } + if v < 0 { + return 0 + } + return uint8(v) +} diff --git a/drill.go b/drill.go new file mode 100644 index 0000000..6c2bd66 --- /dev/null +++ b/drill.go @@ -0,0 +1,189 @@ +package main + +import ( + "bufio" + "math" + "os" + "regexp" + "strconv" + "strings" +) + +// DrillHoleType classifies a drill hole by function +type DrillHoleType int + +const ( + DrillTypeUnknown DrillHoleType = iota + DrillTypeVia // ViaDrill — ignore for enclosure + DrillTypeComponent // ComponentDrill — component leads + DrillTypeMounting // Mounting holes (from NPTH) +) + +// DrillHole represents a single drill hole with position, diameter, and type +type DrillHole struct { + X, Y float64 // Position in mm + Diameter float64 // Diameter in mm + Type DrillHoleType // Classified by TA.AperFunction + ToolNum int // Tool number (T1, T2, etc.) +} + +// ParseDrill parses an Excellon drill file and returns hole positions +func ParseDrill(filename string) ([]DrillHole, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var holes []DrillHole + type toolInfo struct { + diameter float64 + holeType DrillHoleType + } + tools := make(map[int]toolInfo) + currentTool := 0 + inHeader := true + units := "MM" + isNPTH := false + + // Format spec + formatDec := 0 + + // Pending aperture function for the next tool definition + pendingType := DrillTypeUnknown + + scanner := bufio.NewScanner(file) + + reToolDef := regexp.MustCompile(`^T(\d+)C([\d.]+)`) + reToolSelect := regexp.MustCompile(`^T(\d+)$`) + reCoord := regexp.MustCompile(`^X([+-]?[\d.]+)Y([+-]?[\d.]+)`) + reAperFunc := regexp.MustCompile(`TA\.AperFunction,(\w+),(\w+),(\w+)`) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Check file function for NPTH + if strings.Contains(line, "TF.FileFunction") { + if strings.Contains(line, "NonPlated") || strings.Contains(line, "NPTH") { + isNPTH = true + } + } + + // Parse TA.AperFunction comments (appears before tool definition) + if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "G04") { + m := reAperFunc.FindStringSubmatch(line) + if len(m) >= 4 { + funcType := m[3] + switch funcType { + case "ViaDrill": + pendingType = DrillTypeVia + case "ComponentDrill": + pendingType = DrillTypeComponent + default: + pendingType = DrillTypeUnknown + } + } + // Also check for format spec + if strings.HasPrefix(line, ";FORMAT=") { + re := regexp.MustCompile(`\{(\d+):(\d+)\}`) + fm := re.FindStringSubmatch(line) + if len(fm) == 3 { + formatDec, _ = strconv.Atoi(fm[2]) + } + } + continue + } + + // Detect header end + if line == "%" || line == "M95" { + inHeader = false + continue + } + + // Units + if strings.Contains(line, "METRIC") || line == "M71" { + units = "MM" + continue + } + if strings.Contains(line, "INCH") || line == "M72" { + units = "IN" + continue + } + + // Tool definitions (in header): T01C0.300 + if inHeader { + m := reToolDef.FindStringSubmatch(line) + if len(m) == 3 { + toolNum, _ := strconv.Atoi(m[1]) + dia, _ := strconv.ParseFloat(m[2], 64) + + ht := pendingType + // If this is an NPTH file and type is unknown, classify as mounting + if isNPTH && ht == DrillTypeUnknown { + ht = DrillTypeMounting + } + + tools[toolNum] = toolInfo{diameter: dia, holeType: ht} + pendingType = DrillTypeUnknown // Reset + continue + } + } + + // Tool selection: T01 + m := reToolSelect.FindStringSubmatch(line) + if len(m) == 2 { + toolNum, _ := strconv.Atoi(m[1]) + currentTool = toolNum + continue + } + + // End of file + if line == "M30" || line == "M00" { + break + } + + // Coordinate: X123456Y789012 + mc := reCoord.FindStringSubmatch(line) + if len(mc) == 3 && currentTool != 0 { + x := parseExcellonCoord(mc[1], formatDec) + y := parseExcellonCoord(mc[2], formatDec) + + ti := tools[currentTool] + dia := ti.diameter + + // Convert inches to mm if needed + if units == "IN" { + x *= 25.4 + y *= 25.4 + if dia < 1.0 { + dia *= 25.4 + } + } + + holes = append(holes, DrillHole{ + X: x, + Y: y, + Diameter: dia, + Type: ti.holeType, + ToolNum: currentTool, + }) + } + } + + return holes, nil +} + +func parseExcellonCoord(s string, fmtDec int) float64 { + if strings.Contains(s, ".") { + val, _ := strconv.ParseFloat(s, 64) + return val + } + val, _ := strconv.ParseFloat(s, 64) + if fmtDec > 0 { + return val / math.Pow(10, float64(fmtDec)) + } + return val / 1000.0 +} diff --git a/enclosure.go b/enclosure.go new file mode 100644 index 0000000..7acda29 --- /dev/null +++ b/enclosure.go @@ -0,0 +1,1217 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "math" +) + +// EnclosureConfig holds parameters for enclosure generation +type EnclosureConfig struct { + PCBThickness float64 `json:"pcbThickness"` + WallThickness float64 `json:"wallThickness"` + WallHeight float64 `json:"wallHeight"` + Clearance float64 `json:"clearance"` + DPI float64 `json:"dpi"` + OutlineBounds *Bounds `json:"-"` +} + +// Default enclosure values +const ( + DefaultPCBThickness = 1.6 + DefaultEncWallHeight = 10.0 + DefaultEncWallThick = 1.5 + DefaultClearance = 0.3 +) + +// EnclosureResult contains the generated meshes +type EnclosureResult struct { + EnclosureTriangles [][3]Point + TrayTriangles [][3]Point +} + +// SideCutout defines a cutout on a side wall face +type SideCutout struct { + Side int `json:"side"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"w"` + Height float64 `json:"h"` + CornerRadius float64 `json:"r"` + Layer string `json:"l"` +} + +// LidCutout defines a cutout on the lid or tray plane (top/bottom flat surfaces) +type LidCutout struct { + ID int `json:"id"` + Plane string `json:"plane"` // "lid" or "tray" + MinX float64 `json:"minX"` // gerber mm coordinates + MinY float64 `json:"minY"` + MaxX float64 `json:"maxX"` + MaxY float64 `json:"maxY"` + IsDado bool `json:"isDado"` + Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut +} + +// Cutout is the unified cutout type — replaces separate SideCutout/LidCutout. +type Cutout struct { + ID string `json:"id"` + Surface string `json:"surface"` // "top", "bottom", "side" + SideNum int `json:"sideNum"` // only when Surface="side" + X float64 `json:"x"` // side: mm along side; top/bottom: gerber mm minX + Y float64 `json:"y"` // side: mm height from PCB; top/bottom: gerber mm minY + Width float64 `json:"w"` + Height float64 `json:"h"` + CornerRadius float64 `json:"r"` + IsDado bool `json:"isDado"` + Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut + SourceLayer string `json:"sourceLayer"` // "F" or "B" for side cutouts +} + +// CutoutToSideCutout converts a unified Cutout (surface="side") to legacy SideCutout +func CutoutToSideCutout(c Cutout) SideCutout { + return SideCutout{ + Side: c.SideNum, + X: c.X, + Y: c.Y, + Width: c.Width, + Height: c.Height, + CornerRadius: c.CornerRadius, + Layer: c.SourceLayer, + } +} + +// CutoutToLidCutout converts a unified Cutout (surface="top"/"bottom") to legacy LidCutout +func CutoutToLidCutout(c Cutout) LidCutout { + plane := "lid" + if c.Surface == "bottom" { + plane = "tray" + } + return LidCutout{ + Plane: plane, + MinX: c.X, + MinY: c.Y, + MaxX: c.X + c.Width, + MaxY: c.Y + c.Height, + IsDado: c.IsDado, + Depth: c.Depth, + } +} + +// SplitCutouts partitions unified cutouts into side and lid slices for SCAD/STL generation. +func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) { + var sides []SideCutout + var lids []LidCutout + for _, c := range cutouts { + switch c.Surface { + case "side": + sides = append(sides, CutoutToSideCutout(c)) + case "top", "bottom": + lids = append(lids, CutoutToLidCutout(c)) + } + } + return sides, lids +} + +// BoardSide represents a physical straight edge of the board outline +type BoardSide struct { + Num int `json:"num"` + Label string `json:"label"` + Length float64 `json:"length"` + StartX float64 `json:"startX"` + StartY float64 `json:"startY"` + EndX float64 `json:"endX"` + EndY float64 `json:"endY"` + Angle float64 `json:"angle"` // Angle in radians of the normal vector pushing OUT of the board +} + +func perpendicularDistance(pt, lineStart, lineEnd [2]float64) float64 { + dx := lineEnd[0] - lineStart[0] + dy := lineEnd[1] - lineStart[1] + + // Normalize line vector + mag := math.Sqrt(dx*dx + dy*dy) + if mag == 0 { + return math.Sqrt((pt[0]-lineStart[0])*(pt[0]-lineStart[0]) + (pt[1]-lineStart[1])*(pt[1]-lineStart[1])) + } + dx /= mag + dy /= mag + + // Vector from lineStart to pt + px := pt[0] - lineStart[0] + py := pt[1] - lineStart[1] + + // Cross product gives perpendicular distance + return math.Abs(px*dy - py*dx) +} + +func simplifyPolygonRDP(points [][2]float64, epsilon float64) [][2]float64 { + if len(points) < 3 { + return points + } + + dmax := 0.0 + index := 0 + end := len(points) - 1 + + for i := 1; i < end; i++ { + d := perpendicularDistance(points[i], points[0], points[end]) + if d > dmax { + index = i + dmax = d + } + } + + if dmax > epsilon { + recResults1 := simplifyPolygonRDP(points[:index+1], epsilon) + recResults2 := simplifyPolygonRDP(points[index:], epsilon) + + result := append([][2]float64{}, recResults1[:len(recResults1)-1]...) + result = append(result, recResults2...) + return result + } + + return [][2]float64{points[0], points[end]} +} + +func ExtractBoardSides(poly [][2]float64) []BoardSide { + if len(poly) < 3 { + return nil + } + + // Determine "center" of polygon to find outward normals + cx, cy := 0.0, 0.0 + for _, p := range poly { + cx += p[0] + cy += p[1] + } + cx /= float64(len(poly)) + cy /= float64(len(poly)) + + // Ensure the polygon is closed for RDP, if it isn't already + if poly[0][0] != poly[len(poly)-1][0] || poly[0][1] != poly[len(poly)-1][1] { + poly = append(poly, poly[0]) + } + + simplified := simplifyPolygonRDP(poly, 0.2) // 0.2mm tolerance + fmt.Printf("[DEBUG] ExtractBoardSides: poly points = %d, simplified points = %d\n", len(poly), len(simplified)) + + var sides []BoardSide + sideNum := 1 + + for i := 0; i < len(simplified)-1; i++ { + p1 := simplified[i] + p2 := simplified[i+1] + + dx := p2[0] - p1[0] + dy := p2[1] - p1[1] + length := math.Sqrt(dx*dx + dy*dy) + + // Only keep substantial straight edges (e.g. > 4mm) + if length > 4.0 { + // Calculate outward normal angle + // The segment path vector is (dx, dy). Normal is either (-dy, dx) or (dy, -dx) + nx := dy + ny := -dx + // Dot product with center->midpoint to check if it points out + midX := (p1[0] + p2[0]) / 2.0 + midY := (p1[1] + p2[1]) / 2.0 + vx := midX - cx + vy := midY - cy + if nx*vx+ny*vy < 0 { + nx = -nx + ny = -ny + } + angle := math.Atan2(ny, nx) + + sides = append(sides, BoardSide{ + Num: sideNum, + Label: fmt.Sprintf("Side %d (%.1fmm)", sideNum, length), + Length: length, + StartX: p1[0], + StartY: p1[1], + EndX: p2[0], + EndY: p2[1], + Angle: angle, + }) + sideNum++ + } + } + return sides +} + +// ExtractBoardSidesFromMask traces the outer boundary of a boolean mask +// and simplifies it into BoardSides. This perfectly matches the 3D generation. +func ExtractBoardSidesFromMask(mask []bool, imgW, imgH int, pixelToMM float64, bounds *Bounds) []BoardSide { + // Find top-leftmost pixel of mask + startX, startY := -1, -1 +outer: + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if mask[y*imgW+x] { + startX, startY = x, y + break outer + } + } + } + if startX == -1 { + return nil + } + + // Moore-neighbor boundary tracing + var boundary [][2]int + dirs := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}} + + curX, curY := startX, startY + boundary = append(boundary, [2]int{curX, curY}) + + // Initial previous neighbor direction (up/west of top-left is empty) + pDir := 6 + + for { + found := false + for i := 0; i < 8; i++ { + // Scan clockwise starting from dir after the previous background pixel + testDir := (pDir + 1 + i) % 8 + nx, ny := curX+dirs[testDir][0], curY+dirs[testDir][1] + if nx >= 0 && nx < imgW && ny >= 0 && ny < imgH && mask[ny*imgW+nx] { + curX, curY = nx, ny + boundary = append(boundary, [2]int{curX, curY}) + // The new background pixel is opposite to the direction we found the solid one + pDir = (testDir + 4) % 8 + found = true + break + } + } + if !found { + break // Isolated pixel + } + // Stop when we return to the start and moved in the same direction + if curX == startX && curY == startY { + break + } + // Failsafe for complex shapes + if len(boundary) > imgW*imgH { + break + } + } + + // Convert boundary pixels to Gerber mm coordinates + var poly [][2]float64 + for _, p := range boundary { + px := float64(p[0])*pixelToMM + bounds.MinX + // Image Y=0 is MaxY in Gerber space + py := bounds.MaxY - float64(p[1])*pixelToMM + poly = append(poly, [2]float64{px, py}) + } + + sides := ExtractBoardSides(poly) + fmt.Printf("[DEBUG] ExtractBoardSidesFromMask: mask size=%dx%d, boundary pixels=%d, sides extracted=%d\n", imgW, imgH, len(boundary), len(sides)) + if len(sides) == 0 && len(poly) > 0 { + fmt.Printf("[DEBUG] poly[0]=%v, poly[n/2]=%v, poly[last]=%v\n", poly[0], poly[len(poly)/2], poly[len(poly)-1]) + } + return sides +} + +// GenerateEnclosure creates enclosure + tray meshes from a board outline image and drill holes. +// The enclosure walls conform to the actual board outline shape. +// courtyardImg is optional — if provided, component courtyard regions are cut from the lid (flood-filled). +// soldermaskImg is optional — if provided, soldermask pad openings are also cut from the lid. +func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg EnclosureConfig, courtyardImg image.Image, soldermaskImg image.Image, sideCutouts []SideCutout, boardSides []BoardSide) *EnclosureResult { + pixelToMM := 25.4 / cfg.DPI + bounds := outlineImg.Bounds() + imgW := bounds.Max.X + imgH := bounds.Max.Y + + // Use ComputeWallMask to get the board shape and wall around it + // WallThickness for enclosure = clearance + 2 * wall thickness + clearance := cfg.Clearance + wt := cfg.WallThickness + lidThick := wt + snapHeight := 2.5 + totalWallMM := clearance + 2.0*wt + fmt.Printf("Computing board shape (wall=%.1fmm)...\n", totalWallMM) + wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM) // wallMask is now an int slice + + // Determine the actual enclosure boundary = wall | board (expanded by clearance) + // wallMask = pixels that are the wall + // boardMask = pixels inside the board outline + // clearanceMask is just an expansion of boardMask using distance logic up to clearance + // However, we already have wallDist which measures distance OUTWARD from board + clearanceDistPx := int(clearance * cfg.DPI / 25.4) + trayWallOuterPx := int((clearance + wt) * cfg.DPI / 25.4) + encWallOuterPx := int((clearance + 2.0*wt) * cfg.DPI / 25.4) + snapDepthPx := int(0.5 * cfg.DPI / 25.4) + if snapDepthPx < 1 { + snapDepthPx = 1 + } + + // Total height of the enclosure (from bottom of tray to top of lid) + pcbT := cfg.PCBThickness + trayFloor := pcbT + 0.5 // Tray floor is 0.5mm thick, sits below PCB + totalH := trayFloor + cfg.WallHeight + lidThick + + size := imgW * imgH + + var encTris [][3]Point + var trayTris [][3]Point + + // Mounting pegs from NPTH holes: cylinders going from lid downward + pegMask := make([]bool, size) // true = peg/socket at this pixel (exclude from tray floor) + if cfg.OutlineBounds != nil { + mountingHoles := 0 + for _, h := range drillHoles { + if h.Type != DrillTypeMounting { + continue + } + mountingHoles++ + + // Convert drill mm coordinates to pixel coordinates + px := (h.X - cfg.OutlineBounds.MinX) * cfg.DPI / 25.4 + py := (h.Y - cfg.OutlineBounds.MinY) * cfg.DPI / 25.4 + + // Peg radius slightly smaller than hole for press fit + pegRadiusMM := (h.Diameter / 2) - 0.15 + pegRadiusPx := pegRadiusMM * cfg.DPI / 25.4 + // Socket radius slightly larger for easy insertion + socketRadiusPx := (h.Diameter/2 + 0.1) * cfg.DPI / 25.4 + + // Peg height: from bottom (z=0) up to lid + pegH := totalH - lidThick + + // Scan a bounding box around the hole + rInt := int(socketRadiusPx) + 2 + cx, cy := int(px), int(py) + + for dy := -rInt; dy <= rInt; dy++ { + for dx := -rInt; dx <= rInt; dx++ { + ix, iy := cx+dx, cy+dy + if ix < 0 || ix >= imgW || iy < 0 || iy >= imgH { + continue + } + dist := math.Sqrt(float64(dx*dx + dy*dy)) + + // Peg cylinder (in enclosure, from z=0 up to lid) + if dist <= pegRadiusPx { + bx := float64(ix) * pixelToMM + by := float64(iy) * pixelToMM + addBoxAtZ(&encTris, bx, by, 0, pixelToMM, pixelToMM, pegH) + } + + // Socket mask (for tray floor removal) + if dist <= socketRadiusPx { + pegMask[iy*imgW+ix] = true + } + } + } + } + if mountingHoles > 0 { + fmt.Printf("Generated %d mounting pegs\n", mountingHoles) + } + } + + // Pre-compute board bounding box (needed for side cutout detection and removal tabs) + minBX, minBY := imgW, imgH + maxBX, maxBY := 0, 0 + boardCenterX, boardCenterY := 0.0, 0.0 + boardCount := 0 + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if boardMask[y*imgW+x] { + boardCenterX += float64(x) + boardCenterY += float64(y) + boardCount++ + if x < minBX { + minBX = x + } + if x > maxBX { + maxBX = x + } + if y < minBY { + minBY = y + } + if y > maxBY { + maxBY = y + } + } + } + } + + // === APPLY PRY SLOT CUTOUTS TO WALL MASK BEFORE MESHING === + // We want 8mm wide by 1.5mm deep slots in the left and right exterior walls + if boardCount > 0 { + pryWPx := int(8.0 * cfg.DPI / 25.4) + pryDPx := int(1.5 * cfg.DPI / 25.4) + if pryWPx < 1 { + pryWPx = 1 + } + if pryDPx < 1 { + pryDPx = 1 + } + + centerYPx := int(boardCenterY / float64(boardCount)) + leftXPx := minBX + rightXPx := maxBX + + // For the left side, we clear the wall mask from minBX-wallPx up to minBX-wallPx+pryDPx + for y := centerYPx - pryWPx/2; y <= centerYPx+pryWPx/2; y++ { + if y < 0 || y >= imgH { + continue + } + // Find outer edge of wall on the left + for x := 0; x < leftXPx; x++ { + idx := y*imgW + x + if wallMask[idx] > clearanceDistPx { + // Blank out the outermost pryDPx pixels of the wall + for dx := 0; dx < pryDPx; dx++ { + if x+dx < imgW { + wallMask[y*imgW+(x+dx)] = -1 + } + } + break // Only do the outer edge + } + } + + // Find outer edge of wall on the right (search backwards) + for x := imgW - 1; x > rightXPx; x-- { + idx := y*imgW + x + if wallMask[idx] > clearanceDistPx { + // Blank out the outermost pryDPx pixels of the wall + for dx := 0; dx < pryDPx; dx++ { + if x-dx >= 0 { + wallMask[y*imgW+(x-dx)] = -1 + } + } + break // Only do the outer edge + } + } + } + } + + // Build wall-cutout mask from side cutouts + // For each side cutout, determine which wall pixels to subtract + wallCutoutMask := make([]bool, size) + if len(sideCutouts) > 0 && cfg.OutlineBounds != nil { + // Board bounding box in pixels + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + idx := y*imgW + x + if !(wallMask[idx] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !boardMask[idx]) { + continue // not a wall pixel + } + + // Determine which side this wall pixel belongs to + bx := float64(x)*pixelToMM + cfg.OutlineBounds.MinX + by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM + + sideNum := -1 + minDist := math.MaxFloat64 + var posAlongSide float64 + + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + + t := ((bx-bs.StartX)*dx + (by-bs.StartY)*dy) / lenSq + tClamp := math.Max(0, math.Min(1, t)) + + projX := bs.StartX + tClamp*dx + projY := bs.StartY + tClamp*dy + + dist := math.Sqrt((bx-projX)*(bx-projX) + (by-projY)*(by-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + posAlongSide = t * bs.Length + } + } + + // Check all cutouts for this side + for _, c := range sideCutouts { + if c.Side != sideNum { + continue + } + // Check if this pixel's position falls within the cutout X range + if posAlongSide >= c.X && posAlongSide <= c.X+c.Width { + wallCutoutMask[idx] = true + break + } + } + } + } + fmt.Printf("Wall cutout mask: applied %d side cutouts\n", len(sideCutouts)) + } + + // ENCLOSURE (top shell — conforms to board shape) + // ========================================== + fmt.Println("Generating edge-cut conforming enclosure...") + + // The Enclosure Wall sits on top of the Tray Floor (starts at Z = trayFloor) + // Inner Wall (above snapHeight) = `clearanceDistPx` to `trayWallOuterPx` + // Outer Wall (full height) = `trayWallOuterPx` to `encWallOuterPx` + for y := 0; y < imgH; y++ { + runStartX := -1 + curIsInner := false + curIsSnap := false + for x := 0; x <= imgW; x++ { + isWallPx := false + isInnerWall := false + isSnapGroove := false + if x < imgW { + idx := y*imgW + x + dist := wallMask[idx] + if dist > clearanceDistPx && dist <= encWallOuterPx && !boardMask[idx] && !pegMask[idx] { + isWallPx = true + if dist <= trayWallOuterPx { + isInnerWall = true + } else if dist <= trayWallOuterPx+snapDepthPx { + isSnapGroove = true + } + } + } + + if isWallPx { + if runStartX == -1 { + runStartX = x + curIsInner = isInnerWall + curIsSnap = isSnapGroove + } else if isInnerWall != curIsInner || isSnapGroove != curIsSnap { + // boundary between inner, outer, and snap groove + bx := float64(runStartX) * pixelToMM + by := float64(y) * pixelToMM + bw := float64(x-runStartX) * pixelToMM + bh := pixelToMM + + isSpringRelief := false + if !curIsInner && bx >= float64(minBX)*pixelToMM-clearance-wt-1.0 && bx <= float64(maxBX)*pixelToMM+clearance+wt+1.0 { + // Check if the current pixel run constitutes either the left or right clip relief + pryWMM := 8.0 + by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM + + leftClipX := float64(minBX)*pixelToMM - clearance - wt + rightClipX := float64(maxBX)*pixelToMM + clearance + wt + + if by >= by_center-pryWMM/2.0-0.5 && by <= by_center+pryWMM/2.0+0.5 { + if math.Abs(bx-leftClipX) <= 1.5 || math.Abs(bx-rightClipX) <= 1.5 { + isSpringRelief = true + } + } + } + + if curIsInner { + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) + } else { + if isSpringRelief { + // For relief wall cut, omit the bottom solid wall material from the tray floor + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight+1.0, bw, bh, totalH-(trayFloor+snapHeight+1.0)) + } else if curIsSnap { + // Snap groove: remove material from (trayFloor+snapHeight-0.7) to (trayFloor+snapHeight-0.1) + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) + } else { + // Outer wall + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) + } + } + + runStartX = x + curIsInner = isInnerWall + curIsSnap = isSnapGroove + } + } else { + if runStartX != -1 { + bx := float64(runStartX) * pixelToMM + by := float64(y) * pixelToMM + bw := float64(x-runStartX) * pixelToMM + bh := pixelToMM + + isSpringRelief := false + if !curIsInner && bx >= float64(minBX)*pixelToMM-clearance-wt-1.0 && bx <= float64(maxBX)*pixelToMM+clearance+wt+1.0 { + pryWMM := 8.0 + by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM + + leftClipX := float64(minBX)*pixelToMM - clearance - wt + rightClipX := float64(maxBX)*pixelToMM + clearance + wt + + if by >= by_center-pryWMM/2.0-0.5 && by <= by_center+pryWMM/2.0+0.5 { + if math.Abs(bx-leftClipX) <= 1.5 || math.Abs(bx-rightClipX) <= 1.5 { + isSpringRelief = true + } + } + } + + if curIsInner { + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) + } else { + if isSpringRelief { + // For relief wall cut, omit the bottom solid wall material from the tray floor + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight+1.0, bw, bh, totalH-(trayFloor+snapHeight+1.0)) + } else if curIsSnap { + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) + addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) + } else { + addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) + } + } + runStartX = -1 + } + } + } + } + + // Now subtract side cutout regions from the walls + // For each cutout, we remove wall material in the Z range [cutout.Y, cutout.Y+cutout.H] + // by NOT generating boxes in that region. Since we already generated full-height walls, + // we rebuild wall columns where cutouts exist with gaps. + if len(sideCutouts) > 0 { + var cutoutEncTris [][3]Point + for y := 0; y < imgH; y++ { + runStart := -1 + for x := 0; x <= imgW; x++ { + isCutWall := false + if x < imgW { + idx := y*imgW + x + isCutWall = wallCutoutMask[idx] + } + + if isCutWall { + if runStart == -1 { + runStart = x + } + } else { + if runStart != -1 { + // This run of wall pixels has cutouts — find which cutout + midX := (runStart + x) / 2 + midIdx := y*imgW + midX + _ = midIdx + + // Find the dominant side and cutout for this run + bx := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX + by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM + + sideNum := -1 + minDist := math.MaxFloat64 + var bestPosAlongSide float64 + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + t := ((bx-bs.StartX)*dx + (by-bs.StartY)*dy) / lenSq + tClamp := math.Max(0, math.Min(1, t)) + projX := bs.StartX + tClamp*dx + projY := bs.StartY + tClamp*dy + dist := math.Sqrt((bx-projX)*(bx-projX) + (by-projY)*(by-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + bestPosAlongSide = t * bs.Length + } + } + + bx2 := float64(runStart) * pixelToMM + by2 := float64(y) * pixelToMM + bw := float64(x-runStart) * pixelToMM + bh := pixelToMM + + // Find the matching cutout for this side + for _, c := range sideCutouts { + if c.Side != sideNum { + continue + } + + minZ, maxZ := cutoutZBounds(c, bestPosAlongSide) + minZ += trayFloor + pcbT + maxZ += trayFloor + pcbT + + // Wall below cutout: from trayFloor to minZ (preserve enclosure floor) + if minZ > trayFloor+0.3 { + addBoxAtZ(&cutoutEncTris, bx2, by2, trayFloor, bw, bh, minZ-trayFloor) + } + // Wall above cutout: from maxZ to totalH + if maxZ < totalH-0.05 { + addBoxAtZ(&cutoutEncTris, bx2, by2, maxZ, bw, bh, totalH-maxZ) + } + break + } + runStart = -1 + } + } + } + } + // Replace full-height walls with cutout walls + // First remove the original full-height boxes for cutout pixels + // (They were already added above, so we need to rebuild) + // Simpler approach: rebuild encTris without cutout regions, then add partial walls + var newEncTris [][3]Point + // Re-generate walls, skipping cutout pixels + for y := 0; y < imgH; y++ { + runStart := -1 + for x := 0; x <= imgW; x++ { + isWallPixel := false + if x < imgW { + idx := y*imgW + x + isWallPixel = wallMask[idx] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !boardMask[idx] && !wallCutoutMask[idx] + } + + if isWallPixel { + if runStart == -1 { + runStart = x + } + } else { + if runStart != -1 { + bx := float64(runStart) * pixelToMM + by2 := float64(y) * pixelToMM + bw := float64(x-runStart) * pixelToMM + bh := pixelToMM + addBoxAtZ(&newEncTris, bx, by2, trayFloor, bw, bh, totalH-trayFloor) + runStart = -1 + } + } + } + } + // Add the partial (cut) wall sections + newEncTris = append(newEncTris, cutoutEncTris...) + encTris = newEncTris + } + + // Note: We handled pry slots by cropping the wallMask before running the generation. + + // Lid: cover the entire enclosure footprint at the top + // Lid pixels = any pixel in wallMask OR clearanceMask OR boardMask + // Subtract courtyard regions (component footprints) from the lid + fmt.Println("Generating lid...") + + // Build courtyard cutout mask using flood-fill + courtyardMask := buildCutoutMask(courtyardImg, imgW, imgH, true) // flood-fill closed outlines + if courtyardImg != nil { + cutoutCount := 0 + for _, v := range courtyardMask { + if v { + cutoutCount++ + } + } + fmt.Printf("Courtyard cutout (flood-filled): %d pixels\n", cutoutCount) + } + + // Build soldermask cutout mask (direct pixel match, no flood-fill) + soldermaskMask := buildCutoutMask(soldermaskImg, imgW, imgH, false) + if soldermaskImg != nil { + cutoutCount := 0 + for _, v := range soldermaskMask { + if v { + cutoutCount++ + } + } + fmt.Printf("Soldermask cutout: %d pixels\n", cutoutCount) + } + + // Combined cutout: union of courtyard (filled) and soldermask + combinedCutout := make([]bool, size) + for i := 0; i < size; i++ { + combinedCutout[i] = courtyardMask[i] || soldermaskMask[i] + } + + for y := 0; y < imgH; y++ { + runStart := -1 + for x := 0; x <= imgW; x++ { + isLidPixel := false + if x < imgW { + idx := y*imgW + x + inFootprint := (wallMask[idx] >= 0 && wallMask[idx] <= encWallOuterPx) || boardMask[idx] + // Cut lid where combined cutout exists inside the board area + isCutout := combinedCutout[idx] && boardMask[idx] + isLidPixel = inFootprint && !isCutout + } + + if isLidPixel { + if runStart == -1 { + runStart = x + } + } else { + if runStart != -1 { + bx := float64(runStart) * pixelToMM + by := float64(y) * pixelToMM + bw := float64(x-runStart) * pixelToMM + bh := pixelToMM + addBoxAtZ(&encTris, bx, by, totalH-lidThick, bw, bh, lidThick) + runStart = -1 + } + } + } + } + // (Peg calculations moved above) + + // ========================================== + // TRAY (bottom — conforms to board shape) + // ========================================== + fmt.Println("Generating edge-cut conforming tray...") + + for y := 0; y < imgH; y++ { + runStartX := -1 + curIsWall := false + for x := 0; x <= imgW; x++ { + isTrayFloor := false + isTrayWall := false + if x < imgW { + idx := y*imgW + x + if !pegMask[idx] { + dist := wallMask[idx] + // Tray Floor covers everything up to encWallOuterPx + if (dist >= 0 && dist <= encWallOuterPx) || boardMask[idx] { + isTrayFloor = true + } + // Tray Wall goes from clearance to trayWallOuterPx + if dist > clearanceDistPx && dist <= trayWallOuterPx && !boardMask[idx] { + isTrayWall = true + } + } + } + + if isTrayFloor { + if runStartX == -1 { + runStartX = x + curIsWall = isTrayWall + } else if isTrayWall != curIsWall { + bx := float64(runStartX) * pixelToMM + by := float64(y) * pixelToMM + bw := float64(x-runStartX) * pixelToMM + bh := pixelToMM + + addBoxAtZ(&trayTris, bx, by, 0, bw, bh, trayFloor) + + wallBase := trayFloor + wallH := snapHeight + + // Evaluate cutout limits if this pixel run falls into a cutout mask + isCutout := false + for testX := runStartX; testX < x; testX++ { + if wallCutoutMask[y*imgW+testX] { + isCutout = true + break + } + } + + if isCutout && len(sideCutouts) > 0 { + midX := (runStartX + x) / 2 + bxMid := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX + byMid := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM + + sideNum := -1 + minDist := math.MaxFloat64 + var bestPosAlongSide float64 + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + t := ((bxMid-bs.StartX)*dx + (byMid-bs.StartY)*dy) / lenSq + tClamp := math.Max(0, math.Min(1, t)) + projX := bs.StartX + tClamp*dx + projY := bs.StartY + tClamp*dy + dist := math.Sqrt((bxMid-projX)*(bxMid-projX) + (byMid-projY)*(byMid-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + bestPosAlongSide = t * bs.Length + } + } + + for _, c := range sideCutouts { + if c.Side == sideNum { + minZ, _ := cutoutZBounds(c, bestPosAlongSide) + minZ += trayFloor + pcbT + + // Tray wall goes up to trayFloor + snapHeight. If minZ is lower, truncate it. + if minZ < trayFloor+wallH { + wallH = minZ - trayFloor + if wallH < 0 { + wallH = 0 + } + } + break + } + } + } + + if curIsWall && wallH > 0.05 { + addBoxAtZ(&trayTris, bx, by, wallBase, bw, bh, wallH) + } + + runStartX = x + curIsWall = isTrayWall + } + } else { + if runStartX != -1 { + bx := float64(runStartX) * pixelToMM + by := float64(y) * pixelToMM + bw := float64(x-runStartX) * pixelToMM + bh := pixelToMM + + addBoxAtZ(&trayTris, bx, by, 0, bw, bh, trayFloor) + + wallBase := trayFloor + wallH := snapHeight + + // Evaluate cutout limits if this pixel run falls into a cutout mask + isCutout := false + for testX := runStartX; testX < x; testX++ { + if wallCutoutMask[y*imgW+testX] { + isCutout = true + break + } + } + + if isCutout && len(sideCutouts) > 0 { + midX := (runStartX + x) / 2 + bxMid := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX + byMid := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM + + sideNum := -1 + minDist := math.MaxFloat64 + var bestPosAlongSide float64 + for _, bs := range boardSides { + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + lenSq := dx*dx + dy*dy + if lenSq == 0 { + continue + } + t := ((bxMid-bs.StartX)*dx + (byMid-bs.StartY)*dy) / lenSq + tClamp := math.Max(0, math.Min(1, t)) + projX := bs.StartX + tClamp*dx + projY := bs.StartY + tClamp*dy + dist := math.Sqrt((bxMid-projX)*(bxMid-projX) + (byMid-projY)*(byMid-projY)) + if dist < minDist { + minDist = dist + sideNum = bs.Num + bestPosAlongSide = t * bs.Length + } + } + + for _, c := range sideCutouts { + if c.Side == sideNum { + minZ, _ := cutoutZBounds(c, bestPosAlongSide) + minZ += trayFloor + pcbT + + if minZ < trayFloor+wallH { + wallH = minZ - trayFloor + if wallH < 0 { + wallH = 0 + } + } + break + } + } + } + + if curIsWall && wallH > 0.05 { + addBoxAtZ(&trayTris, bx, by, wallBase, bw, bh, wallH) + } + runStartX = -1 + } + } + } + } + + // Add Pry Clips to the Tray to sit under the Enclosure Pry Slots + if boardCount > 0 { + pryWMM := 8.0 + pryDMM := 1.0 + clipH := 0.8 + + leftX := float64(minBX)*pixelToMM - clearance - wt + rightX := float64(maxBX)*pixelToMM + clearance + wt + by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM + + // Z coordinates: trayFloor + snapHeight - clipH ensures the clip finishes flush with the top of the tray wall + addBoxAtZ(&trayTris, leftX-pryDMM, by_center-pryWMM/2.0, trayFloor+snapHeight-clipH, pryDMM, pryWMM, clipH) + addBoxAtZ(&trayTris, rightX, by_center-pryWMM/2.0, trayFloor+snapHeight-clipH, pryDMM, pryWMM, clipH) + } + + _ = math.Pi // keep math import for Phase 2 cylindrical pegs + + // Shift meshes to origin so the exported STL is centered + offsetX := float64(imgW) * pixelToMM / 2.0 + offsetY := float64(imgH) * pixelToMM / 2.0 + + for i := range encTris { + for j := 0; j < 3; j++ { + encTris[i][j].X -= offsetX + encTris[i][j].Y -= offsetY + } + } + for i := range trayTris { + for j := 0; j < 3; j++ { + trayTris[i][j].X -= offsetX + trayTris[i][j].Y -= offsetY + } + } + + return &EnclosureResult{ + EnclosureTriangles: encTris, + TrayTriangles: trayTris, + } +} + +// addBoxAtZ creates a box at a specific Z offset +func addBoxAtZ(triangles *[][3]Point, x, y, z, w, h, zHeight float64) { + x0, y0 := x, y + x1, y1 := x+w, y+h + z0, z1 := z, z+zHeight + + p000 := Point{x0, y0, z0} + p100 := Point{x1, y0, z0} + p110 := Point{x1, y1, z0} + p010 := Point{x0, y1, z0} + p001 := Point{x0, y0, z1} + p101 := Point{x1, y0, z1} + p111 := Point{x1, y1, z1} + p011 := Point{x0, y1, z1} + + addQuad := func(a, b, c, d Point) { + *triangles = append(*triangles, [3]Point{a, b, c}) + *triangles = append(*triangles, [3]Point{c, d, a}) + } + + addQuad(p000, p010, p110, p100) // Bottom + addQuad(p101, p111, p011, p001) // Top + addQuad(p000, p100, p101, p001) // Front + addQuad(p100, p110, p111, p101) // Right + addQuad(p110, p010, p011, p111) // Back + addQuad(p010, p000, p001, p011) // Left +} + +// buildCutoutMask creates a boolean mask from an image. +// If floodFill is true, it flood-fills from the edges to find closed regions. +func buildCutoutMask(img image.Image, w, h int, floodFill bool) []bool { + size := w * h + mask := make([]bool, size) + + if img == nil { + return mask + } + + // First: build raw pixel mask from the image + bounds := img.Bounds() + rawPixels := make([]bool, size) + for y := 0; y < h && y < bounds.Max.Y; y++ { + for x := 0; x < w && x < bounds.Max.X; x++ { + r, g, b, _ := img.At(x+bounds.Min.X, y+bounds.Min.Y).RGBA() + gray := color.GrayModel.Convert(color.NRGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}).(color.Gray) + if gray.Y > 128 { + rawPixels[y*w+x] = true + } + } + } + + if !floodFill { + // Direct mode: raw pixels are the mask + return rawPixels + } + + // Flood-fill mode: fill from edges to find exterior, invert to get interiors + // Exterior = everything reachable from edges without crossing a white pixel + exterior := floodFillExterior(rawPixels, w, h) + + // Interior = NOT exterior AND NOT raw pixel (the outline itself) + // Actually, interior = NOT exterior (includes both outline pixels and filled regions) + for i := 0; i < size; i++ { + mask[i] = !exterior[i] + } + + return mask +} + +// floodFillExterior marks all pixels reachable from the image edges +// without crossing a white (true) pixel as exterior +func floodFillExterior(pixels []bool, w, h int) []bool { + size := w * h + exterior := make([]bool, size) + + // BFS queue starting from all edge pixels that are not white + queue := make([]int, 0, w*2+h*2) + + for x := 0; x < w; x++ { + // Top edge + if !pixels[x] { + exterior[x] = true + queue = append(queue, x) + } + // Bottom edge + idx := (h-1)*w + x + if !pixels[idx] { + exterior[idx] = true + queue = append(queue, idx) + } + } + for y := 0; y < h; y++ { + // Left edge + idx := y * w + if !pixels[idx] { + exterior[idx] = true + queue = append(queue, idx) + } + // Right edge + idx = y*w + (w - 1) + if !pixels[idx] { + exterior[idx] = true + queue = append(queue, idx) + } + } + + // BFS + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + + x := cur % w + y := cur / w + + for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { + nx, ny := x+d[0], y+d[1] + if nx >= 0 && nx < w && ny >= 0 && ny < h { + ni := ny*w + nx + if !exterior[ni] && !pixels[ni] { + exterior[ni] = true + queue = append(queue, ni) + } + } + } + } + + return exterior +} + +// cutoutZBounds calculates the accurate Z bounds taking into account corner radii +func cutoutZBounds(c SideCutout, posAlongSide float64) (float64, float64) { + minZ := c.Y + maxZ := c.Y + c.Height + + if c.CornerRadius > 0 { + r := c.CornerRadius + localX := posAlongSide - c.X + + if localX < r { + dx := r - localX + dy := r - math.Sqrt(math.Max(0, r*r-dx*dx)) + minZ += dy + maxZ -= dy + } else if localX > c.Width-r { + dx := localX - (c.Width - r) + dy := r - math.Sqrt(math.Max(0, r*r-dx*dx)) + minZ += dy + maxZ -= dy + } + } + return minZ, maxZ +} diff --git a/former.go b/former.go new file mode 100644 index 0000000..9a1dc6a --- /dev/null +++ b/former.go @@ -0,0 +1,412 @@ +package main + +import ( + "image" + "image/color" + "strings" +) + +// KiCad-standard layer colors (NRGBA, no alpha — alpha is controlled by BaseAlpha) +var ( + ColorFCu = color.NRGBA{R: 200, G: 52, B: 52, A: 255} + ColorBCu = color.NRGBA{R: 77, G: 127, B: 196, A: 255} + ColorFPaste = color.NRGBA{R: 200, G: 52, B: 52, A: 255} + ColorBPaste = color.NRGBA{R: 0, G: 194, B: 194, A: 255} + ColorFMask = color.NRGBA{R: 132, G: 0, B: 132, A: 255} + ColorBMask = color.NRGBA{R: 0, G: 132, B: 132, A: 255} + ColorFSilkS = color.NRGBA{R: 232, G: 232, B: 232, A: 255} + ColorBSilkS = color.NRGBA{R: 200, G: 200, B: 200, A: 255} + ColorFab = color.NRGBA{R: 132, G: 132, B: 132, A: 255} + ColorEdgeCuts = color.NRGBA{R: 200, G: 200, B: 0, A: 255} + ColorCrtYd = color.NRGBA{R: 194, G: 194, B: 194, A: 255} + ColorEnclosure = color.NRGBA{R: 255, G: 253, B: 230, A: 255} + ColorStencil = color.NRGBA{R: 180, G: 180, B: 180, A: 255} +) + +// FormerLayer represents a single displayable layer in The Former. +type FormerLayer struct { + Name string + Color color.NRGBA + Source image.Image // raw white-on-black gerber render + Visible bool + Highlight bool + BaseAlpha float64 // default opacity 0.0–1.0 (all layers slightly transparent) + SourceFile string // original gerber filename (key into AllLayerGerbers) +} + +// ElementBBox describes a selectable graphic element's bounding box on a layer. +type ElementBBox struct { + ID int `json:"id"` + MinX float64 `json:"minX"` + MinY float64 `json:"minY"` + MaxX float64 `json:"maxX"` + MaxY float64 `json:"maxY"` + Type string `json:"type"` + Footprint string `json:"footprint"` +} + +// ExtractElementBBoxes walks gerber commands and returns bounding boxes in image pixel coordinates. +func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []ElementBBox { + if gf == nil { + return nil + } + + mmToPx := func(mmX, mmY float64) (float64, float64) { + px := (mmX - bounds.MinX) * dpi / 25.4 + py := (bounds.MaxY - mmY) * dpi / 25.4 + return px, py + } + + apertureRadius := func(dcode int) float64 { + if ap, ok := gf.State.Apertures[dcode]; ok && len(ap.Modifiers) > 0 { + return ap.Modifiers[0] / 2.0 + } + return 0.25 + } + + var elements []ElementBBox + id := 0 + curX, curY := 0.0, 0.0 + curDCode := 0 + + for _, cmd := range gf.Commands { + switch cmd.Type { + case "APERTURE": + if cmd.D != nil { + curDCode = *cmd.D + } + continue + case "G01", "G02", "G03", "G36", "G37": + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + switch cmd.Type { + case "FLASH": // D03 + r := apertureRadius(curDCode) + px, py := mmToPx(curX, curY) + rpx := r * dpi / 25.4 + elements = append(elements, ElementBBox{ + ID: id, + MinX: px - rpx, + MinY: py - rpx, + MaxX: px + rpx, + MaxY: py + rpx, + Type: "pad", + Footprint: cmd.Footprint, + }) + id++ + + case "DRAW": // D01 + r := apertureRadius(curDCode) + px1, py1 := mmToPx(prevX, prevY) + px2, py2 := mmToPx(curX, curY) + rpx := r * dpi / 25.4 + minPx := px1 + if px2 < minPx { + minPx = px2 + } + maxPx := px1 + if px2 > maxPx { + maxPx = px2 + } + minPy := py1 + if py2 < minPy { + minPy = py2 + } + maxPy := py1 + if py2 > maxPy { + maxPy = py2 + } + elements = append(elements, ElementBBox{ + ID: id, + MinX: minPx - rpx, + MinY: minPy - rpx, + MaxX: maxPx + rpx, + MaxY: maxPy + rpx, + Type: "trace", + Footprint: cmd.Footprint, + }) + id++ + } + } + + return elements +} + +// colorizeLayer converts a white-on-black source image into a colored NRGBA image. +// Bright pixels become the layer color at the given alpha; dark pixels become transparent. +func colorizeLayer(src image.Image, col color.NRGBA, alpha float64) *image.NRGBA { + bounds := src.Bounds() + w := bounds.Dx() + h := bounds.Dy() + dst := image.NewNRGBA(image.Rect(0, 0, w, h)) + + a := uint8(alpha * 255) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, _, _, _ := src.At(x+bounds.Min.X, y+bounds.Min.Y).RGBA() + // r is 0–65535; treat anything above ~10% as "active" + if r > 6500 { + dst.SetNRGBA(x, y, color.NRGBA{R: col.R, G: col.G, B: col.B, A: a}) + } + // else: stays transparent (zero value) + } + } + return dst +} + +// composeLayers blends all visible layers into a single image. +// Background matches the app theme. If any layer has Highlight=true, +// that layer renders at full BaseAlpha while others are dimmed. +func composeLayers(layers []*FormerLayer, width, height int) *image.NRGBA { + dst := image.NewNRGBA(image.Rect(0, 0, width, height)) + + // Fill with theme background + bg := color.NRGBA{R: 52, G: 53, B: 60, A: 255} + for i := 0; i < width*height*4; i += 4 { + dst.Pix[i+0] = bg.R + dst.Pix[i+1] = bg.G + dst.Pix[i+2] = bg.B + dst.Pix[i+3] = bg.A + } + + // Check if any layer is highlighted + hasHighlight := false + for _, l := range layers { + if l.Highlight && l.Visible { + hasHighlight = true + break + } + } + + for _, l := range layers { + if !l.Visible || l.Source == nil { + continue + } + + alpha := l.BaseAlpha + if hasHighlight && !l.Highlight { + alpha *= 0.3 // dim non-highlighted layers + } + + colored := colorizeLayer(l.Source, l.Color, alpha) + + // Alpha-blend colored layer onto dst + srcBounds := colored.Bounds() + for y := 0; y < height && y < srcBounds.Dy(); y++ { + for x := 0; x < width && x < srcBounds.Dx(); x++ { + si := (y*srcBounds.Dx() + x) * 4 + di := (y*width + x) * 4 + + sa := float64(colored.Pix[si+3]) / 255.0 + if sa == 0 { + continue + } + + sr := float64(colored.Pix[si+0]) + sg := float64(colored.Pix[si+1]) + sb := float64(colored.Pix[si+2]) + + dr := float64(dst.Pix[di+0]) + dg := float64(dst.Pix[di+1]) + db := float64(dst.Pix[di+2]) + + inv := 1.0 - sa + dst.Pix[di+0] = uint8(sr*sa + dr*inv) + dst.Pix[di+1] = uint8(sg*sa + dg*inv) + dst.Pix[di+2] = uint8(sb*sa + db*inv) + dst.Pix[di+3] = 255 + } + } + } + + return dst +} + +// layerInfo holds the display name and color for a KiCad layer. +type layerInfo struct { + Name string + Color color.NRGBA + DefaultOn bool // visible by default + Alpha float64 // base alpha + SortOrder int // lower = drawn first (bottom) +} + +// inferLayer maps a gerber filename to its KiCad layer name, color, and defaults. +func inferLayer(filename string) layerInfo { + lf := strings.ToLower(filename) + switch { + case strings.Contains(lf, "edge_cuts") || strings.Contains(lf, "edge.cuts"): + return layerInfo{"Edge Cuts", ColorEdgeCuts, true, 0.7, 10} + case strings.Contains(lf, "f_cu") || strings.Contains(lf, "f.cu") || strings.Contains(lf, "-gtl"): + return layerInfo{"F.Cu", ColorFCu, false, 0.7, 20} + case strings.Contains(lf, "b_cu") || strings.Contains(lf, "b.cu") || strings.Contains(lf, "-gbl"): + return layerInfo{"B.Cu", ColorBCu, false, 0.7, 21} + case (strings.Contains(lf, "in1") || strings.Contains(lf, "in_1")) && strings.Contains(lf, "cu"): + return layerInfo{"In1.Cu", color.NRGBA{R: 200, G: 160, B: 52, A: 255}, false, 0.7, 22} + case (strings.Contains(lf, "in2") || strings.Contains(lf, "in_2")) && strings.Contains(lf, "cu"): + return layerInfo{"In2.Cu", color.NRGBA{R: 200, G: 52, B: 200, A: 255}, false, 0.7, 23} + case strings.Contains(lf, "f_paste") || strings.Contains(lf, "f.paste") || strings.Contains(lf, "-gtp"): + return layerInfo{"F.Paste", ColorFPaste, false, 0.7, 30} + case strings.Contains(lf, "b_paste") || strings.Contains(lf, "b.paste") || strings.Contains(lf, "-gbp"): + return layerInfo{"B.Paste", ColorBPaste, false, 0.7, 31} + case strings.Contains(lf, "f_silks") || strings.Contains(lf, "f.silks") || strings.Contains(lf, "f_silk"): + return layerInfo{"F.SilkS", ColorFSilkS, false, 0.7, 40} + case strings.Contains(lf, "b_silks") || strings.Contains(lf, "b.silks") || strings.Contains(lf, "b_silk"): + return layerInfo{"B.SilkS", ColorBSilkS, false, 0.7, 41} + case strings.Contains(lf, "f_mask") || strings.Contains(lf, "f.mask") || strings.Contains(lf, "-gts"): + return layerInfo{"F.Mask", ColorFMask, false, 0.6, 50} + case strings.Contains(lf, "b_mask") || strings.Contains(lf, "b.mask") || strings.Contains(lf, "-gbs"): + return layerInfo{"B.Mask", ColorBMask, false, 0.6, 51} + case strings.Contains(lf, "f_courtyard") || strings.Contains(lf, "f_crtyd") || strings.Contains(lf, "f.crtyd"): + return layerInfo{"F.CrtYd", ColorCrtYd, false, 0.6, 60} + case strings.Contains(lf, "b_courtyard") || strings.Contains(lf, "b_crtyd") || strings.Contains(lf, "b.crtyd"): + return layerInfo{"B.CrtYd", ColorCrtYd, false, 0.6, 61} + case strings.Contains(lf, "f_fab") || strings.Contains(lf, "f.fab"): + return layerInfo{"F.Fab", ColorFab, false, 0.6, 70} + case strings.Contains(lf, "b_fab") || strings.Contains(lf, "b.fab"): + return layerInfo{"B.Fab", ColorFab, false, 0.6, 71} + case strings.Contains(lf, ".gbrjob"): + return layerInfo{} // skip job files + default: + return layerInfo{filename, color.NRGBA{R: 160, G: 160, B: 160, A: 255}, false, 0.6, 100} + } +} + +// renderEnclosureWallImage generates a 2D top-down image of the enclosure walls +// from the outline image and config. White pixels = wall area. +func renderEnclosureWallImage(outlineImg image.Image, cfg EnclosureConfig) image.Image { + bounds := outlineImg.Bounds() + w := bounds.Dx() + h := bounds.Dy() + + pixelToMM := 25.4 / cfg.DPI + wallDist, boardMask := ComputeWallMask(outlineImg, cfg.WallThickness+cfg.Clearance, pixelToMM) + + wallThickPx := int(cfg.WallThickness / pixelToMM) + + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + idx := y*w + x + // Wall area: outside the board, within wall thickness distance + if !boardMask[idx] && wallDist[idx] > 0 && wallDist[idx] <= wallThickPx { + dst.Pix[idx*4+0] = 255 + dst.Pix[idx*4+1] = 255 + dst.Pix[idx*4+2] = 255 + dst.Pix[idx*4+3] = 255 + } + } + } + return dst +} + +// buildStencilLayers creates FormerLayer slice for the stencil workflow. +func buildStencilLayers(pasteImg, outlineImg image.Image) []*FormerLayer { + var layers []*FormerLayer + + if outlineImg != nil { + layers = append(layers, &FormerLayer{ + Name: "Edge Cuts", + Color: ColorEdgeCuts, + Source: outlineImg, + Visible: true, + BaseAlpha: 0.7, + }) + } + + if pasteImg != nil { + layers = append(layers, &FormerLayer{ + Name: "Solder Paste", + Color: ColorFPaste, + Source: pasteImg, + Visible: true, + BaseAlpha: 0.8, + }) + } + + return layers +} + +// buildEnclosureLayers creates FormerLayer slice for the enclosure workflow +// using ALL uploaded gerber layers, not just the ones with special roles. +func buildEnclosureLayers(session *EnclosureSession) []*FormerLayer { + type sortedLayer struct { + layer *FormerLayer + order int + } + var sorted []sortedLayer + + // Tray — hidden by default, rendered as 3D geometry in the Former + if session.EnclosureWallImg != nil { + sorted = append(sorted, sortedLayer{ + layer: &FormerLayer{ + Name: "Tray", + Color: color.NRGBA{R: 180, G: 200, B: 160, A: 255}, + Source: session.EnclosureWallImg, // placeholder image + Visible: false, + BaseAlpha: 0.4, + }, + order: -1, + }) + } + + // Enclosure walls — bottom layer, very transparent + if session.EnclosureWallImg != nil { + sorted = append(sorted, sortedLayer{ + layer: &FormerLayer{ + Name: "Enclosure", + Color: ColorEnclosure, + Source: session.EnclosureWallImg, + Visible: true, + BaseAlpha: 0.35, + }, + order: 0, + }) + } + + // All gerber layers from uploaded files + for origName, img := range session.AllLayerImages { + if img == nil { + continue + } + info := inferLayer(origName) + if info.Name == "" { + continue + } + sorted = append(sorted, sortedLayer{ + layer: &FormerLayer{ + Name: info.Name, + Color: info.Color, + Source: img, + Visible: info.DefaultOn, + BaseAlpha: info.Alpha, + SourceFile: origName, + }, + order: info.SortOrder, + }) + } + + // Sort by order (lower = bottom) + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].order < sorted[i].order { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + layers := make([]*FormerLayer, len(sorted)) + for i, s := range sorted { + layers[i] = s.layer + } + return layers +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a47569c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,33 @@ + + + + + + Former + + + +
+ + + + +
+
+
+
+
+
+
+
+
+
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..15fe86f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,16 @@ +{ + "name": "former-frontend", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^6.0.0" + }, + "dependencies": { + "three": "^0.183.1" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100755 index 0000000..af9ef55 --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +98e9f2b9e6d5bad224e73bd97622e3b9 \ No newline at end of file diff --git a/frontend/src/former3d.js b/frontend/src/former3d.js new file mode 100644 index 0000000..edda5d8 --- /dev/null +++ b/frontend/src/former3d.js @@ -0,0 +1,1344 @@ +// Former 3D — Three.js layer viewer with orbit controls, layer selection, cutout tools, and grid +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +const Z_SPACING = 3; + +export class Former3D { + constructor(container) { + this.container = container; + this.layers = []; + this.layerMeshes = []; + this.selectedLayerIndex = -1; + this.cutoutMode = false; + this.elements = []; + this.elementMeshes = []; + this.hoveredElement = -1; + this.cutouts = []; + this.enclosureMesh = null; + this.trayMesh = null; + this.enclosureLayerIndex = -1; + this.trayLayerIndex = -1; + + this._onLayerSelect = null; + this._onCutoutSelect = null; + this._onCutoutHover = null; + + this._initScene(); + this._initControls(); + this._initGrid(); + this._initRaycasting(); + this._animate(); + } + + _initScene() { + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0x000000); + + const w = this.container.clientWidth; + const h = this.container.clientHeight; + this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 50000); + this.camera.position.set(0, -60, 80); + this.camera.up.set(0, 0, 1); + this.camera.lookAt(0, 0, 0); + + this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true }); + this.renderer.setSize(w, h); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.container.appendChild(this.renderer.domElement); + + this.scene.add(new THREE.AmbientLight(0xffffff, 0.9)); + const dirLight = new THREE.DirectionalLight(0xffffff, 0.3); + dirLight.position.set(50, -50, 100); + this.scene.add(dirLight); + + this.layerGroup = new THREE.Group(); + this.scene.add(this.layerGroup); + + this.arrowGroup = new THREE.Group(); + this.arrowGroup.visible = false; + this.scene.add(this.arrowGroup); + + this.elementGroup = new THREE.Group(); + this.elementGroup.visible = false; + this.scene.add(this.elementGroup); + + this.selectionOutline = null; + + this._resizeObserver = new ResizeObserver(() => this._onResize()); + this._resizeObserver.observe(this.container); + } + + _initControls() { + this.controls = new OrbitControls(this.camera, this.renderer.domElement); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.1; + this.controls.mouseButtons = { + LEFT: THREE.MOUSE.ROTATE, + MIDDLE: THREE.MOUSE.PAN, + RIGHT: THREE.MOUSE.DOLLY + }; + this.controls.target.set(0, 0, 0); + this.controls.update(); + } + + _initGrid() { + this.gridHelper = new THREE.GridHelper(2000, 100, 0x333333, 0x222222); + this.gridHelper.rotation.x = Math.PI / 2; + this.gridHelper.position.z = -0.5; + this.scene.add(this.gridHelper); + this.gridVisible = true; + } + + toggleGrid() { + this.gridVisible = !this.gridVisible; + this.gridHelper.visible = this.gridVisible; + return this.gridVisible; + } + + _initRaycasting() { + this.raycaster = new THREE.Raycaster(); + this.mouse = new THREE.Vector2(); + this._isDragging = false; + this._mouseDownPos = { x: 0, y: 0 }; + + const canvas = this.renderer.domElement; + + canvas.addEventListener('mousedown', e => { + this._isDragging = false; + this._mouseDownPos = { x: e.clientX, y: e.clientY }; + + // Start rectangle selection in dado mode (left button only) + if (this.cutoutMode && this.isDadoMode && e.button === 0) { + this._rectSelecting = true; + this._rectStart = { x: e.clientX, y: e.clientY }; + // Create overlay div + if (!this._rectOverlay) { + this._rectOverlay = document.createElement('div'); + this._rectOverlay.style.cssText = 'position:fixed;border:2px dashed #f9e2af;background:rgba(249,226,175,0.1);pointer-events:none;z-index:9999;display:none;'; + document.body.appendChild(this._rectOverlay); + } + } + }); + + canvas.addEventListener('mousemove', e => { + const dx = e.clientX - this._mouseDownPos.x; + const dy = e.clientY - this._mouseDownPos.y; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + this._isDragging = true; + } + + // Update rectangle overlay during dado drag + if (this._rectSelecting && this._rectStart && this._rectOverlay) { + const x1 = Math.min(this._rectStart.x, e.clientX); + const y1 = Math.min(this._rectStart.y, e.clientY); + const w = Math.abs(e.clientX - this._rectStart.x); + const h = Math.abs(e.clientY - this._rectStart.y); + this._rectOverlay.style.left = x1 + 'px'; + this._rectOverlay.style.top = y1 + 'px'; + this._rectOverlay.style.width = w + 'px'; + this._rectOverlay.style.height = h + 'px'; + this._rectOverlay.style.display = (w > 5 || h > 5) ? 'block' : 'none'; + } + + if (this.cutoutMode && this.elementMeshes.length > 0) { + this._updateMouse(e); + this.raycaster.setFromCamera(this.mouse, this.camera); + const hits = this.raycaster.intersectObjects(this.elementMeshes); + const newHover = hits.length > 0 ? this.elementMeshes.indexOf(hits[0].object) : -1; + if (newHover !== this.hoveredElement) { + if (this.hoveredElement >= 0 && this.hoveredElement < this.elementMeshes.length) { + const m = this.elementMeshes[this.hoveredElement]; + if (!m.userData.selected) { + m.material.opacity = 0.2; + m.material.color.setHex(0x89b4fa); + } + } + if (newHover >= 0) { + const m = this.elementMeshes[newHover]; + if (!m.userData.selected) { + m.material.opacity = 0.6; + m.material.color.setHex(0xfab387); + } + } + this.hoveredElement = newHover; + if (this._onCutoutHover) this._onCutoutHover(newHover); + } + } + }); + + canvas.addEventListener('mouseup', e => { + if (this._rectSelecting && this._rectStart && this._isDragging) { + const x1 = Math.min(this._rectStart.x, e.clientX); + const y1 = Math.min(this._rectStart.y, e.clientY); + const x2 = Math.max(this._rectStart.x, e.clientX); + const y2 = Math.max(this._rectStart.y, e.clientY); + + if (x2 - x1 > 10 && y2 - y1 > 10) { + // Select all elements whose projected center falls within the rectangle + for (let i = 0; i < this.elementMeshes.length; i++) { + const m = this.elementMeshes[i]; + if (m.userData.selected) continue; + // Project mesh center to screen + const pos = m.position.clone(); + pos.project(this.camera); + const rect = this.renderer.domElement.getBoundingClientRect(); + const sx = (pos.x * 0.5 + 0.5) * rect.width + rect.left; + const sy = (-pos.y * 0.5 + 0.5) * rect.height + rect.top; + + if (sx >= x1 && sx <= x2 && sy >= y1 && sy <= y2) { + m.userData.selected = true; + m.material.color.setHex(0xf9e2af); + m.material.opacity = 0.7; + this.cutouts.push(this.elements[i]); + if (this._onCutoutSelect) this._onCutoutSelect(this.elements[i], true); + } + } + } + } + this._rectSelecting = false; + this._rectStart = null; + if (this._rectOverlay) this._rectOverlay.style.display = 'none'; + }); + + canvas.addEventListener('click', e => { + if (this._isDragging) return; + + this._updateMouse(e); + this.raycaster.setFromCamera(this.mouse, this.camera); + + if (this.cutoutMode) { + if (this.hoveredElement >= 0 && this.hoveredElement < this.elements.length) { + const el = this.elements[this.hoveredElement]; + const m = this.elementMeshes[this.hoveredElement]; + if (m.userData.selected) { + m.userData.selected = false; + m.material.color.setHex(0xfab387); + m.material.opacity = 0.6; + this.cutouts = this.cutouts.filter(c => c.id !== el.id); + } else { + m.userData.selected = true; + const selColor = this.isDadoMode ? 0xf9e2af : 0xa6e3a1; + m.material.color.setHex(selColor); + m.material.opacity = 0.7; + this.cutouts.push(el); + } + if (this._onCutoutSelect) this._onCutoutSelect(el, m.userData.selected); + } + } else { + // Check for cutout viz click first + if (this._cutoutVizMeshes && this._cutoutVizMeshes.length > 0) { + const cutoutHits = this.raycaster.intersectObjects(this._cutoutVizMeshes); + if (cutoutHits.length > 0) { + const hitMesh = cutoutHits[0].object; + const cutoutId = hitMesh.userData.cutoutId; + if (e.shiftKey) { + this._toggleCutoutSelection(cutoutId); + } else { + this._selectCutout(cutoutId); + } + return; + } + } + + const clickables = this.layerMeshes.filter((m, i) => m && this.layers[i]?.visible); + const hits = this.raycaster.intersectObjects(clickables, true); + if (hits.length > 0) { + let hitObj = hits[0].object; + let idx = this.layerMeshes.indexOf(hitObj); + if (idx < 0) { + // For enclosure mesh, check ancestors + hitObj.traverseAncestors(p => { + const ei = this.layerMeshes.indexOf(p); + if (ei >= 0) idx = ei; + }); + } + if (idx >= 0) this.selectLayer(idx); + } else { + this._deselectAllCutouts(); + this.selectLayer(-1); + } + } + }); + } + + _updateMouse(e) { + const rect = this.renderer.domElement.getBoundingClientRect(); + this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + } + + _onResize() { + const w = this.container.clientWidth; + const h = this.container.clientHeight; + if (w === 0 || h === 0) return; + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(w, h); + } + + _animate() { + this._animId = requestAnimationFrame(() => this._animate()); + this.controls.update(); + this.renderer.render(this.scene, this.camera); + } + + // ===== Layer loading ===== + async loadLayers(layers, imageUrls) { + this.layers = layers; + const loader = new THREE.TextureLoader(); + + while (this.layerGroup.children.length > 0) { + const child = this.layerGroup.children[0]; + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (child.material.map) child.material.map.dispose(); + child.material.dispose(); + } + this.layerGroup.remove(child); + } + this.layerMeshes = []; + this.enclosureMesh = null; + this.trayMesh = null; + this.enclosureLayerIndex = -1; + this.trayLayerIndex = -1; + + let maxW = 0, maxH = 0; + + for (let i = 0; i < layers.length; i++) { + const layer = layers[i]; + const url = imageUrls[i]; + + if (layer.name === 'Enclosure') { + this.enclosureLayerIndex = i; + this.layerMeshes.push(null); // placeholder, replaced by loadEnclosureGeometry + continue; + } + if (layer.name === 'Tray') { + this.trayLayerIndex = i; + this.layerMeshes.push(null); // placeholder, replaced by loadEnclosureGeometry + continue; + } + + if (!url) { this.layerMeshes.push(null); continue; } + + try { + const tex = await new Promise((resolve, reject) => { + loader.load(url, resolve, undefined, reject); + }); + tex.minFilter = THREE.LinearFilter; + tex.magFilter = THREE.LinearFilter; + + const imgW = tex.image.width; + const imgH = tex.image.height; + if (imgW > maxW) maxW = imgW; + if (imgH > maxH) maxH = imgH; + + const geo = new THREE.PlaneGeometry(imgW, imgH); + const mat = new THREE.MeshBasicMaterial({ + map: tex, + transparent: true, + opacity: layer.visible ? layer.baseAlpha : 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(imgW / 2, -imgH / 2, i * Z_SPACING); + mesh.visible = layer.visible; + mesh.userData = { layerIndex: i }; + + this.layerGroup.add(mesh); + this.layerMeshes.push(mesh); + } catch (e) { + console.warn(`Failed to load layer ${i}:`, e); + this.layerMeshes.push(null); + } + } + + this._maxW = maxW; + this._maxH = maxH; + + if (maxW > 0 && maxH > 0) { + const cx = maxW / 2; + const cy = -maxH / 2; + const cz = (layers.length * Z_SPACING) / 2; + this.controls.target.set(cx, cy, cz); + const dist = Math.max(maxW, maxH) * 0.7; + this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6); + this.camera.lookAt(cx, cy, cz); + this.controls.update(); + + this.gridHelper.position.set(cx, cy, -0.5); + } + } + + // ===== 3D Enclosure geometry ===== + // Creates full enclosure + tray geometry from the same parameters as the SCAD output. + loadEnclosureGeometry(encData, dpi, minX, maxY) { + if (!encData || !encData.outlinePoints || encData.outlinePoints.length < 3) return; + + // Store context for cutout viz and side highlighting + this.storeEnclosureContext(encData, dpi, minX, maxY); + + // Remove previous meshes + this._disposeEnclosureMeshes(); + + const s = dpi / 25.4; // mm to pixels + + // Convert mm to 3D pixel-space coordinates (Y inverted for image space) + const toPixel = (mmX, mmY) => [ + (mmX - minX) * s, + -(maxY - mmY) * s + ]; + + const pts = encData.outlinePoints.map(p => toPixel(p[0], p[1])); + + // Compute winding and offset function + let area = 0; + for (let i = 0; i < pts.length; i++) { + const j = (i + 1) % pts.length; + area += pts[i][0] * pts[j][1] - pts[j][0] * pts[i][1]; + } + const sign = area < 0 ? 1 : -1; // outward offset sign + + const offsetPoly = (points, dist) => { + const n = points.length; + const result = []; + const maxMiter = Math.abs(dist) * 2; // clamp miter to 2x offset (bevel-style at sharp corners) + for (let i = 0; i < n; i++) { + const prev = points[(i - 1 + n) % n]; + const curr = points[i]; + const next = points[(i + 1) % n]; + const e1x = curr[0] - prev[0], e1y = curr[1] - prev[1]; + const e2x = next[0] - curr[0], e2y = next[1] - curr[1]; + const len1 = Math.sqrt(e1x * e1x + e1y * e1y) || 1; + const len2 = Math.sqrt(e2x * e2x + e2y * e2y) || 1; + const n1x = -e1y / len1, n1y = e1x / len1; + const n2x = -e2y / len2, n2y = e2x / len2; + let nx = n1x + n2x, ny = n1y + n2y; + const nlen = Math.sqrt(nx * nx + ny * ny) || 1; + nx /= nlen; ny /= nlen; + const dot = n1x * nx + n1y * ny; + const rawMiter = dot > 0.01 ? dist / dot : dist; + // Clamp: if corner is too sharp, insert bevel (two points) + if (Math.abs(rawMiter) > maxMiter) { + const d = dist; + result.push([curr[0] + n1x * d, curr[1] + n1y * d]); + result.push([curr[0] + n2x * d, curr[1] + n2y * d]); + } else { + result.push([curr[0] + nx * rawMiter, curr[1] + ny * rawMiter]); + } + } + return result; + }; + + const makeShape = (poly) => { + const shape = new THREE.Shape(); + shape.moveTo(poly[0][0], poly[0][1]); + for (let i = 1; i < poly.length; i++) shape.lineTo(poly[i][0], poly[i][1]); + shape.closePath(); + return shape; + }; + + const makeHole = (poly) => { + const path = new THREE.Path(); + path.moveTo(poly[0][0], poly[0][1]); + for (let i = 1; i < poly.length; i++) path.lineTo(poly[i][0], poly[i][1]); + path.closePath(); + return path; + }; + + const makeRing = (outerPoly, innerPoly, depth, zPos) => { + const shape = makeShape(outerPoly); + shape.holes.push(makeHole(innerPoly)); + const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false }); + return { geo, zPos }; + }; + + const makeSolid = (poly, depth, zPos) => { + const shape = makeShape(poly); + const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false }); + return { geo, zPos }; + }; + + // Key dimensions from SCAD (converted to pixels for XY, raw mm for Z which we scale) + const cl = encData.clearance; + const wt = encData.wallThickness; + const trayFloor = encData.trayFloor; + const snapH = encData.snapHeight; + const lidThick = encData.lidThick; + const totalH = encData.totalH; + + // Pre-compute offset polygons in pixel space + const polyInner = offsetPoly(pts, sign * cl * s); // offset(clearance) + const polyTrayWall = offsetPoly(pts, sign * (cl + wt) * s); // offset(clearance + wt) + const polyOuter = offsetPoly(pts, sign * (cl + 2 * wt) * s); // offset(clearance + 2*wt) + + const enclosureParts = []; + const trayParts = []; + + // ===== ENCLOSURE (lid-on-top piece) ===== + // Small epsilon to prevent Z-fighting at shared boundaries + const eps = 0.05 * s; + + // 1. Lid plate: solid from totalH-lidThick to totalH + enclosureParts.push(makeSolid(polyOuter, lidThick * s, (totalH - lidThick) * s + eps)); + + // 2. Upper wall ring: outer to inner, from trayFloor+snapH to totalH-lidThick + const upperWallH = totalH - lidThick - (trayFloor + snapH); + if (upperWallH > 0.1) { + enclosureParts.push(makeRing(polyOuter, polyInner, upperWallH * s - eps, (trayFloor + snapH) * s + eps)); + } + + // 3. Lower wall ring: outer to trayWall, from trayFloor to trayFloor+snapH + // (wider inner cavity for tray snap-fit recess) + if (snapH > 0.1) { + enclosureParts.push(makeRing(polyOuter, polyTrayWall, snapH * s - eps, trayFloor * s)); + } + + // 4. Mounting pegs (cylinders) + if (encData.mountingHoles) { + for (const h of encData.mountingHoles) { + const [px, py] = toPixel(h.x, h.y); + const r = ((h.diameter / 2) - 0.15) * s; + const pegH = (totalH - lidThick) * s; + const cylGeo = new THREE.CylinderGeometry(r, r, pegH, 16); + cylGeo.rotateX(Math.PI / 2); // align cylinder with Z axis + enclosureParts.push({ geo: cylGeo, zPos: pegH / 2, cx: px, cy: py, isCyl: true }); + } + } + + // ===== TRAY ===== + // 1. Tray floor: solid, from 0 to trayFloor + trayParts.push(makeSolid(polyOuter, trayFloor * s, 0)); + + // 2. Tray inner wall: ring from trayWall to inner, from trayFloor to trayFloor+snapH + if (snapH > 0.1) { + trayParts.push(makeRing(polyTrayWall, polyInner, snapH * s - eps, trayFloor * s + eps)); + } + + // Build enclosure group mesh + const encMat = new THREE.MeshPhongMaterial({ + color: 0xfffdcc, transparent: true, opacity: 0.55, + side: THREE.DoubleSide, depthWrite: false, + polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, + }); + + const encGroup = new THREE.Group(); + for (const part of enclosureParts) { + const mesh = new THREE.Mesh(part.geo, encMat.clone()); + if (part.isCyl) { + mesh.position.set(part.cx, part.cy, part.zPos); + } else { + mesh.position.z = part.zPos; + } + encGroup.add(mesh); + } + + if (this.enclosureLayerIndex >= 0) { + encGroup.position.z = this.enclosureLayerIndex * Z_SPACING; + encGroup.userData = { layerIndex: this.enclosureLayerIndex, isEnclosure: true }; + const layer = this.layers[this.enclosureLayerIndex]; + encGroup.visible = layer ? layer.visible : true; + this.enclosureMesh = encGroup; + this.layerMeshes[this.enclosureLayerIndex] = encGroup; + this.layerGroup.add(encGroup); + } + + // Build tray group mesh + const trayMat = new THREE.MeshPhongMaterial({ + color: 0xb8c8a0, transparent: true, opacity: 0.5, + side: THREE.DoubleSide, depthWrite: false, + polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, + }); + + const trayGroup = new THREE.Group(); + for (const part of trayParts) { + const mesh = new THREE.Mesh(part.geo, trayMat.clone()); + mesh.position.z = part.zPos; + trayGroup.add(mesh); + } + + if (this.trayLayerIndex >= 0) { + trayGroup.position.z = this.trayLayerIndex * Z_SPACING; + trayGroup.userData = { layerIndex: this.trayLayerIndex, isEnclosure: true }; + const layer = this.layers[this.trayLayerIndex]; + trayGroup.visible = layer ? layer.visible : false; + this.trayMesh = trayGroup; + this.layerMeshes[this.trayLayerIndex] = trayGroup; + this.layerGroup.add(trayGroup); + } + } + + _disposeEnclosureMeshes() { + for (const mesh of [this.enclosureMesh, this.trayMesh]) { + if (mesh) { + this.layerGroup.remove(mesh); + mesh.traverse(c => { + if (c.geometry) c.geometry.dispose(); + if (c.material) c.material.dispose(); + }); + } + } + this.enclosureMesh = null; + this.trayMesh = null; + } + + // ===== Layer visibility ===== + setLayerVisibility(index, visible) { + if (index < 0 || index >= this.layerMeshes.length) return; + const mesh = this.layerMeshes[index]; + if (!mesh) return; + this.layers[index].visible = visible; + mesh.visible = visible; + if (mesh.material && !mesh.userData?.isEnclosure) { + if (!visible) mesh.material.opacity = 0; + else mesh.material.opacity = this.layers[index].baseAlpha; + } + } + + _setGroupOpacity(group, opacity) { + group.traverse(c => { + if (c.material) c.material.opacity = opacity; + }); + } + + setLayerHighlight(index, highlight) { + const hasHL = highlight && index >= 0; + this.layers.forEach((l, i) => { + l.highlight = (i === index && highlight); + const mesh = this.layerMeshes[i]; + if (!mesh || !l.visible) return; + if (mesh.userData?.isEnclosure) { + this._setGroupOpacity(mesh, (hasHL && !l.highlight) ? 0.15 : 0.55); + } else if (mesh.material) { + mesh.material.opacity = (hasHL && !l.highlight) ? l.baseAlpha * 0.3 : l.baseAlpha; + } + }); + } + + // ===== Selection ===== + selectLayer(index) { + this.selectedLayerIndex = index; + + if (this.selectionOutline) { + this.scene.remove(this.selectionOutline); + this.selectionOutline.geometry?.dispose(); + this.selectionOutline.material?.dispose(); + this.selectionOutline = null; + } + + this.arrowGroup.visible = false; + while (this.arrowGroup.children.length) { + const c = this.arrowGroup.children[0]; + this.arrowGroup.remove(c); + } + + if (index < 0 || index >= this.layerMeshes.length) { + if (this._onLayerSelect) this._onLayerSelect(-1); + return; + } + + const mesh = this.layerMeshes[index]; + if (!mesh) return; + + // Selection outline (skip for enclosure — too complex) + if (mesh.geometry && !mesh.userData?.isEnclosure) { + const edges = new THREE.EdgesGeometry(mesh.geometry); + const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ + color: 0x89b4fa, linewidth: 2, + })); + line.position.copy(mesh.position); + line.position.z += 0.1; + this.selectionOutline = line; + this.scene.add(line); + } + + // Z-axis arrows + const pos = mesh.position.clone(); + if (mesh.userData?.isEnclosure) { + const box = new THREE.Box3().setFromObject(mesh); + box.getCenter(pos); + } + const arrowLen = 8; + const upArrow = new THREE.ArrowHelper( + new THREE.Vector3(0, 0, 1), + new THREE.Vector3(pos.x, pos.y, pos.z + 2), + arrowLen, 0x89b4fa, 3, 2 + ); + const downArrow = new THREE.ArrowHelper( + new THREE.Vector3(0, 0, -1), + new THREE.Vector3(pos.x, pos.y, pos.z - 2), + arrowLen, 0x89b4fa, 3, 2 + ); + this.arrowGroup.add(upArrow); + this.arrowGroup.add(downArrow); + this.arrowGroup.visible = true; + + if (this._onLayerSelect) this._onLayerSelect(index); + } + + moveSelectedZ(delta) { + if (this.selectedLayerIndex < 0) return; + const mesh = this.layerMeshes[this.selectedLayerIndex]; + if (!mesh) return; + mesh.position.z += delta; + if (this.selectionOutline) this.selectionOutline.position.z = mesh.position.z + 0.1; + if (this.arrowGroup.children.length >= 2) { + const pos = mesh.position; + this.arrowGroup.children[0].position.set(pos.x, pos.y, pos.z + 2); + this.arrowGroup.children[1].position.set(pos.x, pos.y, pos.z - 2); + } + } + + // ===== Cutout mode ===== + enterCutoutMode(elements, layerIndex, isDado = false) { + this.cutoutMode = true; + this.isDadoMode = isDado; + this.elements = elements; + this.hoveredElement = -1; + this.cutouts = []; + this._rectSelecting = false; + this._rectStart = null; + this._rectOverlay = null; + + while (this.elementGroup.children.length) { + const c = this.elementGroup.children[0]; + c.geometry?.dispose(); + c.material?.dispose(); + this.elementGroup.remove(c); + } + this.elementMeshes = []; + + const layerMesh = this.layerMeshes[layerIndex]; + const layerZ = layerMesh ? layerMesh.position.z : 0; + + for (const el of elements) { + const w = el.maxX - el.minX; + const h = el.maxY - el.minY; + if (w < 0.5 || h < 0.5) continue; + + const geo = new THREE.PlaneGeometry(w, h); + const mat = new THREE.MeshBasicMaterial({ + color: 0x89b4fa, + transparent: true, + opacity: 0.2, + side: THREE.DoubleSide, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + const elCx = el.minX + w / 2; + const elCy = el.minY + h / 2; + mesh.position.set(elCx, -elCy, layerZ + 0.2); + mesh.userData = { elementId: el.id, selected: false }; + + this.elementGroup.add(mesh); + this.elementMeshes.push(mesh); + } + + this.elementGroup.visible = true; + this._homeTopDown(layerIndex); + } + + exitCutoutMode() { + this.cutoutMode = false; + this.isDadoMode = false; + this.elements = []; + this.hoveredElement = -1; + this._rectSelecting = false; + this._rectStart = null; + if (this._rectOverlay) { + this._rectOverlay.remove(); + this._rectOverlay = null; + } + this.elementGroup.visible = false; + while (this.elementGroup.children.length) { + const c = this.elementGroup.children[0]; + c.geometry?.dispose(); + c.material?.dispose(); + this.elementGroup.remove(c); + } + this.elementMeshes = []; + } + + // ===== Camera ===== + _homeTopDown(layerIndex) { + const mesh = this.layerMeshes[layerIndex]; + if (!mesh) return; + const pos = mesh.position.clone(); + if (mesh.userData?.isEnclosure) { + const box = new THREE.Box3().setFromObject(mesh); + box.getCenter(pos); + } + let imgW, imgH; + if (mesh.geometry?.parameters) { + imgW = mesh.geometry.parameters.width; + imgH = mesh.geometry.parameters.height; + } else { + imgW = this._maxW || 500; + imgH = this._maxH || 500; + } + const dist = Math.max(imgW, imgH) * 1.1; + this.camera.position.set(pos.x, pos.y, pos.z + dist); + this.camera.up.set(0, 1, 0); + this.controls.target.set(pos.x, pos.y, pos.z); + this.controls.update(); + } + + homeTopDown(layerIndex) { + if (layerIndex !== undefined && layerIndex >= 0) { + this._homeTopDown(layerIndex); + } else if (this.selectedLayerIndex >= 0) { + this._homeTopDown(this.selectedLayerIndex); + } else { + const cx = (this._maxW || 500) / 2; + const cy = -(this._maxH || 500) / 2; + const cz = (this.layers.length * Z_SPACING) / 2; + const dist = Math.max(this._maxW || 500, this._maxH || 500) * 1.1; + this.camera.position.set(cx, cy, cz + dist); + this.camera.up.set(0, 1, 0); + this.controls.target.set(cx, cy, cz); + this.controls.update(); + } + } + + resetView() { + if (this.layers.length === 0) return; + const maxW = this._maxW || 500; + const maxH = this._maxH || 500; + const cx = maxW / 2; + const cy = -maxH / 2; + const cz = (this.layers.length * Z_SPACING) / 2; + const dist = Math.max(maxW, maxH) * 0.7; + this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6); + this.camera.up.set(0, 0, 1); + this.controls.target.set(cx, cy, cz); + this.controls.update(); + } + + // Switch to solid render preview: show only enclosure+tray opaque, hide all other layers + enterSolidView() { + this._savedVisibility = this.layers.map(l => l.visible); + for (let i = 0; i < this.layers.length; i++) { + const mesh = this.layerMeshes[i]; + if (!mesh) continue; + if (i === this.enclosureLayerIndex || i === this.trayLayerIndex) { + mesh.visible = true; + mesh.traverse(c => { + if (c.material) { + c.material.opacity = 1.0; + c.material.transparent = false; + c.material.depthWrite = true; + c.material.side = THREE.FrontSide; + c.material.needsUpdate = true; + } + }); + } else { + mesh.visible = false; + } + } + this.selectLayer(-1); + this.gridHelper.visible = false; + this.scene.background = new THREE.Color(0x1e1e2e); + this.resetView(); + } + + // Return from solid view to normal editor + exitSolidView() { + this.scene.background = new THREE.Color(0x000000); + this.gridHelper.visible = this.gridVisible; + for (let i = 0; i < this.layers.length; i++) { + const mesh = this.layerMeshes[i]; + if (!mesh) continue; + const wasVisible = this._savedVisibility ? this._savedVisibility[i] : this.layers[i].visible; + this.layers[i].visible = wasVisible; + mesh.visible = wasVisible; + if (i === this.enclosureLayerIndex || i === this.trayLayerIndex) { + const baseOpacity = i === this.enclosureLayerIndex ? 0.55 : 0.5; + mesh.traverse(c => { + if (c.material) { + c.material.opacity = baseOpacity; + c.material.transparent = true; + c.material.depthWrite = false; + c.material.side = THREE.DoubleSide; + c.material.needsUpdate = true; + } + }); + } + } + this._savedVisibility = null; + this.resetView(); + } + + // ===== Placed cutout selection ===== + _selectCutout(cutoutId) { + this.selectedCutoutIds = new Set([cutoutId]); + this._updateCutoutHighlights(); + if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([...this.selectedCutoutIds]); + } + + _toggleCutoutSelection(cutoutId) { + if (!this.selectedCutoutIds) this.selectedCutoutIds = new Set(); + if (this.selectedCutoutIds.has(cutoutId)) { + this.selectedCutoutIds.delete(cutoutId); + } else { + this.selectedCutoutIds.add(cutoutId); + } + this._updateCutoutHighlights(); + if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([...this.selectedCutoutIds]); + } + + _deselectAllCutouts() { + if (this.selectedCutoutIds && this.selectedCutoutIds.size > 0) { + this.selectedCutoutIds = new Set(); + this._updateCutoutHighlights(); + if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([]); + } + } + + _updateCutoutHighlights() { + // Remove old outlines + if (this._cutoutOutlines) { + for (const ol of this._cutoutOutlines) { + this.scene.remove(ol); + ol.geometry.dispose(); + ol.material.dispose(); + } + } + this._cutoutOutlines = []; + + if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return; + for (const mesh of this._cutoutVizMeshes) { + const isSelected = this.selectedCutoutIds.has(mesh.userData.cutoutId); + mesh.material.opacity = isSelected ? 0.9 : 0.6; + if (isSelected) { + const edges = new THREE.EdgesGeometry(mesh.geometry); + const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ + color: 0x89b4fa, linewidth: 2, + })); + line.position.copy(mesh.position); + line.quaternion.copy(mesh.quaternion); + this._cutoutOutlines.push(line); + this.scene.add(line); + } + } + } + + onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; } + + getSelectedCutouts() { + if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return []; + return this._cutoutVizMeshes + .filter(m => this.selectedCutoutIds.has(m.userData.cutoutId)) + .map(m => m.userData.cutout); + } + + // ===== Store enclosure context for cutout viz / side highlight ===== + storeEnclosureContext(encData, dpi, minX, maxY) { + this._encData = encData; + this._dpi = dpi; + this._minX = minX; + this._maxY = maxY; + this._s = dpi / 25.4; + } + + _toPixel(mmX, mmY) { + const s = this._s; + return [(mmX - this._minX) * s, -(this._maxY - mmY) * s]; + } + + // ===== Side Highlight ===== + highlightSide(sideNum) { + this.clearSideHighlight(); + if (!this._encData) return; + const side = this._encData.sides.find(s => s.num === sideNum); + if (!side) return; + + const s = this._s; + const [startPx, startPy] = this._toPixel(side.startX, side.startY); + const [endPx, endPy] = this._toPixel(side.endX, side.endY); + const dx = endPx - startPx; + const dy = endPy - startPy; + const len = Math.sqrt(dx * dx + dy * dy); + const totalH = this._encData.totalH * s; + const cl = this._encData.clearance; + const wt = this._encData.wallThickness; + + const geo = new THREE.PlaneGeometry(len, totalH); + const sideColors = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0x8b5cf6, 0xec4899, 0x14b8a6, 0xf97316]; + const color = sideColors[(sideNum - 1) % sideColors.length]; + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.4, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + + // Position at wall midpoint + const midX = (startPx + endPx) / 2; + const midY = (startPy + endPy) / 2; + const wallAngle = Math.atan2(dy, dx); + + // Offset outward to wall exterior + const offset = (cl + wt) * s; + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + + const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; + + mesh.position.set( + midX + nx * offset, + midY + ny * offset, + encZ + totalH / 2 + ); + + // Rotate: first face XY plane upright, then rotate around Z to match wall direction + mesh.rotation.set(Math.PI / 2, 0, wallAngle); + mesh.rotation.order = 'ZXY'; + mesh.rotation.set(0, 0, 0); + // Build rotation manually: wall runs along wallAngle in XY, and is vertical in Z + mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + + this._sideHighlightMesh = mesh; + this.scene.add(mesh); + + // Add label sprite + const canvas2d = document.createElement('canvas'); + canvas2d.width = 64; + canvas2d.height = 64; + const ctx = canvas2d.getContext('2d'); + ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`; + ctx.beginPath(); + ctx.arc(32, 32, 28, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'white'; + ctx.font = 'bold 32px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(sideNum.toString(), 32, 33); + + const tex = new THREE.CanvasTexture(canvas2d); + const spriteMat = new THREE.SpriteMaterial({ map: tex, depthWrite: false }); + const sprite = new THREE.Sprite(spriteMat); + const labelScale = Math.max(len, totalH) * 0.15; + sprite.scale.set(labelScale, labelScale, 1); + sprite.position.set( + midX + nx * offset * 1.5, + midY + ny * offset * 1.5, + encZ + totalH / 2 + ); + this._sideHighlightLabel = sprite; + this.scene.add(sprite); + } + + clearSideHighlight() { + if (this._sideHighlightMesh) { + this.scene.remove(this._sideHighlightMesh); + this._sideHighlightMesh.geometry.dispose(); + this._sideHighlightMesh.material.dispose(); + this._sideHighlightMesh = null; + } + if (this._sideHighlightLabel) { + this.scene.remove(this._sideHighlightLabel); + this._sideHighlightLabel.material.map.dispose(); + this._sideHighlightLabel.material.dispose(); + this._sideHighlightLabel = null; + } + } + + lookAtSide(sideNum) { + if (!this._encData) return; + const side = this._encData.sides.find(s => s.num === sideNum); + if (!side) return; + + const s = this._s; + const [startPx, startPy] = this._toPixel(side.startX, side.startY); + const [endPx, endPy] = this._toPixel(side.endX, side.endY); + const midX = (startPx + endPx) / 2; + const midY = (startPy + endPy) / 2; + const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; + const totalH = this._encData.totalH * s; + const midZ = encZ + totalH / 2; + + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + const dist = Math.max(this._maxW || 500, this._maxH || 500) * 0.5; + + this.camera.position.set(midX + nx * dist, midY + ny * dist, midZ); + this.camera.up.set(0, 0, 1); + this.controls.target.set(midX, midY, midZ); + this.controls.update(); + } + + // ===== Cutout Visualization ===== + refreshCutouts(cutouts, encData, dpi, minX, maxY) { + this._disposeCutoutViz(); + if (!cutouts || cutouts.length === 0 || !encData) return; + + this.storeEnclosureContext(encData, dpi, minX, maxY); + const s = this._s; + + this._cutoutVizGroup = new THREE.Group(); + this._cutoutVizMeshes = []; + + const cl = encData.clearance; + const wt = encData.wallThickness; + const trayFloor = encData.trayFloor; + const pcbT = encData.pcbThickness; + const totalH = encData.totalH; + const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; + + for (const c of cutouts) { + let mesh; + const color = c.isDado ? 0xf9e2af : 0xa6e3a1; + + if (c.surface === 'top') { + const geo = new THREE.PlaneGeometry(c.w * s, c.h * s); + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.6, + side: THREE.DoubleSide, depthWrite: false, + }); + mesh = new THREE.Mesh(geo, mat); + const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); + mesh.position.set(px, py, encZ + totalH * s + 0.5); + + } else if (c.surface === 'bottom') { + const geo = new THREE.PlaneGeometry(c.w * s, c.h * s); + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.6, + side: THREE.DoubleSide, depthWrite: false, + }); + mesh = new THREE.Mesh(geo, mat); + const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); + mesh.position.set(px, py, encZ - 0.5); + + } else if (c.surface === 'side') { + const side = encData.sides.find(sd => sd.num === c.sideNum); + if (!side) continue; + + const geo = new THREE.PlaneGeometry(c.w * s, c.h * s); + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.6, + side: THREE.DoubleSide, depthWrite: false, + }); + mesh = new THREE.Mesh(geo, mat); + + // Position along the side edge + const sdx = side.endX - side.startX; + const sdy = side.endY - side.startY; + const sLen = Math.sqrt(sdx * sdx + sdy * sdy); + const ux = sdx / sLen, uy = sdy / sLen; + const midAlongSide = c.x + c.w / 2; + const mmX = side.startX + ux * midAlongSide; + const mmY = side.startY + uy * midAlongSide; + const [px, py] = this._toPixel(mmX, mmY); + + // Z = trayFloor + pcbT + y + h/2 + const zMM = trayFloor + pcbT + c.y + c.h / 2; + + // Offset outward to wall exterior + const offset = (cl + wt) * s; + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + + mesh.position.set( + px + nx * offset, + py + ny * offset, + encZ + zMM * s + ); + + // Rotate to face outward along the wall + const wallAngle = Math.atan2( + this._toPixel(side.endX, side.endY)[1] - this._toPixel(side.startX, side.startY)[1], + this._toPixel(side.endX, side.endY)[0] - this._toPixel(side.startX, side.startY)[0] + ); + mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + } + + if (mesh) { + mesh.userData.cutoutId = c.id; + mesh.userData.cutout = c; + this._cutoutVizGroup.add(mesh); + this._cutoutVizMeshes.push(mesh); + } + } + + this.scene.add(this._cutoutVizGroup); + } + + _disposeCutoutViz() { + if (this._cutoutVizGroup) { + this.scene.remove(this._cutoutVizGroup); + this._cutoutVizGroup.traverse(c => { + if (c.geometry) c.geometry.dispose(); + if (c.material) c.material.dispose(); + }); + this._cutoutVizGroup = null; + } + this._cutoutVizMeshes = []; + } + + // ===== Side Placement Mode ===== + // Enter mode where ghost cutouts follow mouse on a side wall. + // Returns a promise that resolves with array of {y} positions when user clicks to place. + enterSidePlacementMode(projectedCutouts, sideNum) { + return new Promise((resolve) => { + if (!this._encData) { resolve(null); return; } + const side = this._encData.sides.find(sd => sd.num === sideNum); + if (!side) { resolve(null); return; } + + const s = this._s; + const cl = this._encData.clearance; + const wt = this._encData.wallThickness; + const trayFloor = this._encData.trayFloor; + const pcbT = this._encData.pcbThickness; + const totalH = this._encData.totalH; + const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; + + // Compute wall geometry + const [startPx, startPy] = this._toPixel(side.startX, side.startY); + const [endPx, endPy] = this._toPixel(side.endX, side.endY); + const wallDx = endPx - startPx; + const wallDy = endPy - startPy; + const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy); + const wallAngle = Math.atan2(wallDy, wallDx); + + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + const offset = (cl + wt) * s; + + // Move camera to face the side + this.lookAtSide(sideNum); + this.highlightSide(sideNum); + + // Create invisible raycast plane covering the side wall + const planeW = wallLen * 2; + const planeH = totalH * s * 2; + const planeGeo = new THREE.PlaneGeometry(planeW, planeH); + const planeMat = new THREE.MeshBasicMaterial({ visible: false, side: THREE.DoubleSide }); + const planeMesh = new THREE.Mesh(planeGeo, planeMat); + const midX = (startPx + endPx) / 2 + nx * offset; + const midY = (startPy + endPy) / 2 + ny * offset; + planeMesh.position.set(midX, midY, encZ + totalH * s / 2); + planeMesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + this.scene.add(planeMesh); + + // Create ghost meshes + const ghostMeshes = []; + for (const pc of projectedCutouts) { + const geo = new THREE.PlaneGeometry(pc.width * s, pc.height * s); + const mat = new THREE.MeshBasicMaterial({ + color: 0xa6e3a1, transparent: true, opacity: 0.5, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + + // Position along wall at projected X + const ux = wallDx / wallLen, uy = wallDy / wallLen; + const t = pc.x + pc.width / 2; // center along side in mm + const mmX = side.startX + (ux / s) * t * s; // hmm, just use side coords + const posX = side.startX + (side.endX - side.startX) * (t / side.length); + const posY = side.startY + (side.endY - side.startY) * (t / side.length); + const [px, py] = this._toPixel(posX, posY); + + mesh.position.set(px + nx * offset, py + ny * offset, encZ + totalH * s / 2); + mesh.userData = { projectedCutout: pc }; + this.scene.add(mesh); + ghostMeshes.push(mesh); + } + + // Default Y (mm above PCB) = wall center + const wallHeight = totalH - trayFloor - pcbT; + let currentYMM = wallHeight / 2; + + const updateGhostZ = (zMM) => { + for (const gm of ghostMeshes) { + const fullZ = trayFloor + pcbT + zMM; + gm.position.z = encZ + fullZ * s; + } + }; + updateGhostZ(currentYMM); + + // Mouse move handler: update Z from raycast + const moveHandler = (e) => { + this._updateMouse(e); + this.raycaster.setFromCamera(this.mouse, this.camera); + const hits = this.raycaster.intersectObject(planeMesh); + if (hits.length > 0) { + const hitZ = hits[0].point.z; + // Convert back to mm + const zMM = (hitZ - encZ) / s; + // Y above PCB + const yAbovePCB = zMM - trayFloor - pcbT; + const maxH = Math.max(...projectedCutouts.map(pc => pc.height)); + const clamped = Math.max(0, Math.min(wallHeight - maxH, yAbovePCB - maxH / 2)); + currentYMM = clamped; + updateGhostZ(currentYMM); + } + }; + + const canvas = this.renderer.domElement; + + // Click handler: place and resolve + const clickHandler = (e) => { + cleanup(); + const results = projectedCutouts.map(pc => ({ + x: pc.x, + y: currentYMM, + width: pc.width, + height: pc.height, + })); + resolve(results); + }; + + // Escape handler: cancel + const escHandler = (e) => { + if (e.key === 'Escape') { + cleanup(); + resolve(null); + } + }; + + const cleanup = () => { + canvas.removeEventListener('mousemove', moveHandler); + canvas.removeEventListener('click', clickHandler); + document.removeEventListener('keydown', escHandler); + this.scene.remove(planeMesh); + planeGeo.dispose(); + planeMat.dispose(); + for (const gm of ghostMeshes) { + this.scene.remove(gm); + gm.geometry.dispose(); + gm.material.dispose(); + } + this.clearSideHighlight(); + }; + + canvas.addEventListener('mousemove', moveHandler); + canvas.addEventListener('click', clickHandler); + document.addEventListener('keydown', escHandler); + }); + } + + // Callbacks + onLayerSelect(cb) { this._onLayerSelect = cb; } + onCutoutSelect(cb) { this._onCutoutSelect = cb; } + onCutoutHover(cb) { this._onCutoutHover = cb; } + + dispose() { + if (this._animId) cancelAnimationFrame(this._animId); + if (this._resizeObserver) this._resizeObserver.disconnect(); + if (this._rectOverlay) { + this._rectOverlay.remove(); + this._rectOverlay = null; + } + this.clearSideHighlight(); + this._disposeCutoutViz(); + this.controls.dispose(); + this.renderer.dispose(); + if (this.renderer.domElement.parentNode) { + this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); + } + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..6b0d3b1 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,1714 @@ +// Former — PCB Stencil & Enclosure Generator +import './style.css'; +import { Former3D } from './former3d.js'; + +// ===== Helpers ===== +const $ = (sel, ctx = document) => ctx.querySelector(sel); +const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; +const show = el => el.classList.add('active'); +const hide = el => el.classList.remove('active'); + +// Wait for Wails runtime +function wails() { return window.go?.main?.App; } + +// ===== State ===== +const state = { + stencil: { gerberPath: '', outlinePath: '' }, + enclosure: { gbrjobPath: '', gerberPaths: [], drillPath: '', npthPath: '', sourceDir: '' }, + preview: { activeSide: 0, sessionInfo: null, cutouts: [], boardRect: null }, + former: { layers: [], images: {}, scale: 1, offsetX: 0, offsetY: 0, cutoutType: 'cutout', dadoDepth: 0.5 }, +}; + +// ===== Loading ===== +function showLoading(msg = 'Processing...') { + const el = document.createElement('div'); + el.className = 'loading-overlay'; + el.id = 'loading'; + el.innerHTML = `
${msg}
`; + document.body.appendChild(el); +} + +function hideLoading() { + const el = $('#loading'); + if (el) el.remove(); +} + +// ===== Navigation ===== +function navigate(page) { + $$('.page').forEach(p => hide(p)); + const el = $(`#page-${page}`); + if (el) show(el); + + // Initialize page content + switch (page) { + case 'landing': initLanding(); break; + case 'stencil': initStencil(); break; + case 'enclosure': initEnclosure(); break; + case 'preview': initPreview(); break; + case 'former': initFormer(); break; + } +} + +// ===== Landing Page ===== +async function initLanding() { + const page = $('#page-landing'); + const projects = await wails()?.GetRecentProjects() || []; + + let logoSrc = '/api/logo.png'; + + let projectsHTML = ''; + if (projects.length > 0) { + projectsHTML = ` +
Recent Projects
+
+ ${projects.map(p => ` +
+ ${esc(p.name)} + ${p.type} + ${p.boardW > 0 ? p.boardW.toFixed(1) + ' × ' + p.boardH.toFixed(1) + ' mm' : ''} +
+ `).join('')} +
+ `; + } + + page.innerHTML = ` +
+ +
+
+

Former

+

PCB Stencil & Enclosure Generator

+
+
+
+

New Stencil

+

Generate solder paste stencils from gerber files

+
+
+

New Enclosure

+

Generate PCB enclosures from KiCad projects

+
+
+ ${projectsHTML} + `; +} + +// ===== Stencil Page ===== +function initStencil() { + const page = $('#page-stencil'); + state.stencil = { gerberPath: '', outlinePath: '' }; + + page.innerHTML = ` + +
+
Solder Paste Gerber
+
+ No file selected + +
+
+
+
Board Outline (Optional)
+
+ No file selected + + +
+
+
+
Parameters
+
+ Stencil Height (mm) + +
+
+ Wall Height (mm) + +
+
+ Wall Thickness (mm) + +
+
+ DPI + +
+
+
+
Export Formats
+
+ + + + +
+
+
+
+ +
+ `; +} + +async function selectStencilGerber() { + try { + const path = await wails().SelectFile('Select Solder Paste Gerber', '*.gbr;*.gtp;*.gbp'); + if (path) { + state.stencil.gerberPath = path; + $('#stencil-gerber-name').textContent = path.split('/').pop(); + $('#stencil-gerber-name').classList.add('has-file'); + } + } catch (e) { console.error(e); } +} + +async function selectStencilOutline() { + try { + const path = await wails().SelectFile('Select Board Outline Gerber', '*.gbr;*.gko;*.gm1'); + if (path) { + state.stencil.outlinePath = path; + $('#stencil-outline-name').textContent = path.split('/').pop(); + $('#stencil-outline-name').classList.add('has-file'); + } + } catch (e) { console.error(e); } +} + +function clearStencilOutline() { + state.stencil.outlinePath = ''; + const el = $('#stencil-outline-name'); + el.textContent = 'No file selected'; + el.classList.remove('has-file'); +} + +async function generateStencil() { + if (!state.stencil.gerberPath) { + alert('Please select a solder paste gerber file.'); + return; + } + + const exports = []; + if ($('#stencil-stl')?.checked) exports.push('stl'); + if ($('#stencil-scad')?.checked) exports.push('scad'); + if ($('#stencil-svg')?.checked) exports.push('svg'); + if ($('#stencil-png')?.checked) exports.push('png'); + + showLoading('Generating stencil...'); + try { + const result = await wails().GenerateStencil( + state.stencil.gerberPath, + state.stencil.outlinePath, + parseFloat($('#stencil-height')?.value) || 0, + parseFloat($('#stencil-wall-height')?.value) || 0, + parseFloat($('#stencil-wall-thick')?.value) || 0, + parseFloat($('#stencil-dpi')?.value) || 0, + exports, + ); + hideLoading(); + showStencilResult(result.files); + } catch (e) { + hideLoading(); + alert('Generation failed: ' + e); + } +} + +function showStencilResult(files) { + const page = $('#page-stencil-result'); + page.innerHTML = ` + +
+
Generated Files
+
${files.map(f => esc(f)).join('\n')}
+
+
+
+ + +
+ `; + $$('.page').forEach(p => hide(p)); + show(page); +} + +// ===== Enclosure Page ===== +function initEnclosure() { + const page = $('#page-enclosure'); + state.enclosure = { gbrjobPath: '', gerberPaths: [], drillPath: '', npthPath: '', sourceDir: '' }; + + page.innerHTML = ` + +
+
Gerber Job File (.gbrjob)
+
+ No file selected + +
+
+
+
Gerber Files
+

Select a folder to auto-discover, or add files individually.

+
+ No files selected +
+
+ + + +
+
+
+
+
PTH Drill File (Optional)
+
+ No file selected + + +
+
+
+
NPTH Drill File (Optional)
+
+ No file selected + + +
+
+
+
+
Parameters
+
+ Wall Thickness (mm) + +
+
+ Wall Height (mm) + +
+
+ Clearance (mm) + +
+
+ DPI + +
+
+
+
Export Formats
+
+ + + + +
+
+
+
+ +
+ `; +} + +async function selectGbrjob() { + try { + const path = await wails().SelectFile('Select Gerber Job File', '*.gbrjob'); + if (path) { + state.enclosure.gbrjobPath = path; + $('#enc-gbrjob-name').textContent = path.split('/').pop(); + $('#enc-gbrjob-name').classList.add('has-file'); + } + } catch (e) { console.error(e); } +} + +async function selectGerberFolder() { + try { + const dir = await wails().SelectFolder('Select Gerber Files Folder'); + if (dir) { + state.enclosure.sourceDir = dir; + const files = await wails().DiscoverGerberFiles(dir); + if (files && files.length > 0) { + state.enclosure.gerberPaths = files; + $('#enc-gerber-count').textContent = `${files.length} files found`; + $('#enc-gerber-count').classList.add('has-file'); + } else { + $('#enc-gerber-count').textContent = 'No gerber files found in folder'; + } + } + } catch (e) { console.error(e); } +} + +async function addGerberFile() { + try { + const path = await wails().SelectFile('Add Gerber File', '*.gbr;*.gbrjob;*.gtp;*.gbp;*.gko;*.gm1;*.gtl;*.gbl;*.gts;*.gbs'); + if (path) { + if (!state.enclosure.gerberPaths.includes(path)) { + state.enclosure.gerberPaths.push(path); + if (!state.enclosure.sourceDir) { + state.enclosure.sourceDir = path.substring(0, path.lastIndexOf('/')); + } + } + $('#enc-gerber-count').textContent = `${state.enclosure.gerberPaths.length} files selected`; + $('#enc-gerber-count').classList.add('has-file'); + } + } catch (e) { console.error(e); } +} + +function clearGerbers() { + state.enclosure.gerberPaths = []; + const el = $('#enc-gerber-count'); + el.textContent = 'No files selected'; + el.classList.remove('has-file'); +} + +async function selectDrill(type) { + try { + const path = await wails().SelectFile(`Select ${type.toUpperCase()} Drill File`, '*.drl;*.xln;*.txt'); + if (path) { + if (type === 'pth') { + state.enclosure.drillPath = path; + $('#enc-drill-name').textContent = path.split('/').pop(); + $('#enc-drill-name').classList.add('has-file'); + } else { + state.enclosure.npthPath = path; + $('#enc-npth-name').textContent = path.split('/').pop(); + $('#enc-npth-name').classList.add('has-file'); + } + } + } catch (e) { console.error(e); } +} + +function clearDrill(type) { + if (type === 'pth') { + state.enclosure.drillPath = ''; + const el = $('#enc-drill-name'); + el.textContent = 'No file selected'; + el.classList.remove('has-file'); + } else { + state.enclosure.npthPath = ''; + const el = $('#enc-npth-name'); + el.textContent = 'No file selected'; + el.classList.remove('has-file'); + } +} + +async function buildEnclosure() { + if (!state.enclosure.gbrjobPath) { + alert('Please select a Gerber Job file (.gbrjob)'); + return; + } + if (state.enclosure.gerberPaths.length === 0) { + alert('Please select gerber files (use Select Folder or Add File)'); + return; + } + + const exports = []; + if ($('#enc-stl')?.checked) exports.push('stl'); + if ($('#enc-scad')?.checked) exports.push('scad'); + if ($('#enc-svg')?.checked) exports.push('svg'); + if ($('#enc-png')?.checked) exports.push('png'); + + showLoading('Building enclosure session...'); + try { + await wails().BuildEnclosureSession( + state.enclosure.gbrjobPath, + state.enclosure.gerberPaths, + state.enclosure.drillPath, + state.enclosure.npthPath, + parseFloat($('#enc-wall-thick')?.value) || 0, + parseFloat($('#enc-wall-height')?.value) || 0, + parseFloat($('#enc-clearance')?.value) || 0, + parseFloat($('#enc-dpi')?.value) || 0, + exports, + ); + hideLoading(); + navigate('preview'); + } catch (e) { + hideLoading(); + alert('Session build failed: ' + e); + } +} + +// ===== Enclosure Preview Page ===== +async function initPreview() { + const page = $('#page-preview'); + const info = await wails()?.GetSessionInfo(); + if (!info?.hasSession) { + page.innerHTML = '
No session active. Go back and build one.
'; + return; + } + + state.preview.activeSide = info.sides?.length > 0 ? info.sides[0].num : 0; + state.preview.sessionInfo = info; + const cutouts = await wails()?.GetSideCutouts() || []; + state.preview.cutouts = cutouts; + + page.innerHTML = ` + +
+
Board Preview — ${info.boardW.toFixed(1)} × ${info.boardH.toFixed(1)} mm | ${info.sides?.length || 0} sides
+
+ +
+
+
+
Side Cutout Editor
+
+ Active: ${info.sides?.length > 0 ? esc(info.sides[0].label) : 'none'} +
+
+ ${(info.sides || []).map((s, i) => ` + + `).join('')} +
+
+ X (mm) + + +
+
+ Y (mm) + + +
+
+ Width (mm) + +
+
+ Height (mm) + +
+
+ Corner Radius (mm) + +
+
+ Board Layer + +
+
+ +
+ +
Cutouts:
+
+ ${renderCutouts(cutouts)} +
+
+ +
+
+ +
+
+
+ +
+ + +
+ `; + + // Draw board preview with side labels + drawBoardPreview('#preview-board-canvas', info); + // Draw side-face cutout canvas + drawSideFace(); +} + +function renderCutouts(cutouts) { + if (!cutouts || cutouts.length === 0) return '
No cutouts added
'; + return cutouts.map((c, i) => ` +
+ Side ${c.side} [${c.l || 'F'}] X=${c.x.toFixed(1)} Y=${c.y.toFixed(1)} W=${c.w.toFixed(1)} H=${c.h.toFixed(1)} R=${c.r.toFixed(1)} + +
+ `).join(''); +} + +function selectSide(num, label, btn) { + state.preview.activeSide = num; + $$('.side-tab').forEach(t => t.classList.remove('active')); + btn.classList.add('active'); + $('#side-active-label').textContent = `Active: ${label}`; + drawSideFace(); +} + +async function centerX() { + const w = parseFloat($('#cut-w')?.value) || 9.0; + try { + const sideLen = await wails().GetSideLength(state.preview.activeSide); + const cx = Math.max(0, (sideLen - w) / 2); + $('#cut-x').value = cx.toFixed(2); + } catch (e) { console.error(e); } +} + +async function centerY() { + const h = parseFloat($('#cut-h')?.value) || 3.26; + const info = await wails()?.GetSessionInfo(); + if (info) { + const cy = Math.max(0, (info.totalH - h) / 2); + $('#cut-y').value = cy.toFixed(2); + } +} + +function presetUSBC() { + $('#cut-w').value = '9.0'; + $('#cut-h').value = '3.26'; + $('#cut-r').value = '1.3'; +} + +async function addCutout() { + try { + await wails().AddSideCutout( + state.preview.activeSide, + parseFloat($('#cut-x')?.value) || 0, + parseFloat($('#cut-y')?.value) || 0, + parseFloat($('#cut-w')?.value) || 9.0, + parseFloat($('#cut-h')?.value) || 3.26, + parseFloat($('#cut-r')?.value) || 1.3, + $('#cut-layer')?.value || 'F', + ); + const cutouts = await wails().GetSideCutouts(); + state.preview.cutouts = cutouts; + $('#cutout-list').innerHTML = renderCutouts(cutouts); + drawSideFace(); + } catch (e) { console.error(e); } +} + +async function removeCutout(index) { + try { + await wails().RemoveSideCutout(index); + const cutouts = await wails().GetSideCutouts(); + state.preview.cutouts = cutouts; + $('#cutout-list').innerHTML = renderCutouts(cutouts); + drawSideFace(); + } catch (e) { console.error(e); } +} + +async function saveProfile() { + const name = prompt('Profile name:'); + if (name === null) return; + try { + await wails().SaveEnclosureProfile(name || ''); + alert('Profile saved!'); + } catch (e) { + alert('Save failed: ' + e); + } +} + +async function generateEnclosure() { + showLoading('Generating enclosure...'); + try { + const result = await wails().GenerateEnclosureOutputs(); + hideLoading(); + showEnclosureResult(result.files); + } catch (e) { + hideLoading(); + alert('Generation failed: ' + e); + } +} + +function showEnclosureResult(files) { + const page = $('#page-enclosure-result'); + page.innerHTML = ` + +
+
Generated Files
+
${files.map(f => esc(f)).join('\n')}
+
+
+
+ + +
+ `; + $$('.page').forEach(p => hide(p)); + show(page); +} + +// ===== Open Project ===== +async function openProject(path) { + showLoading('Opening project...'); + try { + await wails().OpenProject(path); + hideLoading(); + navigate('preview'); + } catch (e) { + hideLoading(); + alert('Failed to open project: ' + e); + } +} + +// ===== THE FORMER (3D) ===== +let former3d = null; // Former3D instance + +async function initFormer() { + const page = $('#page-former'); + const layers = await wails()?.GetFormerLayers() || []; + state.former.layers = layers; + state.former.selectedLayer = -1; + + // Dispose previous instance + if (former3d) { + former3d.dispose(); + former3d = null; + } + if (state.former._escHandler) { + document.removeEventListener('keydown', state.former._escHandler); + state.former._escHandler = null; + } + + page.innerHTML = ` +
+
+
+
+

Layers

+ +
+
+ ${layers.map((l, i) => ` +
+ + +
+ ${esc(l.name)} +
+ `).join('')} +
+ + + + +
+
Left-click: rotate. Right-click: zoom. Middle: pan.
+
+ + +
+ +
+
+
+ `; + + const wrap = $('#former-canvas-wrap'); + if (!wrap) return; + + // Create 3D scene + former3d = new Former3D(wrap); + + // Layer selection callback — update sidebar + former3d.onLayerSelect(index => { + state.former.selectedLayer = index; + const tools = $('#former-selection-tools'); + if (index >= 0 && index < layers.length) { + tools.style.display = 'block'; + $('#former-sel-name').textContent = layers[index].name; + // Highlight the row + $$('.former-layer-row').forEach(r => r.classList.remove('selected')); + const row = $(`#layer-row-${index}`); + if (row) row.classList.add('selected'); + } else { + tools.style.display = 'none'; + $$('.former-layer-row').forEach(r => r.classList.remove('selected')); + } + }); + + former3d.onCutoutSelect((el, selected) => { + console.log('Cutout element:', el.id, selected ? 'selected' : 'deselected'); + const count = former3d.cutouts.length; + const status = $('#former-cutout-status'); + if (status) status.textContent = `${count} element${count !== 1 ? 's' : ''} selected`; + }); + + // Placed cutout selection callback + former3d.onPlacedCutoutSelect(ids => { + const panel = $('#former-cutout-edit'); + if (!panel) return; + if (ids.length === 0) { + panel.style.display = 'none'; + return; + } + const cutouts = former3d.getSelectedCutouts(); + if (cutouts.length === 1) { + showCutoutEditPanel(cutouts[0]); + } else { + panel.style.display = 'block'; + panel.innerHTML = ` +
${cutouts.length} cutouts selected
+
+ +
+ `; + } + }); + + // Keyboard handler for cutout editing + escape + const keyHandler = e => { + if (e.key === 'Escape') { + if (former3d?.cutoutMode) { + formerExitCutoutMode(); + } else if (former3d?.selectedCutoutIds?.size > 0) { + former3d._deselectAllCutouts(); + } + return; + } + // Delete/Backspace: remove selected cutouts + if ((e.key === 'Delete' || e.key === 'Backspace') && former3d?.selectedCutoutIds?.size > 0) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + e.preventDefault(); + deleteSelectedCutouts(); + return; + } + // Arrow keys: nudge selected cutouts 0.5mm + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && former3d?.selectedCutoutIds?.size > 0) { + if (e.target.tagName === 'INPUT') return; + e.preventDefault(); + nudgeSelectedCutouts(e.key); + return; + } + // Cmd/Ctrl+D: duplicate + if (e.key === 'd' && (e.metaKey || e.ctrlKey) && former3d?.selectedCutoutIds?.size > 0) { + e.preventDefault(); + duplicateSelectedCutouts(); + return; + } + }; + document.addEventListener('keydown', keyHandler); + state.former._escHandler = keyHandler; + + // Build image URLs and load layers into 3D scene + const imageUrls = layers.map((_, i) => `/api/layers/${i}.png?t=${Date.now()}`); + await former3d.loadLayers(layers, imageUrls); + + // Load 3D enclosure geometry if available + try { + const encData = await wails()?.GetEnclosure3DData(); + if (encData && encData.outlinePoints && former3d) { + const info = await wails()?.GetSessionInfo(); + if (info?.hasSession) { + former3d.loadEnclosureGeometry(encData, info.dpi, info.minX, info.maxY); + // Show existing cutouts + const cutouts = encData.cutouts || []; + if (cutouts.length > 0) { + former3d.refreshCutouts(cutouts, encData, info.dpi, info.minX, info.maxY); + } + } + } + } catch (e) { + console.warn('Could not load enclosure 3D data:', e); + } +} + +function toggleLayerVis(index) { + const layer = state.former.layers[index]; + if (!layer) return; + layer.visible = !layer.visible; + if (!layer.visible) layer.highlight = false; + wails()?.SetLayerVisibility(index, layer.visible); + if (former3d) former3d.setLayerVisibility(index, layer.visible); + updateLayerUI(); +} + +function toggleLayerHL(index) { + const layer = state.former.layers[index]; + if (!layer) return; + if (layer.highlight) { + layer.highlight = false; + wails()?.ToggleHighlight(index); + if (former3d) former3d.setLayerHighlight(-1, false); + } else { + state.former.layers.forEach(l => l.highlight = false); + layer.highlight = true; + layer.visible = true; + wails()?.ToggleHighlight(index); + if (former3d) { + former3d.setLayerVisibility(index, true); + former3d.setLayerHighlight(index, true); + } + } + updateLayerUI(); +} + +function updateLayerUI() { + const hasHL = state.former.layers.some(l => l.highlight && l.visible); + state.former.layers.forEach((l, i) => { + const visBtn = $(`#layer-vis-${i}`); + const hlBtn = $(`#layer-hl-${i}`); + const row = $(`#layer-row-${i}`); + const name = $(`#layer-name-${i}`); + if (visBtn) { + visBtn.textContent = l.visible ? '👁' : '○'; + visBtn.classList.toggle('active', l.visible); + } + if (hlBtn) hlBtn.classList.toggle('active', l.highlight); + if (row) row.classList.toggle('highlighted', l.highlight); + if (name) name.classList.toggle('dimmed', hasHL && !l.highlight && l.visible); + }); +} + +function resetFormerView() { + if (former3d) former3d.resetView(); +} + +function formerMoveZ(delta) { + if (former3d) former3d.moveSelectedZ(delta); +} + +async function formerSelectCutoutElement() { + state.former.cutoutType = 'cutout'; + await _enterElementSelection(false); +} + +async function formerSelectDadoElement() { + const depthStr = prompt('Dado/engrave depth (mm):', '0.5'); + if (depthStr === null) return; + state.former.dadoDepth = parseFloat(depthStr) || 0.5; + state.former.cutoutType = 'dado'; + await _enterElementSelection(true); +} + +async function _enterElementSelection(isDado) { + if (!former3d || state.former.selectedLayer < 0) return; + const index = state.former.selectedLayer; + const layer = state.former.layers[index]; + + // Show cutout tools, hide selection tools + const cutoutTools = $('#former-cutout-tools'); + const selTools = $('#former-selection-tools'); + if (cutoutTools) cutoutTools.style.display = 'block'; + if (selTools) selTools.style.display = 'none'; + const status = $('#former-cutout-status'); + if (status) status.textContent = '0 elements selected'; + + // Update cutout mode label + const modeLabel = cutoutTools?.querySelector('div:first-child'); + if (modeLabel) { + modeLabel.textContent = isDado ? 'Engrave Selection Mode' : 'Cutout Selection Mode'; + modeLabel.style.color = isDado ? '#f9e2af' : 'var(--accent)'; + } + const modeHint = cutoutTools?.querySelectorAll('div')[1]; + if (modeHint && isDado) { + modeHint.textContent = 'Click or drag-rectangle to select elements. Esc to exit.'; + } + + try { + const elements = await wails()?.GetLayerElements(index); + if (!elements || elements.length === 0) { + alert(`No graphic elements found on layer "${layer?.name || index}". This layer may not have selectable elements.`); + if (cutoutTools) cutoutTools.style.display = 'none'; + if (selTools) selTools.style.display = 'block'; + return; + } + former3d.enterCutoutMode(elements, index, isDado); + } catch (e) { + console.error('GetLayerElements failed:', e); + alert('Could not load layer elements: ' + e); + if (cutoutTools) cutoutTools.style.display = 'none'; + if (selTools) selTools.style.display = 'block'; + } +} + +function formerCutoutAll() { + if (!former3d || state.former.selectedLayer < 0) return; + const layer = state.former.layers[state.former.selectedLayer]; + console.log('Cutout all for layer:', layer?.name); + // Mark the entire layer as a cutout region + alert(`Marked entire "${layer?.name}" layer as cutout.`); +} + +async function formerExitCutoutMode() { + if (!former3d) return; + + const selected = [...former3d.cutouts]; + const isDado = state.former.cutoutType === 'dado'; + const depth = isDado ? state.former.dadoDepth : 0; + + former3d.exitCutoutMode(); + + if (selected.length > 0) { + const info = await wails()?.GetSessionInfo(); + const sides = info?.sides || []; + const result = await showSurfacePicker(sides); + + if (result) { + try { + if (result.surface === 'top' || result.surface === 'bottom') { + const plane = result.surface === 'top' ? 'lid' : 'tray'; + await wails()?.AddLidCutouts(selected, plane, isDado, depth); + console.log(`Added ${selected.length} ${isDado ? 'dado' : 'cutout'} elements to ${result.surface}`); + } else if (result.surface === 'side') { + const side = sides.find(s => s.num === result.sideNum); + if (side && info) { + const projected = selected.map(el => + projectElementToSide(el, side, info.dpi, info.minX, info.maxY) + ); + // Enter side placement mode for interactive Y positioning + const placements = await former3d.enterSidePlacementMode(projected, result.sideNum); + if (placements) { + for (const p of placements) { + await wails()?.AddCutout({ + id: '', surface: 'side', sideNum: result.sideNum, + x: p.x, y: p.y, w: p.width, h: p.height, + r: 0, isDado, depth, sourceLayer: '', + }); + } + console.log(`Added ${placements.length} side cutouts to side ${result.sideNum}`); + } + } + } + await refreshCutoutViz(); + } catch (e) { + console.error('Failed to save cutouts:', e); + alert('Failed to save cutouts: ' + e); + } + } + } + + // Restore UI + const cutoutTools = $('#former-cutout-tools'); + const selTools = $('#former-selection-tools'); + if (cutoutTools) cutoutTools.style.display = 'none'; + if (state.former.selectedLayer >= 0) { + if (selTools) selTools.style.display = 'block'; + } +} + +const surfacePickerColors = ['#ef4444','#3b82f6','#22c55e','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316']; + +function showSurfacePicker(sides) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;'; + const box = document.createElement('div'); + box.style.cssText = 'background:var(--bg-card,#1e1e2e);border:1px solid var(--border-light,#45475a);border-radius:12px;padding:24px;min-width:300px;text-align:center;'; + + let sidesHTML = ''; + if (sides && sides.length > 0) { + sidesHTML = ` +
Sides
+
+ ${sides.map((s, i) => { + const col = surfacePickerColors[i % surfacePickerColors.length]; + return ``; + }).join('')} +
+ `; + } + + box.innerHTML = ` +
Apply cutout to which surface?
+
+ + +
+ ${sidesHTML} +
+ +
+ `; + overlay.appendChild(box); + document.body.appendChild(overlay); + + const done = (result) => { overlay.remove(); resolve(result); }; + box.querySelector('#surface-top').onclick = () => done({ surface: 'top' }); + box.querySelector('#surface-bottom').onclick = () => done({ surface: 'bottom' }); + box.querySelector('#surface-cancel').onclick = () => done(null); + overlay.onclick = e => { if (e.target === overlay) done(null); }; + + // Side buttons with hover preview + box.querySelectorAll('.surface-side-btn').forEach(btn => { + const sideNum = parseInt(btn.dataset.side); + btn.addEventListener('mouseenter', () => { + if (former3d) { + former3d.highlightSide(sideNum); + former3d.lookAtSide(sideNum); + } + }); + btn.addEventListener('mouseleave', () => { + if (former3d) former3d.clearSideHighlight(); + }); + btn.onclick = () => done({ surface: 'side', sideNum }); + }); + }); +} + +function projectElementToSide(el, side, dpi, minX, maxY) { + const mmMinX = el.minX * (25.4 / dpi) + minX; + const mmMaxX = el.maxX * (25.4 / dpi) + minX; + const mmMinY = maxY - el.maxY * (25.4 / dpi); + const mmMaxY = maxY - el.minY * (25.4 / dpi); + + const cx = (mmMinX + mmMaxX) / 2; + const cy = (mmMinY + mmMaxY) / 2; + const w = mmMaxX - mmMinX; + const h = mmMaxY - mmMinY; + + const sdx = side.endX - side.startX; + const sdy = side.endY - side.startY; + const sLen = Math.sqrt(sdx * sdx + sdy * sdy); + const t = ((cx - side.startX) * sdx + (cy - side.startY) * sdy) / (sLen * sLen); + const posAlongSide = t * sLen; + + const ux = sdx / sLen, uy = sdy / sLen; + const projW = Math.abs(w * ux) + Math.abs(h * uy); + const projH = Math.abs(w * uy) + Math.abs(h * ux); + + return { x: posAlongSide - projW / 2, width: projW, height: projH }; +} + +async function refreshCutoutViz() { + if (!former3d) return; + try { + const cutouts = await wails()?.GetCutouts(); + const encData = await wails()?.GetEnclosure3DData(); + const info = await wails()?.GetSessionInfo(); + if (encData && info?.hasSession) { + former3d.refreshCutouts(cutouts || [], encData, info.dpi, info.minX, info.maxY); + } + } catch (e) { + console.warn('refreshCutoutViz failed:', e); + } +} + +function showCutoutEditPanel(cutout) { + const panel = $('#former-cutout-edit'); + if (!panel) return; + panel.style.display = 'block'; + + const surfaceLabel = cutout.surface === 'side' ? `Side ${cutout.sideNum}` : + cutout.surface === 'top' ? 'Top (Lid)' : 'Bottom (Tray)'; + + panel.innerHTML = ` +
Edit Cutout — ${surfaceLabel}
+
+ X + +
+
+ Y + +
+
+ W + +
+
+ H + +
+
+ R + +
+ ${cutout.isDado ? ` +
+ Depth + +
` : ''} +
+ + +
+ `; + + // Live update on input change + const debounceUpdate = (() => { + let timer; + return () => { + clearTimeout(timer); + timer = setTimeout(() => updateCutoutFromFields(cutout.id), 200); + }; + })(); + ['cedit-x', 'cedit-y', 'cedit-w', 'cedit-h', 'cedit-r', 'cedit-depth'].forEach(id => { + const el = $(`#${id}`); + if (el) el.addEventListener('input', debounceUpdate); + }); +} + +async function updateCutoutFromFields(cutoutId) { + try { + // Get current full cutout from backend, merge fields + const allCutouts = await wails()?.GetCutouts() || []; + const existing = allCutouts.find(c => c.id === cutoutId); + if (!existing) return; + existing.x = parseFloat($('#cedit-x')?.value) || 0; + existing.y = parseFloat($('#cedit-y')?.value) || 0; + existing.w = parseFloat($('#cedit-w')?.value) || 0; + existing.h = parseFloat($('#cedit-h')?.value) || 0; + existing.r = parseFloat($('#cedit-r')?.value) || 0; + const depthEl = $('#cedit-depth'); + if (depthEl) existing.depth = parseFloat(depthEl.value) || 0; + await wails()?.UpdateCutout(existing); + await refreshCutoutViz(); + } catch (e) { + console.error('updateCutout failed:', e); + } +} + +async function deleteSelectedCutouts() { + if (!former3d?.selectedCutoutIds) return; + const ids = [...former3d.selectedCutoutIds]; + for (const id of ids) { + try { await wails()?.RemoveCutout(id); } catch (e) { console.error(e); } + } + former3d.selectedCutoutIds = new Set(); + const panel = $('#former-cutout-edit'); + if (panel) panel.style.display = 'none'; + await refreshCutoutViz(); +} + +async function duplicateSelectedCutouts() { + if (!former3d?.selectedCutoutIds) return; + const ids = [...former3d.selectedCutoutIds]; + for (const id of ids) { + try { await wails()?.DuplicateCutout(id); } catch (e) { console.error(e); } + } + await refreshCutoutViz(); +} + +async function nudgeSelectedCutouts(key) { + if (!former3d?.selectedCutoutIds) return; + // Get fresh cutout data from backend + const allCutouts = await wails()?.GetCutouts() || []; + const selectedIds = [...former3d.selectedCutoutIds]; + const delta = 0.5; + for (const id of selectedIds) { + const c = allCutouts.find(x => x.id === id); + if (!c) continue; + if (key === 'ArrowLeft') c.x -= delta; + else if (key === 'ArrowRight') c.x += delta; + else if (key === 'ArrowUp') c.y += delta; + else if (key === 'ArrowDown') c.y -= delta; + try { await wails()?.UpdateCutout(c); } catch (e) { console.error(e); } + } + await refreshCutoutViz(); + // Update edit panel if single selection + if (selectedIds.length === 1) { + const fresh = await wails()?.GetCutouts() || []; + const updated = fresh.find(c => c.id === selectedIds[0]); + if (updated) showCutoutEditPanel(updated); + } +} + +function formerToggleGrid() { + if (!former3d) return; + const on = former3d.toggleGrid(); + const btn = $('#former-grid-btn'); + if (btn) btn.textContent = on ? 'Grid: On' : 'Grid: Off'; +} + +async function formerRenderAndView() { + if (!former3d) return; + showLoading('Rendering outputs...'); + try { + const result = await wails()?.RenderFromFormer(); + hideLoading(); + if (!result || !result.files || result.files.length === 0) { + alert('Render produced no output files.'); + return; + } + // Switch to solid view + former3d.enterSolidView(); + // Show render result panel, hide normal actions + const renderResult = $('#former-render-result'); + const normalActions = $('#former-actions-normal'); + const selTools = $('#former-selection-tools'); + const cutoutTools = $('#former-cutout-tools'); + if (renderResult) { + renderResult.style.display = 'block'; + const filesDiv = $('#former-render-files'); + if (filesDiv) filesDiv.innerHTML = result.files.map(f => esc(f.split('/').pop())).join('
'); + } + if (normalActions) normalActions.style.display = 'none'; + if (selTools) selTools.style.display = 'none'; + if (cutoutTools) cutoutTools.style.display = 'none'; + // Show nav button + const navBtn = $('#nav-open-output'); + if (navBtn) navBtn.style.display = ''; + } catch (e) { + hideLoading(); + alert('Render failed: ' + e); + } +} + +function formerReturnToEditor() { + if (!former3d) return; + former3d.exitSolidView(); + const renderResult = $('#former-render-result'); + const normalActions = $('#former-actions-normal'); + if (renderResult) renderResult.style.display = 'none'; + if (normalActions) normalActions.style.display = 'block'; +} + +async function openOutputFolder() { + try { + await wails()?.OpenOutputFolder(); + } catch (e) { + alert('Could not open output folder: ' + e); + } +} + +// ===== Auto-Align USB Port ===== +async function autoAlignUSB() { + try { + const paths = await wails().SelectMultipleFiles('Select Fab Layer Gerbers (F.Fab, B.Fab)', '*.gbr'); + if (!paths || paths.length === 0) return; + + showLoading('Detecting footprints...'); + const result = await wails().UploadAndDetectFootprints(paths); + hideLoading(); + + if (!result || !result.footprints || result.footprints.length === 0) { + alert('No footprints found in the selected Fab gerbers.'); + return; + } + + state.preview.alignMode = 'SELECT_FOOTPRINT'; + state.preview.footprints = result.footprints; + state.preview.hoverFP = null; + state.preview.selectedFP = null; + state.preview.hoverEdge = null; + state.preview.fabImgSrc = result.fabImageURL; + + // Redraw board with footprint overlay + drawBoardPreviewWithAlign(); + } catch (e) { + hideLoading(); + console.error(e); + alert('Auto-detect failed: ' + e); + } +} + +function drawBoardPreviewWithAlign() { + const canvas = $('#preview-board-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const info = state.preview.sessionInfo; + if (!info) return; + + const boardImg = new Image(); + boardImg.onload = () => { + const scale = Math.min(canvas.width / boardImg.width, canvas.height / boardImg.height) * 0.75; + const w = boardImg.width * scale; + const h = boardImg.height * scale; + const x = (canvas.width - w) / 2; + const y = (canvas.height - h) / 2; + + ctx.fillStyle = '#34353c'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(boardImg, x, y, w, h); + + // Draw fab overlay if in align mode + if (state.preview.alignMode && state.preview.fabImgSrc) { + const fabImg = new Image(); + fabImg.onload = () => { + ctx.globalAlpha = 0.5; + ctx.drawImage(fabImg, x, y, w, h); + ctx.globalAlpha = 1.0; + drawAlignOverlays(ctx, x, y, w, h, scale, info); + }; + fabImg.src = state.preview.fabImgSrc; + } else { + drawSideLabels(ctx, x, y, w, h, scale, info); + } + }; + boardImg.src = `/api/board-preview.png?t=${Date.now()}`; +} + +function drawAlignOverlays(ctx, bx, by, bw, bh, scale, info) { + const dpi = info.dpi || 600; + const mmToPx = (mmX, mmY) => [ + bx + (mmX - info.minX) * dpi / 25.4 * scale, + by + (info.maxY - mmY) * dpi / 25.4 * scale + ]; + + if (state.preview.alignMode === 'SELECT_FOOTPRINT') { + (state.preview.footprints || []).forEach(fp => { + const [px1, py1] = mmToPx(fp.minX, fp.maxY); + const [px2, py2] = mmToPx(fp.maxX, fp.minY); + const fw = px2 - px1; + const fh = py2 - py1; + + ctx.beginPath(); + ctx.rect(px1, py1, fw, fh); + if (state.preview.hoverFP && state.preview.hoverFP.name === fp.name && + state.preview.hoverFP.centerX === fp.centerX) { + ctx.fillStyle = 'rgba(59, 130, 246, 0.4)'; + ctx.fill(); + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 2; + } else { + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + } + ctx.stroke(); + }); + + // Instruction banner + ctx.fillStyle = 'rgba(37, 99, 235, 0.85)'; + ctx.fillRect(0, 0, 600, 28); + ctx.fillStyle = 'white'; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Click to select the USB-C footprint. Press Escape to cancel.', 300, 14); + } else if (state.preview.alignMode === 'SELECT_EDGE' && state.preview.selectedFP) { + const fp = state.preview.selectedFP; + const [px1, py1] = mmToPx(fp.minX, fp.maxY); + const [px2, py2] = mmToPx(fp.maxX, fp.minY); + + const edges = [ + { id: 'top', x1: px1, y1: py1, x2: px2, y2: py1 }, + { id: 'bottom', x1: px1, y1: py2, x2: px2, y2: py2 }, + { id: 'left', x1: px1, y1: py1, x2: px1, y2: py2 }, + { id: 'right', x1: px2, y1: py1, x2: px2, y2: py2 } + ]; + + edges.forEach(e => { + ctx.beginPath(); + ctx.moveTo(e.x1, e.y1); + ctx.lineTo(e.x2, e.y2); + ctx.strokeStyle = (state.preview.hoverEdge === e.id) ? '#ef4444' : '#3b82f6'; + ctx.lineWidth = (state.preview.hoverEdge === e.id) ? 4 : 2; + ctx.stroke(); + }); + + ctx.fillStyle = 'rgba(37, 99, 235, 0.85)'; + ctx.fillRect(0, 0, 600, 28); + ctx.fillStyle = 'white'; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Click the outermost edge of the connector lip.', 300, 14); + } +} + +function drawSideLabels(ctx, x, y, w, h, scale, info) { + const sides = info.sides || []; + const labelPad = 18; + ctx.font = 'bold 13px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + sides.forEach(side => { + const color = sideColors[(side.num - 1) % sideColors.length]; + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + + if (side.startX !== undefined) { + const dpi = info.dpi || 600; + const px1 = x + (side.startX - info.minX) * dpi / 25.4 * scale; + const py1 = y + (info.maxY - side.startY) * dpi / 25.4 * scale; + const px2 = x + (side.endX - info.minX) * dpi / 25.4 * scale; + const py2 = y + (info.maxY - side.endY) * dpi / 25.4 * scale; + + ctx.beginPath(); + ctx.moveTo(px1, py1); + ctx.lineTo(px2, py2); + ctx.stroke(); + + const midPxX = (px1 + px2) / 2; + const midPxY = (py1 + py2) / 2; + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + const lx = midPxX + nx * labelPad; + const ly = midPxY + ny * labelPad; + + ctx.beginPath(); + ctx.arc(lx, ly, 12, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'white'; + ctx.fillText(side.num, lx, ly + 1); + } + }); +} + +function setupBoardCanvasEvents() { + const canvas = $('#preview-board-canvas'); + if (!canvas) return; + + canvas.addEventListener('mousemove', e => { + if (!state.preview.alignMode) return; + const info = state.preview.sessionInfo; + if (!info) return; + + const rect = canvas.getBoundingClientRect(); + const pxX = (e.clientX - rect.left) * (canvas.width / rect.width); + const pxY = (e.clientY - rect.top) * (canvas.height / rect.height); + + const br = state.preview.boardRect; + if (!br) return; + + const dpi = info.dpi || 600; + const mmX = info.minX + (pxX - br.x) / br.scale * 25.4 / dpi; + const mmY = info.maxY - (pxY - br.y) / br.scale * 25.4 / dpi; + + if (state.preview.alignMode === 'SELECT_FOOTPRINT') { + state.preview.hoverFP = null; + for (const fp of (state.preview.footprints || [])) { + if (mmX >= fp.minX && mmX <= fp.maxX && mmY >= fp.minY && mmY <= fp.maxY) { + state.preview.hoverFP = fp; + break; + } + } + drawBoardPreviewWithAlign(); + } else if (state.preview.alignMode === 'SELECT_EDGE' && state.preview.selectedFP) { + const fp = state.preview.selectedFP; + const dists = [ + { id: 'top', d: Math.abs(mmY - fp.maxY) }, + { id: 'bottom', d: Math.abs(mmY - fp.minY) }, + { id: 'left', d: Math.abs(mmX - fp.minX) }, + { id: 'right', d: Math.abs(mmX - fp.maxX) } + ].sort((a, b) => a.d - b.d); + state.preview.hoverEdge = (dists[0].d < 3.0) ? dists[0].id : null; + drawBoardPreviewWithAlign(); + } + }); + + canvas.addEventListener('click', e => { + if (!state.preview.alignMode) return; + + if (state.preview.alignMode === 'SELECT_FOOTPRINT' && state.preview.hoverFP) { + state.preview.selectedFP = state.preview.hoverFP; + state.preview.alignMode = 'SELECT_EDGE'; + drawBoardPreviewWithAlign(); + } else if (state.preview.alignMode === 'SELECT_EDGE' && state.preview.hoverEdge && state.preview.selectedFP) { + applyAutoAlignment(state.preview.selectedFP, state.preview.hoverEdge); + state.preview.alignMode = null; + drawBoardPreview('#preview-board-canvas', state.preview.sessionInfo); + } + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && state.preview.alignMode) { + state.preview.alignMode = null; + drawBoardPreview('#preview-board-canvas', state.preview.sessionInfo); + } + }); +} + +function applyAutoAlignment(fp, lip) { + const info = state.preview.sessionInfo; + if (!info) return; + + let bx = fp.centerX, by = fp.centerY; + if (lip === 'top') by = fp.maxY; + else if (lip === 'bottom') by = fp.minY; + else if (lip === 'left') bx = fp.minX; + else if (lip === 'right') bx = fp.maxX; + + let closestSide = null; + let minDist = Infinity; + let bestPosX = 0; + + (info.sides || []).forEach(s => { + if (s.startX === undefined) return; + const dx = s.endX - s.startX; + const dy = s.endY - s.startY; + const lenSq = dx * dx + dy * dy; + if (lenSq <= 0) return; + const t = Math.max(0, Math.min(1, ((bx - s.startX) * dx + (by - s.startY) * dy) / lenSq)); + const rx = s.startX + t * dx; + const ry = s.startY + t * dy; + const dist = Math.sqrt((bx - rx) ** 2 + (by - ry) ** 2); + if (dist < minDist) { + minDist = dist; + closestSide = s; + bestPosX = t * s.length; + } + }); + + if (closestSide) { + const cutW = 9.0; + const cutH = 3.26; + const cutR = 1.3; + const cutX = Math.max(0, bestPosX - cutW / 2); + + // Set form fields + if ($('#cut-w')) $('#cut-w').value = cutW.toFixed(2); + if ($('#cut-h')) $('#cut-h').value = cutH.toFixed(2); + if ($('#cut-r')) $('#cut-r').value = cutR.toFixed(2); + if ($('#cut-x')) $('#cut-x').value = cutX.toFixed(2); + if ($('#cut-y')) $('#cut-y').value = '0.00'; + + // Switch to correct side and add + state.preview.activeSide = closestSide.num; + $$('.side-tab').forEach(t => t.classList.remove('active')); + const tab = $(`.side-tab[data-side="${closestSide.num}"]`); + if (tab) tab.classList.add('active'); + $('#side-active-label').textContent = `Active: ${closestSide.label}`; + + addCutout(); + } +} + +// ===== Board Preview Canvas with Side Labels ===== +const sideColors = ['#ef4444','#3b82f6','#22c55e','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316']; + +function drawBoardPreview(canvasId, info) { + const canvas = $(canvasId); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.onload = () => { + const scale = Math.min(canvas.width / img.width, canvas.height / img.height) * 0.75; + const w = img.width * scale; + const h = img.height * scale; + const x = (canvas.width - w) / 2; + const y = (canvas.height - h) / 2; + + ctx.fillStyle = '#34353c'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, x, y, w, h); + + drawSideLabels(ctx, x, y, w, h, scale, info); + state.preview.boardRect = { x, y, w, h, scale }; + + // Setup events for auto-detect (only once) + if (!canvas._eventsSetup) { + canvas._eventsSetup = true; + setupBoardCanvasEvents(); + } + }; + img.src = `/api/board-preview.png?t=${Date.now()}`; +} + +// ===== Side-Face Cutout Canvas ===== +function drawSideFace() { + const canvas = $('#side-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const info = state.preview.sessionInfo; + if (!info) return; + + const side = (info.sides || []).find(s => s.num === state.preview.activeSide); + if (!side) return; + + const dims = { width: side.length, height: info.totalH }; + const scaleX = (canvas.width - 40) / dims.width; + const scaleY = (canvas.height - 30) / dims.height; + const sc = Math.min(scaleX, scaleY); + const offX = (canvas.width - dims.width * sc) / 2; + const offY = 10; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Wall face rectangle + ctx.fillStyle = '#45475a'; + ctx.strokeStyle = sideColors[(state.preview.activeSide - 1) % sideColors.length]; + ctx.lineWidth = 2; + ctx.fillRect(offX, offY, dims.width * sc, dims.height * sc); + ctx.strokeRect(offX, offY, dims.width * sc, dims.height * sc); + + // Side label + ctx.fillStyle = sideColors[(state.preview.activeSide - 1) % sideColors.length]; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(side.label, offX, offY - 2); + + // Draw cutouts for this side + const cutouts = state.preview.cutouts || []; + ctx.fillStyle = '#1e1e2e'; + cutouts.forEach(c => { + if (c.side !== state.preview.activeSide) return; + drawRoundedRect(ctx, + offX + c.x * sc, + offY + (dims.height - c.y - c.h) * sc, + c.w * sc, c.h * sc, c.r * sc); + }); + + // mm grid labels + ctx.fillStyle = '#6c7086'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + const step = Math.ceil(dims.width / 10); + for (let mm = 0; mm <= dims.width; mm += step) { + ctx.fillText(mm + '', offX + mm * sc, offY + dims.height * sc + 14); + } +} + +function drawRoundedRect(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + ctx.fill(); +} + +// ===== Utility ===== +function esc(str) { + if (!str) return ''; + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; +} + +// ===== Expose globals for onclick handlers ===== +Object.assign(window, { + navigate, openProject, + selectStencilGerber, selectStencilOutline, clearStencilOutline, generateStencil, + selectGbrjob, selectGerberFolder, addGerberFile, clearGerbers, + selectDrill, clearDrill, buildEnclosure, + selectSide, centerX, centerY, presetUSBC, addCutout, removeCutout, + saveProfile, generateEnclosure, autoAlignUSB, + toggleLayerVis, toggleLayerHL, resetFormerView, + formerMoveZ, formerSelectCutoutElement, formerSelectDadoElement, formerCutoutAll, formerExitCutoutMode, formerToggleGrid, + formerRenderAndView, formerReturnToEditor, openOutputFolder, + refreshCutoutViz, deleteSelectedCutouts, duplicateSelectedCutouts, +}); + +// ===== Init ===== +window.addEventListener('DOMContentLoaded', () => { + navigate('landing'); +}); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..ede9b93 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,713 @@ +/* Former — Dark Theme (Google AI Studio Match) */ +:root { + --bg-base: #131314; + --bg-surface: #1e1f20; + --bg-overlay: #282a2c; + --bg-input: #1e1f20; + --text-primary: #e3e3e3; + --text-secondary: #c4c7c5; + --text-subtle: #8e918f; + --accent: #e3e3e3; + --accent-hover: #ffffff; + --accent-dim: rgba(227, 227, 227, 0.1); + --success: #81c995; + --error: #f28b82; + --warning: #fdd663; + --border: #444746; + --border-light: #333638; + --radius: 12px; + --radius-sm: 8px; + --transition: 150ms ease; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'SF Mono', 'Menlo', 'Consolas', monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font); + background: var(--bg-base); + color: var(--text-primary); + overflow: hidden; + height: 100vh; + -webkit-font-smoothing: antialiased; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Navigation (includes macOS titlebar drag region) */ +#nav { + display: flex; + align-items: center; + padding: 0 16px 0 76px; /* left padding for macOS traffic lights */ + height: 44px; + flex-shrink: 0; + background: var(--bg-base); + border-bottom: 1px solid var(--border-light); + gap: 4px; + -webkit-app-region: drag; + -webkit-user-select: none; + user-select: none; +} + +.nav-btn { + background: none; + border: none; + color: var(--text-secondary); + font: inherit; + font-size: 13px; + font-weight: 500; + padding: 6px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition); +} + +.nav-btn:hover { + background: var(--bg-overlay); + color: var(--text-primary); +} + +.nav-brand { + font-weight: 500; + color: var(--text-primary); + font-size: 14px; + cursor: pointer; + -webkit-app-region: no-drag; + -webkit-user-select: none; + user-select: none; +} + +.nav-btn { + -webkit-app-region: no-drag; +} + +.nav-spacer { flex: 1; } + +/* Main content */ +#main { + flex: 1; + overflow-y: auto; + position: relative; +} + +.page { + display: none; + padding: 32px 24px; + max-width: 720px; + margin: 0 auto; + animation: fadeIn 200ms ease; +} + +.page.active { + display: block; +} + +.page-former.active { + display: flex; + max-width: none; + padding: 0; + height: 100%; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Landing page */ +#page-landing { + position: relative; + overflow: hidden; +} + +.landing-bg-art { + position: relative; + width: 100%; + display: flex; + justify-content: center; + margin-top: -8px; + margin-bottom: -60px; + pointer-events: none; + z-index: 0; + -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.5) 100%); + mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.2) 100%); +} + +.landing-bg-logo { + width: 50%; + height: auto; + opacity: 0.8; + filter: brightness(1.4); +} + +.landing-hero { + text-align: center; + padding: 0 0 32px; + position: relative; + z-index: 1; +} + +.landing-hero h1 { + font-size: 32px; + font-weight: 500; + letter-spacing: -0.5px; + margin-bottom: 8px; +} + +.landing-hero p { + color: var(--text-secondary); + font-size: 15px; +} + +.landing-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 40px; + position: relative; + z-index: 1; +} + +.action-card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 24px; + cursor: pointer; + transition: all var(--transition); + text-align: left; +} + +.action-card:hover { + border-color: var(--border); + background: var(--bg-overlay); +} + +.action-card h3 { + font-size: 15px; + font-weight: 500; + margin-bottom: 6px; + color: var(--text-primary); +} + +.action-card p { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; +} + +/* Section titles */ +.section-title { + font-size: 12px; + font-weight: 500; + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 16px; +} + +/* Recent projects */ +.project-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.project-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition); +} + +.project-item:hover { + border-color: var(--border); + background: var(--bg-overlay); +} + +.project-name { + flex: 1; + font-size: 14px; + font-weight: 500; +} + +.project-meta { + font-size: 12px; + color: var(--text-subtle); +} + +.badge { + font-size: 10px; + padding: 4px 8px; + border-radius: 6px; + background: var(--bg-base); + border: 1px solid var(--border-light); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} + +/* Cards */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; +} + +.card-title { + font-size: 14px; + font-weight: 500; + margin-bottom: 16px; +} + +/* Form elements */ +.form-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.form-row:last-child { + margin-bottom: 0; +} + +.form-label { + font-size: 13px; + color: var(--text-secondary); + min-width: 140px; +} + +.form-input { + flex: 1; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font: inherit; + font-size: 13px; + padding: 8px 12px; + outline: none; + transition: all var(--transition); +} + +.form-input:focus { + border-color: var(--text-secondary); + outline: 1px solid var(--text-secondary); +} + +.form-input::placeholder { + color: var(--text-subtle); +} + +select.form-input { + cursor: pointer; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-surface); + color: var(--text-primary); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.btn:hover { + background: var(--bg-overlay); + border-color: var(--text-secondary); +} + +.btn-primary { + background: var(--text-primary); + border-color: var(--text-primary); + color: var(--bg-base); +} + +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.btn-danger { + color: var(--error); + border-color: var(--border); +} + +.btn-danger:hover { + background: rgba(242, 139, 130, 0.1); + border-color: var(--error); +} + +/* File picker row */ +.file-row { + display: flex; + align-items: center; + gap: 12px; +} + +.file-name { + flex: 1; + font-size: 13px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-name.has-file { + color: var(--text-primary); +} + +/* Checkbox row */ +.check-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.check-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; +} + +.check-label input[type="checkbox"] { + accent-color: var(--text-primary); +} + +/* Action bar */ +.action-bar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 20px; +} + +.action-bar .spacer { + flex: 1; +} + +/* Page header */ +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.page-header h2 { + font-size: 18px; + font-weight: 500; +} + +/* Board preview canvas */ +.board-canvas-wrap { + background: var(--bg-base); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 0; + text-align: center; +} + +.board-canvas-wrap canvas { + max-width: 100%; + border-radius: var(--radius); +} + +/* Side buttons */ +.side-tabs { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.side-tab { + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-surface); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); +} + +.side-tab.active { + background: var(--text-primary); + border-color: var(--text-primary); + color: var(--bg-base); +} + +/* Cutout list */ +.cutout-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; +} + +.cutout-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-base); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + font-size: 12px; + font-family: var(--font-mono); +} + +.cutout-item .cutout-text { + flex: 1; + color: var(--text-secondary); +} + +/* Result page */ +.result-files { + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + background: var(--bg-base); + border: 1px solid var(--border-light); + padding: 16px; + border-radius: var(--radius-sm); + margin-bottom: 20px; +} + +/* Loading spinner */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--text-primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(19, 19, 20, 0.8); + z-index: 100; + flex-direction: column; + gap: 16px; +} + +.loading-overlay .spinner { + width: 32px; + height: 32px; + border-width: 3px; +} + +.loading-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +/* ===== THE FORMER ===== */ +.former-container { + display: flex; + width: 100%; + height: 100%; +} + +.former-canvas-wrap { + flex: 1; + background: #000000; + position: relative; + overflow: hidden; +} + +.former-canvas-wrap canvas { + position: absolute; + top: 0; + left: 0; +} + +.former-sidebar { + width: 280px; + background: var(--bg-base); + border-left: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow-y: auto; + flex-shrink: 0; +} + +.former-sidebar-header { + padding: 16px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + gap: 8px; +} + +.former-sidebar-header h3 { + flex: 1; + font-size: 14px; + font-weight: 500; +} + +.former-layers { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.former-layer-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--radius-sm); + transition: background var(--transition); +} + +.former-layer-row:hover { + background: var(--bg-surface); +} + +.former-layer-row.highlighted { + background: var(--bg-overlay); +} + +.former-layer-row.selected { + background: var(--bg-surface); + border-left: 2px solid var(--text-primary); + padding-left: 8px; +} + +.layer-vis-btn, +.layer-hl-btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-subtle); + cursor: pointer; + font-size: 14px; + transition: all var(--transition); +} + +.layer-vis-btn:hover, +.layer-hl-btn:hover { + background: var(--bg-overlay); + color: var(--text-primary); +} + +.layer-vis-btn.active { + color: var(--text-primary); +} + +.layer-hl-btn.active { + color: var(--warning); +} + +.layer-swatch { + width: 12px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; +} + +.layer-name { + font-size: 13px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.layer-name.dimmed { + color: var(--text-subtle); +} + +.former-actions { + padding: 16px; + border-top: 1px solid var(--border-light); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-light); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border); +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 40px 0; + color: var(--text-subtle); + font-size: 14px; +} + +/* Preset buttons */ +.preset-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..78e7e46 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + outDir: 'dist', + }, +}); diff --git a/gbrjob.go b/gbrjob.go new file mode 100644 index 0000000..7a8a229 --- /dev/null +++ b/gbrjob.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// GerberJob represents a KiCad .gbrjob file +type GerberJob struct { + Header struct { + GenerationSoftware struct { + Vendor string `json:"Vendor"` + Application string `json:"Application"` + Version string `json:"Version"` + } `json:"GenerationSoftware"` + } `json:"Header"` + GeneralSpecs struct { + ProjectId struct { + Name string `json:"Name"` + } `json:"ProjectId"` + Size struct { + X float64 `json:"X"` + Y float64 `json:"Y"` + } `json:"Size"` + BoardThickness float64 `json:"BoardThickness"` + } `json:"GeneralSpecs"` + FilesAttributes []struct { + Path string `json:"Path"` + FileFunction string `json:"FileFunction"` + FilePolarity string `json:"FilePolarity"` + } `json:"FilesAttributes"` +} + +// GerberJobResult contains the auto-discovered file assignments +type GerberJobResult struct { + ProjectName string + BoardWidth float64 // mm + BoardHeight float64 // mm + BoardThickness float64 // mm + EdgeCutsFile string // Profile + FabFile string // AssemblyDrawing,Top + CourtyardFile string // matches courtyard naming + SoldermaskFile string // matches mask naming +} + +// ParseGerberJob parses a .gbrjob file and returns auto-discovered file mappings +func ParseGerberJob(filename string) (*GerberJobResult, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read gbrjob: %w", err) + } + + var job GerberJob + if err := json.Unmarshal(data, &job); err != nil { + return nil, fmt.Errorf("parse gbrjob JSON: %w", err) + } + + result := &GerberJobResult{ + ProjectName: job.GeneralSpecs.ProjectId.Name, + BoardWidth: job.GeneralSpecs.Size.X, + BoardHeight: job.GeneralSpecs.Size.Y, + BoardThickness: job.GeneralSpecs.BoardThickness, + } + + // Map FileFunction to our layer types + for _, f := range job.FilesAttributes { + fn := strings.ToLower(f.FileFunction) + path := f.Path + + switch { + case fn == "profile": + result.EdgeCutsFile = path + case strings.HasPrefix(fn, "assemblydrawing"): + // F.Fab = AssemblyDrawing,Top + if strings.Contains(fn, "top") { + result.FabFile = path + } + } + + // Also match by filename patterns for courtyard/mask + lp := strings.ToLower(path) + switch { + case strings.Contains(lp, "courtyard") || strings.Contains(lp, "courty"): + if strings.Contains(lp, "f_courty") || strings.Contains(lp, "f.courty") || strings.Contains(lp, "-f_courty") { + result.CourtyardFile = path + } + case strings.Contains(lp, "mask") || strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask"): + if strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask") || strings.Contains(lp, "-f_mask") { + result.SoldermaskFile = path + } + } + } + + fmt.Printf("GerberJob: project=%s, board=%.1fx%.1fmm, thickness=%.1fmm\n", + result.ProjectName, result.BoardWidth, result.BoardHeight, result.BoardThickness) + fmt.Printf(" EdgeCuts: %s\n", result.EdgeCutsFile) + fmt.Printf(" F.Fab: %s\n", result.FabFile) + fmt.Printf(" F.Courtyard: %s\n", result.CourtyardFile) + fmt.Printf(" F.Mask: %s\n", result.SoldermaskFile) + + if result.EdgeCutsFile == "" { + return nil, fmt.Errorf("no Edge.Cuts (Profile) layer found in gbrjob") + } + + return result, nil +} diff --git a/gerber.go b/gerber.go new file mode 100644 index 0000000..4029d8f --- /dev/null +++ b/gerber.go @@ -0,0 +1,1004 @@ +package main + +import ( + "bufio" + "image" + "image/color" + "image/draw" + "math" + "os" + "regexp" + "strconv" + "strings" +) + +// Aperture types +const ( + ApertureCircle = "C" + ApertureRect = "R" + ApertureObround = "O" + AperturePolygon = "P" +) + +type Aperture struct { + Type string + Modifiers []float64 +} + +type MacroPrimitive struct { + Code int + Modifiers []string // Store as strings to handle $1, $2, expressions like $1+$1 +} + +type Macro struct { + Name string + Primitives []MacroPrimitive +} + +type GerberState struct { + Apertures map[int]Aperture + Macros map[string]Macro + CurrentAperture int + X, Y float64 // Current coordinates in mm + FormatX, FormatY struct { + Integer, Decimal int + } + Units string // "MM" or "IN" + CurrentFootprint string // Stored from %TO.C,Footprint,...*% +} + +type GerberCommand struct { + Type string // "D01", "D02", "D03", "AD", "FS", etc. + X, Y *float64 + I, J *float64 + D *int + Footprint string +} + +type GerberFile struct { + Commands []GerberCommand + State GerberState +} + +// Footprint represents a component bounding area deduced from Gerber X2 attributes +type Footprint struct { + Name string `json:"name"` + MinX float64 `json:"minX"` + MinY float64 `json:"minY"` + MaxX float64 `json:"maxX"` + MaxY float64 `json:"maxY"` + CenterX float64 `json:"centerX"` + CenterY float64 `json:"centerY"` +} + +func ExtractFootprints(gf *GerberFile) []Footprint { + fps := make(map[string]*Footprint) + + for _, cmd := range gf.Commands { + if cmd.Footprint == "" { + continue + } + if cmd.X == nil || cmd.Y == nil { + continue + } + + fp, exists := fps[cmd.Footprint] + if !exists { + fp = &Footprint{ + Name: cmd.Footprint, + MinX: *cmd.X, + MaxX: *cmd.X, + MinY: *cmd.Y, + MaxY: *cmd.Y, + } + fps[cmd.Footprint] = fp + } else { + if *cmd.X < fp.MinX { + fp.MinX = *cmd.X + } + if *cmd.X > fp.MaxX { + fp.MaxX = *cmd.X + } + if *cmd.Y < fp.MinY { + fp.MinY = *cmd.Y + } + if *cmd.Y > fp.MaxY { + fp.MaxY = *cmd.Y + } + } + } + + var result []Footprint + for _, fp := range fps { + fp.CenterX = (fp.MinX + fp.MaxX) / 2.0 + fp.CenterY = (fp.MinY + fp.MaxY) / 2.0 + result = append(result, *fp) + } + return result +} + +func NewGerberFile() *GerberFile { + return &GerberFile{ + State: GerberState{ + Apertures: make(map[int]Aperture), + Macros: make(map[string]Macro), + Units: "MM", // Default, usually set by MO + }, + } +} + +// ParseGerber parses a simple RS-274X file +func ParseGerber(filename string) (*GerberFile, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + gf := NewGerberFile() + scanner := bufio.NewScanner(file) + + // Regex for coordinates: X123Y456D01 + reCoord := regexp.MustCompile(`([XYDIJ])([\d\.\-]+)`) + // Regex for Aperture Definition: %ADD10C,0.5*% or %ADD10RoundRect,0.25X-0.75X...*% + reAD := regexp.MustCompile(`%ADD(\d+)([A-Za-z0-9_]+),?([\d\.\-X]+)?\*%`) + // Regex for Format Spec: %FSLAX24Y24*% + reFS := regexp.MustCompile(`%FSLAX(\d)(\d)Y(\d)(\d)\*%`) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Handle Parameters + if strings.HasPrefix(line, "%") { + if strings.HasPrefix(line, "%FS") { + matches := reFS.FindStringSubmatch(line) + if len(matches) == 5 { + gf.State.FormatX.Integer, _ = strconv.Atoi(matches[1]) + gf.State.FormatX.Decimal, _ = strconv.Atoi(matches[2]) + gf.State.FormatY.Integer, _ = strconv.Atoi(matches[3]) + gf.State.FormatY.Decimal, _ = strconv.Atoi(matches[4]) + } + } else if strings.HasPrefix(line, "%AD") { + matches := reAD.FindStringSubmatch(line) + if len(matches) >= 3 { + dCode, _ := strconv.Atoi(matches[1]) + apType := matches[2] + var mods []float64 + if len(matches) > 3 && matches[3] != "" { + parts := strings.Split(matches[3], "X") + for _, p := range parts { + val, _ := strconv.ParseFloat(p, 64) + mods = append(mods, val) + } + } + gf.State.Apertures[dCode] = Aperture{Type: apType, Modifiers: mods} + } + } else if strings.HasPrefix(line, "%AM") { + // Parse Macro + name := strings.TrimPrefix(line, "%AM") + name = strings.TrimSuffix(name, "*") + + var primitives []MacroPrimitive + + for scanner.Scan() { + mLine := strings.TrimSpace(scanner.Text()) + // Skip comment lines (start with "0 ") + if strings.HasPrefix(mLine, "0 ") { + continue + } + // Skip empty lines + if mLine == "" { + continue + } + + // Check if this line ends the macro definition + // Standard allows ending with *% at end of last primitive OR a separate line with % + trimmedLine := strings.TrimSpace(mLine) + if trimmedLine == "%" { + break + } + + endsWithPercent := strings.HasSuffix(mLine, "*%") + + // Remove trailing *% or just * + mLine = strings.TrimSuffix(mLine, "*%") + mLine = strings.TrimSuffix(mLine, "*") + + // Parse primitive + parts := strings.Split(mLine, ",") + if len(parts) > 0 && parts[0] != "" { + code, err := strconv.Atoi(parts[0]) + if err == nil && code > 0 { + // Store modifiers as strings to handle $1, $2, expressions + var mods []string + for _, p := range parts[1:] { + mods = append(mods, strings.TrimSpace(p)) + } + primitives = append(primitives, MacroPrimitive{Code: code, Modifiers: mods}) + } + } + + // If line ended with *%, macro definition is complete + if endsWithPercent { + break + } + } + gf.State.Macros[name] = Macro{Name: name, Primitives: primitives} + } else if strings.HasPrefix(line, "%MO") { + if strings.Contains(line, "IN") { + gf.State.Units = "IN" + } else { + gf.State.Units = "MM" + } + } else if strings.HasPrefix(line, "%TO") { + parts := strings.Split(line, ",") + if len(parts) >= 2 && strings.HasPrefix(parts[0], "%TO.C") { + refDes := strings.TrimSuffix(parts[1], "*%") + if refDes != "" { + gf.State.CurrentFootprint = refDes + } + } + } else if strings.HasPrefix(line, "%TD") { + if strings.Contains(line, "%TD*%") || strings.Contains(line, "%TD,C*%") || strings.Contains(line, "%TD,Footprint*%") { + gf.State.CurrentFootprint = "" + } + } + continue + } + + // Handle Standard Commands + // Split by * + parts := strings.Split(line, "*") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Check for G-codes + if strings.HasPrefix(part, "G") { + if part == "G01" { + // Linear interpolation (default) + gf.Commands = append(gf.Commands, GerberCommand{Type: "G01"}) + } else if part == "G02" { + // Clockwise circular interpolation + gf.Commands = append(gf.Commands, GerberCommand{Type: "G02"}) + } else if part == "G03" { + // Counter-clockwise circular interpolation + gf.Commands = append(gf.Commands, GerberCommand{Type: "G03"}) + } else if part == "G36" { + // Region fill start + gf.Commands = append(gf.Commands, GerberCommand{Type: "G36"}) + } else if part == "G37" { + // Region fill end + gf.Commands = append(gf.Commands, GerberCommand{Type: "G37"}) + } + continue + } + + // Handle Aperture Selection (e.g., D10*) + if strings.HasPrefix(part, "D") && len(part) >= 2 { + // Likely D10, D11 etc. + dCode, err := strconv.Atoi(part[1:]) + if err == nil && dCode >= 10 { + gf.Commands = append(gf.Commands, GerberCommand{Type: "APERTURE", D: &dCode}) + continue + } + } + + // Handle Coordinates and Draw/Flash commands + // X...Y...D01* + matches := reCoord.FindAllStringSubmatch(part, -1) + if len(matches) > 0 { + cmd := GerberCommand{Type: "MOVE", Footprint: gf.State.CurrentFootprint} + for _, m := range matches { + valStr := m[2] + + switch m[1] { + case "X": + v := gf.parseCoordinate(valStr, gf.State.FormatX) + cmd.X = &v + case "Y": + v := gf.parseCoordinate(valStr, gf.State.FormatY) + cmd.Y = &v + case "I": + v := gf.parseCoordinate(valStr, gf.State.FormatX) + cmd.I = &v + case "J": + v := gf.parseCoordinate(valStr, gf.State.FormatY) + cmd.J = &v + case "D": + val, _ := strconv.ParseFloat(valStr, 64) + d := int(val) + cmd.D = &d + if d == 1 { + cmd.Type = "DRAW" + } else if d == 2 { + cmd.Type = "MOVE" + } else if d == 3 { + cmd.Type = "FLASH" + } + } + } + gf.Commands = append(gf.Commands, cmd) + } + } + } + + return gf, nil +} + +func (gf *GerberFile) parseCoordinate(valStr string, fmtSpec struct{ Integer, Decimal int }) float64 { + if strings.Contains(valStr, ".") { + val, _ := strconv.ParseFloat(valStr, 64) + return val + } + val, _ := strconv.ParseFloat(valStr, 64) + divisor := math.Pow(10, float64(fmtSpec.Decimal)) + return val / divisor +} + +// evaluateMacroExpression evaluates a macro expression like "$1", "$1+$1", "0.5", etc. +// with variable substitution from aperture modifiers +func evaluateMacroExpression(expr string, params []float64) float64 { + expr = strings.TrimSpace(expr) + + // Handle simple addition (e.g., "$1+$1") + if strings.Contains(expr, "+") { + parts := strings.Split(expr, "+") + result := 0.0 + for _, part := range parts { + result += evaluateMacroExpression(strings.TrimSpace(part), params) + } + return result + } + + // Handle simple subtraction (e.g., "$1-$2") + if strings.Contains(expr, "-") && !strings.HasPrefix(expr, "-") { + parts := strings.Split(expr, "-") + if len(parts) == 2 { + left := evaluateMacroExpression(strings.TrimSpace(parts[0]), params) + right := evaluateMacroExpression(strings.TrimSpace(parts[1]), params) + return left - right + } + } + + // Handle variable substitution (e.g., "$1", "$2") + if strings.HasPrefix(expr, "$") { + varNum, err := strconv.Atoi(expr[1:]) + if err == nil && varNum > 0 && varNum <= len(params) { + return params[varNum-1] + } + return 0.0 + } + + // Handle literal numbers + val, _ := strconv.ParseFloat(expr, 64) + return val +} + +// rotatePoint rotates a point (x, y) around the origin by angleDegrees +func rotatePoint(x, y, angleDegrees float64) (float64, float64) { + if angleDegrees == 0 { + return x, y + } + angleRad := angleDegrees * math.Pi / 180.0 + cosA := math.Cos(angleRad) + sinA := math.Sin(angleRad) + return x*cosA - y*sinA, x*sinA + y*cosA +} + +type Bounds struct { + MinX, MinY, MaxX, MaxY float64 +} + +func (gf *GerberFile) CalculateBounds() Bounds { + minX, minY := 1e9, 1e9 + maxX, maxY := -1e9, -1e9 + + updateBounds := func(x, y float64) { + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x > maxX { + maxX = x + } + if y > maxY { + maxY = y + } + } + + curX, curY := 0.0, 0.0 + for _, cmd := range gf.Commands { + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + if cmd.Type == "FLASH" { + updateBounds(curX, curY) + } else if cmd.Type == "DRAW" { + updateBounds(prevX, prevY) + updateBounds(curX, curY) + } + } + + if minX == 1e9 { + // No drawing commands found, default to 0,0 + minX, minY = 0, 0 + maxX, maxY = 10, 10 // Arbitrary small size + } + + // Add some padding + padding := 2.0 // mm + minX -= padding + minY -= padding + maxX += padding + maxY += padding + + return Bounds{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY} +} + +// Render generates an image from the parsed Gerber commands +func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image { + var b Bounds + if bounds != nil { + b = *bounds + } else { + b = gf.CalculateBounds() + } + + widthMM := b.MaxX - b.MinX + heightMM := b.MaxY - b.MinY + + var scale float64 + if gf.State.Units == "IN" { + scale = dpi + } else { + scale = dpi / 25.4 + } + + imgWidth := int(widthMM * scale) + imgHeight := int(heightMM * scale) + + img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) + + // Fill black (stencil material) + draw.Draw(img, img.Bounds(), &image.Uniform{color.Black}, image.Point{}, draw.Src) + + // White for holes + white := &image.Uniform{color.White} + + // Helper to convert mm to pixels + toPix := func(x, y float64) (int, int) { + px := int((x - b.MinX) * scale) + py := int((heightMM - (y - b.MinY)) * scale) // Flip Y for image coords + return px, py + } + + curX, curY := 0.0, 0.0 + curDCode := 0 + interpolationMode := "G01" // Default linear + inRegion := false + var regionVertices [][2]int + + for _, cmd := range gf.Commands { + if cmd.Type == "APERTURE" { + curDCode = *cmd.D + continue + } + if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { + interpolationMode = cmd.Type + continue + } + if cmd.Type == "G36" { + inRegion = true + regionVertices = nil + continue + } + if cmd.Type == "G37" { + // End region: fill the collected polygon + if len(regionVertices) >= 3 { + drawFilledPolygon(img, regionVertices) + } + inRegion = false + regionVertices = nil + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + // In region mode, collect contour vertices instead of drawing + if inRegion { + if cmd.Type == "MOVE" { + // D02 in region: start a new contour + px, py := toPix(curX, curY) + regionVertices = append(regionVertices, [2]int{px, py}) + } else if cmd.Type == "DRAW" { + if interpolationMode == "G01" { + // Linear segment: add endpoint + px, py := toPix(curX, curY) + regionVertices = append(regionVertices, [2]int{px, py}) + } else { + // Arc segment: sample points along the arc + iVal := 0.0 + jVal := 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + centerX := prevX + iVal + centerY := prevY + jVal + radius := math.Sqrt(iVal*iVal + jVal*jVal) + startAngle := math.Atan2(prevY-centerY, prevX-centerX) + endAngle := math.Atan2(curY-centerY, curX-centerX) + if interpolationMode == "G03" { + if endAngle <= startAngle { + endAngle += 2 * math.Pi + } + } else { + if startAngle <= endAngle { + startAngle += 2 * math.Pi + } + } + arcLen := math.Abs(endAngle-startAngle) * radius + steps := int(arcLen * scale * 2) + if steps < 10 { + steps = 10 + } + for s := 1; s <= steps; s++ { + t := float64(s) / float64(steps) + angle := startAngle + t*(endAngle-startAngle) + ax := centerX + radius*math.Cos(angle) + ay := centerY + radius*math.Sin(angle) + px, py := toPix(ax, ay) + regionVertices = append(regionVertices, [2]int{px, py}) + } + } + } + continue + } + + if cmd.Type == "FLASH" { + // Draw Aperture at curX, curY + ap, ok := gf.State.Apertures[curDCode] + if ok { + cx, cy := toPix(curX, curY) + gf.drawAperture(img, cx, cy, ap, scale, white) + } + } else if cmd.Type == "DRAW" { + ap, ok := gf.State.Apertures[curDCode] + if ok { + if interpolationMode == "G01" { + // Linear + x1, y1 := toPix(prevX, prevY) + x2, y2 := toPix(curX, curY) + gf.drawLine(img, x1, y1, x2, y2, ap, scale, white) + } else { + // Circular Interpolation (G02/G03) + iVal := 0.0 + jVal := 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + + centerX := prevX + iVal + centerY := prevY + jVal + + radius := math.Sqrt(iVal*iVal + jVal*jVal) + startAngle := math.Atan2(prevY-centerY, prevX-centerX) + endAngle := math.Atan2(curY-centerY, curX-centerX) + + // Adjust angles for G02 (CW) vs G03 (CCW) + if interpolationMode == "G03" { // CCW + if endAngle <= startAngle { + endAngle += 2 * math.Pi + } + } else { // G02 CW + if startAngle <= endAngle { + startAngle += 2 * math.Pi + } + } + + // Arc length approximation + arcLen := math.Abs(endAngle-startAngle) * radius + steps := int(arcLen * scale * 2) // 2x pixel density for smoothness + if steps < 10 { + steps = 10 + } + + for s := 0; s <= steps; s++ { + t := float64(s) / float64(steps) + angle := startAngle + t*(endAngle-startAngle) + px := centerX + radius*math.Cos(angle) + py := centerY + radius*math.Sin(angle) + + ix, iy := toPix(px, py) + gf.drawAperture(img, ix, iy, ap, scale, white) + } + } + } + } + } + + return img +} + +func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale float64, c image.Image) { + switch ap.Type { + case ApertureCircle: // C + // Modifiers[0] is diameter + if len(ap.Modifiers) > 0 { + radius := int((ap.Modifiers[0] * scale) / 2) + drawCircle(img, x, y, radius) + } + return + case ApertureRect: // R + // Modifiers[0] is width, [1] is height + if len(ap.Modifiers) >= 2 { + w := int(ap.Modifiers[0] * scale) + h := int(ap.Modifiers[1] * scale) + r := image.Rect(x-w/2, y-h/2, x+w/2, y+h/2) + draw.Draw(img, r, c, image.Point{}, draw.Src) + } + return + case ApertureObround: // O + // Modifiers[0] is width, [1] is height + if len(ap.Modifiers) >= 2 { + w := int(ap.Modifiers[0] * scale) + h := int(ap.Modifiers[1] * scale) + // Draw Obround + // If w > h: Horizontal Pill. Central Rect is (w-h) x h. Two circles of dia h. + // If h > w: Vertical Pill. Central Rect is w x (h-w). Two circles of dia w. + // If w == h: Circle dia w. + + drawObround := func(target *image.RGBA, x, y, w, h int, color image.Image) { + if w == h { + radius := w / 2 + drawCircle(target, x, y, radius) + return + } + + if w > h { + // Horizontal + rectW := w - h + if rectW < 0 { + rectW = 0 + } // Should be impossible if w > h + + // Center Rect + r := image.Rect(x-rectW/2, y-h/2, x+rectW/2, y+h/2) + draw.Draw(target, r, color, image.Point{}, draw.Src) + + // Left Circle + drawCircle(target, x-rectW/2, y, h/2) + // Right Circle + drawCircle(target, x+rectW/2, y, h/2) + } else { + // Vertical + rectH := h - w + if rectH < 0 { + rectH = 0 + } + + // Center Rect + r := image.Rect(x-w/2, y-rectH/2, x+w/2, y+rectH/2) + draw.Draw(target, r, color, image.Point{}, draw.Src) + + // Top Circle (Y decreases upwards in image coords usually, but here we treat y as center) + // Note: In our coordinate system, y is center. + drawCircle(target, x, y-rectH/2, w/2) + // Bottom Circle + drawCircle(target, x, y+rectH/2, w/2) + } + } + drawObround(img, x, y, w, h, c) + } + return + case AperturePolygon: // P + // Modifiers: [0] diameter, [1] vertices, [2] rotation (optional) + if len(ap.Modifiers) >= 2 { + diameter := ap.Modifiers[0] + numVertices := int(ap.Modifiers[1]) + rotation := 0.0 + if len(ap.Modifiers) >= 3 { + rotation = ap.Modifiers[2] + } + + if numVertices >= 3 { + radius := (diameter * scale) / 2 + vertices := make([][2]int, numVertices) + for i := 0; i < numVertices; i++ { + angleDeg := rotation + float64(i)*360.0/float64(numVertices) + angleRad := angleDeg * math.Pi / 180.0 + px := int(radius * math.Cos(angleRad)) + py := int(radius * math.Sin(angleRad)) + vertices[i] = [2]int{x + px, y - py} + } + drawFilledPolygon(img, vertices) + } + } + return + } + + // Check for Macros + if macro, ok := gf.State.Macros[ap.Type]; ok { + for _, prim := range macro.Primitives { + switch prim.Code { + case 1: // Circle + // Mods: Exposure, Diameter, CenterX, CenterY + if len(prim.Modifiers) >= 4 { + exposure := evaluateMacroExpression(prim.Modifiers[0], ap.Modifiers) + if exposure == 0 { + break + } + dia := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + + px := int(cx * scale) + py := int(cy * scale) + + radius := int((dia * scale) / 2) + drawCircle(img, x+px, y-py, radius) + } + case 4: // Outline (Polygon) + // Mods: Exposure, NumVertices, X1, Y1, ..., Xn, Yn, Rotation + if len(prim.Modifiers) >= 3 { + exposure := evaluateMacroExpression(prim.Modifiers[0], ap.Modifiers) + if exposure == 0 { + // Skip if exposure is off + break + } + numVertices := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)) + // Need at least numVertices * 2 coordinates + rotation + if len(prim.Modifiers) >= 2+numVertices*2+1 { + rotation := evaluateMacroExpression(prim.Modifiers[2+numVertices*2], ap.Modifiers) + + // Extract vertices + vertices := make([][2]int, numVertices) + for i := 0; i < numVertices; i++ { + vx := evaluateMacroExpression(prim.Modifiers[2+i*2], ap.Modifiers) + vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], ap.Modifiers) + + // Apply rotation + vx, vy = rotatePoint(vx, vy, rotation) + + px := int(vx * scale) + py := int(vy * scale) + + vertices[i] = [2]int{x + px, y - py} + } + + // Draw filled polygon using scanline algorithm + drawFilledPolygon(img, vertices) + } + } + case 5: // Regular Polygon + // Mods: Exposure, NumVertices, CenterX, CenterY, Diameter, Rotation + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], ap.Modifiers) + if exposure == 0 { + break + } + numVertices := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)) + if numVertices >= 3 { + cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + diameter := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + rotation := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers) + + radius := (diameter * scale) / 2 + pxCenter := int(cx * scale) + pyCenter := int(cy * scale) + + vertices := make([][2]int, numVertices) + for i := 0; i < numVertices; i++ { + angleDeg := rotation + float64(i)*360.0/float64(numVertices) + angleRad := angleDeg * math.Pi / 180.0 + px := int(radius * math.Cos(angleRad)) + py := int(radius * math.Sin(angleRad)) + vertices[i] = [2]int{x + pxCenter + px, y - pyCenter - py} + } + drawFilledPolygon(img, vertices) + } + } + case 20: // Vector Line + // Mods: Exposure, Width, StartX, StartY, EndX, EndY, Rotation + if len(prim.Modifiers) >= 7 { + exposure := evaluateMacroExpression(prim.Modifiers[0], ap.Modifiers) + if exposure == 0 { + // Skip if exposure is off + break + } + width := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + startX := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + startY := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + endX := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + endY := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers) + rotation := evaluateMacroExpression(prim.Modifiers[6], ap.Modifiers) + + // Apply rotation to start and end points + startX, startY = rotatePoint(startX, startY, rotation) + endX, endY = rotatePoint(endX, endY, rotation) + + // Calculate the rectangle representing the line + // The line goes from (startX, startY) to (endX, endY) with width + dx := endX - startX + dy := endY - startY + length := math.Sqrt(dx*dx + dy*dy) + + if length > 0 { + // Perpendicular vector for width + perpX := -dy / length * width / 2 + perpY := dx / length * width / 2 + + // Four corners of the rectangle + vertices := [][2]int{ + {x + int((startX-perpX)*scale), y - int((startY-perpY)*scale)}, + {x + int((startX+perpX)*scale), y - int((startY+perpY)*scale)}, + {x + int((endX+perpX)*scale), y - int((endY+perpY)*scale)}, + {x + int((endX-perpX)*scale), y - int((endY-perpY)*scale)}, + } + + drawFilledPolygon(img, vertices) + } + } + case 21: // Center Line (Rect) + // Mods: Exposure, Width, Height, CenterX, CenterY, Rotation + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], ap.Modifiers) + if exposure == 0 { + break + } + width := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + height := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cx := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + rot := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers) + + // Calculate the four corners of the rectangle (centered at origin) + halfW := width / 2 + halfH := height / 2 + + // Four corners before rotation + corners := [][2]float64{ + {-halfW, -halfH}, + {halfW, -halfH}, + {halfW, halfH}, + {-halfW, halfH}, + } + + // Apply rotation and translation + vertices := make([][2]int, 4) + for i, corner := range corners { + // Rotate around origin + rx, ry := rotatePoint(corner[0], corner[1], rot) + // Translate to center position + rx += cx + ry += cy + // Convert to pixels + px := int(rx * scale) + py := int(ry * scale) + vertices[i] = [2]int{x + px, y - py} + } + + // Draw as polygon + drawFilledPolygon(img, vertices) + } + } + } + } +} + +func drawCircle(img *image.RGBA, x0, y0, r int) { + // Simple Bresenham or scanline + for y := -r; y <= r; y++ { + for x := -r; x <= r; x++ { + if x*x+y*y <= r*r { + img.Set(x0+x, y0+y, color.White) + } + } + } +} + +func drawFilledPolygon(img *image.RGBA, vertices [][2]int) { + if len(vertices) < 3 { + return + } + + // Find bounding box + minY, maxY := vertices[0][1], vertices[0][1] + for _, v := range vertices { + if v[1] < minY { + minY = v[1] + } + if v[1] > maxY { + maxY = v[1] + } + } + + // Scanline fill algorithm + for y := minY; y <= maxY; y++ { + // Find intersections with polygon edges + var intersections []int + + for i := 0; i < len(vertices); i++ { + j := (i + 1) % len(vertices) + y1, y2 := vertices[i][1], vertices[j][1] + x1, x2 := vertices[i][0], vertices[j][0] + + // Check if scanline intersects this edge + if (y1 <= y && y < y2) || (y2 <= y && y < y1) { + // Calculate x intersection + x := x1 + (y-y1)*(x2-x1)/(y2-y1) + intersections = append(intersections, x) + } + } + + // Sort intersections + for i := 0; i < len(intersections)-1; i++ { + for j := i + 1; j < len(intersections); j++ { + if intersections[i] > intersections[j] { + intersections[i], intersections[j] = intersections[j], intersections[i] + } + } + } + + // Fill between pairs of intersections + for i := 0; i < len(intersections)-1; i += 2 { + x1 := intersections[i] + x2 := intersections[i+1] + for x := x1; x <= x2; x++ { + if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y { + img.Set(x, y, color.White) + } + } + } + } +} + +func (gf *GerberFile) drawLine(img *image.RGBA, x1, y1, x2, y2 int, ap Aperture, scale float64, c image.Image) { + // Bresenham's line algorithm, but we need to stroke it with the aperture. + // For simplicity, if aperture is Circle, we draw a circle at each step (inefficient but works). + // If aperture is Rect, we draw rect at each step. + + // Optimized: Just draw a thick line if it's a circle aperture + + dx := float64(x2 - x1) + dy := float64(y2 - y1) + dist := math.Sqrt(dx*dx + dy*dy) + steps := int(dist) // 1 pixel steps + + if steps == 0 { + gf.drawAperture(img, x1, y1, ap, scale, c) + return + } + + for i := 0; i <= steps; i++ { + t := float64(i) / float64(steps) + x := int(float64(x1) + t*dx) + y := int(float64(y1) + t*dy) + gf.drawAperture(img, x, y, ap, scale, c) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..70ffe04 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module pcb-to-stencil + +go 1.23.0 + +require github.com/wailsapp/wails/v2 v2.11.0 + +require ( + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..11f7d59 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= +github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/instance.go b/instance.go new file mode 100644 index 0000000..e449305 --- /dev/null +++ b/instance.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "image" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +// InstanceData holds the serializable state for a saved enclosure instance. +type InstanceData struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + CreatedAt time.Time `json:"createdAt"` + + // Source files (basenames relative to the project directory) + GerberFiles map[string]string `json:"gerberFiles"` + DrillPath string `json:"drillPath,omitempty"` + NPTHPath string `json:"npthPath,omitempty"` + + // Discovered layer filenames (keys into GerberFiles) + EdgeCutsFile string `json:"edgeCutsFile"` + CourtyardFile string `json:"courtyardFile,omitempty"` + SoldermaskFile string `json:"soldermaskFile,omitempty"` + FabFile string `json:"fabFile,omitempty"` + + // Configuration + Config EnclosureConfig `json:"config"` + Exports []string `json:"exports"` + + // Board display info + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` + ProjectName string `json:"projectName,omitempty"` + + // Unified cutouts (new format) + Cutouts []Cutout `json:"cutouts,omitempty"` + + // Legacy cutout fields — kept for backward compatibility when loading old projects + SideCutouts []SideCutout `json:"sideCutouts,omitempty"` + LidCutouts []LidCutout `json:"lidCutouts,omitempty"` +} + +// MigrateCutouts returns the unified cutouts list, converting legacy fields if needed. +func (inst *InstanceData) MigrateCutouts() []Cutout { + if len(inst.Cutouts) > 0 { + return inst.Cutouts + } + // Migrate legacy side cutouts + var result []Cutout + for _, sc := range inst.SideCutouts { + result = append(result, Cutout{ + ID: randomID(), + Surface: "side", + SideNum: sc.Side, + X: sc.X, + Y: sc.Y, + Width: sc.Width, + Height: sc.Height, + CornerRadius: sc.CornerRadius, + SourceLayer: sc.Layer, + }) + } + // Migrate legacy lid cutouts + for _, lc := range inst.LidCutouts { + surface := "top" + if lc.Plane == "tray" { + surface = "bottom" + } + result = append(result, Cutout{ + ID: randomID(), + Surface: surface, + X: lc.MinX, + Y: lc.MinY, + Width: lc.MaxX - lc.MinX, + Height: lc.MaxY - lc.MinY, + IsDado: lc.IsDado, + Depth: lc.Depth, + }) + } + return result +} + +// restoreSessionFromDir rebuilds an EnclosureSession from an InstanceData, +// resolving all file paths relative to baseDir. +func restoreSessionFromDir(inst *InstanceData, baseDir string) (string, *EnclosureSession, error) { + outlineBasename, ok := inst.GerberFiles[inst.EdgeCutsFile] + if !ok { + return "", nil, fmt.Errorf("edge cuts file not found: %s", inst.EdgeCutsFile) + } + outlinePath := filepath.Join(baseDir, outlineBasename) + if _, err := os.Stat(outlinePath); err != nil { + return "", nil, fmt.Errorf("source files no longer available: %v", err) + } + + outlineGf, err := ParseGerber(outlinePath) + if err != nil { + return "", nil, fmt.Errorf("parse outline: %v", err) + } + + outlineBounds := outlineGf.CalculateBounds() + actualBoardW := outlineBounds.MaxX - outlineBounds.MinX + actualBoardH := outlineBounds.MaxY - outlineBounds.MinY + + ecfg := inst.Config + margin := ecfg.WallThickness + ecfg.Clearance + 5.0 + outlineBounds.MinX -= margin + outlineBounds.MinY -= margin + outlineBounds.MaxX += margin + outlineBounds.MaxY += margin + ecfg.OutlineBounds = &outlineBounds + + outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds) + + minBX, maxBX := outlineImg.Bounds().Max.X, -1 + var boardCenterY float64 + var boardCount int + _, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4) + imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if boardMask[y*imgW+x] { + if x < minBX { + minBX = x + } + if x > maxBX { + maxBX = x + } + boardCenterY += float64(y) + boardCount++ + } + } + } + if boardCount > 0 { + boardCenterY /= float64(boardCount) + } + + // Resolve gerber paths relative to baseDir and render ALL layers + resolvedGerbers := make(map[string]string) + allLayers := make(map[string]image.Image) + allGerbers := make(map[string]*GerberFile) + for origName, basename := range inst.GerberFiles { + fullPath := filepath.Join(baseDir, basename) + resolvedGerbers[origName] = fullPath + if strings.HasSuffix(strings.ToLower(origName), ".gbrjob") { + continue + } + if origName == inst.EdgeCutsFile { + allLayers[origName] = outlineImg + allGerbers[origName] = outlineGf + continue + } + if gf, err := ParseGerber(fullPath); err == nil { + allLayers[origName] = gf.Render(ecfg.DPI, &outlineBounds) + allGerbers[origName] = gf + } + } + + var courtyardImg, soldermaskImg image.Image + if inst.CourtyardFile != "" { + courtyardImg = allLayers[inst.CourtyardFile] + } + if inst.SoldermaskFile != "" { + soldermaskImg = allLayers[inst.SoldermaskFile] + } + if courtyardImg == nil && inst.FabFile != "" { + courtyardImg = allLayers[inst.FabFile] + } + + var drillHoles []DrillHole + if inst.DrillPath != "" { + if holes, err := ParseDrill(filepath.Join(baseDir, inst.DrillPath)); err == nil { + drillHoles = append(drillHoles, holes...) + } + } + if inst.NPTHPath != "" { + if holes, err := ParseDrill(filepath.Join(baseDir, inst.NPTHPath)); err == nil { + drillHoles = append(drillHoles, holes...) + } + } + var filteredHoles []DrillHole + for _, h := range drillHoles { + if h.Type != DrillTypeVia { + filteredHoles = append(filteredHoles, h) + } + } + + pixelToMM := 25.4 / ecfg.DPI + sessionID := randomID() + session := &EnclosureSession{ + Exports: inst.Exports, + OutlineGf: outlineGf, + OutlineImg: outlineImg, + CourtyardImg: courtyardImg, + SoldermaskImg: soldermaskImg, + DrillHoles: filteredHoles, + Config: ecfg, + OutlineBounds: outlineBounds, + BoardW: actualBoardW, + BoardH: actualBoardH, + TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0, + MinBX: float64(minBX)*pixelToMM + outlineBounds.MinX, + MaxBX: float64(maxBX)*pixelToMM + outlineBounds.MinX, + BoardCenterY: outlineBounds.MaxY - boardCenterY*pixelToMM, + Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, pixelToMM, &outlineBounds), + GerberFiles: inst.GerberFiles, + DrillPath: inst.DrillPath, + NPTHPath: inst.NPTHPath, + ProjectName: inst.ProjectName, + EdgeCutsFile: inst.EdgeCutsFile, + CourtyardFile: inst.CourtyardFile, + SoldermaskFile: inst.SoldermaskFile, + FabFile: inst.FabFile, + EnclosureWallImg: renderEnclosureWallImage(outlineImg, ecfg), + AllLayerImages: allLayers, + AllLayerGerbers: allGerbers, + SourceDir: baseDir, + } + + log.Printf("Restored session %s from %s (%s)", sessionID, baseDir, inst.ProjectName) + return sessionID, session, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cb4083d --- /dev/null +++ b/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "embed" + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/mac" +) + +//go:embed all:frontend/dist +var assets embed.FS + +//go:embed static/vectors/Former.svg +var formerLogoSVG []byte + +func main() { + // CLI flags + flagHeight := flag.Float64("height", DefaultStencilHeight, "Stencil height in mm") + flagWallHeight := flag.Float64("wall-height", DefaultWallHeight, "Wall height in mm") + flagWallThickness := flag.Float64("wall-thickness", DefaultWallThickness, "Wall thickness in mm") + flagDPI := flag.Float64("dpi", DefaultDPI, "DPI for rendering") + flagKeepPNG := flag.Bool("keep-png", false, "Save intermediate PNG file") + + flag.Parse() + + // If files are passed as arguments, run in CLI mode + if flag.NArg() > 0 { + cfg := Config{ + StencilHeight: *flagHeight, + WallHeight: *flagWallHeight, + WallThickness: *flagWallThickness, + DPI: *flagDPI, + KeepPNG: *flagKeepPNG, + } + runCLI(cfg, flag.Args()) + return + } + + // Ensure working directories exist + os.MkdirAll("temp", 0755) + ensureFormerDirs() + + runGUI() +} + +func runCLI(cfg Config, args []string) { + if len(args) < 1 { + fmt.Println("Usage: former [options] [outline_file]") + fmt.Println(" former (no args = launch GUI)") + flag.PrintDefaults() + os.Exit(1) + } + gerberPath := args[0] + var outlinePath string + if len(args) > 1 { + outlinePath = args[1] + } + _, _, _, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"}) + if err != nil { + log.Fatalf("Error: %v", err) + } + fmt.Println("Success! Happy printing.") +} + +func runGUI() { + imageServer := NewImageServer() + app := NewApp(imageServer) + + err := wails.Run(&options.App{ + Title: "Former", + Width: 960, + Height: 720, + MinWidth: 640, + MinHeight: 480, + AssetServer: &assetserver.Options{ + Assets: assets, + Handler: imageServer, + }, + OnStartup: app.startup, + OnBeforeClose: func(ctx context.Context) (prevent bool) { + // Force-exit after brief grace period to prevent ghost PIDs on macOS + go func() { + time.Sleep(500 * time.Millisecond) + os.Exit(0) + }() + return false + }, + Bind: []interface{}{ + app, + }, + Mac: &mac.Options{ + TitleBar: mac.TitleBarHiddenInset(), + About: &mac.AboutInfo{ + Title: "Former", + Message: "PCB Stencil & Enclosure Generator", + }, + WebviewIsTransparent: true, + WindowIsTranslucent: false, + }, + }) + + if err != nil { + log.Fatal(err) + } +} diff --git a/scad.go b/scad.go new file mode 100644 index 0000000..419afdf --- /dev/null +++ b/scad.go @@ -0,0 +1,1069 @@ +package main + +import ( + "fmt" + "math" + "os" +) + +// snapToLine rounds a dimension to the nearest quarter-multiple of lineWidth. +// If lineWidth is 0, the value is returned unchanged. +func snapToLine(v, lineWidth float64) float64 { + if lineWidth <= 0 { + return v + } + unit := lineWidth / 4.0 + return math.Round(v/unit) * unit +} + +func WriteSCAD(filename string, triangles [][3]Point) error { + // Fallback/legacy mesh WriteSCAD + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + fmt.Fprintf(f, "// Generated by pcb-to-stencil\npolyhedron(\n points=[\n") + for i, t := range triangles { + fmt.Fprintf(f, " [%f, %f, %f], [%f, %f, %f], [%f, %f, %f]", t[0].X, t[0].Y, t[0].Z, t[1].X, t[1].Y, t[1].Z, t[2].X, t[2].Y, t[2].Z) + if i < len(triangles)-1 { + fmt.Fprintf(f, ",\n") + } else { + fmt.Fprintf(f, "\n") + } + } + fmt.Fprintf(f, " ],\n faces=[\n") + for i := 0; i < len(triangles); i++ { + idx := i * 3 + fmt.Fprintf(f, " [%d, %d, %d]", idx, idx+1, idx+2) + if i < len(triangles)-1 { + fmt.Fprintf(f, ",\n") + } else { + fmt.Fprintf(f, "\n") + } + } + fmt.Fprintf(f, " ]\n);\n") + return nil +} + +// approximateArc returns intermediate arc points from (x1,y1) to (x2,y2), +// excluding the start point, including the end point. +func approximateArc(x1, y1, x2, y2, iVal, jVal float64, mode string) [][2]float64 { + centerX := x1 + iVal + centerY := y1 + jVal + radius := math.Sqrt(iVal*iVal + jVal*jVal) + startAngle := math.Atan2(y1-centerY, x1-centerX) + endAngle := math.Atan2(y2-centerY, x2-centerX) + if mode == "G03" { + if endAngle <= startAngle { + endAngle += 2 * math.Pi + } + } else { + if startAngle <= endAngle { + startAngle += 2 * math.Pi + } + } + arcLen := math.Abs(endAngle-startAngle) * radius + steps := int(arcLen * 8) + if steps < 4 { + steps = 4 + } + if steps > 128 { + steps = 128 + } + pts := make([][2]float64, steps) + for s := 0; s < steps; s++ { + t := float64(s+1) / float64(steps) + a := startAngle + t*(endAngle-startAngle) + pts[s] = [2]float64{centerX + radius*math.Cos(a), centerY + radius*math.Sin(a)} + } + return pts +} + +// writeApertureFlash2D writes a 2D aperture shape centered at (x, y) into a SCAD file. +// gf is needed to resolve macro apertures. lw is the nozzle line width for snapping. +func writeApertureFlash2D(f *os.File, gf *GerberFile, ap Aperture, x, y, lw float64, indent string) { + switch ap.Type { + case "C": + if len(ap.Modifiers) > 0 { + r := snapToLine(ap.Modifiers[0]/2, lw) + fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, x, y, r) + } + case "R": + if len(ap.Modifiers) >= 2 { + w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw) + fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n", indent, x, y, w, h) + } + case "O": + if len(ap.Modifiers) >= 2 { + w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw) + r := math.Min(w, h) / 2 + fmt.Fprintf(f, "%stranslate([%f, %f]) hull() {\n", indent, x, y) + if w >= h { + d := (w - h) / 2 + fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, d, r) + fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, -d, r) + } else { + d := (h - w) / 2 + fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, d, r) + fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, -d, r) + } + fmt.Fprintf(f, "%s}\n", indent) + } + case "P": + if len(ap.Modifiers) >= 2 { + dia, numV := ap.Modifiers[0], int(ap.Modifiers[1]) + r := snapToLine(dia/2, lw) + rot := 0.0 + if len(ap.Modifiers) >= 3 { + rot = ap.Modifiers[2] + } + fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n", + indent, x, y, rot, r, numV) + } + default: + // Macro aperture – compute bounding box from primitives and emit a simple square. + if gf == nil { + return + } + macro, ok := gf.State.Macros[ap.Type] + if !ok { + return + } + minX, minY := math.Inf(1), math.Inf(1) + maxX, maxY := math.Inf(-1), math.Inf(-1) + trackPt := func(px, py, radius float64) { + if px-radius < minX { minX = px - radius } + if px+radius > maxX { maxX = px + radius } + if py-radius < minY { minY = py - radius } + if py+radius > maxY { maxY = py + radius } + } + for _, prim := range macro.Primitives { + switch prim.Code { + case 1: // Circle + if len(prim.Modifiers) >= 4 { + dia := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + trackPt(cx, cy, dia/2) + } + case 4: // Outline polygon + if len(prim.Modifiers) >= 3 { + numV := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)) + for i := 0; i < numV && 2+i*2+1 < len(prim.Modifiers); i++ { + vx := evaluateMacroExpression(prim.Modifiers[2+i*2], ap.Modifiers) + vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], ap.Modifiers) + trackPt(vx, vy, 0) + } + } + case 20: // Vector line + if len(prim.Modifiers) >= 7 { + w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + sx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + sy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + ex := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + ey := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers) + trackPt(sx, sy, w/2) + trackPt(ex, ey, w/2) + } + case 21: // Center line rect + if len(prim.Modifiers) >= 6 { + w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + h := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cx := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + trackPt(cx, cy, math.Max(w, h)/2) + } + } + } + if !math.IsInf(minX, 1) { + w := snapToLine(maxX-minX, lw) + h := snapToLine(maxY-minY, lw) + cx := (minX + maxX) / 2 + cy := (minY + maxY) / 2 + fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n", + indent, x+cx, y+cy, w, h) + } + } +} + +// writeMacroPrimitive2D emits a single macro primitive as 2D SCAD geometry. +func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, indent string) { + switch prim.Code { + case 1: // Circle: Exposure, Diameter, CenterX, CenterY + if len(prim.Modifiers) >= 4 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + dia := evaluateMacroExpression(prim.Modifiers[1], params) + cx := evaluateMacroExpression(prim.Modifiers[2], params) + cy := evaluateMacroExpression(prim.Modifiers[3], params) + fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, cx, cy, dia/2) + } + case 4: // Outline (Polygon): Exposure, NumVertices, X1,Y1,...,Xn,Yn, Rotation + if len(prim.Modifiers) >= 3 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + numV := int(evaluateMacroExpression(prim.Modifiers[1], params)) + if len(prim.Modifiers) < 2+numV*2+1 { + return + } + rot := evaluateMacroExpression(prim.Modifiers[2+numV*2], params) + fmt.Fprintf(f, "%srotate([0, 0, %f]) polygon(points=[\n", indent, rot) + for i := 0; i < numV; i++ { + vx := evaluateMacroExpression(prim.Modifiers[2+i*2], params) + vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], params) + comma := "," + if i == numV-1 { + comma = "" + } + fmt.Fprintf(f, "%s [%f, %f]%s\n", indent, vx, vy, comma) + } + fmt.Fprintf(f, "%s]);\n", indent) + } + case 5: // Regular Polygon: Exposure, NumVertices, CenterX, CenterY, Diameter, Rotation + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + numV := int(evaluateMacroExpression(prim.Modifiers[1], params)) + cx := evaluateMacroExpression(prim.Modifiers[2], params) + cy := evaluateMacroExpression(prim.Modifiers[3], params) + dia := evaluateMacroExpression(prim.Modifiers[4], params) + rot := evaluateMacroExpression(prim.Modifiers[5], params) + fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n", + indent, cx, cy, rot, dia/2, numV) + } + case 20: // Vector Line: Exposure, Width, StartX, StartY, EndX, EndY, Rotation + if len(prim.Modifiers) >= 7 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + width := evaluateMacroExpression(prim.Modifiers[1], params) + sx := evaluateMacroExpression(prim.Modifiers[2], params) + sy := evaluateMacroExpression(prim.Modifiers[3], params) + ex := evaluateMacroExpression(prim.Modifiers[4], params) + ey := evaluateMacroExpression(prim.Modifiers[5], params) + rot := evaluateMacroExpression(prim.Modifiers[6], params) + // hull() of two squares at start/end for a rectangle with the given width + fmt.Fprintf(f, "%srotate([0, 0, %f]) hull() {\n", indent, rot) + fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, sx, sy, width) + fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, ex, ey, width) + fmt.Fprintf(f, "%s}\n", indent) + } + case 21: // Center Line (Rect): Exposure, Width, Height, CenterX, CenterY, Rotation + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + w := evaluateMacroExpression(prim.Modifiers[1], params) + h := evaluateMacroExpression(prim.Modifiers[2], params) + cx := evaluateMacroExpression(prim.Modifiers[3], params) + cy := evaluateMacroExpression(prim.Modifiers[4], params) + rot := evaluateMacroExpression(prim.Modifiers[5], params) + fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) square([%f, %f], center=true);\n", + indent, cx, cy, rot, w, h) + } + } +} + +// writeApertureLinearDraw2D writes a 2D stroke between two points using hull() of the aperture. +func writeApertureLinearDraw2D(f *os.File, ap Aperture, x1, y1, x2, y2 float64, indent string) { + switch ap.Type { + case "C": + if len(ap.Modifiers) > 0 { + r := ap.Modifiers[0] / 2 + fmt.Fprintf(f, "%shull() {\n", indent) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r) + fmt.Fprintf(f, "%s}\n", indent) + } + case "R": + if len(ap.Modifiers) >= 2 { + w, h := ap.Modifiers[0], ap.Modifiers[1] + fmt.Fprintf(f, "%shull() {\n", indent) + fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x1, y1, w, h) + fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x2, y2, w, h) + fmt.Fprintf(f, "%s}\n", indent) + } + default: + if len(ap.Modifiers) > 0 { + r := ap.Modifiers[0] / 2 + fmt.Fprintf(f, "%shull() {\n", indent) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r) + fmt.Fprintf(f, "%s}\n", indent) + } + } +} + +// writeGerberShapes2D writes a 2D SCAD union body representing all drawn shapes +// from the Gerber file. Call this inside a union() block. +func writeGerberShapes2D(f *os.File, gf *GerberFile, lw float64, indent string) { + curX, curY := 0.0, 0.0 + curDCode := 0 + interpolationMode := "G01" + inRegion := false + var regionPts [][2]float64 + + for _, cmd := range gf.Commands { + if cmd.Type == "APERTURE" { + if cmd.D != nil { + curDCode = *cmd.D + } + continue + } + if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { + interpolationMode = cmd.Type + continue + } + if cmd.Type == "G36" { + inRegion = true + regionPts = nil + continue + } + if cmd.Type == "G37" { + if len(regionPts) >= 3 { + fmt.Fprintf(f, "%spolygon(points=[\n", indent) + for i, pt := range regionPts { + fmt.Fprintf(f, "%s [%f, %f]", indent, pt[0], pt[1]) + if i < len(regionPts)-1 { + fmt.Fprintf(f, ",") + } + fmt.Fprintf(f, "\n") + } + fmt.Fprintf(f, "%s]);\n", indent) + } + inRegion = false + regionPts = nil + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + if inRegion { + switch cmd.Type { + case "MOVE": + regionPts = append(regionPts, [2]float64{curX, curY}) + case "DRAW": + if interpolationMode == "G01" { + regionPts = append(regionPts, [2]float64{curX, curY}) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode) + regionPts = append(regionPts, arcPts...) + } + } + continue + } + + ap, ok := gf.State.Apertures[curDCode] + if !ok { + continue + } + switch cmd.Type { + case "FLASH": + writeApertureFlash2D(f, gf, ap, curX, curY, lw, indent) + case "DRAW": + if interpolationMode == "G01" { + writeApertureLinearDraw2D(f, ap, prevX, prevY, curX, curY, indent) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode) + all := append([][2]float64{{prevX, prevY}}, arcPts...) + for i := 0; i < len(all)-1; i++ { + writeApertureLinearDraw2D(f, ap, all[i][0], all[i][1], all[i+1][0], all[i+1][1], indent) + } + } + } + } +} + +// WriteStencilSCAD generates native parametric OpenSCAD for a solder paste stencil. +// Instead of a rasterised mesh, it uses CSG primitives (circles, squares, hulls, +// polygons) so the result prints cleanly at any nozzle size. +func WriteStencilSCAD(filename string, gf *GerberFile, outlineGf *GerberFile, cfg Config, bounds *Bounds) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n") + fmt.Fprintf(f, "$fn = 60;\n\n") + lw := cfg.LineWidth + fmt.Fprintf(f, "stencil_height = %f; // mm – solder paste layer thickness\n", snapToLine(cfg.StencilHeight, lw)) + fmt.Fprintf(f, "wall_height = %f; // mm – alignment frame height\n", snapToLine(cfg.WallHeight, lw)) + fmt.Fprintf(f, "wall_thickness = %f; // mm – alignment frame wall thickness\n", snapToLine(cfg.WallThickness, lw)) + if lw > 0 { + fmt.Fprintf(f, "// line_width = %f; // mm – all dimensions snapped to multiples/fractions of this\n", lw) + } + fmt.Fprintf(f, "\n") + + var outlineVerts [][2]float64 + if outlineGf != nil { + outlineVerts = ExtractPolygonFromGerber(outlineGf) + } + + centerX := (bounds.MinX + bounds.MaxX) / 2.0 + centerY := (bounds.MinY + bounds.MaxY) / 2.0 + + // Board outline module (2D) + if len(outlineVerts) > 0 { + fmt.Fprintf(f, "module board_outline() {\n polygon(points=[\n") + for i, v := range outlineVerts { + fmt.Fprintf(f, " [%f, %f]", v[0], v[1]) + if i < len(outlineVerts)-1 { + fmt.Fprintf(f, ",") + } + fmt.Fprintf(f, "\n") + } + fmt.Fprintf(f, " ]);\n}\n\n") + } else { + // Fallback: bounding rectangle + fmt.Fprintf(f, "module board_outline() {\n") + fmt.Fprintf(f, " translate([%f, %f]) square([%f, %f]);\n", + bounds.MinX, bounds.MinY, bounds.MaxX-bounds.MinX, bounds.MaxY-bounds.MinY) + fmt.Fprintf(f, "}\n\n") + } + + // Paste pad openings module (2D union of all aperture shapes) + fmt.Fprintf(f, "module paste_pads() {\n union() {\n") + writeGerberShapes2D(f, gf, cfg.LineWidth, " ") + fmt.Fprintf(f, " }\n}\n\n") + + // Main body – centred at origin for easy placement on the print bed + fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY) + fmt.Fprintf(f, " difference() {\n") + fmt.Fprintf(f, " union() {\n") + fmt.Fprintf(f, " // Thin stencil plate\n") + fmt.Fprintf(f, " linear_extrude(height=stencil_height)\n") + fmt.Fprintf(f, " board_outline();\n") + fmt.Fprintf(f, " // Alignment wall – keeps stencil registered to the PCB edge\n") + fmt.Fprintf(f, " linear_extrude(height=wall_height)\n") + fmt.Fprintf(f, " difference() {\n") + fmt.Fprintf(f, " offset(r=wall_thickness) board_outline();\n") + fmt.Fprintf(f, " board_outline();\n") + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " // Paste pad cutouts (punched through the stencil plate)\n") + fmt.Fprintf(f, " translate([0, 0, -0.1])\n") + fmt.Fprintf(f, " linear_extrude(height=stencil_height + 0.2)\n") + fmt.Fprintf(f, " paste_pads();\n") + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, "}\n") + + return nil +} + +// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon +func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 { + var strokes [][][2]float64 + var currentStroke [][2]float64 + curX, curY := 0.0, 0.0 + interpolationMode := "G01" + + for _, cmd := range gf.Commands { + if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { + interpolationMode = cmd.Type + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + if cmd.Type == "MOVE" { + if len(currentStroke) > 0 { + strokes = append(strokes, currentStroke) + currentStroke = nil + } + } else if cmd.Type == "DRAW" { + if len(currentStroke) == 0 { + currentStroke = append(currentStroke, [2]float64{prevX, prevY}) + } + + if interpolationMode == "G01" { + currentStroke = append(currentStroke, [2]float64{curX, curY}) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + centerX, centerY := prevX+iVal, prevY+jVal + radius := math.Sqrt(iVal*iVal + jVal*jVal) + startAngle := math.Atan2(prevY-centerY, prevX-centerX) + endAngle := math.Atan2(curY-centerY, curX-centerX) + if interpolationMode == "G03" { + if endAngle <= startAngle { + endAngle += 2 * math.Pi + } + } else { + if startAngle <= endAngle { + startAngle += 2 * math.Pi + } + } + arcLen := math.Abs(endAngle-startAngle) * radius + steps := int(arcLen * 10) + if steps < 5 { + steps = 5 + } + for s := 1; s <= steps; s++ { + t := float64(s) / float64(steps) + a := startAngle + t*(endAngle-startAngle) + ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a) + currentStroke = append(currentStroke, [2]float64{ax, ay}) + } + } + } + } + if len(currentStroke) > 0 { + strokes = append(strokes, currentStroke) + } + + if len(strokes) == 0 { + return nil + } + + // Stitch strokes into closed loops + var loops [][][2]float64 + used := make([]bool, len(strokes)) + epsilon := 0.05 // 0.05mm tolerance + + for startIdx := 0; startIdx < len(strokes); startIdx++ { + if used[startIdx] { + continue + } + used[startIdx] = true + path := append([][2]float64{}, strokes[startIdx]...) + + for { + endPt := path[len(path)-1] + startPt := path[0] + found := false + + for j := 0; j < len(strokes); j++ { + if used[j] { + continue + } + s := strokes[j] + sStart := s[0] + sEnd := s[len(s)-1] + + dist := func(a, b [2]float64) float64 { + dx, dy := a[0]-b[0], a[1]-b[1] + return math.Sqrt(dx*dx + dy*dy) + } + + if dist(endPt, sStart) < epsilon { + path = append(path, s[1:]...) + used[j] = true + found = true + break + } else if dist(endPt, sEnd) < epsilon { + for k := len(s) - 2; k >= 0; k-- { + path = append(path, s[k]) + } + used[j] = true + found = true + break + } else if dist(startPt, sEnd) < epsilon { + // prepend + newPath := append([][2]float64{}, s[:len(s)-1]...) + path = append(newPath, path...) + used[j] = true + found = true + break + } else if dist(startPt, sStart) < epsilon { + // reversed prepend + var newPath [][2]float64 + for k := len(s) - 1; k > 0; k-- { + newPath = append(newPath, s[k]) + } + path = append(newPath, path...) + used[j] = true + found = true + break + } + } + if !found { + break + } + } + loops = append(loops, path) + } + + // Find the longest loop (the main board outline) + var bestLoop [][2]float64 + maxLen := 0.0 + for _, l := range loops { + loopLen := 0.0 + for i := 0; i < len(l)-1; i++ { + dx := l[i+1][0] - l[i][0] + dy := l[i+1][1] - l[i][1] + loopLen += math.Sqrt(dx*dx + dy*dy) + } + if loopLen > maxLen { + maxLen = loopLen + bestLoop = l + } + } + + // Always ensure path is closed + if len(bestLoop) > 2 { + first := bestLoop[0] + last := bestLoop[len(bestLoop)-1] + if math.Abs(first[0]-last[0]) > epsilon || math.Abs(first[1]-last[1]) > epsilon { + bestLoop = append(bestLoop, first) + } + } + + return bestLoop +} + +// WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code +func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n") + fmt.Fprintf(f, "$fn = 60;\n\n") + + // 1. Output the Board Polygon Module + fmt.Fprintf(f, "module board_polygon() {\n polygon(points=[\n") + for i, v := range outlineVertices { + fmt.Fprintf(f, " [%f, %f]", v[0], v[1]) + if i < len(outlineVertices)-1 { + fmt.Fprintf(f, ",\n") + } + } + fmt.Fprintf(f, "\n ]);\n}\n\n") + + // Dimensions + clearance := cfg.Clearance + wt := cfg.WallThickness + lidThick := wt + snapHeight := 2.5 + trayFloor := 1.5 + pcbT := cfg.PCBThickness + totalH := cfg.WallHeight + pcbT + trayFloor + lipH := pcbT + 1.5 + + // Create Peg and Socket helper + fmt.Fprintf(f, "module mounting_pegs(isSocket) {\n") + for _, h := range holes { + if h.Type == DrillTypeMounting { + r := (h.Diameter / 2.0) - 0.15 + if isTray { + // We subtract sockets from the tray floor + r = (h.Diameter / 2.0) + 0.1 + fmt.Fprintf(f, " translate([%f, %f, -1]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, trayFloor+2) + } else { + fmt.Fprintf(f, " translate([%f, %f, 0]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, totalH-lidThick) + } + } + } + fmt.Fprintf(f, "}\n\n") + + // Print Side Cutouts module + fmt.Fprintf(f, "module side_cutouts() {\n") + for _, c := range cutouts { + var bs *BoardSide + for i := range sides { + if sides[i].Num == c.Side { + bs = &sides[i] + break + } + } + if bs == nil { + continue + } + + // Cutouts are relative to board. UI specifies c.Y from bottom, so c.Y adds to Z. + z := c.Height/2 + trayFloor + pcbT + c.Y + wallDepth := 2*(clearance+2*wt) + 2.0 // just enough to cut through walls + w, d, h := c.Width, wallDepth, c.Height + + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + length := math.Sqrt(dx*dx + dy*dy) + if length > 0 { + dx /= length + dy /= length + } + + midX := bs.StartX + dx*(c.X+w/2) + midY := bs.StartY + dy*(c.X+w/2) + + rotDeg := (bs.Angle * 180.0 / math.Pi) - 90.0 + + if c.CornerRadius > 0 { + r := c.CornerRadius + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) {\n", midX, midY, z, rotDeg) + fmt.Fprintf(f, " hull() {\n") + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", w/2-r, h/2-r, r, d) + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", -(w/2 - r), h/2-r, r, d) + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", w/2-r, -(h/2 - r), r, d) + fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", -(w/2 - r), -(h/2 - r), r, d) + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " }\n") + } else { + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", midX, midY, z, rotDeg, w, d, h) + } + } + fmt.Fprintf(f, "}\n\n") + + // Print Pry Slots Module + fmt.Fprintf(f, "module pry_slots() {\n") + pryW := 8.0 + pryD := 1.5 + fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", minBX-clearance-wt+pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3) + fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt-pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3) + fmt.Fprintf(f, "}\n\n") + + // Print Pry Clips Module + fmt.Fprintf(f, "module pry_clips() {\n") + clipH := 0.8 + clipZ := trayFloor + snapHeight - clipH/2.0 + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", minBX-clearance-wt-0.5, boardCenterY, clipZ, 1.0, pryW, clipH) + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt+0.5, boardCenterY, clipZ, 1.0, pryW, clipH) + fmt.Fprintf(f, "}\n\n") + + // Lid/Tray Cutouts Module + fmt.Fprintf(f, "module lid_cutouts() {\n") + for _, lc := range lidCutouts { + cx := (lc.MinX + lc.MaxX) / 2.0 + cy := (lc.MinY + lc.MaxY) / 2.0 + w := lc.MaxX - lc.MinX + h := lc.MaxY - lc.MinY + if w < 0.01 || h < 0.01 { + continue + } + if lc.Plane == "lid" { + if lc.IsDado && lc.Depth > 0 { + // Dado on lid: cut from top surface downward + fmt.Fprintf(f, " // Lid dado (depth=%.2f)\n", lc.Depth) + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", + cx, cy, totalH-lc.Depth/2.0, w, h, lc.Depth+0.1) + } else { + // Through-cut on lid + fmt.Fprintf(f, " // Lid through-cut\n") + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", + cx, cy, totalH-lidThick/2.0, w, h, lidThick+0.2) + } + } else if lc.Plane == "tray" { + if lc.IsDado && lc.Depth > 0 { + // Dado on tray: cut from bottom surface upward + fmt.Fprintf(f, " // Tray dado (depth=%.2f)\n", lc.Depth) + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", + cx, cy, lc.Depth/2.0-0.05, w, h, lc.Depth+0.1) + } else { + // Through-cut on tray floor + fmt.Fprintf(f, " // Tray through-cut\n") + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", + cx, cy, trayFloor/2.0, w, h, trayFloor+0.2) + } + } + } + fmt.Fprintf(f, "}\n\n") + + // cutoutMid returns the midpoint XY and rotation angle for a side cutout, + // matching the geometry used in side_cutouts(). + cutoutMid := func(c SideCutout) (midX, midY, rotDeg float64, ok bool) { + for i := range sides { + if sides[i].Num != c.Side { + continue + } + bs := &sides[i] + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + if l := math.Sqrt(dx*dx + dy*dy); l > 0 { + dx /= l + dy /= l + } + midX = bs.StartX + dx*(c.X+c.Width/2) + midY = bs.StartY + dy*(c.X+c.Width/2) + rotDeg = (bs.Angle*180.0/math.Pi) - 90.0 + ok = true + return + } + return + } + + centerX := cfg.OutlineBounds.MinX + (cfg.OutlineBounds.MaxX-cfg.OutlineBounds.MinX)/2.0 + centerY := cfg.OutlineBounds.MinY + (cfg.OutlineBounds.MaxY-cfg.OutlineBounds.MinY)/2.0 + fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY) + + if isTray { + // --- TRAY --- + fmt.Fprintf(f, "// --- TRAY ---\n") + fmt.Fprintf(f, "difference() {\n") + fmt.Fprintf(f, " union() {\n") + fmt.Fprintf(f, " // Tray Floor (extends to clearance + 2*wt so it is flush with enclosure outside)\n") + fmt.Fprintf(f, " linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, clearance+2*wt) + fmt.Fprintf(f, " // Tray Inner Wall (thickness wt)\n") + fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor, snapHeight) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance) + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " }\n") + + fmt.Fprintf(f, " // Subtract Lip Recess (for easy opening)\n") + fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) difference() {\n", trayFloor+lipH+0.5) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt+1.0) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt-0.5) // Assuming lipCut is 0.5mm + fmt.Fprintf(f, " }\n") + + // Remove peg holes from floor + for _, hole := range holes { + if hole.Type != DrillTypeMounting { + continue + } + socketRadius := (hole.Diameter / 2.0) + 0.1 + fmt.Fprintf(f, " translate([%f,%f,-1]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, trayFloor+2, socketRadius) + } + fmt.Fprintf(f, " side_cutouts();\n") + fmt.Fprintf(f, " lid_cutouts();\n") + // Board dado on tray: layer-aware groove on each side with port cutouts. + { + trayWallDepth := 2*(clearance+wt) + 2.0 + type trayDadoInfo struct { + hasF bool + hasB bool + fPortTop float64 + bPortBot float64 + } + trayDadoSides := make(map[int]*trayDadoInfo) + for _, c := range cutouts { + di, ok := trayDadoSides[c.Side] + if !ok { + di = &trayDadoInfo{fPortTop: 0, bPortBot: 1e9} + trayDadoSides[c.Side] = di + } + portBot := trayFloor + pcbT + c.Y + portTop := portBot + c.Height + if c.Layer == "F" { + di.hasF = true + if portTop > di.fPortTop { + di.fPortTop = portTop + } + } else { + di.hasB = true + if portBot < di.bPortBot { + di.bPortBot = portBot + } + } + } + trayH := trayFloor + snapHeight + wt + pcbT + 2.0 + for _, bs := range sides { + di, ok := trayDadoSides[bs.Num] + if !ok { + continue + } + midX := (bs.StartX + bs.EndX) / 2.0 + midY := (bs.StartY + bs.EndY) / 2.0 + rotDeg := (bs.Angle*180.0/math.Pi) - 90.0 + dadoLen := bs.Length + 1.0 + if di.hasF { + // F-layer: dado above ports (toward lid), same direction as enclosure + dadoBot := di.fPortTop + dadoH := trayH - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH) + } + } + if di.hasB { + // B-layer: dado below ports (toward floor) + dadoBot := trayFloor + 0.3 + dadoH := di.bPortBot - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH) + } + } + } + } + fmt.Fprintf(f, "}\n") + fmt.Fprintf(f, "pry_clips();\n\n") + + } else { + // --- ENCLOSURE --- + fmt.Fprintf(f, "// --- ENCLOSURE ---\n") + fmt.Fprintf(f, "difference() {\n") + fmt.Fprintf(f, " union() {\n") + fmt.Fprintf(f, " // Outer Enclosure block (accommodates Tray Wall + Enclosure Wall)\n") + fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, totalH-trayFloor, clearance+2*wt) + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " // Subtract Inner Cavity (Base clearance around board)\n") + fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", totalH-lidThick+1, clearance) + fmt.Fprintf(f, " // Subtract Tray Recess (Accommodates Tray Wall)\n") + fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor+snapHeight+0.2, clearance+wt+0.15) + + fmt.Fprintf(f, " // Vertical relief slots for the tray clips to slide into\n") + reliefClipZ := trayFloor + snapHeight + reliefH := reliefClipZ + 1.0 + reliefZ := trayFloor - 1.0 + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, reliefZ, pryW+1.0, reliefH) + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, reliefZ, pryW+1.0, reliefH) + + fmt.Fprintf(f, " pry_slots();\n") + + // Port cutouts – only these go through the full wall to the outside + fmt.Fprintf(f, " side_cutouts();\n") + fmt.Fprintf(f, " lid_cutouts();\n") + + wallDepth := 2*(clearance+2*wt) + 2.0 + lidBottom := totalH - lidThick + + // Inner wall ring helper – used to limit slots and dado to the + // inner rim only (outer wall stays solid, only ports break through). + // Inner wall spans from offset(clearance) to offset(clearance+wt). + fmt.Fprintf(f, " // --- Entry slots & board dado (inner wall only) ---\n") + fmt.Fprintf(f, " intersection() {\n") + fmt.Fprintf(f, " // Clamp to inner wall ring\n") + fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor-1, totalH+2) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.5) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance-0.5) + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " union() {\n") + + // Port entry slots – vertical channel from port to lid/floor, + // only in the inner wall so the outer wall stays solid. + for _, c := range cutouts { + mX, mY, mRot, ok := cutoutMid(c) + if !ok { + continue + } + zTopCut := trayFloor + pcbT + c.Y + c.Height + + if c.Layer == "F" { + // F-layer: ports on top of board, slot from port top toward lid (plate) + slotH := lidBottom - zTopCut + if slotH > 0.1 { + fmt.Fprintf(f, " // Port entry slot (F-layer, open toward plate)\n") + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + mX, mY, zTopCut+slotH/2.0, mRot, c.Width, wallDepth, slotH) + } + } else { + // B-layer: ports under board, slot from floor up to port bottom + zBotCut := trayFloor + pcbT + c.Y + slotH := zBotCut - (trayFloor + 0.3) + if slotH > 0.1 { + slotBot := trayFloor + 0.3 + fmt.Fprintf(f, " // Port entry slot (B-layer, open toward rim)\n") + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + mX, mY, slotBot+slotH/2.0, mRot, c.Width, wallDepth, slotH) + } + } + } + + // Board dado – full-length groove at PCB height, inner wall only. + // For F-layer: dado sits below ports (board under ports), from tray floor to port bottom. + // For B-layer: dado sits above ports (board over ports), from port top to lid. + // Collect per-side: lowest port bottom (F) or highest port top (B). + type dadoInfo struct { + hasF bool + hasB bool + fPortTop float64 // highest port-top on this side (F-layer) + bPortBot float64 // lowest port-bottom on this side (B-layer) + } + dadoSides := make(map[int]*dadoInfo) + for _, c := range cutouts { + di, ok := dadoSides[c.Side] + if !ok { + di = &dadoInfo{fPortTop: 0, bPortBot: 1e9} + dadoSides[c.Side] = di + } + portBot := trayFloor + pcbT + c.Y + portTop := portBot + c.Height + if c.Layer == "F" { + di.hasF = true + if portTop > di.fPortTop { + di.fPortTop = portTop + } + } else { + di.hasB = true + if portBot < di.bPortBot { + di.bPortBot = portBot + } + } + } + for _, bs := range sides { + di, ok := dadoSides[bs.Num] + if !ok { + continue + } + midX := (bs.StartX + bs.EndX) / 2.0 + midY := (bs.StartY + bs.EndY) / 2.0 + rotDeg := (bs.Angle*180.0/math.Pi) - 90.0 + dadoLen := bs.Length + 1.0 + if di.hasF { + // F-layer: ports on top of board, dado above ports (toward lid/plate) + dadoBot := di.fPortTop + dadoH := lidBottom - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH) + } + } + if di.hasB { + // B-layer: ports under board, dado below ports (toward open rim) + dadoBot := trayFloor + 0.3 + dadoH := di.bPortBot - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH) + } + } + } + + fmt.Fprintf(f, " } // end union\n") + fmt.Fprintf(f, " } // end intersection\n") + fmt.Fprintf(f, "}\n") + fmt.Fprintf(f, "mounting_pegs(false);\n") + } + + fmt.Fprintf(f, "}\n") // Close the top-level translate + + return nil +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..9c2c45d --- /dev/null +++ b/session.go @@ -0,0 +1,362 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "io" + "log" + "os" + "path/filepath" + "strings" +) + +// EnclosureSession holds all state for an active enclosure editing session +type EnclosureSession struct { + Exports []string + OutlineGf *GerberFile + OutlineImg image.Image + CourtyardImg image.Image + SoldermaskImg image.Image + DrillHoles []DrillHole + Config EnclosureConfig + OutlineBounds Bounds + BoardW float64 + BoardH float64 + TotalH float64 + MinBX float64 + MaxBX float64 + BoardCenterY float64 + Sides []BoardSide + FabImg image.Image + EnclosureWallImg image.Image // 2D top-down view of enclosure walls + AllLayerImages map[string]image.Image // all rendered gerber layers keyed by original filename + AllLayerGerbers map[string]*GerberFile // parsed gerber files keyed by original filename + SourceDir string // original directory of the gerber files + + // Persistence metadata + GerberFiles map[string]string + DrillPath string + NPTHPath string + ProjectName string + EdgeCutsFile string + CourtyardFile string + SoldermaskFile string + FabFile string +} + + +// BuildEnclosureSession creates a session from uploaded files and configuration. +// This is used by both the initial upload and by instance restore. +func BuildEnclosureSession( + gbrjobPath string, + gerberPaths map[string]string, // original filename -> saved path + drillPath, npthPath string, + ecfg EnclosureConfig, + exports []string, +) (string, *EnclosureSession, error) { + + // Parse gbrjob + jobResult, err := ParseGerberJob(gbrjobPath) + if err != nil { + return "", nil, fmt.Errorf("parse gbrjob: %v", err) + } + + pcbThickness := jobResult.BoardThickness + if pcbThickness == 0 { + pcbThickness = DefaultPCBThickness + } + ecfg.PCBThickness = pcbThickness + + // Find outline + outlinePath, ok := gerberPaths[jobResult.EdgeCutsFile] + if !ok { + return "", nil, fmt.Errorf("Edge.Cuts file '%s' not found in uploaded gerbers", jobResult.EdgeCutsFile) + } + + // Parse outline + outlineGf, err := ParseGerber(outlinePath) + if err != nil { + return "", nil, fmt.Errorf("parse outline: %v", err) + } + + outlineBounds := outlineGf.CalculateBounds() + actualBoardW := outlineBounds.MaxX - outlineBounds.MinX + actualBoardH := outlineBounds.MaxY - outlineBounds.MinY + + margin := ecfg.WallThickness + ecfg.Clearance + 5.0 + outlineBounds.MinX -= margin + outlineBounds.MinY -= margin + outlineBounds.MaxX += margin + outlineBounds.MaxY += margin + ecfg.OutlineBounds = &outlineBounds + + outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds) + + // Compute board mask + minBX, _, maxBX, _ := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y, -1, -1 + var boardCenterY float64 + var boardCount int + + _, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4) + imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if boardMask[y*imgW+x] { + if x < minBX { + minBX = x + } + if x > maxBX { + maxBX = x + } + boardCenterY += float64(y) + boardCount++ + } + } + } + if boardCount > 0 { + boardCenterY /= float64(boardCount) + } + + // Parse drill files + var drillHoles []DrillHole + if drillPath != "" { + if holes, err := ParseDrill(drillPath); err == nil { + drillHoles = append(drillHoles, holes...) + } + } + if npthPath != "" { + if holes, err := ParseDrill(npthPath); err == nil { + drillHoles = append(drillHoles, holes...) + } + } + + var filteredHoles []DrillHole + for _, h := range drillHoles { + if h.Type != DrillTypeVia { + filteredHoles = append(filteredHoles, h) + } + } + + // Render layer images + var courtyardImg image.Image + if courtPath, ok := gerberPaths[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" { + if courtGf, err := ParseGerber(courtPath); err == nil { + courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds) + } + } + + var soldermaskImg image.Image + if maskPath, ok := gerberPaths[jobResult.SoldermaskFile]; ok && jobResult.SoldermaskFile != "" { + if maskGf, err := ParseGerber(maskPath); err == nil { + soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds) + } + } + + // Fab fallback for courtyard + if courtyardImg == nil && jobResult.FabFile != "" { + if fabPath, ok := gerberPaths[jobResult.FabFile]; ok { + if fabGf, err := ParseGerber(fabPath); err == nil { + courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds) + } + } + } + + pixelToMM := 25.4 / ecfg.DPI + + // Render ALL uploaded gerber layers + allLayers := make(map[string]image.Image) + allGerbers := make(map[string]*GerberFile) + for origName, fullPath := range gerberPaths { + if strings.HasSuffix(strings.ToLower(origName), ".gbrjob") { + continue + } + // Skip edge cuts — already rendered as outlineImg + if origName == jobResult.EdgeCutsFile { + allLayers[origName] = outlineImg + allGerbers[origName] = outlineGf + continue + } + gf, err := ParseGerber(fullPath) + if err != nil { + log.Printf("Warning: could not parse %s: %v", origName, err) + continue + } + allLayers[origName] = gf.Render(ecfg.DPI, &outlineBounds) + allGerbers[origName] = gf + } + + // Build basenames map for persistence + gerberBasenames := make(map[string]string) + for origName, fullPath := range gerberPaths { + gerberBasenames[origName] = filepath.Base(fullPath) + } + + sessionID := randomID() + session := &EnclosureSession{ + Exports: exports, + OutlineGf: outlineGf, + OutlineImg: outlineImg, + CourtyardImg: courtyardImg, + SoldermaskImg: soldermaskImg, + DrillHoles: filteredHoles, + Config: ecfg, + OutlineBounds: outlineBounds, + BoardW: actualBoardW, + BoardH: actualBoardH, + TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0, + MinBX: float64(minBX)*pixelToMM + outlineBounds.MinX, + MaxBX: float64(maxBX)*pixelToMM + outlineBounds.MinX, + BoardCenterY: outlineBounds.MaxY - boardCenterY*pixelToMM, + Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, pixelToMM, &outlineBounds), + GerberFiles: gerberBasenames, + DrillPath: filepath.Base(drillPath), + NPTHPath: filepath.Base(npthPath), + ProjectName: jobResult.ProjectName, + EdgeCutsFile: jobResult.EdgeCutsFile, + CourtyardFile: jobResult.CourtyardFile, + SoldermaskFile: jobResult.SoldermaskFile, + FabFile: jobResult.FabFile, + EnclosureWallImg: renderEnclosureWallImage(outlineImg, ecfg), + AllLayerImages: allLayers, + AllLayerGerbers: allGerbers, + } + + log.Printf("Created session %s for project %s (%.1f x %.1f mm)", sessionID, jobResult.ProjectName, actualBoardW, actualBoardH) + return sessionID, session, nil +} + +// UploadFabAndExtractFootprints processes fab gerber files and returns footprint data +func UploadFabAndExtractFootprints(session *EnclosureSession, fabPaths []string) ([]Footprint, image.Image) { + var allFootprints []Footprint + var fabGfList []*GerberFile + + for _, path := range fabPaths { + gf, err := ParseGerber(path) + if err == nil { + allFootprints = append(allFootprints, ExtractFootprints(gf)...) + fabGfList = append(fabGfList, gf) + } + } + + // Composite fab images + var fabImg image.Image + if len(fabGfList) > 0 { + bounds := session.OutlineBounds + imgW := int((bounds.MaxX - bounds.MinX) * session.Config.DPI / 25.4) + imgH := int((bounds.MaxY - bounds.MinY) * session.Config.DPI / 25.4) + if imgW > 0 && imgH > 0 { + composite := image.NewRGBA(image.Rect(0, 0, imgW, imgH)) + draw.Draw(composite, composite.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) + + for _, gf := range fabGfList { + layerImg := gf.Render(session.Config.DPI, &bounds) + if rgba, ok := layerImg.(*image.RGBA); ok { + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if r, _, _, _ := rgba.At(x, y).RGBA(); r > 0x7FFF { + composite.Set(x, y, color.RGBA{0, 255, 255, 180}) + } + } + } + } + } + fabImg = composite + } + } + + return allFootprints, fabImg +} + +// GenerateEnclosureOutputs produces all requested output files for the enclosure +func GenerateEnclosureOutputs(session *EnclosureSession, cutouts []Cutout, outputDir string) ([]string, error) { + os.MkdirAll(outputDir, 0755) + + // Split unified cutouts into legacy types for STL/SCAD generation + sideCutouts, lidCutouts := SplitCutouts(cutouts) + + id := randomID() + var generatedFiles []string + + wantsType := func(t string) bool { + for _, e := range session.Exports { + if e == t { + return true + } + } + return false + } + + if len(session.Exports) == 0 { + session.Exports = []string{"stl"} + } + + // STL + if wantsType("stl") { + result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts, session.Sides) + encPath := filepath.Join(outputDir, id+"_enclosure.stl") + trayPath := filepath.Join(outputDir, id+"_tray.stl") + WriteSTL(encPath, result.EnclosureTriangles) + WriteSTL(trayPath, result.TrayTriangles) + generatedFiles = append(generatedFiles, encPath, trayPath) + } + + // SCAD + if wantsType("scad") { + scadPathEnc := filepath.Join(outputDir, id+"_enclosure.scad") + scadPathTray := filepath.Join(outputDir, id+"_tray.scad") + outlinePoly := ExtractPolygonFromGerber(session.OutlineGf) + WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) + WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) + generatedFiles = append(generatedFiles, scadPathEnc, scadPathTray) + } + + // SVG + if wantsType("svg") && session.OutlineGf != nil { + svgPath := filepath.Join(outputDir, id+"_outline.svg") + WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds) + generatedFiles = append(generatedFiles, svgPath) + } + + // PNG + if wantsType("png") && session.OutlineImg != nil { + pngPath := filepath.Join(outputDir, id+"_outline.png") + if f, err := os.Create(pngPath); err == nil { + png.Encode(f, session.OutlineImg) + f.Close() + generatedFiles = append(generatedFiles, pngPath) + } + } + + return generatedFiles, nil +} + +// SaveOutlineImage saves the outline image as PNG to a file +func SaveOutlineImage(session *EnclosureSession, path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, session.OutlineImg) +} + +// CopyFile copies a file from src to dst +func CopyFile(src, dst string) error { + s, err := os.Open(src) + if err != nil { + return err + } + defer s.Close() + + d, err := os.Create(dst) + if err != nil { + return err + } + defer d.Close() + + _, err = io.Copy(d, s) + return err +} diff --git a/static/showcase.png b/static/showcase.png new file mode 100644 index 0000000..4c6fe62 Binary files /dev/null and b/static/showcase.png differ diff --git a/static/vectors b/static/vectors new file mode 160000 index 0000000..d1fdfca --- /dev/null +++ b/static/vectors @@ -0,0 +1 @@ +Subproject commit d1fdfca1265d2c295318beec03966301cc5a4d5a diff --git a/stencil_process.go b/stencil_process.go new file mode 100644 index 0000000..1b408ad --- /dev/null +++ b/stencil_process.go @@ -0,0 +1,403 @@ +package main + +import ( + "fmt" + "image" + "image/png" + "log" + "os" + "path/filepath" + "strings" +) + +// Config holds stencil generation parameters +type Config struct { + StencilHeight float64 + WallHeight float64 + WallThickness float64 + LineWidth float64 + DPI float64 + KeepPNG bool +} + +// Default values +const ( + DefaultStencilHeight = 0.16 + DefaultWallHeight = 2.0 + DefaultWallThickness = 1.0 + DefaultDPI = 1000.0 +) + +// ComputeWallMask generates a mask for the wall based on the outline image. +func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]int, []bool) { + bounds := img.Bounds() + w := bounds.Max.X + h := bounds.Max.Y + size := w * h + + dx := []int{0, 0, 1, -1} + dy := []int{1, -1, 0, 0} + + isOutline := make([]bool, size) + outlineQueue := []int{} + for i := 0; i < size; i++ { + cx := i % w + cy := i / w + c := img.At(cx, cy) + r, _, _, _ := c.RGBA() + if r > 10000 { + isOutline[i] = true + outlineQueue = append(outlineQueue, i) + } + } + + gapClosingMM := 0.5 + gapClosingPixels := int(gapClosingMM / pixelToMM) + if gapClosingPixels < 1 { + gapClosingPixels = 1 + } + + dist := make([]int, size) + for i := 0; i < size; i++ { + if isOutline[i] { + dist[i] = 0 + } else { + dist[i] = -1 + } + } + + dilatedOutline := make([]bool, size) + copy(dilatedOutline, isOutline) + + dQueue := make([]int, len(outlineQueue)) + copy(dQueue, outlineQueue) + + for len(dQueue) > 0 { + idx := dQueue[0] + dQueue = dQueue[1:] + d := dist[idx] + if d >= gapClosingPixels { + continue + } + cx := idx % w + cy := idx / w + for i := 0; i < 4; i++ { + nx, ny := cx+dx[i], cy+dy[i] + if nx >= 0 && nx < w && ny >= 0 && ny < h { + nIdx := ny*w + nx + if dist[nIdx] == -1 { + dist[nIdx] = d + 1 + dilatedOutline[nIdx] = true + dQueue = append(dQueue, nIdx) + } + } + } + } + + isOutside := make([]bool, size) + if !dilatedOutline[0] { + isOutside[0] = true + fQueue := []int{0} + for len(fQueue) > 0 { + idx := fQueue[0] + fQueue = fQueue[1:] + cx := idx % w + cy := idx / w + for i := 0; i < 4; i++ { + nx, ny := cx+dx[i], cy+dy[i] + if nx >= 0 && nx < w && ny >= 0 && ny < h { + nIdx := ny*w + nx + if !isOutside[nIdx] && !dilatedOutline[nIdx] { + isOutside[nIdx] = true + fQueue = append(fQueue, nIdx) + } + } + } + } + } + + for i := 0; i < size; i++ { + if isOutside[i] { + dist[i] = 0 + } else { + dist[i] = -1 + } + } + + oQueue := []int{} + for i := 0; i < size; i++ { + if isOutside[i] { + oQueue = append(oQueue, i) + } + } + + isOutsideExpanded := make([]bool, size) + copy(isOutsideExpanded, isOutside) + + for len(oQueue) > 0 { + idx := oQueue[0] + oQueue = oQueue[1:] + d := dist[idx] + if d >= gapClosingPixels { + continue + } + cx := idx % w + cy := idx / w + for i := 0; i < 4; i++ { + nx, ny := cx+dx[i], cy+dy[i] + if nx >= 0 && nx < w && ny >= 0 && ny < h { + nIdx := ny*w + nx + if dist[nIdx] == -1 { + dist[nIdx] = d + 1 + isOutsideExpanded[nIdx] = true + oQueue = append(oQueue, nIdx) + } + } + } + } + + isBoard := make([]bool, size) + for i := 0; i < size; i++ { + isBoard[i] = !isOutsideExpanded[i] + } + + thicknessPixels := int(thicknessMM / pixelToMM) + if thicknessPixels < 1 { + thicknessPixels = 1 + } + + for i := 0; i < size; i++ { + if isBoard[i] { + dist[i] = 0 + } else { + dist[i] = -1 + } + } + + wQueue := []int{} + for i := 0; i < size; i++ { + if isBoard[i] { + wQueue = append(wQueue, i) + } + } + + wallDist := make([]int, size) + for i := range wallDist { + wallDist[i] = -1 + } + + for len(wQueue) > 0 { + idx := wQueue[0] + wQueue = wQueue[1:] + d := dist[idx] + if d >= thicknessPixels { + continue + } + cx := idx % w + cy := idx / w + for i := 0; i < 4; i++ { + nx, ny := cx+dx[i], cy+dy[i] + if nx >= 0 && nx < w && ny >= 0 && ny < h { + nIdx := ny*w + nx + if dist[nIdx] == -1 { + dist[nIdx] = d + 1 + wallDist[nIdx] = d + 1 + wQueue = append(wQueue, nIdx) + } + } + } + } + + return wallDist, isBoard +} + +func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3]Point { + pixelToMM := 25.4 / cfg.DPI + bounds := stencilImg.Bounds() + width := bounds.Max.X + height := bounds.Max.Y + var triangles [][3]Point + + var wallDist []int + var boardMask []bool + if outlineImg != nil { + wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM) + } + + for y := 0; y < height; y++ { + var startX = -1 + var currentHeight = 0.0 + + for x := 0; x < width; x++ { + sc := stencilImg.At(x, y) + sr, sg, sb, _ := sc.RGBA() + isStencilSolid := sr < 10000 && sg < 10000 && sb < 10000 + + isWall := false + isInsideBoard := true + if wallDist != nil { + idx := y*width + x + isWall = wallDist[idx] >= 0 + if boardMask != nil { + isInsideBoard = boardMask[idx] + } + } + + h := 0.0 + if isWall { + h = cfg.WallHeight + } else if isStencilSolid { + if isInsideBoard { + h = cfg.WallHeight + } + } + + if h > 0 { + if startX == -1 { + startX = x + currentHeight = h + } else if h != currentHeight { + stripLen := x - startX + AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM, + float64(stripLen)*pixelToMM, pixelToMM, currentHeight) + startX = x + currentHeight = h + } + } else { + if startX != -1 { + stripLen := x - startX + AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM, + float64(stripLen)*pixelToMM, pixelToMM, currentHeight) + startX = -1 + currentHeight = 0.0 + } + } + } + if startX != -1 { + stripLen := width - startX + AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM, + float64(stripLen)*pixelToMM, pixelToMM, currentHeight) + } + } + return triangles +} + +// processPCB handles stencil generation from gerber files +func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, image.Image, image.Image, error) { + baseName := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + var generatedFiles []string + + wantsType := func(t string) bool { + for _, e := range exports { + if e == t { + return true + } + } + return false + } + + if len(exports) == 0 { + exports = []string{"stl"} + } + + fmt.Printf("Parsing %s...\n", gerberPath) + gf, err := ParseGerber(gerberPath) + if err != nil { + return nil, nil, nil, fmt.Errorf("error parsing gerber: %v", err) + } + + var outlineGf *GerberFile + if outlinePath != "" { + fmt.Printf("Parsing outline %s...\n", outlinePath) + outlineGf, err = ParseGerber(outlinePath) + if err != nil { + return nil, nil, nil, fmt.Errorf("error parsing outline gerber: %v", err) + } + } + + bounds := gf.CalculateBounds() + if outlineGf != nil { + outlineBounds := outlineGf.CalculateBounds() + if outlineBounds.MinX < bounds.MinX { + bounds.MinX = outlineBounds.MinX + } + if outlineBounds.MinY < bounds.MinY { + bounds.MinY = outlineBounds.MinY + } + if outlineBounds.MaxX > bounds.MaxX { + bounds.MaxX = outlineBounds.MaxX + } + if outlineBounds.MaxY > bounds.MaxY { + bounds.MaxY = outlineBounds.MaxY + } + } + + margin := cfg.WallThickness + 5.0 + bounds.MinX -= margin + bounds.MinY -= margin + bounds.MaxX += margin + bounds.MaxY += margin + + fmt.Println("Rendering to internal image...") + img := gf.Render(cfg.DPI, &bounds) + + var outlineImg image.Image + if outlineGf != nil { + outlineImg = outlineGf.Render(cfg.DPI, &bounds) + } + + if cfg.KeepPNG { + pngPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".png" + f, err := os.Create(pngPath) + if err != nil { + log.Printf("Warning: Could not create PNG file: %v", err) + } else { + if err := png.Encode(f, img); err != nil { + log.Printf("Warning: Could not encode PNG: %v", err) + } + f.Close() + } + } + + var triangles [][3]Point + if wantsType("stl") { + fmt.Println("Generating mesh...") + triangles = GenerateMeshFromImages(img, outlineImg, cfg) + } + + if wantsType("stl") { + outputFilename := baseName + ".stl" + fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles)) + if err := WriteSTL(outputFilename, triangles); err != nil { + return nil, nil, nil, fmt.Errorf("error writing stl: %v", err) + } + generatedFiles = append(generatedFiles, outputFilename) + } + + if wantsType("svg") { + outputFilename := baseName + ".svg" + if err := WriteSVG(outputFilename, gf, &bounds); err != nil { + return nil, nil, nil, fmt.Errorf("error writing svg: %v", err) + } + generatedFiles = append(generatedFiles, outputFilename) + } + + if wantsType("png") { + outputFilename := baseName + ".png" + if f, err := os.Create(outputFilename); err == nil { + png.Encode(f, img) + f.Close() + generatedFiles = append(generatedFiles, outputFilename) + } + } + + if wantsType("scad") { + outputFilename := baseName + ".scad" + if err := WriteStencilSCAD(outputFilename, gf, outlineGf, cfg, &bounds); err != nil { + return nil, nil, nil, fmt.Errorf("error writing scad: %v", err) + } + generatedFiles = append(generatedFiles, outputFilename) + } + + return generatedFiles, img, outlineImg, nil +} diff --git a/stl.go b/stl.go new file mode 100644 index 0000000..b09de51 --- /dev/null +++ b/stl.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/binary" + "math" + "os" +) + +// --- STL Types and Helpers --- + +type Point struct { + X, Y, Z float64 +} + +func WriteSTL(filename string, triangles [][3]Point) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + header := make([]byte, 80) + copy(header, "Generated by pcb-to-stencil") + if _, err := f.Write(header); err != nil { + return err + } + + count := uint32(len(triangles)) + if err := binary.Write(f, binary.LittleEndian, count); err != nil { + return err + } + + buf := make([]byte, 50) + for _, t := range triangles { + binary.LittleEndian.PutUint32(buf[0:4], math.Float32bits(0)) + binary.LittleEndian.PutUint32(buf[4:8], math.Float32bits(0)) + binary.LittleEndian.PutUint32(buf[8:12], math.Float32bits(0)) + + binary.LittleEndian.PutUint32(buf[12:16], math.Float32bits(float32(t[0].X))) + binary.LittleEndian.PutUint32(buf[16:20], math.Float32bits(float32(t[0].Y))) + binary.LittleEndian.PutUint32(buf[20:24], math.Float32bits(float32(t[0].Z))) + + binary.LittleEndian.PutUint32(buf[24:28], math.Float32bits(float32(t[1].X))) + binary.LittleEndian.PutUint32(buf[28:32], math.Float32bits(float32(t[1].Y))) + binary.LittleEndian.PutUint32(buf[32:36], math.Float32bits(float32(t[1].Z))) + + binary.LittleEndian.PutUint32(buf[36:40], math.Float32bits(float32(t[2].X))) + binary.LittleEndian.PutUint32(buf[40:44], math.Float32bits(float32(t[2].Y))) + binary.LittleEndian.PutUint32(buf[44:48], math.Float32bits(float32(t[2].Z))) + + binary.LittleEndian.PutUint16(buf[48:50], 0) + + if _, err := f.Write(buf); err != nil { + return err + } + } + return nil +} + +func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) { + x0, y0 := x, y + x1, y1 := x+w, y+h + z0, z1 := 0.0, zHeight + + p000 := Point{x0, y0, z0} + p100 := Point{x1, y0, z0} + p110 := Point{x1, y1, z0} + p010 := Point{x0, y1, z0} + p001 := Point{x0, y0, z1} + p101 := Point{x1, y0, z1} + p111 := Point{x1, y1, z1} + p011 := Point{x0, y1, z1} + + addQuad := func(a, b, c, d Point) { + *triangles = append(*triangles, [3]Point{a, b, c}) + *triangles = append(*triangles, [3]Point{c, d, a}) + } + + addQuad(p000, p010, p110, p100) // Bottom + addQuad(p101, p111, p011, p001) // Top + addQuad(p000, p100, p101, p001) // Front + addQuad(p100, p110, p111, p101) // Right + addQuad(p110, p010, p011, p111) // Back + addQuad(p010, p000, p001, p011) // Left +} diff --git a/storage.go b/storage.go new file mode 100644 index 0000000..63c3901 --- /dev/null +++ b/storage.go @@ -0,0 +1,274 @@ +package main + +import ( + "encoding/json" + "fmt" + "image" + "image/png" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func formerBaseDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "former") +} + +func formerSessionsDir() string { + return filepath.Join(formerBaseDir(), "sessions") +} + +func formerProfilesDir() string { + return filepath.Join(formerBaseDir(), "profiles") +} + +func ensureFormerDirs() { + os.MkdirAll(formerSessionsDir(), 0755) + os.MkdirAll(formerProfilesDir(), 0755) +} + +// ProjectEntry represents a saved project on disk +type ProjectEntry struct { + Path string + Type string // "session" or "profile" + Data InstanceData + ModTime time.Time +} + +// SaveSession persists an enclosure session to ~/former/sessions/ +func SaveSession(inst InstanceData, sourceDir string, thumbnail image.Image) (string, error) { + ensureFormerDirs() + name := sanitizeDirName(inst.ProjectName) + if name == "" { + name = "untitled" + } + id := inst.ID + if len(id) > 8 { + id = id[:8] + } + projectDir := filepath.Join(formerSessionsDir(), fmt.Sprintf("%s-%s", name, id)) + if err := saveProject(projectDir, inst, sourceDir); err != nil { + return "", err + } + if thumbnail != nil { + SaveThumbnail(projectDir, thumbnail) + } + return projectDir, nil +} + +// SaveProfile persists an enclosure session as a named profile to ~/former/profiles/ +func SaveProfile(inst InstanceData, name string, sourceDir string, thumbnail image.Image) (string, error) { + ensureFormerDirs() + dirLabel := sanitizeDirName(name) + if dirLabel == "" { + dirLabel = "untitled" + } + id := inst.ID + if len(id) > 8 { + id = id[:8] + } + projectDir := filepath.Join(formerProfilesDir(), fmt.Sprintf("%s-%s", dirLabel, id)) + inst.Name = name + if err := saveProject(projectDir, inst, sourceDir); err != nil { + return "", err + } + if thumbnail != nil { + SaveThumbnail(projectDir, thumbnail) + } + return projectDir, nil +} + +func saveProject(projectDir string, inst InstanceData, sourceDir string) error { + os.MkdirAll(projectDir, 0755) + + // Copy gerber files using original filenames + newGerberFiles := make(map[string]string) + for origName, savedBasename := range inst.GerberFiles { + srcPath := filepath.Join(sourceDir, savedBasename) + dstPath := filepath.Join(projectDir, origName) + if err := CopyFile(srcPath, dstPath); err != nil { + // Fallback: try using origName directly + srcPath = filepath.Join(sourceDir, origName) + if err2 := CopyFile(srcPath, dstPath); err2 != nil { + return fmt.Errorf("copy %s: %v", origName, err) + } + } + newGerberFiles[origName] = origName + } + inst.GerberFiles = newGerberFiles + + // Copy drill files + if inst.DrillPath != "" { + srcPath := filepath.Join(sourceDir, inst.DrillPath) + ext := filepath.Ext(inst.DrillPath) + if ext == "" { + ext = ".drl" + } + dstName := "drill" + ext + dstPath := filepath.Join(projectDir, dstName) + if CopyFile(srcPath, dstPath) == nil { + inst.DrillPath = dstName + } + } + if inst.NPTHPath != "" { + srcPath := filepath.Join(sourceDir, inst.NPTHPath) + ext := filepath.Ext(inst.NPTHPath) + if ext == "" { + ext = ".drl" + } + dstName := "npth" + ext + dstPath := filepath.Join(projectDir, dstName) + if CopyFile(srcPath, dstPath) == nil { + inst.NPTHPath = dstName + } + } + + // Write former.json + data, err := json.MarshalIndent(inst, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644) +} + +// ListProjects returns all saved projects sorted by modification time (newest first). +// Pass limit=0 for no limit. +func ListProjects(limit int) ([]ProjectEntry, error) { + ensureFormerDirs() + var entries []ProjectEntry + + sessEntries, _ := listProjectsInDir(formerSessionsDir(), "session") + entries = append(entries, sessEntries...) + + profEntries, _ := listProjectsInDir(formerProfilesDir(), "profile") + entries = append(entries, profEntries...) + + sort.Slice(entries, func(i, j int) bool { + return entries[i].ModTime.After(entries[j].ModTime) + }) + + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + return entries, nil +} + +func listProjectsInDir(dir, projType string) ([]ProjectEntry, error) { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var entries []ProjectEntry + for _, de := range dirEntries { + if !de.IsDir() { + continue + } + jsonPath := filepath.Join(dir, de.Name(), "former.json") + info, err := os.Stat(jsonPath) + if err != nil { + continue + } + raw, err := os.ReadFile(jsonPath) + if err != nil { + continue + } + var inst InstanceData + if err := json.Unmarshal(raw, &inst); err != nil { + continue + } + entries = append(entries, ProjectEntry{ + Path: filepath.Join(dir, de.Name()), + Type: projType, + Data: inst, + ModTime: info.ModTime(), + }) + } + return entries, nil +} + +// LoadProject reads former.json from a project directory +func LoadProject(projectDir string) (*InstanceData, error) { + raw, err := os.ReadFile(filepath.Join(projectDir, "former.json")) + if err != nil { + return nil, err + } + var inst InstanceData + if err := json.Unmarshal(raw, &inst); err != nil { + return nil, err + } + return &inst, nil +} + +// UpdateProjectCutouts writes updated cutouts to an existing project's former.json +func UpdateProjectCutouts(projectDir string, cutouts []Cutout) error { + if projectDir == "" { + return nil + } + inst, err := LoadProject(projectDir) + if err != nil { + return err + } + inst.Cutouts = cutouts + // Clear legacy fields so they don't conflict + inst.SideCutouts = nil + inst.LidCutouts = nil + data, err := json.MarshalIndent(inst, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644) +} + +// TouchProject updates the mtime of a project's former.json +func TouchProject(projectDir string) { + jsonPath := filepath.Join(projectDir, "former.json") + now := time.Now() + os.Chtimes(jsonPath, now, now) +} + +// DeleteProject removes a project directory entirely +func DeleteProject(projectDir string) error { + return os.RemoveAll(projectDir) +} + +// RestoreProject loads and rebuilds a session from a project directory +func RestoreProject(projectDir string) (string, *EnclosureSession, *InstanceData, error) { + inst, err := LoadProject(projectDir) + if err != nil { + return "", nil, nil, err + } + sid, session, err := restoreSessionFromDir(inst, projectDir) + if err != nil { + return "", nil, nil, err + } + TouchProject(projectDir) + return sid, session, inst, nil +} + +// SaveThumbnail saves a preview image to the project directory +func SaveThumbnail(projectDir string, img image.Image) error { + f, err := os.Create(filepath.Join(projectDir, "thumbnail.png")) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + +func sanitizeDirName(name string) string { + name = strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { + return '-' + } + return r + }, name) + name = strings.TrimSpace(name) + if len(name) > 50 { + name = name[:50] + } + return name +} diff --git a/svg.go b/svg.go new file mode 100644 index 0000000..6dbd0a0 --- /dev/null +++ b/svg.go @@ -0,0 +1,182 @@ +package main + +import ( + "fmt" + "math" + "os" +) + +func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error { + b := *bounds + + widthMM := b.MaxX - b.MinX + heightMM := b.MaxY - b.MinY + + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + // Use mm directly for SVG + fmt.Fprintf(f, ``, + widthMM, heightMM, widthMM, heightMM) + fmt.Fprintf(f, "\n\n") + + // Note: SVG Y-axis points down. We need to invert Y: (heightMM - (y - b.MinY)) + toSVGX := func(x float64) float64 { return x - b.MinX } + toSVGY := func(y float64) float64 { return heightMM - (y - b.MinY) } + + curX, curY := 0.0, 0.0 + curDCode := 0 + interpolationMode := "G01" // Default linear + inRegion := false + var regionVertices [][2]float64 + + for _, cmd := range gf.Commands { + if cmd.Type == "APERTURE" { + curDCode = *cmd.D + continue + } + if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { + interpolationMode = cmd.Type + continue + } + if cmd.Type == "G36" { + inRegion = true + regionVertices = nil + continue + } + if cmd.Type == "G37" { + if len(regionVertices) >= 3 { + fmt.Fprintf(f, `\n") + } + inRegion = false + regionVertices = nil + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + if inRegion { + if cmd.Type == "MOVE" || (cmd.Type == "DRAW" && interpolationMode == "G01") { + regionVertices = append(regionVertices, [2]float64{curX, curY}) + } else if cmd.Type == "DRAW" && (interpolationMode == "G02" || interpolationMode == "G03") { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode) + for _, pt := range arcPts { + regionVertices = append(regionVertices, pt) + } + } + continue + } + + if cmd.Type == "FLASH" { + ap, ok := gf.State.Apertures[curDCode] + if ok { + writeSVGAperture(f, toSVGX(curX), toSVGY(curY), ap, false) + } + } else if cmd.Type == "DRAW" { + ap, ok := gf.State.Apertures[curDCode] + if ok { + // Basic stroke representation for lines + w := 0.1 // default + if len(ap.Modifiers) > 0 { + w = ap.Modifiers[0] + } + + if interpolationMode == "G01" { + fmt.Fprintf(f, ``+"\n", + toSVGX(prevX), toSVGY(prevY), toSVGX(curX), toSVGY(curY), w) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + + // SVG path arc (Y-axis inverted: G02 CW -> CCW in SVG, G03 CCW -> CW in SVG) + r := math.Sqrt(iVal*iVal + jVal*jVal) + acx, acy := prevX+iVal, prevY+jVal + sa := math.Atan2(prevY-acy, prevX-acx) + ea := math.Atan2(curY-acy, curX-acx) + var arcSpan float64 + if interpolationMode == "G03" { + if ea <= sa { ea += 2 * math.Pi } + arcSpan = ea - sa + } else { + if sa <= ea { sa += 2 * math.Pi } + arcSpan = sa - ea + } + largeArc := 0 + if arcSpan > math.Pi { largeArc = 1 } + sweep := 1 // G03 CCW Gerber -> CW SVG + if interpolationMode == "G02" { sweep = 0 } + + fmt.Fprintf(f, `` + "\n", + toSVGX(prevX), toSVGY(prevY), r, r, largeArc, sweep, toSVGX(curX), toSVGY(curY), w) + } + } + } + } + fmt.Fprintf(f, "\n\n") + return nil +} + +func writeSVGAperture(f *os.File, cx, cy float64, ap Aperture, isMacro bool) { + switch ap.Type { + case "C": + if len(ap.Modifiers) > 0 { + r := ap.Modifiers[0] / 2 + fmt.Fprintf(f, ``+"\n", cx, cy, r) + } + case "R": + if len(ap.Modifiers) >= 2 { + w, h := ap.Modifiers[0], ap.Modifiers[1] + fmt.Fprintf(f, ``+"\n", cx-w/2, cy-h/2, w, h) + } + case "O": + if len(ap.Modifiers) >= 2 { + w, h := ap.Modifiers[0], ap.Modifiers[1] + r := w / 2 + if h < w { + r = h / 2 + } + fmt.Fprintf(f, ``+"\n", cx-w/2, cy-h/2, w, h, r, r) + } + case "P": + if len(ap.Modifiers) >= 2 { + dia, numV := ap.Modifiers[0], int(ap.Modifiers[1]) + r := dia / 2 + rot := 0.0 + if len(ap.Modifiers) >= 3 { + rot = ap.Modifiers[2] + } + fmt.Fprintf(f, ``+"\n") + } + } +} diff --git a/svg_render_darwin.go b/svg_render_darwin.go new file mode 100644 index 0000000..a4cfba2 --- /dev/null +++ b/svg_render_darwin.go @@ -0,0 +1,117 @@ +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework AppKit -framework CoreGraphics + +#import +#import + +// Renders SVG data to raw RGBA pixels using macOS native NSImage. +// Returns NULL on failure. Caller must free() the returned pixels. +unsigned char* nativeRenderSVG(const void* svgBytes, int svgLen, int targetW, int targetH) { + @autoreleasepool { + NSData *data = [NSData dataWithBytesNoCopy:(void*)svgBytes length:svgLen freeWhenDone:NO]; + NSImage *svgImage = [[NSImage alloc] initWithData:data]; + if (!svgImage) return NULL; + + int w = targetW; + int h = targetH; + int rowBytes = w * 4; + int totalBytes = rowBytes * h; + + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:w + pixelsHigh:h + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSDeviceRGBColorSpace + bytesPerRow:rowBytes + bitsPerPixel:32]; + + [NSGraphicsContext saveGraphicsState]; + NSGraphicsContext *ctx = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep]; + [NSGraphicsContext setCurrentContext:ctx]; + + // Start with fully transparent background + [[NSColor clearColor] set]; + NSRectFill(NSMakeRect(0, 0, w, h)); + + // Draw SVG, preserving alpha + [svgImage drawInRect:NSMakeRect(0, 0, w, h) + fromRect:NSZeroRect + operation:NSCompositingOperationSourceOver + fraction:1.0]; + + [NSGraphicsContext restoreGraphicsState]; + + unsigned char* result = (unsigned char*)malloc(totalBytes); + if (result) { + memcpy(result, [rep bitmapData], totalBytes); + } + return result; + } +} +*/ +import "C" + +import ( + "image" + "image/color" + "unsafe" +) + +// renderSVGNative uses macOS NSImage to render SVG data to an image.Image +// with full transparency support. +func renderSVGNative(svgData []byte, width, height int) image.Image { + if len(svgData) == 0 { + return nil + } + + pixels := C.nativeRenderSVG( + unsafe.Pointer(&svgData[0]), + C.int(len(svgData)), + C.int(width), + C.int(height), + ) + if pixels == nil { + return nil + } + defer C.free(unsafe.Pointer(pixels)) + + rawLen := width * height * 4 + raw := unsafe.Slice((*byte)(unsafe.Pointer(pixels)), rawLen) + + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + i := (y*width + x) * 4 + r, g, b, a := raw[i], raw[i+1], raw[i+2], raw[i+3] + + // NSImage gives premultiplied alpha — convert to straight + if a > 0 && a < 255 { + scale := 255.0 / float64(a) + r = clampByte(float64(r) * scale) + g = clampByte(float64(g) * scale) + b = clampByte(float64(b) * scale) + } + + img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a}) + } + } + + return img +} + +func clampByte(v float64) uint8 { + if v > 255 { + return 255 + } + if v < 0 { + return 0 + } + return uint8(v) +} diff --git a/svg_render_other.go b/svg_render_other.go new file mode 100644 index 0000000..452e3e7 --- /dev/null +++ b/svg_render_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package main + +import "image" + +// renderSVGNative is a no-op on non-macOS platforms. +// Returns nil, causing callers to fall back to Fyne's built-in renderer. +func renderSVGNative(svgData []byte, width, height int) image.Image { + return nil +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..037ca77 --- /dev/null +++ b/util.go @@ -0,0 +1,12 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" +) + +func randomID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..b1fb2a1 --- /dev/null +++ b/wails.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Former", + "outputfilename": "Former", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "" + } +}