Initial prototype

This commit is contained in:
Nikolai Danylchyk 2025-12-12 09:55:27 +01:00
commit 2d6cf465e3
7 changed files with 716 additions and 0 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

43
README.md Normal file
View File

@ -0,0 +1,43 @@
# PCB to Stencil Converter
A Go tool to convert Gerber files (specifically solder paste layers) into 3D printable STL stencils.
## Features
- Parses standard RS-274X Gerber files.
- Supports standard apertures (Circle, Rectangle, Obround).
- Supports Aperture Macros (AM) with rotation (e.g., rounded rectangles).
- Automatically crops the output to the PCB bounds.
- Generates a 3D STL mesh optimized for 3D printing.
## Usage
Run the tool using `go run`:
```bash
go run main.go gerber.go [options] <path_to_gerber_file>
```
### Options
- `--height, -h`: Stencil height in mm (default: 0.2mm).
- `--keep-png, --kp`: Save the intermediate PNG image used for mesh generation (useful for debugging).
### Example
```bash
go run main.go gerber.go -height=0.25 -keep-png my_board_paste_top.gbr
```
This will generate `my_board_paste_top.stl` in the same directory.
## How it Works
1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws).
2. **Rendering**: It renders the PCB layer into a high-resolution internal image.
3. **Meshing**: It converts the image into a 3D mesh using a run-length encoding approach to optimize the triangle count.
4. **Export**: The mesh is saved as a binary STL file.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

BIN
bin/pcb-to-stencil Executable file

Binary file not shown.

BIN
bin/pcb-to-stencil.exe Executable file

Binary file not shown.

458
gerber.go Normal file
View File

