package main import ( "bytes" "fmt" "io" "math" "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) if err != nil { return err } defer f.Close() fmt.Fprintf(f, "// Generated by pcb-to-stencil\npolyhedron(\n points=[\n") for i, t := range triangles { fmt.Fprintf(f, " [%f, %f, %f], [%f, %f, %f], [%f, %f, %f]", t[0].X, t[0].Y, t[0].Z, t[1].X, t[1].Y, t[1].Z, t[2].X, t[2].Y, t[2].Z) if i < len(triangles)-1 { fmt.Fprintf(f, ",\n") } else { fmt.Fprintf(f, "\n") } } fmt.Fprintf(f, " ],\n faces=[\n") for i := 0; i < len(triangles); i++ { idx := i * 3 fmt.Fprintf(f, " [%d, %d, %d]", idx, idx+1, idx+2) if i < len(triangles)-1 { fmt.Fprintf(f, ",\n") } else { fmt.Fprintf(f, "\n") } } fmt.Fprintf(f, " ]\n);\n") 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 var currentStroke [][2]float64 curX, curY := 0.0, 0.0 interpolationMode := "G01" for _, cmd := range gf.Commands { if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" { interpolationMode = cmd.Type continue } prevX, prevY := curX, curY if cmd.X != nil { curX = *cmd.X } if cmd.Y != nil { curY = *cmd.Y } if cmd.Type == "MOVE" { if len(currentStroke) > 0 { strokes = append(strokes, currentStroke) currentStroke = nil } } else if cmd.Type == "DRAW" { if len(currentStroke) == 0 { currentStroke = append(currentStroke, [2]float64{prevX, prevY}) } if interpolationMode == "G01" { currentStroke = append(currentStroke, [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 } centerX, centerY := prevX+iVal, prevY+jVal radius := math.Sqrt(iVal*iVal + jVal*jVal) startAngle := math.Atan2(prevY-centerY, prevX-centerX) endAngle := math.Atan2(curY-centerY, curX-centerX) if interpolationMode == "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 * 10) if steps < 5 { steps = 5 } for s := 1; s <= steps; s++ { t := float64(s) / float64(steps) a := startAngle + t*(endAngle-startAngle) ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a) currentStroke = append(currentStroke, [2]float64{ax, ay}) } } } } if len(currentStroke) > 0 { strokes = append(strokes, currentStroke) } if len(strokes) == 0 { return nil } // Stitch strokes into closed loops var loops [][][2]float64 used := make([]bool, len(strokes)) epsilon := 0.05 // 0.05mm tolerance for startIdx := 0; startIdx < len(strokes); startIdx++ { if used[startIdx] { continue } used[startIdx] = true path := append([][2]float64{}, strokes[startIdx]...) for { endPt := path[len(path)-1] startPt := path[0] found := false for j := 0; j < len(strokes); j++ { if used[j] { continue } s := strokes[j] sStart := s[0] sEnd := s[len(s)-1] dist := func(a, b [2]float64) float64 { dx, dy := a[0]-b[0], a[1]-b[1] return math.Sqrt(dx*dx + dy*dy) } if dist(endPt, sStart) < epsilon { path = append(path, s[1:]...) used[j] = true found = true break } else if dist(endPt, sEnd) < epsilon { for k := len(s) - 2; k >= 0; k-- { path = append(path, s[k]) } used[j] = true found = true break } else if dist(startPt, sEnd) < epsilon { // prepend newPath := append([][2]float64{}, s[:len(s)-1]...) path = append(newPath, path...) used[j] = true found = true break } else if dist(startPt, sStart) < epsilon { // reversed prepend var newPath [][2]float64 for k := len(s) - 1; k > 0; k-- { newPath = append(newPath, s[k]) } path = append(newPath, path...) used[j] = true found = true break } } if !found { break } } loops = append(loops, path) } // Find the longest loop (the main board outline) var bestLoop [][2]float64 maxLen := 0.0 for _, l := range loops { loopLen := 0.0 for i := 0; i < len(l)-1; i++ { dx := l[i+1][0] - l[i][0] dy := l[i+1][1] - l[i][1] loopLen += math.Sqrt(dx*dx + dy*dy) } if loopLen > maxLen { maxLen = loopLen bestLoop = l } } // Always ensure path is closed if len(bestLoop) > 2 { first := bestLoop[0] last := bestLoop[len(bestLoop)-1] if math.Abs(first[0]-last[0]) > epsilon || math.Abs(first[1]-last[1]) > epsilon { bestLoop = append(bestLoop, first) } } return bestLoop } // WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error { f, err := os.Create(filename) if err != nil { return err } defer f.Close() return writeNativeSCADTo(f, isTray, outlineVertices, cfg, holes, cutouts, lidCutouts, sides, minBX, maxBX, boardCenterY) } // GenerateNativeSCADString generates native SCAD code and returns it as a string. func GenerateNativeSCADString(isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) (string, error) { var buf bytes.Buffer if err := writeNativeSCADTo(&buf, isTray, outlineVertices, cfg, holes, cutouts, lidCutouts, sides, minBX, maxBX, boardCenterY); err != nil { return "", err } return buf.String(), nil } // writeNativeSCADTo writes native parametric CSG OpenSCAD code to any io.Writer. func writeNativeSCADTo(f io.Writer, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error { fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n") fmt.Fprintf(f, "$fn = 60;\n\n") // 1. Output the Board Polygon Module fmt.Fprintf(f, "module board_polygon() {\n polygon(points=[\n") for i, v := range outlineVertices { fmt.Fprintf(f, " [%f, %f]", v[0], v[1]) if i < len(outlineVertices)-1 { fmt.Fprintf(f, ",\n") } } fmt.Fprintf(f, "\n ]);\n}\n\n") // Dimensions clearance := cfg.Clearance wt := cfg.WallThickness lidThick := wt snapHeight := 2.5 trayFloor := 1.5 pcbT := cfg.PCBThickness totalH := cfg.WallHeight + pcbT + trayFloor lipH := pcbT + 1.5 // Create Peg and Socket helper fmt.Fprintf(f, "module mounting_pegs(isSocket) {\n") for _, h := range holes { if h.Type == DrillTypeMounting { r := (h.Diameter / 2.0) - 0.15 if isTray { // We subtract sockets from the tray floor r = (h.Diameter / 2.0) + 0.1 fmt.Fprintf(f, " translate([%f, %f, -1]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, trayFloor+2) } else { fmt.Fprintf(f, " translate([%f, %f, 0]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, totalH-lidThick) } } } fmt.Fprintf(f, "}\n\n") // Print Side Cutouts module fmt.Fprintf(f, "module side_cutouts() {\n") for _, c := range cutouts { var bs *BoardSide for i := range sides { if sides[i].Num == c.Side { bs = &sides[i] break } } if bs == nil { continue } // 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 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 length := math.Sqrt(dx*dx + dy*dy) if length > 0 { dx /= length dy /= length } midX := bs.StartX + dx*(c.X+w/2) midY := bs.StartY + dy*(c.X+w/2) rotDeg := (bs.Angle * 180.0 / math.Pi) - 90.0 if c.CornerRadius > 0 { r := c.CornerRadius fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) {\n", midX, midY, z, rotDeg) fmt.Fprintf(f, " hull() {\n") fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", w/2-r, h/2-r, r, d) fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", -(w/2 - r), h/2-r, r, d) fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", w/2-r, -(h/2 - r), r, d) fmt.Fprintf(f, " translate([%f, 0, %f]) rotate([90, 0, 0]) cylinder(r=%f, h=%f, center=true);\n", -(w/2 - r), -(h/2 - r), r, d) fmt.Fprintf(f, " }\n") fmt.Fprintf(f, " }\n") } else { fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n", midX, midY, z, rotDeg, w, d, h) } } fmt.Fprintf(f, "}\n\n") // Print Pry Slots Module fmt.Fprintf(f, "module pry_slots() {\n") pryW := 8.0 pryD := 1.5 fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", minBX-clearance-wt+pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3) fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt-pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3) fmt.Fprintf(f, "}\n\n") // Print Pry Clips Module fmt.Fprintf(f, "module pry_clips() {\n") clipH := 0.8 clipZ := trayFloor + snapHeight - clipH/2.0 fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", minBX-clearance-wt-0.5, boardCenterY, clipZ, 1.0, pryW, clipH) 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") // Lid/Tray Cutouts Module fmt.Fprintf(f, "module lid_cutouts() {\n") for _, lc := range lidCutouts { cx := (lc.MinX + lc.MaxX) / 2.0 cy := (lc.MinY + lc.MaxY) / 2.0 w := lc.MaxX - lc.MinX h := lc.MaxY - lc.MinY if w < 0.01 || h < 0.01 { continue } if lc.Plane == "lid" { if lc.IsDado && lc.Depth > 0 { // Dado on lid: cut from top surface downward fmt.Fprintf(f, " // Lid dado (depth=%.2f)\n", lc.Depth) fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", cx, cy, totalH-lc.Depth/2.0, w, h, lc.Depth+0.1) } else { // Through-cut on lid fmt.Fprintf(f, " // Lid through-cut\n") fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", cx, cy, totalH-lidThick/2.0, w, h, lidThick+0.2) } } else if lc.Plane == "tray" { if lc.IsDado && lc.Depth > 0 { // Dado on tray: cut from bottom surface upward fmt.Fprintf(f, " // Tray dado (depth=%.2f)\n", lc.Depth) fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", cx, cy, lc.Depth/2.0-0.05, w, h, lc.Depth+0.1) } else { // Through-cut on tray floor fmt.Fprintf(f, " // Tray through-cut\n") fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", cx, cy, trayFloor/2.0, w, h, trayFloor+0.2) } } } 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) if isTray { // --- TRAY --- fmt.Fprintf(f, "// --- TRAY ---\n") fmt.Fprintf(f, "difference() {\n") fmt.Fprintf(f, " union() {\n") fmt.Fprintf(f, " // Tray Floor (extends to clearance + 2*wt so it is flush with enclosure outside)\n") fmt.Fprintf(f, " linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, clearance+2*wt) fmt.Fprintf(f, " // Tray Inner Wall (thickness wt)\n") fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor, snapHeight) fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt) fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance) fmt.Fprintf(f, " }\n") fmt.Fprintf(f, " }\n") fmt.Fprintf(f, " // Subtract Lip Recess (for easy opening)\n") fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) difference() {\n", trayFloor+lipH+0.5) fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt+1.0) fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt-0.5) // Assuming lipCut is 0.5mm fmt.Fprintf(f, " }\n") // Remove peg holes from floor for _, hole := range holes { if hole.Type != DrillTypeMounting { continue } socketRadius := (hole.Diameter / 2.0) + 0.1 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") fmt.Fprintf(f, " lid_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") } else { // --- ENCLOSURE --- fmt.Fprintf(f, "// --- ENCLOSURE ---\n") fmt.Fprintf(f, "difference() {\n") fmt.Fprintf(f, " union() {\n") fmt.Fprintf(f, " // Outer Enclosure block (accommodates Tray Wall + Enclosure Wall)\n") fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, totalH-trayFloor, clearance+2*wt) fmt.Fprintf(f, " }\n") fmt.Fprintf(f, " // Subtract Inner Cavity (Base clearance around board)\n") fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", totalH-lidThick+1, clearance) fmt.Fprintf(f, " // Subtract Tray Recess (Accommodates Tray Wall)\n") 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") 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") fmt.Fprintf(f, " lid_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") } fmt.Fprintf(f, "}\n") // Close the top-level translate return nil }