diff --git a/README.md b/README.md index f830573..6054446 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ A Go tool to convert Gerber files (specifically solder paste layers) into 3D pri - 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. +- **Enclosure Generation**: Automatically generates a snap-fit enclosure and tray based on the PCB outline. +- **Native OpenSCAD Support**: Exports native `.scad` scripts for parametric editing and customization. +- **Smart Cutouts**: Interactive side-cutout placement with automatic USB port alignment. +- **Tray Snap System**: Robust tray clips with vertical relief slots for secure enclosure latching. ## Usage @@ -35,6 +39,15 @@ go run main.go gerber.go -height=0.16 -keep-png my_board_paste_top.gbr my_board_ This will generate `my_board_paste_top.stl` in the same directory. +### Enclosure Generation + +To generate an enclosure, use the web interface. Upload a `.gbrjob` file and the associated Gerber layers. The tool will auto-discover the board thickness and outline. + +1. **Upload**: Provide the Gerber job and layout files. +2. **Configure**: Adjust wall thickness, clearance, and mounting hole parameters in the UI. +3. **Preview**: Use the interactive preview to place and align side cutouts for connectors. +4. **Export**: Generate STLs or OpenSCAD scripts for both the enclosure top and the tray. + ### Web Interface To start the web interface: @@ -59,10 +72,11 @@ These settings assume you run the tool with `-height=0.16` (the default). ## 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. +1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws). For enclosures, it parses the `.gbrjob` to identify board layers. +2. **Rendering**: It renders the PCB outline and layers into a high-resolution internal image. +3. **Path Extraction**: Board edges are traced and simplified to generate 2.5D geometry. +4. **Meshing**: It converts the image into a 3D mesh (STL) or generates CSG primitives (SCAD) using the board topology. +5. **Export**: The resulting files are saved for 3D printing or further CAD refinement. ## License diff --git a/bin/pcb-to-stencil b/bin/pcb-to-stencil index 910388a..b04d0a8 100755 Binary files a/bin/pcb-to-stencil and b/bin/pcb-to-stencil differ diff --git a/bin/pcb-to-stencil.exe b/bin/pcb-to-stencil.exe new file mode 100644 index 0000000..fdec46e Binary files /dev/null and b/bin/pcb-to-stencil.exe differ diff --git a/enclosure.go b/enclosure.go index f5bd1e0..dfe3a9f 100644 --- a/enclosure.go +++ b/enclosure.go @@ -38,6 +38,7 @@ type SideCutout struct { Width float64 // Width in mm Height float64 // Height in mm CornerRadius float64 // Corner radius in mm (0 for square) + Layer string // "F" (front/top) or "B" (back/bottom); defaults to "F" } // BoardSide represents a physical straight edge of the board outline @@ -657,9 +658,9 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo minZ += trayFloor + pcbT maxZ += trayFloor + pcbT - // Wall below cutout: from 0 to minZ - if minZ > 0.05 { - addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, minZ) + // Wall below cutout: from trayFloor to minZ (preserve enclosure floor) + if minZ > trayFloor+0.3 { + addBoxAtZ(&cutoutEncTris, bx2, by2, trayFloor, bw, bh, minZ-trayFloor) } // Wall above cutout: from maxZ to totalH if maxZ < totalH-0.05 { @@ -697,7 +698,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo by2 := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM bh := pixelToMM - AddBox(&newEncTris, bx, by2, bw, bh, totalH) + addBoxAtZ(&newEncTris, bx, by2, trayFloor, bw, bh, totalH-trayFloor) runStart = -1 } } diff --git a/main.go b/main.go index 3d8d50f..1bf5433 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ type Config struct { StencilHeight float64 WallHeight float64 WallThickness float64 + LineWidth float64 DPI float64 KeepPNG bool } @@ -138,7 +139,7 @@ func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) { addQuad(p010, p000, p001, p011) // Left } -// --- Meshing Logic (Optimized) --- +// --- Meshing Logic --- // 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 @@ -153,7 +154,7 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ dx := []int{0, 0, 1, -1} dy := []int{1, -1, 0, 0} - // 1. Identify Outline Pixels (White) + // Identify Outline Pixels (White) isOutline := make([]bool, size) outlineQueue := []int{} for i := 0; i < size; i++ { @@ -167,8 +168,8 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ } } - // 2. Dilate Outline to close gaps - // We dilate by a small amount (e.g. 0.5mm) to ensure the outline is closed. + // Dilate Outline to close gaps + // Dilates the outline by 0.5mm to ensure a closed boundary. gapClosingMM := 0.5 gapClosingPixels := int(gapClosingMM / pixelToMM) if gapClosingPixels < 1 { @@ -188,7 +189,7 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ dilatedOutline := make([]bool, size) copy(dilatedOutline, isOutline) - // Use a separate queue for dilation to avoid modifying the original outlineQueue if we needed it + // Use a separate queue for dilation to preserve the original outlineQueue. dQueue := make([]int, len(outlineQueue)) copy(dQueue, outlineQueue) @@ -217,7 +218,7 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ } } - // 3. Flood Fill "Outside" using Dilated Outline as barrier + // Flood Fill "Outside" area using Dilated Outline as barrier isOutside := make([]bool, size) // Start from (0,0) - assumed to be outside due to padding if !dilatedOutline[0] { @@ -244,10 +245,9 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ } } - // 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. + // Restore Board Shape by eroding expansion back to original boundary + // Resets distance for expansion to touch the real board edge. + // Board area is then identified as the inverse of the outside area. // Reset dist for Outside expansion for i := 0; i < size; i++ { @@ -299,10 +299,8 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([ isBoard[i] = !isOutsideExpanded[i] } - // 6. Generate Wall - // Wall is generated by expanding Board outwards. - // We want the wall to be strictly OUTSIDE the board. - // If we expand Board, we get pixels outside. + // Generate Wall geometry by expanding the Board area outwards. + // The wall is positioned strictly outside the board boundary. thicknessPixels := int(thicknessMM / pixelToMM) if thicknessPixels < 1 { @@ -475,7 +473,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([ exports = []string{"stl"} } - // 1. Parse Gerber(s) + // Parse Gerber layers for board outline and component placement fmt.Printf("Parsing %s...\n", gerberPath) gf, err := ParseGerber(gerberPath) if err != nil { @@ -491,7 +489,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([ } } - // 2. Calculate Union Bounds + // Calculate combined hardware bounds across all layers bounds := gf.CalculateBounds() if outlineGf != nil { outlineBounds := outlineGf.CalculateBounds() @@ -516,7 +514,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([ bounds.MaxX += margin bounds.MaxY += margin - // 3. Render to Image(s) + // Render vector data to raster images for processing and meshing fmt.Println("Rendering to internal image...") img := gf.Render(cfg.DPI, &bounds) @@ -541,13 +539,13 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([ } var triangles [][3]Point - if wantsType("stl") || wantsType("scad") { - // 4. Generate Mesh + if wantsType("stl") { + // Generate triangle mesh from rasterized board data (only needed for STL output) fmt.Println("Generating mesh...") triangles = GenerateMeshFromImages(img, outlineImg, cfg) } - // 5. Output based on requested formats + // Export assets in requested file formats if wantsType("stl") { outputFilename := baseName + ".stl" fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles)) @@ -578,8 +576,8 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([ if wantsType("scad") { outputFilename := baseName + ".scad" - fmt.Printf("Saving to %s (SCAD)...\n", outputFilename) - if err := WriteSCAD(outputFilename, triangles); err != nil { + fmt.Printf("Saving to %s (Native SCAD)...\n", outputFilename) + if err := WriteStencilSCAD(outputFilename, gf, outlineGf, cfg, &bounds); err != nil { return nil, fmt.Errorf("error writing scad: %v", err) } generatedFiles = append(generatedFiles, outputFilename) @@ -639,9 +637,8 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { } func uploadHandler(w http.ResponseWriter, r *http.Request) { - // Parse the multipart form BEFORE reading FormValue. - // Without this, FormValue can't see fields in a multipart/form-data body, - // so all numeric parameters silently fall back to defaults. + // Multipart form parsing is required to correctly extract numeric fields + // and layer parameters before they fall back to default values. r.ParseMultipartForm(32 << 20) if r.Method != "POST" { @@ -660,6 +657,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64) wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64) wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64) + lineWidth, _ := strconv.ParseFloat(r.FormValue("lineWidth"), 64) if height == 0 { height = DefaultStencilHeight @@ -678,6 +676,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) { StencilHeight: height, WallHeight: wallHeight, WallThickness: wallThickness, + LineWidth: lineWidth, DPI: dpi, KeepPNG: false, } @@ -816,8 +815,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { DPI: dpi, } - // Handle uploaded gerber files (multi-select) - // Save all gerbers, then match to layers from job file + // Store uploaded Gerber files and coordinate with the job description gerberFiles := r.MultipartForm.File["gerbers"] savedGerbers := make(map[string]string) // filename → saved path for _, fh := range gerberFiles { @@ -924,7 +922,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { var boardCount int wallMaskInt, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4) - _ = wallMaskInt // Not used here, but we need boardMask + _ = wallMaskInt // Mask required for internal board topology mapping imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y for y := 0; y < imgH; y++ { @@ -1235,11 +1233,16 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { W float64 `json:"w"` H float64 `json:"h"` R float64 `json:"r"` + L string `json:"l"` } if err := json.Unmarshal([]byte(cutoutsJSON), &rawCutouts); err != nil { log.Printf("Warning: could not parse side cutouts: %v", err) } else { for _, rc := range rawCutouts { + layer := rc.L + if layer == "" { + layer = "F" + } sideCutouts = append(sideCutouts, SideCutout{ Side: rc.Side, X: rc.X, @@ -1247,6 +1250,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { Width: rc.W, Height: rc.H, CornerRadius: rc.R, + Layer: layer, }) } } @@ -1328,7 +1332,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { } } - // We intentionally do NOT delete the session here so the user can hit "Back for Adjustments" + // Maintain the session for UI adjustments and re-generation renderResult(w, "Your files have been generated successfully.", generatedFiles, "/preview?id="+id, zipFile) } @@ -1340,7 +1344,7 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) { } filename := vars[2] - // Security check: ensure no path traversal + // Validate filename to prevent path traversal if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") { http.Error(w, "Invalid filename", http.StatusBadRequest) return @@ -1358,8 +1362,7 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) { } func runServer(port string) { - // Serve static files (CSS, etc.) - // This will serve files under /static/ from the embedded fs + // Serve static assets from the embedded filesystem http.Handle("/static/", http.FileServer(http.FS(staticFiles))) http.HandleFunc("/", indexHandler) diff --git a/scad.go b/scad.go index e5442d7..76ae6c1 100644 --- a/scad.go +++ b/scad.go @@ -6,6 +6,16 @@ import ( "os" ) +// snapToLine rounds a dimension to the nearest quarter-multiple of lineWidth. +// If lineWidth is 0, the value is returned unchanged. +func snapToLine(v, lineWidth float64) float64 { + if lineWidth <= 0 { + return v + } + unit := lineWidth / 4.0 + return math.Round(v/unit) * unit +} + func WriteSCAD(filename string, triangles [][3]Point) error { // Fallback/legacy mesh WriteSCAD f, err := os.Create(filename) @@ -37,6 +47,440 @@ func WriteSCAD(filename string, triangles [][3]Point) error { return nil } +// approximateArc returns intermediate arc points from (x1,y1) to (x2,y2), +// excluding the start point, including the end point. +func approximateArc(x1, y1, x2, y2, iVal, jVal float64, mode string) [][2]float64 { + centerX := x1 + iVal + centerY := y1 + jVal + radius := math.Sqrt(iVal*iVal + jVal*jVal) + startAngle := math.Atan2(y1-centerY, x1-centerX) + endAngle := math.Atan2(y2-centerY, x2-centerX) + if mode == "G03" { + if endAngle <= startAngle { + endAngle += 2 * math.Pi + } + } else { + if startAngle <= endAngle { + startAngle += 2 * math.Pi + } + } + arcLen := math.Abs(endAngle-startAngle) * radius + steps := int(arcLen * 8) + if steps < 4 { + steps = 4 + } + if steps > 128 { + steps = 128 + } + pts := make([][2]float64, steps) + for s := 0; s < steps; s++ { + t := float64(s+1) / float64(steps) + a := startAngle + t*(endAngle-startAngle) + pts[s] = [2]float64{centerX + radius*math.Cos(a), centerY + radius*math.Sin(a)} + } + return pts +} + +// writeApertureFlash2D writes a 2D aperture shape centered at (x, y) into a SCAD file. +// gf is needed to resolve macro apertures. lw is the nozzle line width for snapping. +func writeApertureFlash2D(f *os.File, gf *GerberFile, ap Aperture, x, y, lw float64, indent string) { + switch ap.Type { + case "C": + if len(ap.Modifiers) > 0 { + r := snapToLine(ap.Modifiers[0]/2, lw) + fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, x, y, r) + } + case "R": + if len(ap.Modifiers) >= 2 { + w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw) + fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n", indent, x, y, w, h) + } + case "O": + if len(ap.Modifiers) >= 2 { + w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw) + r := math.Min(w, h) / 2 + fmt.Fprintf(f, "%stranslate([%f, %f]) hull() {\n", indent, x, y) + if w >= h { + d := (w - h) / 2 + fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, d, r) + fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, -d, r) + } else { + d := (h - w) / 2 + fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, d, r) + fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, -d, r) + } + fmt.Fprintf(f, "%s}\n", indent) + } + case "P": + if len(ap.Modifiers) >= 2 { + dia, numV := ap.Modifiers[0], int(ap.Modifiers[1]) + r := snapToLine(dia/2, lw) + rot := 0.0 + if len(ap.Modifiers) >= 3 { + rot = ap.Modifiers[2] + } + fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n", + indent, x, y, rot, r, numV) + } + default: + // Macro aperture – compute bounding box from primitives and emit a simple square. + if gf == nil { + return + } + macro, ok := gf.State.Macros[ap.Type] + if !ok { + return + } + minX, minY := math.Inf(1), math.Inf(1) + maxX, maxY := math.Inf(-1), math.Inf(-1) + trackPt := func(px, py, radius float64) { + if px-radius < minX { minX = px - radius } + if px+radius > maxX { maxX = px + radius } + if py-radius < minY { minY = py - radius } + if py+radius > maxY { maxY = py + radius } + } + for _, prim := range macro.Primitives { + switch prim.Code { + case 1: // Circle + if len(prim.Modifiers) >= 4 { + dia := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + trackPt(cx, cy, dia/2) + } + case 4: // Outline polygon + if len(prim.Modifiers) >= 3 { + numV := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)) + for i := 0; i < numV && 2+i*2+1 < len(prim.Modifiers); i++ { + vx := evaluateMacroExpression(prim.Modifiers[2+i*2], ap.Modifiers) + vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], ap.Modifiers) + trackPt(vx, vy, 0) + } + } + case 20: // Vector line + if len(prim.Modifiers) >= 7 { + w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + sx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + sy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + ex := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + ey := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers) + trackPt(sx, sy, w/2) + trackPt(ex, ey, w/2) + } + case 21: // Center line rect + if len(prim.Modifiers) >= 6 { + w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers) + h := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers) + cx := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers) + cy := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) + trackPt(cx, cy, math.Max(w, h)/2) + } + } + } + if !math.IsInf(minX, 1) { + w := snapToLine(maxX-minX, lw) + h := snapToLine(maxY-minY, lw) + cx := (minX + maxX) / 2 + cy := (minY + maxY) / 2 + fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n", + indent, x+cx, y+cy, w, h) + } + } +} + +// writeMacroPrimitive2D emits a single macro primitive as 2D SCAD geometry. +func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, indent string) { + switch prim.Code { + case 1: // Circle: Exposure, Diameter, CenterX, CenterY + if len(prim.Modifiers) >= 4 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + dia := evaluateMacroExpression(prim.Modifiers[1], params) + cx := evaluateMacroExpression(prim.Modifiers[2], params) + cy := evaluateMacroExpression(prim.Modifiers[3], params) + fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, cx, cy, dia/2) + } + case 4: // Outline (Polygon): Exposure, NumVertices, X1,Y1,...,Xn,Yn, Rotation + if len(prim.Modifiers) >= 3 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + numV := int(evaluateMacroExpression(prim.Modifiers[1], params)) + if len(prim.Modifiers) < 2+numV*2+1 { + return + } + rot := evaluateMacroExpression(prim.Modifiers[2+numV*2], params) + fmt.Fprintf(f, "%srotate([0, 0, %f]) polygon(points=[\n", indent, rot) + for i := 0; i < numV; i++ { + vx := evaluateMacroExpression(prim.Modifiers[2+i*2], params) + vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], params) + comma := "," + if i == numV-1 { + comma = "" + } + fmt.Fprintf(f, "%s [%f, %f]%s\n", indent, vx, vy, comma) + } + fmt.Fprintf(f, "%s]);\n", indent) + } + case 5: // Regular Polygon: Exposure, NumVertices, CenterX, CenterY, Diameter, Rotation + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + numV := int(evaluateMacroExpression(prim.Modifiers[1], params)) + cx := evaluateMacroExpression(prim.Modifiers[2], params) + cy := evaluateMacroExpression(prim.Modifiers[3], params) + dia := evaluateMacroExpression(prim.Modifiers[4], params) + rot := evaluateMacroExpression(prim.Modifiers[5], params) + fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n", + indent, cx, cy, rot, dia/2, numV) + } + case 20: // Vector Line: Exposure, Width, StartX, StartY, EndX, EndY, Rotation + if len(prim.Modifiers) >= 7 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + width := evaluateMacroExpression(prim.Modifiers[1], params) + sx := evaluateMacroExpression(prim.Modifiers[2], params) + sy := evaluateMacroExpression(prim.Modifiers[3], params) + ex := evaluateMacroExpression(prim.Modifiers[4], params) + ey := evaluateMacroExpression(prim.Modifiers[5], params) + rot := evaluateMacroExpression(prim.Modifiers[6], params) + // hull() of two squares at start/end for a rectangle with the given width + fmt.Fprintf(f, "%srotate([0, 0, %f]) hull() {\n", indent, rot) + fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, sx, sy, width) + fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, ex, ey, width) + fmt.Fprintf(f, "%s}\n", indent) + } + case 21: // Center Line (Rect): Exposure, Width, Height, CenterX, CenterY, Rotation + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { + return + } + w := evaluateMacroExpression(prim.Modifiers[1], params) + h := evaluateMacroExpression(prim.Modifiers[2], params) + cx := evaluateMacroExpression(prim.Modifiers[3], params) + cy := evaluateMacroExpression(prim.Modifiers[4], params) + rot := evaluateMacroExpression(prim.Modifiers[5], params) + fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) square([%f, %f], center=true);\n", + indent, cx, cy, rot, w, h) + } + } +} + +// writeApertureLinearDraw2D writes a 2D stroke between two points using hull() of the aperture. +func writeApertureLinearDraw2D(f *os.File, ap Aperture, x1, y1, x2, y2 float64, indent string) { + switch ap.Type { + case "C": + if len(ap.Modifiers) > 0 { + r := ap.Modifiers[0] / 2 + fmt.Fprintf(f, "%shull() {\n", indent) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r) + fmt.Fprintf(f, "%s}\n", indent) + } + case "R": + if len(ap.Modifiers) >= 2 { + w, h := ap.Modifiers[0], ap.Modifiers[1] + fmt.Fprintf(f, "%shull() {\n", indent) + fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x1, y1, w, h) + fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x2, y2, w, h) + fmt.Fprintf(f, "%s}\n", indent) + } + default: + if len(ap.Modifiers) > 0 { + r := ap.Modifiers[0] / 2 + fmt.Fprintf(f, "%shull() {\n", indent) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r) + fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r) + fmt.Fprintf(f, "%s}\n", indent) + } + } +} + +// writeGerberShapes2D writes a 2D SCAD union body representing all drawn shapes +// from the Gerber file. Call this inside a union() block. +func writeGerberShapes2D(f *os.File, gf *GerberFile, lw float64, indent string) { + curX, curY := 0.0, 0.0 + curDCode := 0 + interpolationMode := "G01" + inRegion := false + var regionPts [][2]float64 + + for _, cmd := range gf.Commands { + if cmd.Type == "APERTURE" { + if cmd.D != nil { + curDCode = *cmd.D + } + continue + } + if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { + interpolationMode = cmd.Type + continue + } + if cmd.Type == "G36" { + inRegion = true + regionPts = nil + continue + } + if cmd.Type == "G37" { + if len(regionPts) >= 3 { + fmt.Fprintf(f, "%spolygon(points=[\n", indent) + for i, pt := range regionPts { + fmt.Fprintf(f, "%s [%f, %f]", indent, pt[0], pt[1]) + if i < len(regionPts)-1 { + fmt.Fprintf(f, ",") + } + fmt.Fprintf(f, "\n") + } + fmt.Fprintf(f, "%s]);\n", indent) + } + inRegion = false + regionPts = nil + continue + } + + prevX, prevY := curX, curY + if cmd.X != nil { + curX = *cmd.X + } + if cmd.Y != nil { + curY = *cmd.Y + } + + if inRegion { + switch cmd.Type { + case "MOVE": + regionPts = append(regionPts, [2]float64{curX, curY}) + case "DRAW": + if interpolationMode == "G01" { + regionPts = append(regionPts, [2]float64{curX, curY}) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode) + regionPts = append(regionPts, arcPts...) + } + } + continue + } + + ap, ok := gf.State.Apertures[curDCode] + if !ok { + continue + } + switch cmd.Type { + case "FLASH": + writeApertureFlash2D(f, gf, ap, curX, curY, lw, indent) + case "DRAW": + if interpolationMode == "G01" { + writeApertureLinearDraw2D(f, ap, prevX, prevY, curX, curY, indent) + } else { + iVal, jVal := 0.0, 0.0 + if cmd.I != nil { + iVal = *cmd.I + } + if cmd.J != nil { + jVal = *cmd.J + } + arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode) + all := append([][2]float64{{prevX, prevY}}, arcPts...) + for i := 0; i < len(all)-1; i++ { + writeApertureLinearDraw2D(f, ap, all[i][0], all[i][1], all[i+1][0], all[i+1][1], indent) + } + } + } + } +} + +// WriteStencilSCAD generates native parametric OpenSCAD for a solder paste stencil. +// Instead of a rasterised mesh, it uses CSG primitives (circles, squares, hulls, +// polygons) so the result prints cleanly at any nozzle size. +func WriteStencilSCAD(filename string, gf *GerberFile, outlineGf *GerberFile, cfg Config, bounds *Bounds) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n") + fmt.Fprintf(f, "$fn = 60;\n\n") + lw := cfg.LineWidth + fmt.Fprintf(f, "stencil_height = %f; // mm – solder paste layer thickness\n", snapToLine(cfg.StencilHeight, lw)) + fmt.Fprintf(f, "wall_height = %f; // mm – alignment frame height\n", snapToLine(cfg.WallHeight, lw)) + fmt.Fprintf(f, "wall_thickness = %f; // mm – alignment frame wall thickness\n", snapToLine(cfg.WallThickness, lw)) + if lw > 0 { + fmt.Fprintf(f, "// line_width = %f; // mm – all dimensions snapped to multiples/fractions of this\n", lw) + } + fmt.Fprintf(f, "\n") + + var outlineVerts [][2]float64 + if outlineGf != nil { + outlineVerts = ExtractPolygonFromGerber(outlineGf) + } + + centerX := (bounds.MinX + bounds.MaxX) / 2.0 + centerY := (bounds.MinY + bounds.MaxY) / 2.0 + + // Board outline module (2D) + if len(outlineVerts) > 0 { + fmt.Fprintf(f, "module board_outline() {\n polygon(points=[\n") + for i, v := range outlineVerts { + fmt.Fprintf(f, " [%f, %f]", v[0], v[1]) + if i < len(outlineVerts)-1 { + fmt.Fprintf(f, ",") + } + fmt.Fprintf(f, "\n") + } + fmt.Fprintf(f, " ]);\n}\n\n") + } else { + // Fallback: bounding rectangle + fmt.Fprintf(f, "module board_outline() {\n") + fmt.Fprintf(f, " translate([%f, %f]) square([%f, %f]);\n", + bounds.MinX, bounds.MinY, bounds.MaxX-bounds.MinX, bounds.MaxY-bounds.MinY) + fmt.Fprintf(f, "}\n\n") + } + + // Paste pad openings module (2D union of all aperture shapes) + fmt.Fprintf(f, "module paste_pads() {\n union() {\n") + writeGerberShapes2D(f, gf, cfg.LineWidth, " ") + fmt.Fprintf(f, " }\n}\n\n") + + // Main body – centred at origin for easy placement on the print bed + fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY) + fmt.Fprintf(f, " difference() {\n") + fmt.Fprintf(f, " union() {\n") + fmt.Fprintf(f, " // Thin stencil plate\n") + fmt.Fprintf(f, " linear_extrude(height=stencil_height)\n") + fmt.Fprintf(f, " board_outline();\n") + fmt.Fprintf(f, " // Alignment wall – keeps stencil registered to the PCB edge\n") + fmt.Fprintf(f, " linear_extrude(height=wall_height)\n") + fmt.Fprintf(f, " difference() {\n") + fmt.Fprintf(f, " offset(r=wall_thickness) board_outline();\n") + fmt.Fprintf(f, " board_outline();\n") + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " // Paste pad cutouts (punched through the stencil plate)\n") + fmt.Fprintf(f, " translate([0, 0, -0.1])\n") + fmt.Fprintf(f, " linear_extrude(height=stencil_height + 0.2)\n") + fmt.Fprintf(f, " paste_pads();\n") + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, "}\n") + + return nil +} + // ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 { var strokes [][][2]float64 @@ -272,7 +716,8 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, // Cutouts are relative to board. UI specifies c.Y from bottom, so c.Y adds to Z. z := c.Height/2 + trayFloor + pcbT + c.Y - w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls + wallDepth := 2*(clearance+2*wt) + 2.0 // just enough to cut through walls + w, d, h := c.Width, wallDepth, c.Height dx := bs.EndX - bs.StartX dy := bs.EndY - bs.StartY @@ -319,6 +764,29 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt+0.5, boardCenterY, clipZ, 1.0, pryW, clipH) fmt.Fprintf(f, "}\n\n") + // cutoutMid returns the midpoint XY and rotation angle for a side cutout, + // matching the geometry used in side_cutouts(). + cutoutMid := func(c SideCutout) (midX, midY, rotDeg float64, ok bool) { + for i := range sides { + if sides[i].Num != c.Side { + continue + } + bs := &sides[i] + dx := bs.EndX - bs.StartX + dy := bs.EndY - bs.StartY + if l := math.Sqrt(dx*dx + dy*dy); l > 0 { + dx /= l + dy /= l + } + midX = bs.StartX + dx*(c.X+c.Width/2) + midY = bs.StartY + dy*(c.X+c.Width/2) + rotDeg = (bs.Angle*180.0/math.Pi) - 90.0 + ok = true + return + } + return + } + centerX := cfg.OutlineBounds.MinX + (cfg.OutlineBounds.MaxX-cfg.OutlineBounds.MinX)/2.0 centerY := cfg.OutlineBounds.MinY + (cfg.OutlineBounds.MaxY-cfg.OutlineBounds.MinY)/2.0 fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY) @@ -352,6 +820,68 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, fmt.Fprintf(f, " translate([%f,%f,-1]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, trayFloor+2, socketRadius) } fmt.Fprintf(f, " side_cutouts();\n") + // Board dado on tray: layer-aware groove on each side with port cutouts. + { + trayWallDepth := 2*(clearance+wt) + 2.0 + type trayDadoInfo struct { + hasF bool + hasB bool + fPortTop float64 + bPortBot float64 + } + trayDadoSides := make(map[int]*trayDadoInfo) + for _, c := range cutouts { + di, ok := trayDadoSides[c.Side] + if !ok { + di = &trayDadoInfo{fPortTop: 0, bPortBot: 1e9} + trayDadoSides[c.Side] = di + } + portBot := trayFloor + pcbT + c.Y + portTop := portBot + c.Height + if c.Layer == "F" { + di.hasF = true + if portTop > di.fPortTop { + di.fPortTop = portTop + } + } else { + di.hasB = true + if portBot < di.bPortBot { + di.bPortBot = portBot + } + } + } + trayH := trayFloor + snapHeight + wt + pcbT + 2.0 + for _, bs := range sides { + di, ok := trayDadoSides[bs.Num] + if !ok { + continue + } + midX := (bs.StartX + bs.EndX) / 2.0 + midY := (bs.StartY + bs.EndY) / 2.0 + rotDeg := (bs.Angle*180.0/math.Pi) - 90.0 + dadoLen := bs.Length + 1.0 + if di.hasF { + // F-layer: dado above ports (toward lid), same direction as enclosure + dadoBot := di.fPortTop + dadoH := trayH - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH) + } + } + if di.hasB { + // B-layer: dado below ports (toward floor) + dadoBot := trayFloor + 0.3 + dadoH := di.bPortBot - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH) + } + } + } + } fmt.Fprintf(f, "}\n") fmt.Fprintf(f, "pry_clips();\n\n") @@ -369,12 +899,126 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor+snapHeight+0.2, clearance+wt+0.15) fmt.Fprintf(f, " // Vertical relief slots for the tray clips to slide into\n") - fmt.Fprintf(f, " clipZ = %f;\n", trayFloor+snapHeight) - fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, pryW+1.0) - fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, pryW+1.0) + reliefClipZ := trayFloor + snapHeight + reliefH := reliefClipZ + 1.0 + reliefZ := trayFloor - 1.0 + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, reliefZ, pryW+1.0, reliefH) + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, reliefZ, pryW+1.0, reliefH) fmt.Fprintf(f, " pry_slots();\n") + + // Port cutouts – only these go through the full wall to the outside fmt.Fprintf(f, " side_cutouts();\n") + + wallDepth := 2*(clearance+2*wt) + 2.0 + lidBottom := totalH - lidThick + + // Inner wall ring helper – used to limit slots and dado to the + // inner rim only (outer wall stays solid, only ports break through). + // Inner wall spans from offset(clearance) to offset(clearance+wt). + fmt.Fprintf(f, " // --- Entry slots & board dado (inner wall only) ---\n") + fmt.Fprintf(f, " intersection() {\n") + fmt.Fprintf(f, " // Clamp to inner wall ring\n") + fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor-1, totalH+2) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.5) + fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance-0.5) + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " union() {\n") + + // Port entry slots – vertical channel from port to lid/floor, + // only in the inner wall so the outer wall stays solid. + for _, c := range cutouts { + mX, mY, mRot, ok := cutoutMid(c) + if !ok { + continue + } + zTopCut := trayFloor + pcbT + c.Y + c.Height + + if c.Layer == "F" { + // F-layer: ports on top of board, slot from port top toward lid (plate) + slotH := lidBottom - zTopCut + if slotH > 0.1 { + fmt.Fprintf(f, " // Port entry slot (F-layer, open toward plate)\n") + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + mX, mY, zTopCut+slotH/2.0, mRot, c.Width, wallDepth, slotH) + } + } else { + // B-layer: ports under board, slot from floor up to port bottom + zBotCut := trayFloor + pcbT + c.Y + slotH := zBotCut - (trayFloor + 0.3) + if slotH > 0.1 { + slotBot := trayFloor + 0.3 + fmt.Fprintf(f, " // Port entry slot (B-layer, open toward rim)\n") + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + mX, mY, slotBot+slotH/2.0, mRot, c.Width, wallDepth, slotH) + } + } + } + + // Board dado – full-length groove at PCB height, inner wall only. + // For F-layer: dado sits below ports (board under ports), from tray floor to port bottom. + // For B-layer: dado sits above ports (board over ports), from port top to lid. + // Collect per-side: lowest port bottom (F) or highest port top (B). + type dadoInfo struct { + hasF bool + hasB bool + fPortTop float64 // highest port-top on this side (F-layer) + bPortBot float64 // lowest port-bottom on this side (B-layer) + } + dadoSides := make(map[int]*dadoInfo) + for _, c := range cutouts { + di, ok := dadoSides[c.Side] + if !ok { + di = &dadoInfo{fPortTop: 0, bPortBot: 1e9} + dadoSides[c.Side] = di + } + portBot := trayFloor + pcbT + c.Y + portTop := portBot + c.Height + if c.Layer == "F" { + di.hasF = true + if portTop > di.fPortTop { + di.fPortTop = portTop + } + } else { + di.hasB = true + if portBot < di.bPortBot { + di.bPortBot = portBot + } + } + } + for _, bs := range sides { + di, ok := dadoSides[bs.Num] + if !ok { + continue + } + midX := (bs.StartX + bs.EndX) / 2.0 + midY := (bs.StartY + bs.EndY) / 2.0 + rotDeg := (bs.Angle*180.0/math.Pi) - 90.0 + dadoLen := bs.Length + 1.0 + if di.hasF { + // F-layer: ports on top of board, dado above ports (toward lid/plate) + dadoBot := di.fPortTop + dadoH := lidBottom - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH) + } + } + if di.hasB { + // B-layer: ports under board, dado below ports (toward open rim) + dadoBot := trayFloor + 0.3 + dadoH := di.bPortBot - dadoBot + if dadoH > 0.1 { + fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num) + fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", + midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH) + } + } + } + + fmt.Fprintf(f, " } // end union\n") + fmt.Fprintf(f, " } // end intersection\n") fmt.Fprintf(f, "}\n") fmt.Fprintf(f, "mounting_pegs(false);\n") } diff --git a/static/index.html b/static/index.html index 7c3405a..ff8ffc9 100644 --- a/static/index.html +++ b/static/index.html @@ -64,10 +64,18 @@ +