@ -0,0 +1,458 @@
package main
import (
"bufio"
"image"
"image/color"
"image/draw"
"math"
"os"
"regexp"
"strconv"
"strings"
)
// Aperture types
const (
ApertureCircle = "C"
ApertureRect = "R"
ApertureObround = "O"
// Add macros later if needed
)
type Aperture struct {
Type string
Modifiers []float64
}
type MacroPrimitive struct {
Code int
Modifiers []float64
}
type Macro struct {
Name string
Primitives []MacroPrimitive
}
type GerberState struct {
Apertures map[int]Aperture
Macros map[string]Macro
CurrentAperture int
X, Y float64 // Current coordinates in mm
FormatX, FormatY struct {
Integer, Decimal int
}
Units string // "MM" or "IN"
}
type GerberCommand struct {
Type string // "D01", "D02", "D03", "AD", "FS", etc.
X, Y *float64
D *int
}
type GerberFile struct {
Commands []GerberCommand
State GerberState
}
func NewGerberFile() *GerberFile {
return &GerberFile{
State: GerberState{
Apertures: make(map[int]Aperture),
Macros: make(map[string]Macro),
Units: "MM", // Default, usually set by MO
},
}
}
// ParseGerber parses a simple RS-274X file
func ParseGerber(filename string) (*GerberFile, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
gf := NewGerberFile()
scanner := bufio.NewScanner(file)
// Regex for coordinates: X123Y456D01
reCoord := regexp.MustCompile(`([XYD])([\d\.\-]+)`)
// Regex for Aperture Definition: %ADD10C,0.5*%
reAD := regexp.MustCompile(`%ADD(\d+)([A-Za-z0-9_]+),?([\d\.X]+)?\*%`)
// Regex for Format Spec: %FSLAX24Y24*%
reFS := regexp.MustCompile(`%FSLAX(\d)(\d)Y(\d)(\d)\*%`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Handle Parameters
if strings.HasPrefix(line, "%") {
if strings.HasPrefix(line, "%FS") {
matches := reFS.FindStringSubmatch(line)
if len(matches) == 5 {
gf.State.FormatX.Integer, _ = strconv.Atoi(matches[1])
gf.State.FormatX.Decimal, _ = strconv.Atoi(matches[2])
gf.State.FormatY.Integer, _ = strconv.Atoi(matches[3])
gf.State.FormatY.Decimal, _ = strconv.Atoi(matches[4])
}
} else if strings.HasPrefix(line, "%AD") {
matches := reAD.FindStringSubmatch(line)
if len(matches) >= 3 {
dCode, _ := strconv.Atoi(matches[1])
apType := matches[2]
var mods []float64
if len(matches) > 3 && matches[3] != "" {
parts := strings.Split(matches[3], "X")
for _, p := range parts {
val, _ := strconv.ParseFloat(p, 64)
mods = append(mods, val)
}
}
gf.State.Apertures[dCode] = Aperture{Type: apType, Modifiers: mods}
}
} else if strings.HasPrefix(line, "%AM") {
// Parse Macro
name := strings.TrimPrefix(line, "%AM")
name = strings.TrimSuffix(name, "*")
var primitives []MacroPrimitive
for scanner.Scan() {
mLine := strings.TrimSpace(scanner.Text())
if mLine == "%" {
break
}
mLine = strings.TrimSuffix(mLine, "*")
parts := strings.Split(mLine, ",")
if len(parts) > 0 {
code, _ := strconv.Atoi(parts[0])
var mods []float64
for _, p := range parts[1:] {
val, _ := strconv.ParseFloat(p, 64)
mods = append(mods, val)
}
primitives = append(primitives, MacroPrimitive{Code: code, Modifiers: mods})
}
}
gf.State.Macros[name] = Macro{Name: name, Primitives: primitives}
} else if strings.HasPrefix(line, "%MO") {
if strings.Contains(line, "IN") {
gf.State.Units = "IN"
} else {
gf.State.Units = "MM"
}
}
continue
}
// Handle Standard Commands
// Split by *
parts := strings.Split(line, "*")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Check for G-codes (G54 is aperture selection in older files, but usually Dnn is used)
// We focus on D-codes and Coordinates
// Handle Aperture Selection (e.g., D10*)
if strings.HasPrefix(part, "D") && len(part) >= 2 {
// Likely D10, D11 etc.
dCode, err := strconv.Atoi(part[1:])
if err == nil && dCode >= 10 {
gf.Commands = append(gf.Commands, GerberCommand{Type: "APERTURE", D: &dCode})
continue
}
}
// Handle Coordinates and Draw/Flash commands
// X...Y...D01*
matches := reCoord.FindAllStringSubmatch(part, -1)
if len(matches) > 0 {
cmd := GerberCommand{Type: "MOVE"}
for _, m := range matches {
valStr := m[2]
switch m[1] {
case "X":
v := gf.parseCoordinate(valStr, gf.State.FormatX)
cmd.X = &v
case "Y":
v := gf.parseCoordinate(valStr, gf.State.FormatY)
cmd.Y = &v
case "D":
val, _ := strconv.ParseFloat(valStr, 64)
d := int(val)
cmd.D = &d
if d == 1 {
cmd.Type = "DRAW"
} else if d == 2 {
cmd.Type = "MOVE"
} else if d == 3 {
cmd.Type = "FLASH"
}
}
}
gf.Commands = append(gf.Commands, cmd)
}
}
}
return gf, nil
}
func (gf *GerberFile) parseCoordinate(valStr string, fmtSpec struct{ Integer, Decimal int }) float64 {
if strings.Contains(valStr, ".") {
val, _ := strconv.ParseFloat(valStr, 64)
return val
}
val, _ := strconv.ParseFloat(valStr, 64)
divisor := math.Pow(10, float64(fmtSpec.Decimal))
return val / divisor
}
// Render generates an image from the parsed Gerber commands
func (gf *GerberFile) Render(dpi float64) image.Image {
// 1. Calculate Bounds
minX, minY := 1e9, 1e9
maxX, maxY := -1e9, -1e9
updateBounds := func(x, y float64) {
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
curX, curY := 0.0, 0.0
for _, cmd := range gf.Commands {
prevX, prevY := curX, curY
if cmd.X != nil {
curX = *cmd.X
}
if cmd.Y != nil {
curY = *cmd.Y
}
if cmd.Type == "FLASH" {
updateBounds(curX, curY)
} else if cmd.Type == "DRAW" {
updateBounds(prevX, prevY)
updateBounds(curX, curY)
}
}
if minX == 1e9 {
// No drawing commands found, default to 0,0
minX, minY = 0, 0
maxX, maxY = 10, 10 // Arbitrary small size
}
// Add some padding
padding := 2.0 // mm
minX -= padding
minY -= padding
maxX += padding
maxY += padding
widthMM := maxX - minX
heightMM := maxY - minY
var scale float64
if gf.State.Units == "IN" {
scale = dpi
} else {
scale = dpi / 25.4
}
imgWidth := int(widthMM * scale)
imgHeight := int(heightMM * scale)
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
// Fill black (stencil material)
draw.Draw(img, img.Bounds(), &image.Uniform{color.Black}, image.Point{}, draw.Src)
// White for holes
white := &image.Uniform{color.White}
// Helper to convert mm to pixels
toPix := func(x, y float64) (int, int) {
px := int((x - minX) * scale)
py := int((heightMM - (y - minY)) * scale) // Flip Y for image coords
return px, py
}
curX, curY = 0.0, 0.0
curDCode := 0
for _, cmd := range gf.Commands {
if cmd.Type == "APERTURE" {
curDCode = *cmd.D
continue
}
prevX, prevY := curX, curY
if cmd.X != nil {
curX = *cmd.X
}
if cmd.Y != nil {
curY = *cmd.Y
}
if cmd.Type == "FLASH" {
// Draw Aperture at curX, curY
ap, ok := gf.State.Apertures[curDCode]
if ok {
cx, cy := toPix(curX, curY)
gf.drawAperture(img, cx, cy, ap, scale, white)
}
} else if cmd.Type == "DRAW" {
// Draw Line from prevX, prevY to curX, curY using current aperture
ap, ok := gf.State.Apertures[curDCode]
if ok {
x1, y1 := toPix(prevX, prevY)
x2, y2 := toPix(curX, curY)
gf.drawLine(img, x1, y1, x2, y2, ap, scale, white)
}
}
}
return img
}
func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale float64, c image.Image) {
switch ap.Type {
case ApertureCircle: // C
// Modifiers[0] is diameter
if len(ap.Modifiers) > 0 {
radius := int((ap.Modifiers[0] * scale) / 2)
drawCircle(img, x, y, radius, c)
}
return
case ApertureRect: // R
// Modifiers[0] is width, [1] is height
if len(ap.Modifiers) >= 2 {
w := int(ap.Modifiers[0] * scale)
h := int(ap.Modifiers[1] * scale)
r := image.Rect(x-w/2, y-h/2, x+w/2, y+h/2)
draw.Draw(img, r, c, image.Point{}, draw.Src)
}
return
case ApertureObround: // O
// Similar to rect but with rounded corners. For now, treat as Rect or implement properly.
// Implementing as Rect for MVP
if len(ap.Modifiers) >= 2 {
w := int(ap.Modifiers[0] * scale)
h := int(ap.Modifiers[1] * scale)
r := image.Rect(x-w/2, y-h/2, x+w/2, y+h/2)
draw.Draw(img, r, c, image.Point{}, draw.Src)
}
return
}
// Check for Macros
if macro, ok := gf.State.Macros[ap.Type]; ok {
for _, prim := range macro.Primitives {
switch prim.Code {
case 1: // Circle
// Mods: Exposure, Diameter, CenterX, CenterY
if len(prim.Modifiers) >= 4 {
// exposure := prim.Modifiers[0] // 1=on, 0=off (assuming 1 for now)
dia := prim.Modifiers[1]
cx := prim.Modifiers[2]
cy := prim.Modifiers[3]
px := int(cx * scale)
py := int(cy * scale)
radius := int((dia * scale) / 2)
drawCircle(img, x+px, y-py, radius, c)
}
case 21: // Center Line (Rect)
// Mods: Exposure, Width, Height, CenterX, CenterY, Rotation
if len(prim.Modifiers) >= 6 {
width := prim.Modifiers[1]
height := prim.Modifiers[2]
cx := prim.Modifiers[3]
cy := prim.Modifiers[4]
rot := prim.Modifiers[5]
// Normalize rotation to 0-360
rot = math.Mod(rot, 360)
if rot < 0 {
rot += 360
}
// Handle simple 90-degree rotations (swap width/height)
if math.Abs(rot-90) < 1.0 || math.Abs(rot-270) < 1.0 {
width, height = height, width
}
w := int(width * scale)
h := int(height * scale)
icx := int(cx * scale)
icy := int(cy * scale)
rx := x + icx
ry := y - icy
r := image.Rect(rx-w/2, ry-h/2, rx+w/2, ry+h/2)
draw.Draw(img, r, c, image.Point{}, draw.Src)
}
}
}
}
}
func drawCircle(img *image.RGBA, x0, y0, r int, c image.Image) {
// Simple Bresenham or scanline
for y := -r; y <= r; y++ {
for x := -r; x <= r; x++ {
if x*x+y*y <= r*r {
img.Set(x0+x, y0+y, color.White)
}
}
}
}
func (gf *GerberFile) drawLine(img *image.RGBA, x1, y1, x2, y2 int, ap Aperture, scale float64, c image.Image) {
// Bresenham's line algorithm, but we need to stroke it with the aperture.
// For simplicity, if aperture is Circle, we draw a circle at each step (inefficient but works).
// If aperture is Rect, we draw rect at each step.
// Optimized: Just draw a thick line if it's a circle aperture
dx := float64(x2 - x1)
dy := float64(y2 - y1)
dist := math.Sqrt(dx*dx + dy*dy)
steps := int(dist) // 1 pixel steps
if steps == 0 {
gf.drawAperture(img, x1, y1, ap, scale, c)
return
}
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
x := int(float64(x1) + t*dx)
y := int(float64(y1) + t*dy)
gf.drawAperture(img, x, y, ap, scale, c)
}
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module pcb-to-stencil
go 1.23.0

