pcb-to-stencil/session.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)
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
}