pcb-to-stencil/main.go

555 lines
13 KiB
Go

package main
import (
"encoding/binary"
"flag"
"fmt"
"image"
"image/png"
"log"
"math"
"os"
"path/filepath"
"strings"
)
// --- Configuration ---
var DPI float64 = 1000.0 // Higher DPI = smoother curves
var PixelToMM float64 = 25.4 / DPI
var StencilHeight float64 = 0.16 // mm, default
var WallHeight float64 = 2.0 // mm, default
var WallThickness float64 = 1.0 // mm, default
var KeepPNG bool
// --- 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) ([]bool, []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 (or centered on outline? User said "starts at outline").
// 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)
}
}
isWall := make([]bool, size)
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
isWall[nIdx] = true
wQueue = append(wQueue, nIdx)
}
}
}
}
return isWall, isBoard
}
func GenerateMeshFromImages(stencilImg, outlineImg image.Image) [][3]Point {
bounds := stencilImg.Bounds()
width := bounds.Max.X
height := bounds.Max.Y
var triangles [][3]Point
var wallMask []bool
var boardMask []bool
if outlineImg != nil {
fmt.Println("Computing wall mask...")
wallMask, boardMask = ComputeWallMask(outlineImg, WallThickness)
}
// 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 wallMask != nil {
idx := y*width + x
isWall = wallMask[idx]
if boardMask != nil {
isInsideBoard = boardMask[idx]
}
}
// Determine height at this pixel
h := 0.0
if isWall {
h = WallHeight
} else if isStencilSolid {
if isInsideBoard {
h = StencilHeight
}
}
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
}
// --- Main ---
func main() {
flag.Float64Var(&StencilHeight, "height", 0.16, "Stencil height in mm")
flag.Float64Var(&WallHeight, "wall-height", 2.0, "Wall height in mm")
flag.Float64Var(&WallThickness, "wall-thickness", 1, "Wall thickness in mm")
flag.Float64Var(&DPI, "dpi", 1000.0, "DPI for rendering (lower = smaller file, rougher curves)")
flag.BoolVar(&KeepPNG, "keep-png", false, "Save intermediate PNG file")
flag.Parse()
// Update PixelToMM based on DPI flag
PixelToMM = 25.4 / DPI
args := flag.Args()
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]
}
outputPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".stl"
// 1. Parse Gerber(s)
fmt.Printf("Parsing %s...\n", gerberPath)
gf, err := ParseGerber(gerberPath)
if err != nil {
log.Fatalf("Error parsing gerber: %v", err)
}
var outlineGf *GerberFile
if outlinePath != "" {
fmt.Printf("Parsing outline %s...\n", outlinePath)
outlineGf, err = ParseGerber(outlinePath)
if err != nil {
log.Fatalf("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
// We add WallThickness + extra margin to all sides
margin := 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(DPI, &bounds)
var outlineImg image.Image
if outlineGf != nil {
fmt.Println("Rendering outline to internal image...")
outlineImg = outlineGf.Render(DPI, &bounds)
}
if 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()
}
if outlineImg != nil {
outlinePngPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + "_outline.png"
fmt.Printf("Saving intermediate Outline PNG to %s...\n", outlinePngPath)
f, err := os.Create(outlinePngPath)
if err != nil {
log.Printf("Warning: Could not create Outline PNG file: %v", err)
} else {
if err := png.Encode(f, outlineImg); err != nil {
log.Printf("Warning: Could not encode Outline PNG: %v", err)
}
f.Close()
}
}
}
// 4. Generate Mesh
fmt.Println("Generating mesh (this may take 10-20 seconds for large boards)...")
triangles := GenerateMeshFromImages(img, outlineImg)
// 5. Save STL
fmt.Printf("Saving to %s (%d triangles)...\n", outputPath, len(triangles))
err = WriteSTL(outputPath, triangles)
if err != nil {
log.Fatalf("Error writing STL: %v", err)
}
fmt.Println("Success! Happy printing.")
}