191
main.go Normal file
View File

@ -0,0 +1,191 @@
package main
import (
"flag"
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"strings"
)
// --- Configuration ---
const (
DPI = 1000.0 // Higher DPI = smoother curves
PixelToMM = 25.4 / DPI
)
var StencilHeight float64 = 0.2 // 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()
// Writing Binary STL is harder, ASCII is fine for this size
f.WriteString("solid stencil\n")
for _, t := range triangles {
f.WriteString("facet normal 0 0 0\n")
f.WriteString(" outer loop\n")
for _, p := range t {
f.WriteString(fmt.Sprintf(" vertex %f %f %f\n", p.X, p.Y, p.Z))
}
f.WriteString(" endloop\n")
f.WriteString("endfacet\n")
}
f.WriteString("endsolid stencil\n")
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) ---
func GenerateMeshFromImage(img image.Image) [][3]Point {
bounds := img.Bounds()
width := bounds.Max.X
height := bounds.Max.Y
var triangles [][3]Point
// Optimization: Run-Length Encoding
for y := 0; y < height; y++ {
var startX = -1
for x := 0; x < width; x++ {
c := img.At(x, y)
r, g, b, _ := c.RGBA()
// Check for BLACK pixels (The Plastic Stencil Body)
// Adjust threshold if gerbv produces slightly gray blacks
isSolid := r < 10000 && g < 10000 && b < 10000
if isSolid {
if startX == -1 {
startX = x
}
} else {
if startX != -1 {
// End of strip, generate box
stripLen := x - startX
AddBox(
&triangles,
float64(startX)*PixelToMM,
float64(y)*PixelToMM,
float64(stripLen)*PixelToMM,
PixelToMM,
StencilHeight,
)
startX = -1
}
}
}
if startX != -1 {
stripLen := width - startX
AddBox(
&triangles,
float64(startX)*PixelToMM,
float64(y)*PixelToMM,
float64(stripLen)*PixelToMM,
PixelToMM,
StencilHeight,
)
}
}
return triangles
}
// --- Main ---
func main() {
flag.Float64Var(&StencilHeight, "height", 0.2, "Stencil height in mm")
flag.Float64Var(&StencilHeight, "h", 0.2, "Stencil height in mm (short)")
flag.BoolVar(&KeepPNG, "keep-png", false, "Save intermediate PNG file")
flag.BoolVar(&KeepPNG, "kp", false, "Save intermediate PNG file (short)")
flag.Parse()
args := flag.Args()
if len(args) < 1 {
fmt.Println("Usage: go run main.go [options] <path_to_gerber_file>")
fmt.Println("Options:")
flag.PrintDefaults()
fmt.Println("Example: go run main.go -height=0.3 MyPCB.GTP")
os.Exit(1)
}
gerberPath := args[0]
outputPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".stl"
// 1. Parse Gerber
fmt.Printf("Parsing %s...\n", gerberPath)
gf, err := ParseGerber(gerberPath)
if err != nil {
log.Fatalf("Error parsing gerber: %v", err)
}
// 2. Render to Image
fmt.Println("Rendering to internal image...")
img := gf.Render(DPI)
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()
}
}
// 3. Generate Mesh
fmt.Println("Generating mesh (this may take 10-20 seconds for large boards)...")
triangles := GenerateMeshFromImage(img)
// 4. 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.")
}