diff --git a/README.md b/README.md index 22bfc56..792f34b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PCB to Stencil Converter +# Gerber Solder Paste Layer to Solder Stencil Converter A Go tool to convert Gerber files (specifically solder paste layers) into 3D printable STL stencils. @@ -20,8 +20,10 @@ 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). +- `--height`: Stencil height in mm (default: 0.2mm). +- `--wall-height`: Wall height mm (default: 2.0mm). +- `--wall-thickness`: Wall thickness in mm (default: 1mm). +- `--keep-png`: Save the intermediate PNG image used for mesh generation (useful for debugging). ### Example diff --git a/bin/pcb-to-stencil b/bin/pcb-to-stencil index 9a02db3..c4fc467 100755 Binary files a/bin/pcb-to-stencil and b/bin/pcb-to-stencil differ diff --git a/gerber.go b/gerber.go index 42cdd0f..e1888d5 100644 --- a/gerber.go +++ b/gerber.go @@ -219,9 +219,11 @@ func (gf *GerberFile) parseCoordinate(valStr string, fmtSpec struct{ Integer, De return val / divisor } -// Render generates an image from the parsed Gerber commands -func (gf *GerberFile) Render(dpi float64) image.Image { - // 1. Calculate Bounds +type Bounds struct { + MinX, MinY, MaxX, MaxY float64 +} + +func (gf *GerberFile) CalculateBounds() Bounds { minX, minY := 1e9, 1e9 maxX, maxY := -1e9, -1e9 @@ -271,8 +273,20 @@ func (gf *GerberFile) Render(dpi float64) image.Image { maxX += padding maxY += padding - widthMM := maxX - minX - heightMM := maxY - minY + return Bounds{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY} +} + +// Render generates an image from the parsed Gerber commands +func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image { + var b Bounds + if bounds != nil { + b = *bounds + } else { + b = gf.CalculateBounds() + } + + widthMM := b.MaxX - b.MinX + heightMM := b.MaxY - b.MinY var scale float64 if gf.State.Units == "IN" { @@ -294,12 +308,12 @@ func (gf *GerberFile) Render(dpi float64) image.Image { // 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 + px := int((x - b.MinX) * scale) + py := int((heightMM - (y - b.MinY)) * scale) // Flip Y for image coords return px, py } - curX, curY = 0.0, 0.0 + curX, curY := 0.0, 0.0 curDCode := 0 for _, cmd := range gf.Commands { @@ -343,7 +357,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale // Modifiers[0] is diameter if len(ap.Modifiers) > 0 { radius := int((ap.Modifiers[0] * scale) / 2) - drawCircle(img, x, y, radius, c) + drawCircle(img, x, y, radius) } return case ApertureRect: // R @@ -383,7 +397,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale py := int(cy * scale) radius := int((dia * scale) / 2) - drawCircle(img, x+px, y-py, radius, c) + drawCircle(img, x+px, y-py, radius) } case 21: // Center Line (Rect) // Mods: Exposure, Width, Height, CenterX, CenterY, Rotation @@ -421,7 +435,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale } } -func drawCircle(img *image.RGBA, x0, y0, r int, c image.Image) { +func drawCircle(img *image.RGBA, x0, y0, r int) { // Simple Bresenham or scanline for y := -r; y <= r; y++ { for x := -r; x <= r; x++ { diff --git a/main.go b/main.go index 0a9e734..7d3fb02 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,25 @@ package main import ( + "encoding/binary" "flag" "fmt" "image" "image/png" "log" + "math" "os" "path/filepath" "strings" ) // --- Configuration --- -const ( - DPI = 1000.0 // Higher DPI = smoother curves - PixelToMM = 25.4 / DPI -) +var DPI float64 = 1000.0 // Higher DPI = smoother curves +var PixelToMM float64 = 25.4 / DPI var StencilHeight float64 = 0.2 // mm, default +var WallHeight float64 = 2.0 // mm, default +var WallThickness float64 = 1.0 // mm, default var KeepPNG bool // --- STL Helpers --- @@ -33,18 +35,58 @@ func WriteSTL(filename string, triangles [][3]Point) error { } 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") + // 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 + } } - f.WriteString("endsolid stencil\n") return nil } @@ -77,27 +119,283 @@ func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) { // --- Meshing Logic (Optimized) --- -func GenerateMeshFromImage(img image.Image) [][3]Point { +// 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++ { - c := img.At(x, y) - r, g, b, _ := c.RGBA() + // Check stencil (black = solid) + sc := stencilImg.At(x, y) + sr, sg, sb, _ := sc.RGBA() + isStencilSolid := sr < 10000 && sg < 10000 && sb < 10000 - // Check for BLACK pixels (The Plastic Stencil Body) - // Adjust threshold if gerbv produces slightly gray blacks - isSolid := r < 10000 && g < 10000 && b < 10000 + // Check wall + isWall := false + isInsideBoard := true + if wallMask != nil { + idx := y*width + x + isWall = wallMask[idx] + if boardMask != nil { + isInsideBoard = boardMask[idx] + } + } - if isSolid { + // 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 { @@ -109,9 +407,10 @@ func GenerateMeshFromImage(img image.Image) [][3]Point { float64(y)*PixelToMM, float64(stripLen)*PixelToMM, PixelToMM, - StencilHeight, + currentHeight, ) startX = -1 + currentHeight = 0.0 } } } @@ -123,7 +422,7 @@ func GenerateMeshFromImage(img image.Image) [][3]Point { float64(y)*PixelToMM, float64(stripLen)*PixelToMM, PixelToMM, - StencilHeight, + currentHeight, ) } } @@ -134,33 +433,83 @@ func GenerateMeshFromImage(img image.Image) [][3]Point { func main() { flag.Float64Var(&StencilHeight, "height", 0.2, "Stencil height in mm") - flag.Float64Var(&StencilHeight, "h", 0.2, "Stencil height in mm (short)") + 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.BoolVar(&KeepPNG, "kp", false, "Save intermediate PNG file (short)") 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] ") + fmt.Println("Usage: go run main.go [options] [path_to_outline_gerber_file]") fmt.Println("Options:") flag.PrintDefaults() - fmt.Println("Example: go run main.go -height=0.3 MyPCB.GTP") + 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 + // 1. Parse Gerber(s) fmt.Printf("Parsing %s...\n", gerberPath) gf, err := ParseGerber(gerberPath) if err != nil { log.Fatalf("Error parsing gerber: %v", err) } - // 2. Render to Image + 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) + 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" @@ -174,13 +523,27 @@ func main() { } 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() + } + } } - // 3. Generate Mesh + // 4. Generate Mesh fmt.Println("Generating mesh (this may take 10-20 seconds for large boards)...") - triangles := GenerateMeshFromImage(img) + triangles := GenerateMeshFromImages(img, outlineImg) - // 4. Save STL + // 5. Save STL fmt.Printf("Saving to %s (%d triangles)...\n", outputPath, len(triangles)) err = WriteSTL(outputPath, triangles) if err != nil {