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