363 lines
10 KiB
Go
363 lines
10 KiB
Go
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
|
|
}
|