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, session.AllLayerGerbers) 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 }