commit 2d6cf465e3a6aebb6e372fde92a67f7795c5acda Author: Nikolai Danylchyk Date: Fri Dec 12 09:55:27 2025 +0100 Initial prototype diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e4c433 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..22bfc56 --- /dev/null +++ b/README.md @@ -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] +``` + +### 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. \ No newline at end of file diff --git a/bin/pcb-to-stencil b/bin/pcb-to-stencil new file mode 100755 index 0000000..9a02db3 Binary files /dev/null and b/bin/pcb-to-stencil differ diff --git a/bin/pcb-to-stencil.exe b/bin/pcb-to-stencil.exe new file mode 100755 index 0000000..5d3e360 Binary files /dev/null and b/bin/pcb-to-stencil.exe differ diff --git a/gerber.go b/gerber.go new file mode 100644 index 0000000..42cdd0f --- /dev/null +++ b/gerber.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a3cf92d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module pcb-to-stencil + +go 1.23.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..0a9e734 --- /dev/null +++ b/main.go @@ -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] ") + 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.") +}