diff --git a/.gitignore b/.gitignore index a670ef6..2943729 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ ./temp/* -temp/ \ No newline at end of file +temp/ +build/bin +frontend/node_modules/ +frontend/dist/ +data/ +frontend/wailsjs/ +bin/ \ No newline at end of file diff --git a/README.md b/README.md index f830573..6054446 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ A Go tool to convert Gerber files (specifically solder paste layers) into 3D pri - Supports Aperture Macros (AM) with rotation (e.g., rounded rectangles). - Automatically crops the output to the PCB bounds. - Generates a 3D STL mesh optimized for 3D printing. +- **Enclosure Generation**: Automatically generates a snap-fit enclosure and tray based on the PCB outline. +- **Native OpenSCAD Support**: Exports native `.scad` scripts for parametric editing and customization. +- **Smart Cutouts**: Interactive side-cutout placement with automatic USB port alignment. +- **Tray Snap System**: Robust tray clips with vertical relief slots for secure enclosure latching. ## Usage @@ -35,6 +39,15 @@ go run main.go gerber.go -height=0.16 -keep-png my_board_paste_top.gbr my_board_ This will generate `my_board_paste_top.stl` in the same directory. +### Enclosure Generation + +To generate an enclosure, use the web interface. Upload a `.gbrjob` file and the associated Gerber layers. The tool will auto-discover the board thickness and outline. + +1. **Upload**: Provide the Gerber job and layout files. +2. **Configure**: Adjust wall thickness, clearance, and mounting hole parameters in the UI. +3. **Preview**: Use the interactive preview to place and align side cutouts for connectors. +4. **Export**: Generate STLs or OpenSCAD scripts for both the enclosure top and the tray. + ### Web Interface To start the web interface: @@ -59,10 +72,11 @@ These settings assume you run the tool with `-height=0.16` (the default). ## How it Works -1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws). -2. **Rendering**: It renders the PCB layer into a high-resolution internal image. -3. **Meshing**: It converts the image into a 3D mesh using a run-length encoding approach to optimize the triangle count. -4. **Export**: The mesh is saved as a binary STL file. +1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws). For enclosures, it parses the `.gbrjob` to identify board layers. +2. **Rendering**: It renders the PCB outline and layers into a high-resolution internal image. +3. **Path Extraction**: Board edges are traced and simplified to generate 2.5D geometry. +4. **Meshing**: It converts the image into a 3D mesh (STL) or generates CSG primitives (SCAD) using the board topology. +5. **Export**: The resulting files are saved for 3D printing or further CAD refinement. ## License diff --git a/app.go b/app.go new file mode 100644 index 0000000..402b08c --- /dev/null +++ b/app.go @@ -0,0 +1,946 @@ +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 + formerLayers []*FormerLayer + stencilFiles []string +} + +func NewApp(imageServer *ImageServer) *App { + return &App{ + imageServer: imageServer, + } +} + +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() + defer a.mu.Unlock() + a.cutouts = append(a.cutouts, Cutout{ + ID: randomID(), + Surface: "side", + SideNum: side, + X: x, + Y: y, + Width: w, + Height: h, + CornerRadius: radius, + SourceLayer: layer, + }) +} + +func (a *App) RemoveSideCutout(index int) { + a.mu.Lock() + defer a.mu.Unlock() + // 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:]...) + return + } + count++ + } + } +} + +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() + defer a.mu.Unlock() + if c.ID == "" { + c.ID = randomID() + } + a.cutouts = append(a.cutouts, c) + return c.ID +} + +func (a *App) UpdateCutout(c Cutout) { + a.mu.Lock() + defer a.mu.Unlock() + for i, existing := range a.cutouts { + if existing.ID == c.ID { + a.cutouts[i] = c + return + } + } +} + +func (a *App) RemoveCutout(id string) { + a.mu.Lock() + defer a.mu.Unlock() + for i, c := range a.cutouts { + if c.ID == id { + a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...) + return + } + } +} + +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() + defer a.mu.Unlock() + for _, c := range a.cutouts { + if c.ID == id { + dup := c + dup.ID = randomID() + // Offset slightly so it's visible + if dup.Surface == "side" { + dup.X += 1.0 + } else { + dup.X += 1.0 + dup.Y += 1.0 + } + a.cutouts = append(a.cutouts, dup) + return dup.ID + } + } + return "" +} + +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() + defer a.mu.Unlock() + + if a.enclosureSession == nil { + return + } + + bounds := a.enclosureSession.OutlineBounds + dpi := a.enclosureSession.Config.DPI + + surface := "top" + if plane == "tray" { + surface = "bottom" + } + + for _, el := range elements { + // Convert pixel coordinates to gerber mm coordinates + 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, + }) + } +} + +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() + defer a.mu.Unlock() + // Remove only top/bottom cutouts, keep side cutouts + var kept []Cutout + for _, c := range a.cutouts { + if c.Surface == "side" { + kept = append(kept, c) + } + } + a.cutouts = kept +} + +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 _, saveErr := SaveSession(inst, filepath.Join(".", "temp"), session.OutlineImg); saveErr != nil { + log.Printf("Warning: could not save session: %v", saveErr) + } + + // 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") + } + _, err := SaveProfile(inst, name, sourceDir, session.OutlineImg) + 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.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/bin/pcb-to-stencil b/bin/pcb-to-stencil deleted file mode 100755 index 910388a..0000000 Binary files a/bin/pcb-to-stencil and /dev/null differ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6615bd8 --- /dev/null +++ b/build.sh @@ -0,0 +1,12 @@ +#!/bin/bash +pkill -f Former || true +export SDKROOT=$(xcrun --show-sdk-path) +export CC=/usr/bin/clang +export CGO_ENABLED=1 + +# Generate app icon from Former.svg +echo "Generating app icon..." +go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped." + +~/go/bin/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..d377b15 --- /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/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/enclosure.go b/enclosure.go index f5bd1e0..7acda29 100644 --- a/enclosure.go +++ b/enclosure.go @@ -9,12 +9,12 @@ import ( // EnclosureConfig holds parameters for enclosure generation type EnclosureConfig struct { - PCBThickness float64 // mm - WallThickness float64 // mm - WallHeight float64 // mm (height of walls above PCB) - Clearance float64 // mm (gap between PCB and enclosure wall) - DPI float64 - OutlineBounds *Bounds // gerber coordinate bounds for drill mapping + 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 @@ -33,11 +33,85 @@ type EnclosureResult struct { // SideCutout defines a cutout on a side wall face type SideCutout struct { - Side int // 1-indexed side number (matches BoardSide.Num) - X, Y float64 // Position on the face in mm (from StartX/StartY, from bottom) - Width float64 // Width in mm - Height float64 // Height in mm - CornerRadius float64 // Corner radius in mm (0 for square) + 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 @@ -657,9 +731,9 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo minZ += trayFloor + pcbT maxZ += trayFloor + pcbT - // Wall below cutout: from 0 to minZ - if minZ > 0.05 { - addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, minZ) + // 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 { @@ -697,7 +771,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo by2 := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM bh := pixelToMM - AddBox(&newEncTris, bx, by2, bw, bh, totalH) + addBoxAtZ(&newEncTris, bx, by2, trayFloor, bw, bh, totalH-trayFloor) runStart = -1 } } 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-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9ab03a4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1113 @@ +{ + "name": "former-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "former-frontend", + "version": "0.0.0", + "dependencies": { + "three": "^0.183.1" + }, + "devDependencies": { + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.1.tgz", + "integrity": "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} 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..1d36e98 --- /dev/null +++ b/frontend/src/former3d.js @@ -0,0 +1,873 @@ +// 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 { + 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.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; + + // 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(); + } + + // 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.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..6fb7d8c --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,1441 @@ +// 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`; + }); + + // Escape key exits cutout mode + const escHandler = e => { + if (e.key === 'Escape' && former3d?.cutoutMode) { + formerExitCutoutMode(); + } + }; + document.addEventListener('keydown', escHandler); + // Store so we can remove on dispose + state.former._escHandler = escHandler; + + // 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); + } + } + } 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 plane = await showPlaneDialog(); + if (plane) { + try { + await wails()?.AddLidCutouts(selected, plane, isDado, depth); + console.log(`Added ${selected.length} ${isDado ? 'dado' : 'cutout'} elements to ${plane}`); + } catch (e) { + console.error('AddLidCutouts failed:', 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'; + } +} + +function showPlaneDialog() { + 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:240px;text-align:center;'; + box.innerHTML = ` +
Apply cutout to which surface?
+
+ + + +
+ `; + overlay.appendChild(box); + document.body.appendChild(overlay); + box.querySelector('#plane-lid').onclick = () => { overlay.remove(); resolve('lid'); }; + box.querySelector('#plane-tray').onclick = () => { overlay.remove(); resolve('tray'); }; + box.querySelector('#plane-cancel').onclick = () => { overlay.remove(); resolve(null); }; + overlay.onclick = e => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + }); +} + +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, +}); + +// ===== Init ===== +window.addEventListener('DOMContentLoaded', () => { + navigate('landing'); +}); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..5a229c0 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,700 @@ +/* Former — Dark Theme */ +:root { + --bg-base: #1e1e2e; + --bg-surface: #313244; + --bg-overlay: #45475a; + --bg-input: #1e1e2e; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-subtle: #6c7086; + --accent: #89b4fa; + --accent-hover: #74c7ec; + --accent-dim: rgba(137, 180, 250, 0.15); + --success: #a6e3a1; + --error: #f38ba8; + --warning: #fab387; + --border: #585b70; + --border-light: #45475a; + --radius: 8px; + --radius-sm: 4px; + --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: 38px; + flex-shrink: 0; + background: var(--bg-surface); + 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; + 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: 600; + 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: 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.1) 100%); + mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.1) 100%); +} + +.landing-bg-logo { + width: 50%; + height: auto; + opacity: 0.8; + filter: brightness(1.4); +} + +.landing-hero { + text-align: center; + padding: 0 0 28px; + position: relative; + z-index: 1; +} + +.landing-hero h1 { + font-size: 28px; + font-weight: 700; + margin-bottom: 4px; +} + +.landing-hero p { + color: var(--text-secondary); + font-size: 14px; +} + +.landing-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 32px; + position: relative; + z-index: 1; +} + +.action-card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 20px; + cursor: pointer; + transition: all var(--transition); + text-align: left; +} + +.action-card:hover { + border-color: var(--accent); + background: var(--accent-dim); +} + +.action-card h3 { + font-size: 15px; + font-weight: 600; + margin-bottom: 4px; +} + +.action-card p { + font-size: 13px; + color: var(--text-secondary); +} + +/* Section titles */ +.section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +/* Recent projects */ +.project-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.project-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + 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(--accent); + background: var(--accent-dim); +} + +.project-name { + flex: 1; + font-size: 14px; + font-weight: 500; +} + +.project-meta { + font-size: 12px; + color: var(--text-subtle); +} + +.badge { + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + background: var(--bg-overlay); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* Cards */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 12px; +} + +.card-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 12px; +} + +/* Form elements */ +.form-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.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: 6px 10px; + outline: none; + transition: border-color var(--transition); +} + +.form-input:focus { + border-color: var(--accent); +} + +.form-input::placeholder { + color: var(--text-subtle); +} + +select.form-input { + cursor: pointer; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-surface); + color: var(--text-primary); + font: inherit; + font-size: 13px; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.btn:hover { + background: var(--bg-overlay); + border-color: var(--text-subtle); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #1e1e2e; + font-weight: 600; +} + +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-danger { + color: var(--error); + border-color: var(--error); +} + +.btn-danger:hover { + background: rgba(243, 139, 168, 0.1); +} + +/* File picker row */ +.file-row { + display: flex; + align-items: center; + gap: 8px; +} + +.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: 4px; + font-size: 13px; + cursor: pointer; +} + +.check-label input[type="checkbox"] { + accent-color: var(--accent); +} + +/* Action bar */ +.action-bar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; +} + +.action-bar .spacer { + flex: 1; +} + +/* Page header */ +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.page-header h2 { + font-size: 18px; + font-weight: 600; +} + +/* Board preview canvas */ +.board-canvas-wrap { + background: #34353c; + 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: 4px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.side-tab { + padding: 4px 10px; + font-size: 12px; + 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(--accent); + border-color: var(--accent); + color: #1e1e2e; +} + +/* Cutout list */ +.cutout-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; +} + +.cutout-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-base); + 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); + padding: 12px; + border-radius: var(--radius-sm); + margin-bottom: 16px; +} + +/* Loading spinner */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--accent); + 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(30, 30, 46, 0.8); + z-index: 100; + flex-direction: column; + gap: 12px; +} + +.loading-overlay .spinner { + width: 32px; + height: 32px; + border-width: 3px; +} + +.loading-text { + font-size: 14px; + color: var(--text-secondary); +} + +/* ===== 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: 260px; + background: var(--bg-surface); + border-left: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow-y: auto; + flex-shrink: 0; +} + +.former-sidebar-header { + padding: 12px 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: 600; +} + +.former-layers { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.former-layer-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: var(--radius-sm); + transition: background var(--transition); +} + +.former-layer-row:hover { + background: var(--bg-overlay); +} + +.former-layer-row.highlighted { + background: var(--accent-dim); +} + +.former-layer-row.selected { + background: rgba(137, 180, 250, 0.25); + border-left: 2px solid var(--accent); + padding-left: 6px; +} + +.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: 12px 16px; + border-top: 1px solid var(--border-light); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--bg-overlay); + 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: 6px; + flex-wrap: wrap; +} 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/go.mod b/go.mod index a3cf92d..70ffe04 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +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 index 3d8d50f..af8caf2 100644 --- a/main.go +++ b/main.go @@ -1,1414 +1,103 @@ package main import ( - "archive/zip" - "crypto/rand" "embed" - "encoding/binary" - "encoding/hex" - "encoding/json" "flag" "fmt" - "html/template" - "image" - "image/color" - "image/draw" - "image/png" - "io" "log" - "math" - "net/http" "os" - "path/filepath" - "strconv" - "strings" - "sync" + + "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" ) -// --- Configuration --- +//go:embed all:frontend/dist +var assets embed.FS -type Config struct { - StencilHeight float64 - WallHeight float64 - WallThickness float64 - DPI float64 - KeepPNG bool +//go:embed static/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() } -// Default values -const ( - DefaultStencilHeight = 0.16 - DefaultWallHeight = 2.0 - DefaultWallThickness = 1.0 - DefaultDPI = 1000.0 -) - -// --- STL 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() - - // Write Binary STL Header (80 bytes) - header := make([]byte, 80) - copy(header, "Generated by pcb-to-stencil") - if _, err := f.Write(header); err != nil { - return err - } - - // Write Number of Triangles (4 bytes uint32) - count := uint32(len(triangles)) - if err := binary.Write(f, binary.LittleEndian, count); err != nil { - return err - } - - // Write Triangles - // Each triangle is 50 bytes: - // Normal (3 floats = 12 bytes) - // Vertex 1 (3 floats = 12 bytes) - // Vertex 2 (3 floats = 12 bytes) - // Vertex 3 (3 floats = 12 bytes) - // Attribute byte count (2 bytes uint16) - - // Buffer for a single triangle to minimize syscalls - buf := make([]byte, 50) - - for _, t := range triangles { - // Normal (0,0,0) - 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)) - - // Vertex 1 - 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))) - - // Vertex 2 - 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))) - - // Vertex 3 - 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))) - - // Attribute byte count (0) - 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 -} - -// --- Meshing Logic (Optimized) --- - -// ComputeWallMask generates a mask for the wall based on the outline image. -// It identifies the board area (inside the outline) and creates a wall of -// specified thickness around it. -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 - - // Helper for neighbors - dx := []int{0, 0, 1, -1} - dy := []int{1, -1, 0, 0} - - // 1. Identify Outline Pixels (White) - 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 { // White-ish - isOutline[i] = true - outlineQueue = append(outlineQueue, i) - } - } - - // 2. Dilate Outline to close gaps - // We dilate by a small amount (e.g. 0.5mm) to ensure the outline is closed. - 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 - } - } - - // BFS for Dilation - dilatedOutline := make([]bool, size) - copy(dilatedOutline, isOutline) - - // Use a separate queue for dilation to avoid modifying the original outlineQueue if we needed it - 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) - } - } - } - } - - // 3. Flood Fill "Outside" using Dilated Outline as barrier - isOutside := make([]bool, size) - // Start from (0,0) - assumed to be outside due to padding - 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) - } - } - } - } - } - - // 4. Restore Board Shape (Erode "Outside" back to original boundary) - // We dilated the outline, so "Outside" stopped 'gapClosingPixels' away from the real board edge. - // We need to expand "Outside" inwards by 'gapClosingPixels' to touch the real board edge. - // Then "Board" = !Outside. - - // Reset dist for Outside expansion - 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) - } - } - } - } - - // 5. Define Board - isBoard := make([]bool, size) - for i := 0; i < size; i++ { - isBoard[i] = !isOutsideExpanded[i] - } - - // 6. Generate Wall - // Wall is generated by expanding Board outwards. - // We want the wall to be strictly OUTSIDE the board. - // If we expand Board, we get pixels outside. - - thicknessPixels := int(thicknessMM / pixelToMM) - if thicknessPixels < 1 { - thicknessPixels = 1 - } - - // Reset dist for Wall generation - 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 { - fmt.Println("Computing wall mask...") - wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM) - } - - // Optimization: Run-Length Encoding - for y := 0; y < height; y++ { - var startX = -1 - var currentHeight = 0.0 - - for x := 0; x < width; x++ { - // Check stencil (black = solid) - sc := stencilImg.At(x, y) - sr, sg, sb, _ := sc.RGBA() - isStencilSolid := sr < 10000 && sg < 10000 && sb < 10000 - - // Check wall - isWall := false - isInsideBoard := true - if wallDist != nil { - idx := y*width + x - isWall = wallDist[idx] >= 0 - if boardMask != nil { - isInsideBoard = boardMask[idx] - } - } - - // Determine height at this pixel - 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 { - // Height changed, end current strip and start new one - stripLen := x - startX - AddBox( - &triangles, - float64(startX)*pixelToMM, - float64(y)*pixelToMM, - float64(stripLen)*pixelToMM, - pixelToMM, - currentHeight, - ) - startX = x - currentHeight = h - } - } else { - if startX != -1 { - // End of strip, generate box - 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 -} - -// --- Logic --- - -func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, error) { - baseName := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) - var generatedFiles []string - - // Helper to check what formats were requested - wantsType := func(t string) bool { - for _, e := range exports { - if e == t { - return true - } - } - return false - } - - // Always default to STL if nothing is specified - if len(exports) == 0 { - exports = []string{"stl"} - } - - // 1. Parse Gerber(s) - fmt.Printf("Parsing %s...\n", gerberPath) - gf, err := ParseGerber(gerberPath) - if err != nil { - return 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, fmt.Errorf("error parsing outline gerber: %v", err) - } - } - - // 2. Calculate Union Bounds - 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 - } - } - - // Expand bounds to accommodate wall thickness and prevent clipping - margin := cfg.WallThickness + 5.0 // mm - bounds.MinX -= margin - bounds.MinY -= margin - bounds.MaxX += margin - bounds.MaxY += margin - - // 3. Render to Image(s) - fmt.Println("Rendering to internal image...") - img := gf.Render(cfg.DPI, &bounds) - - var outlineImg image.Image - if outlineGf != nil { - fmt.Println("Rendering outline to internal image...") - outlineImg = outlineGf.Render(cfg.DPI, &bounds) - } - - if cfg.KeepPNG { - pngPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".png" - fmt.Printf("Saving intermediate PNG to %s...\n", pngPath) - 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") || wantsType("scad") { - // 4. Generate Mesh - fmt.Println("Generating mesh...") - triangles = GenerateMeshFromImages(img, outlineImg, cfg) - } - - // 5. Output based on requested formats - 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, fmt.Errorf("error writing stl: %v", err) - } - generatedFiles = append(generatedFiles, outputFilename) - } - - if wantsType("svg") { - outputFilename := baseName + ".svg" - fmt.Printf("Saving to %s (SVG)...\n", outputFilename) - if err := WriteSVG(outputFilename, gf, &bounds); err != nil { - return nil, fmt.Errorf("error writing svg: %v", err) - } - generatedFiles = append(generatedFiles, outputFilename) - } - - if wantsType("png") { - outputFilename := baseName + ".png" - fmt.Printf("Saving to %s (PNG)...\n", outputFilename) - if f, err := os.Create(outputFilename); err == nil { - png.Encode(f, img) - f.Close() - generatedFiles = append(generatedFiles, outputFilename) - } - } - - if wantsType("scad") { - outputFilename := baseName + ".scad" - fmt.Printf("Saving to %s (SCAD)...\n", outputFilename) - if err := WriteSCAD(outputFilename, triangles); err != nil { - return nil, fmt.Errorf("error writing scad: %v", err) - } - generatedFiles = append(generatedFiles, outputFilename) - } - - return generatedFiles, nil -} - -// --- CLI --- - func runCLI(cfg Config, args []string) { if len(args) < 1 { - fmt.Println("Usage: go run main.go [options] [path_to_outline_gerber_file]") - fmt.Println("Options:") + fmt.Println("Usage: former [options] [outline_file]") + fmt.Println(" former (no args = launch GUI)") flag.PrintDefaults() - fmt.Println("Example: go run main.go -height=0.3 MyPCB.GTP MyPCB.GKO") os.Exit(1) } - gerberPath := args[0] var outlinePath string if len(args) > 1 { outlinePath = args[1] } - - _, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"}) + _, _, _, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"}) if err != nil { log.Fatalf("Error: %v", err) } fmt.Println("Success! Happy printing.") } -// --- Server --- +func runGUI() { + imageServer := NewImageServer() + app := NewApp(imageServer) -//go:embed static/* -var staticFiles embed.FS + 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, + Bind: []interface{}{ + app, + }, + Mac: &mac.Options{ + TitleBar: mac.TitleBarHiddenInset(), + About: &mac.AboutInfo{ + Title: "Former", + Message: "PCB Stencil & Enclosure Generator", + }, + WebviewIsTransparent: true, + WindowIsTranslucent: false, + }, + }) -func randomID() string { - b := make([]byte, 16) - rand.Read(b) - return hex.EncodeToString(b) -} - -func indexHandler(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - // Read index.html from embedded FS - content, err := staticFiles.ReadFile("static/index.html") if err != nil { - http.Error(w, "Could not load index page", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/html") - w.Write(content) -} - -func uploadHandler(w http.ResponseWriter, r *http.Request) { - // Parse the multipart form BEFORE reading FormValue. - // Without this, FormValue can't see fields in a multipart/form-data body, - // so all numeric parameters silently fall back to defaults. - r.ParseMultipartForm(32 << 20) - - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Create temp dir - tempDir := filepath.Join(".", "temp") - os.MkdirAll(tempDir, 0755) - - uuid := randomID() - - // Parse params - height, _ := strconv.ParseFloat(r.FormValue("height"), 64) - dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64) - wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64) - wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64) - - if height == 0 { - height = DefaultStencilHeight - } - if dpi == 0 { - dpi = DefaultDPI - } - if wallHeight == 0 { - wallHeight = DefaultWallHeight - } - if wallThickness == 0 { - wallThickness = DefaultWallThickness - } - - cfg := Config{ - StencilHeight: height, - WallHeight: wallHeight, - WallThickness: wallThickness, - DPI: dpi, - KeepPNG: false, - } - - // Handle Gerber File - file, header, err := r.FormFile("gerber") - if err != nil { - http.Error(w, "Error retrieving gerber file", http.StatusBadRequest) - return - } - defer file.Close() - - gerberPath := filepath.Join(tempDir, uuid+"_paste"+filepath.Ext(header.Filename)) - outFile, err := os.Create(gerberPath) - if err != nil { - http.Error(w, "Server error saving file", http.StatusInternalServerError) - return - } - defer outFile.Close() - io.Copy(outFile, file) - - // Handle Outline File (Optional) - outlineFile, outlineHeader, err := r.FormFile("outline") - var outlinePath string - if err == nil { - defer outlineFile.Close() - outlinePath = filepath.Join(tempDir, uuid+"_outline"+filepath.Ext(outlineHeader.Filename)) - outOutline, err := os.Create(outlinePath) - if err == nil { - defer outOutline.Close() - io.Copy(outOutline, outlineFile) - } - } - - // Process - exports := r.Form["exports"] - generatedPaths, err := processPCB(gerberPath, outlinePath, cfg, exports) - if err != nil { - log.Printf("Error processing: %v", err) - http.Error(w, fmt.Sprintf("Error processing PCB: %v", err), http.StatusInternalServerError) - return - } - - var generatedFiles []string - for _, p := range generatedPaths { - generatedFiles = append(generatedFiles, filepath.Base(p)) - } - - // Generate Master Zip if more than 1 file - var zipFile string - if len(generatedFiles) > 1 { - zipPath := filepath.Join(tempDir, uuid+"_all_exports.zip") - zf, err := os.Create(zipPath) - if err == nil { - zw := zip.NewWriter(zf) - for _, fn := range generatedFiles { - fw, _ := zw.Create(fn) - fb, _ := os.ReadFile(filepath.Join(tempDir, fn)) - fw.Write(fb) - } - zw.Close() - zf.Close() - zipFile = filepath.Base(zipPath) - } - } - - // Render Success - renderResult(w, "Your stencil has been generated successfully.", generatedFiles, "/", zipFile) -} - -func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { - r.ParseMultipartForm(32 << 20) - - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - tempDir := filepath.Join(".", "temp") - os.MkdirAll(tempDir, 0755) - uuid := randomID() - - // Parse params - wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64) - wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64) - clearance, _ := strconv.ParseFloat(r.FormValue("clearance"), 64) - dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64) - - if wallThickness == 0 { - wallThickness = DefaultEncWallThick - } - if wallHeight == 0 { - wallHeight = DefaultEncWallHeight - } - if clearance == 0 { - clearance = DefaultClearance - } - if dpi == 0 { - dpi = 600 - } - - // Handle GerberJob file (required) - gbrjobFile, gbrjobHeader, err := r.FormFile("gbrjob") - if err != nil { - http.Error(w, "Gerber job file (.gbrjob) is required", http.StatusBadRequest) - return - } - defer gbrjobFile.Close() - - gbrjobPath := filepath.Join(tempDir, uuid+"_"+gbrjobHeader.Filename) - jf, err := os.Create(gbrjobPath) - if err != nil { - http.Error(w, "Server error saving file", http.StatusInternalServerError) - return - } - io.Copy(jf, gbrjobFile) - jf.Close() - - jobResult, err := ParseGerberJob(gbrjobPath) - if err != nil { - http.Error(w, fmt.Sprintf("Error parsing gbrjob: %v", err), http.StatusBadRequest) - return - } - - // Auto-fill PCB thickness from job file - pcbThickness := jobResult.BoardThickness - if pcbThickness == 0 { - pcbThickness = DefaultPCBThickness - } - - ecfg := EnclosureConfig{ - PCBThickness: pcbThickness, - WallThickness: wallThickness, - WallHeight: wallHeight, - Clearance: clearance, - DPI: dpi, - } - - // Handle uploaded gerber files (multi-select) - // Save all gerbers, then match to layers from job file - gerberFiles := r.MultipartForm.File["gerbers"] - savedGerbers := make(map[string]string) // filename → saved path - for _, fh := range gerberFiles { - f, err := fh.Open() - if err != nil { - continue - } - savePath := filepath.Join(tempDir, uuid+"_"+fh.Filename) - sf, err := os.Create(savePath) - if err != nil { - f.Close() - continue - } - io.Copy(sf, f) - sf.Close() - f.Close() - savedGerbers[fh.Filename] = savePath - } - - // Find the outline (Edge.Cuts) gerber - outlinePath, ok := savedGerbers[jobResult.EdgeCutsFile] - if !ok { - http.Error(w, fmt.Sprintf("Edge.Cuts file '%s' not found in uploaded gerbers. Upload all .gbr files.", jobResult.EdgeCutsFile), http.StatusBadRequest) - return - } - - // Handle PTH Drill File (optional) - var drillHoles []DrillHole - drillFile, drillHeader, err := r.FormFile("drill") - if err == nil { - defer drillFile.Close() - drillPath := filepath.Join(tempDir, uuid+"_drill"+filepath.Ext(drillHeader.Filename)) - df, err := os.Create(drillPath) - if err == nil { - io.Copy(df, drillFile) - df.Close() - holes, err := ParseDrill(drillPath) - if err != nil { - log.Printf("Warning: Could not parse PTH drill file: %v", err) - } else { - drillHoles = append(drillHoles, holes...) - fmt.Printf("Parsed %d PTH drill holes\n", len(holes)) - } - } - } - - // Handle NPTH Drill File (optional) - npthFile, npthHeader, err := r.FormFile("npth") - if err == nil { - defer npthFile.Close() - npthPath := filepath.Join(tempDir, uuid+"_npth"+filepath.Ext(npthHeader.Filename)) - nf, err := os.Create(npthPath) - if err == nil { - io.Copy(nf, npthFile) - nf.Close() - holes, err := ParseDrill(npthPath) - if err != nil { - log.Printf("Warning: Could not parse NPTH drill file: %v", err) - } else { - drillHoles = append(drillHoles, holes...) - fmt.Printf("Parsed %d NPTH drill holes\n", len(holes)) - } - } - } - - // Filter out vias — only keep component and mounting holes - var filteredHoles []DrillHole - for _, h := range drillHoles { - if h.Type != DrillTypeVia { - filteredHoles = append(filteredHoles, h) - } - } - fmt.Printf("After filtering: %d holes (vias removed)\n", len(filteredHoles)) - - // Parse outline gerber - fmt.Printf("Parsing outline %s...\n", outlinePath) - outlineGf, err := ParseGerber(outlinePath) - if err != nil { - http.Error(w, fmt.Sprintf("Error parsing outline: %v", err), http.StatusInternalServerError) - return - } - - outlineBounds := outlineGf.CalculateBounds() - - // Save actual board dimensions before adding margins - actualBoardW := outlineBounds.MaxX - outlineBounds.MinX - actualBoardH := outlineBounds.MaxY - outlineBounds.MinY - - // Add margin for enclosure walls - margin := ecfg.WallThickness + ecfg.Clearance + 5.0 - outlineBounds.MinX -= margin - outlineBounds.MinY -= margin - outlineBounds.MaxX += margin - outlineBounds.MaxY += margin - - // Render outline to image - fmt.Println("Rendering outline...") - ecfg.OutlineBounds = &outlineBounds - outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds) - - // Compute board box and count from the rendered image - minBX, minBY, maxBX, maxBY := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y, -1, -1 - var boardCenterY float64 - var boardCount int - - wallMaskInt, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4) - _ = wallMaskInt // Not used here, but we need boardMask - - 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 y < minBY { - minBY = y - } - if x > maxBX { - maxBX = x - } - if y > maxBY { - maxBY = y - } - boardCenterY += float64(y) - boardCount++ - } - } - } - if boardCount > 0 { - boardCenterY /= float64(boardCount) - } - - // Auto-discover and render F.Courtyard from job file - var courtyardImg image.Image - if courtPath, ok := savedGerbers[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" { - courtGf, err := ParseGerber(courtPath) - if err != nil { - log.Printf("Warning: Could not parse courtyard gerber: %v", err) - } else { - fmt.Println("Rendering courtyard layer...") - courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds) - } - } - - // Auto-discover and render F.Mask from job file - var soldermaskImg image.Image - if maskPath, ok := savedGerbers[jobResult.SoldermaskFile]; ok && jobResult.SoldermaskFile != "" { - maskGf, err := ParseGerber(maskPath) - if err != nil { - log.Printf("Warning: Could not parse soldermask gerber: %v", err) - } else { - fmt.Println("Rendering soldermask layer...") - soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds) - } - } - - // Also try F.Fab as fallback courtyard (many boards have F.Fab but not F.Courtyard) - if courtyardImg == nil && jobResult.FabFile != "" { - if fabPath, ok := savedGerbers[jobResult.FabFile]; ok { - fabGf, err := ParseGerber(fabPath) - if err != nil { - log.Printf("Warning: Could not parse fab gerber: %v", err) - } else { - fmt.Println("Rendering F.Fab layer as courtyard fallback...") - courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds) - } - } - } - - pixelToMM := 25.4 / ecfg.DPI - session := &EnclosureSession{ - Exports: r.Form["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), - } - sessionsMu.Lock() - sessions[uuid] = session - sessionsMu.Unlock() - - // Redirect to preview page - http.Redirect(w, r, "/preview?id="+uuid, http.StatusSeeOther) - -} - -func footprintUploadHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - err := r.ParseMultipartForm(50 << 20) // 50 MB - if err != nil { - http.Error(w, "Error parsing form", http.StatusBadRequest) - return - } - - sessionID := r.FormValue("sessionId") - if sessionID == "" { - http.Error(w, "Missing sessionId", http.StatusBadRequest) - return - } - - sessionsMu.Lock() - session, ok := sessions[sessionID] - sessionsMu.Unlock() - if !ok { - http.Error(w, "Invalid session", http.StatusBadRequest) - return - } - - files := r.MultipartForm.File["gerbers"] - var allFootprints []Footprint - var fabGfList []*GerberFile - - for _, fileHeader := range files { - f, err := fileHeader.Open() - if err != nil { - continue - } - - b := make([]byte, 8) - rand.Read(b) - tempPath := filepath.Join("temp", fmt.Sprintf("%x_%s", b, fileHeader.Filename)) - out, err := os.Create(tempPath) - if err == nil { - io.Copy(out, f) - out.Close() - gf, err := ParseGerber(tempPath) - if err == nil { - allFootprints = append(allFootprints, ExtractFootprints(gf)...) - fabGfList = append(fabGfList, gf) - } - } - f.Close() - } - - // Composite Fab images into one transparent overlay - 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)) - // Initialize with pure transparency - 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++ { - // Gerber render background is Black. White is drawn pixels. - if r, _, _, _ := rgba.At(x, y).RGBA(); r > 0x7FFF { - // Set as cyan overlay for visibility - composite.Set(x, y, color.RGBA{0, 255, 255, 180}) - } - } - } - } - } - sessionsMu.Lock() - session.FabImg = composite - sessionsMu.Unlock() - } - } - - // Return all parsed footprints for visual selection - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(allFootprints) -} - -func renderResult(w http.ResponseWriter, message string, files []string, backURL string, zipFile string) { - tmpl, err := template.ParseFS(staticFiles, "static/result.html") - if err != nil { - http.Error(w, "Template error", http.StatusInternalServerError) - return - } - data := struct { - Message string - Files []string - BackURL string - ZipFile string - }{Message: message, Files: files, BackURL: backURL, ZipFile: zipFile} - tmpl.Execute(w, data) -} - -// --- Enclosure Preview 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 -} - -var ( - sessions = make(map[string]*EnclosureSession) - sessionsMu sync.Mutex -) - -func previewHandler(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("id") - sessionsMu.Lock() - session, ok := sessions[id] - sessionsMu.Unlock() - if !ok { - http.Error(w, "Session not found. Please re-upload your files.", http.StatusNotFound) - return - } - - boardInfo := struct { - BoardW float64 `json:"boardW"` - BoardH float64 `json:"boardH"` - TotalH float64 `json:"totalH"` - Sides []BoardSide `json:"sides"` - MinX float64 `json:"minX"` - MaxY float64 `json:"maxY"` - DPI float64 `json:"dpi"` - }{ - BoardW: session.BoardW, - BoardH: session.BoardH, - TotalH: session.TotalH, - Sides: session.Sides, - MinX: session.OutlineBounds.MinX, - MaxY: session.OutlineBounds.MaxY, - DPI: session.Config.DPI, - } - boardJSON, _ := json.Marshal(boardInfo) - - tmpl, err := template.ParseFS(staticFiles, "static/preview.html") - if err != nil { - http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError) - return - } - - data := struct { - SessionID string - BoardInfoJSON template.JS - }{ - SessionID: id, - BoardInfoJSON: template.JS(boardJSON), - } - tmpl.Execute(w, data) -} - -func previewImageHandler(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(r.URL.Path, "/") - if len(parts) < 3 { - http.NotFound(w, r) - return - } - id := parts[2] - - sessionsMu.Lock() - session, ok := sessions[id] - sessionsMu.Unlock() - if !ok { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Type", "image/png") - png.Encode(w, session.OutlineImg) -} - -func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - r.ParseForm() - - id := r.FormValue("sessionId") - sessionsMu.Lock() - session, ok := sessions[id] - sessionsMu.Unlock() - if !ok { - http.Error(w, "Session expired. Please re-upload your files.", http.StatusNotFound) - return - } - - // Parse side cutouts from JSON - var sideCutouts []SideCutout - cutoutsJSON := r.FormValue("sideCutouts") - if cutoutsJSON != "" && cutoutsJSON != "[]" { - var rawCutouts []struct { - Side int `json:"side"` - X float64 `json:"x"` - Y float64 `json:"y"` - W float64 `json:"w"` - H float64 `json:"h"` - R float64 `json:"r"` - } - if err := json.Unmarshal([]byte(cutoutsJSON), &rawCutouts); err != nil { - log.Printf("Warning: could not parse side cutouts: %v", err) - } else { - for _, rc := range rawCutouts { - sideCutouts = append(sideCutouts, SideCutout{ - Side: rc.Side, - X: rc.X, - Y: rc.Y, - Width: rc.W, - Height: rc.H, - CornerRadius: rc.R, - }) - } - } - fmt.Printf("Side cutouts: %d\n", len(sideCutouts)) - } - - var generatedFiles []string - - // Helper to check what formats were requested - wantsType := func(t string) bool { - for _, e := range session.Exports { - if e == t { - return true - } - } - return false - } - - // Always default to STL if nothing is specified - if len(session.Exports) == 0 { - session.Exports = []string{"stl"} - } - - // Process STL - if wantsType("stl") { - fmt.Println("Generating STLs...") - result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts, session.Sides) - encPath := filepath.Join("temp", id+"_enclosure.stl") - trayPath := filepath.Join("temp", id+"_tray.stl") - WriteSTL(encPath, result.EnclosureTriangles) - WriteSTL(trayPath, result.TrayTriangles) - generatedFiles = append(generatedFiles, filepath.Base(encPath), filepath.Base(trayPath)) - } - - // Process SCAD - if wantsType("scad") { - fmt.Println("Generating Native SCAD scripts...") - scadPathEnc := filepath.Join("temp", id+"_enclosure.scad") - scadPathTray := filepath.Join("temp", id+"_tray.scad") - outlinePoly := ExtractPolygonFromGerber(session.OutlineGf) - WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) - WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) - generatedFiles = append(generatedFiles, filepath.Base(scadPathEnc), filepath.Base(scadPathTray)) - } - - // Process SVG - if wantsType("svg") && session.OutlineGf != nil { - fmt.Println("Generating SVG vector outline...") - svgPath := filepath.Join("temp", id+"_outline.svg") - WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds) - generatedFiles = append(generatedFiles, filepath.Base(svgPath)) - } - - // Process PNG - if wantsType("png") && session.OutlineImg != nil { - fmt.Println("Generating PNG raster outline...") - pngPath := filepath.Join("temp", id+"_outline.png") - fImg, _ := os.Create(pngPath) - png.Encode(fImg, session.OutlineImg) - fImg.Close() - generatedFiles = append(generatedFiles, filepath.Base(pngPath)) - } - - // Generate Master Zip if more than 1 file - var zipFile string - if len(generatedFiles) > 1 { - zipPath := filepath.Join("temp", id+"_all_exports.zip") - zf, err := os.Create(zipPath) - if err == nil { - zw := zip.NewWriter(zf) - for _, fn := range generatedFiles { - fw, _ := zw.Create(fn) - fb, _ := os.ReadFile(filepath.Join("temp", fn)) - fw.Write(fb) - } - zw.Close() - zf.Close() - zipFile = filepath.Base(zipPath) - } - } - - // We intentionally do NOT delete the session here so the user can hit "Back for Adjustments" - renderResult(w, "Your files have been generated successfully.", generatedFiles, "/preview?id="+id, zipFile) -} - -func downloadHandler(w http.ResponseWriter, r *http.Request) { - vars := strings.Split(r.URL.Path, "/") - if len(vars) < 3 { - http.NotFound(w, r) - return - } - filename := vars[2] - - // Security check: ensure no path traversal - if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") { - http.Error(w, "Invalid filename", http.StatusBadRequest) - return - } - - path := filepath.Join("temp", filename) - if _, err := os.Stat(path); os.IsNotExist(err) { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Disposition", "attachment; filename="+filename) - w.Header().Set("Content-Type", "application/octet-stream") - http.ServeFile(w, r, path) -} - -func runServer(port string) { - // Serve static files (CSS, etc.) - // This will serve files under /static/ from the embedded fs - http.Handle("/static/", http.FileServer(http.FS(staticFiles))) - - http.HandleFunc("/", indexHandler) - http.HandleFunc("/upload", uploadHandler) - http.HandleFunc("/upload-enclosure", enclosureUploadHandler) - http.HandleFunc("/upload-footprints", footprintUploadHandler) - http.HandleFunc("/preview", previewHandler) - http.HandleFunc("/preview-image/", previewImageHandler) - http.HandleFunc("/generate-enclosure", generateEnclosureHandler) - http.HandleFunc("/download/", downloadHandler) - - fmt.Printf("Starting server on http://0.0.0.0:%s\n", port) - log.Fatal(http.ListenAndServe(":"+port, nil)) -} - -// --- Main --- - -var ( - flagStencilHeight float64 - flagWallHeight float64 - flagWallThickness float64 - flagDPI float64 - flagKeepPNG bool - flagServer bool - flagPort string -) - -func main() { - flag.Float64Var(&flagStencilHeight, "height", DefaultStencilHeight, "Stencil height in mm") - flag.Float64Var(&flagWallHeight, "wall-height", DefaultWallHeight, "Wall height in mm") - flag.Float64Var(&flagWallThickness, "wall-thickness", DefaultWallThickness, "Wall thickness in mm") - flag.Float64Var(&flagDPI, "dpi", DefaultDPI, "DPI for rendering (lower = smaller file, rougher curves)") - flag.BoolVar(&flagKeepPNG, "keep-png", false, "Save intermediate PNG file") - - flag.BoolVar(&flagServer, "server", false, "Start in server mode") - flag.StringVar(&flagPort, "port", "8080", "Port to run the server on") - - flag.Parse() - - if flagServer { - runServer(flagPort) - } else { - cfg := Config{ - StencilHeight: flagStencilHeight, - WallHeight: flagWallHeight, - WallThickness: flagWallThickness, - DPI: flagDPI, - KeepPNG: flagKeepPNG, - } - runCLI(cfg, flag.Args()) + log.Fatal(err) } } diff --git a/run_server.sh b/run_server.sh deleted file mode 100755 index f202855..0000000 --- a/run_server.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -pkill -f ./bin/pcb-to-stencil\ -server -go clean && go build -o bin/pcb-to-stencil . -./bin/pcb-to-stencil -server diff --git a/scad.go b/scad.go index e5442d7..419afdf 100644 --- a/scad.go +++ b/scad.go @@ -6,6 +6,16 @@ import ( "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) @@ -37,6 +47,440 @@ func WriteSCAD(filename string, triangles [][3]Point) error { 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 @@ -210,7 +654,7 @@ func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 { } // WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code -func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error { +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 @@ -272,7 +716,8 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, // 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 - w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls + 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 @@ -319,6 +764,67 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, 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) @@ -352,6 +858,69 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, 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") @@ -369,12 +938,127 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, 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") - fmt.Fprintf(f, " clipZ = %f;\n", trayFloor+snapHeight) - fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, pryW+1.0) - fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, pryW+1.0) + 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") } 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/Former.svg b/static/Former.svg new file mode 100644 index 0000000..2e69678 --- /dev/null +++ b/static/Former.svg @@ -0,0 +1,15427 @@ + + + +SE0SE0S-S-RE0RE0F+F+S+S+F-F-CE0CE0C12C12C4C4C5C5RCAL2RCAL2C6C6C13C13U1U1C10C10RCAL1RCAL1R1R1R2R2FB1FB1C18C18C7C7C1C1RxGAIN1RxGAIN1C16C16C_BULK3C_BULK3C14C14C9C9C8C8C11C11C2C2C15C15C17C17100n100nC12C12J3J31u1uC4C4J4J41u1uC5C5200Ω200ΩRCAL2RCAL2J7J7470n470nC6C6J5J5J6J610p10pC13C13U1U1470n470nC10C103KΩ3KΩRCAL1RCAL12.2Ω2.2ΩR1R110KΩ10KΩR2R2FB1FB11u1uC18C18J2J2470n470nC7C7100n100nC1C13KΩ3KΩRxGAIN1RxGAIN1J1J110u10uC16C1610uF10uFC_BULK3C_BULK31u1uC14C14470n470nC9C94.7u4.7uC8C810p10pC11C11AD5941AD5941470n470nC2C21u1uC15C1510u10uC17C17 diff --git a/static/Former_Alt.svg b/static/Former_Alt.svg new file mode 100644 index 0000000..be1e599 --- /dev/null +++ b/static/Former_Alt.svg @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/index.html b/static/index.html index 7c3405a..4e339a1 100644 --- a/static/index.html +++ b/static/index.html @@ -64,10 +64,18 @@ +
+ + +
Pad sizes snap to multiples of this.
+
+ +
+
@@ -150,6 +158,18 @@
Processing... This may take 10-20 seconds.
+ + + diff --git a/static/preview.html b/static/preview.html index 4e6f0ad..41d093e 100644 --- a/static/preview.html +++ b/static/preview.html @@ -359,7 +359,13 @@ -
+
+ + +
All values in mm (0.01mm precision)
@@ -388,6 +394,8 @@ Go Back +
@@ -824,7 +832,8 @@ y: parseFloat(document.getElementById('cutY').value) || 0, w: parseFloat(document.getElementById('cutW').value) || 9, h: parseFloat(document.getElementById('cutH').value) || 3.5, - r: parseFloat(document.getElementById('cutR').value) || 1.3 + r: parseFloat(document.getElementById('cutR').value) || 1.3, + l: document.getElementById('cutLayer').value || 'F' }; sideCutouts.push(c); updateCutoutList(); @@ -838,7 +847,9 @@ var div = document.createElement('div'); div.className = 'cutout-item'; var color = sideColors[(c.side - 1) % sideColors.length]; - div.innerHTML = 'Side ' + c.side + '  ' + + var layerLabel = (c.l === 'B') ? 'B' : 'F'; + div.innerHTML = 'Side ' + c.side + ' ' + + '' + layerLabel + '  ' + c.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' + c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) + ''; @@ -973,6 +984,47 @@ } }); + // --- Restore cutouts from sessionStorage (instance restore) --- + (function() { + var stored = sessionStorage.getItem('restoredCutouts'); + if (stored) { + sessionStorage.removeItem('restoredCutouts'); + try { + var restored = JSON.parse(stored); + if (restored && restored.length > 0) { + sideCutouts = restored; + // Enable side editor + document.getElementById('optSideCutout').checked = true; + document.getElementById('sideEditor').classList.add('active'); + updateCutoutList(); + drawSideFace(); + } + } catch(e) { console.log('Could not restore cutouts:', e); } + } + })(); + + // --- Save Profile Button --- + document.getElementById('btnSaveProfile').addEventListener('click', function() { + var name = prompt('Save profile as:'); + if (!name) return; + fetch('/api/profiles/from-session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionId, + name: name, + sideCutouts: sideCutouts + }) + }).then(function(r) { + if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); + return r.json(); + }).then(function() { + alert('Profile saved!'); + }).catch(function(e) { + alert('Save failed: ' + e.message); + }); + }); + function applyAlignment(fp, lip) { var bx = fp.centerX; var by = fp.centerY; @@ -1017,6 +1069,7 @@ var cutX = bestPosX - (9.0 / 2); document.getElementById('cutX').value = cutX.toFixed(2); document.getElementById('cutY').value = '0.00'; + // Default to F layer for auto-aligned cutouts (user can change before adding) currentSide = closestSide.num; document.getElementById('btnAddCutout').click(); diff --git a/static/screenshot_gerber_output_dialogue.png b/static/screenshot_gerber_output_dialogue.png deleted file mode 100644 index 227accd..0000000 Binary files a/static/screenshot_gerber_output_dialogue.png and /dev/null differ diff --git a/static/style.css b/static/style.css index 2cdc60e..eb6a904 100644 --- a/static/style.css +++ b/static/style.css @@ -390,4 +390,86 @@ input[type="file"] { .help-popup-close:hover { color: white; +} + +/* Instance cards */ +.instance-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.instance-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.75rem; + background: #f9fafb; + border: 1px solid var(--border); + border-radius: 6px; + transition: border-color 0.15s; +} + +.instance-card:hover { + border-color: var(--primary); +} + +.instance-card-info { + flex: 1; + cursor: pointer; + min-width: 0; +} + +.instance-card-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.instance-card-meta { + font-size: 0.72rem; + color: #6b7280; + margin-top: 0.15rem; +} + +.instance-card-actions { + display: flex; + gap: 0.3rem; + flex-shrink: 0; + margin-left: 0.5rem; +} + +.inst-btn { + padding: 0.2rem 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background: white; + font-size: 0.72rem; + cursor: pointer; + color: var(--text); +} + +.inst-btn:hover { + background: #f3f4f6; +} + +.inst-btn.inst-save { + color: #059669; + border-color: #a7f3d0; +} + +.inst-btn.inst-save:hover { + background: #ecfdf5; +} + +.inst-btn.inst-del { + color: #dc2626; + border-color: #fecaca; +} + +.inst-btn.inst-del:hover { + background: #fef2f2; } \ No newline at end of file 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..4ba5e1c --- /dev/null +++ b/storage.go @@ -0,0 +1,254 @@ +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 +} + +// 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 index 0187a38..6dbd0a0 100644 --- a/svg.go +++ b/svg.go @@ -69,12 +69,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error { } if inRegion { - if cmd.Type == "MOVE" || cmd.Type == "DRAW" && interpolationMode == "G01" { + 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") { - // We don't have perfect analytic translation to SVG path for region arcs yet. - // We can just output the line for now, or approximate it as before. - // For SVG, we can just output line segments just like we did for image processing. iVal, jVal := 0.0, 0.0 if cmd.I != nil { iVal = *cmd.I @@ -82,29 +79,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error { 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) // 10 segments per mm - if steps < 10 { - steps = 10 - } - 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) - regionVertices = append(regionVertices, [2]float64{ax, ay}) + arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode) + for _, pt := range arcPts { + regionVertices = append(regionVertices, pt) } } continue @@ -136,15 +113,26 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error { jVal = *cmd.J } - // SVG path Arc - rx, ry := math.Sqrt(iVal*iVal+jVal*jVal), math.Sqrt(iVal*iVal+jVal*jVal) - sweep := 1 // G03 CCW -> SVG path sweep up due to inverted Y - if interpolationMode == "G02" { - sweep = 0 + // 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), rx, ry, sweep, toSVGX(curX), toSVGY(curY), w) + fmt.Fprintf(f, `` + "\n", + toSVGX(prevX), toSVGY(prevY), r, r, largeArc, sweep, toSVGX(curX), toSVGY(curY), w) } } } 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": "" + } +}