Initial prototype
This commit is contained in:
commit
2d6cf465e3
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.")
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue