pcb-to-stencil/main.go

1414 lines
36 KiB
Go

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"
)
// --- Configuration ---
type Config struct {
StencilHeight float64
WallHeight float64
WallThickness float64
DPI float64
KeepPNG bool
}
// 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_gerber_file> [path_to_outline_gerber_file]")
fmt.Println("Options:")
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"})
if err != nil {
log.Fatalf("Error: %v", err)
}
fmt.Println("Success! Happy printing.")
}
// --- Server ---
//go:embed static/*
var staticFiles embed.FS
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)
}
}
}
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),
MaxBX: float64(maxBX),
BoardCenterY: boardCenterY,
Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, 25.4/ecfg.DPI, &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())
}
}