pcb-to-stencil/scad.go

1070 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"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()
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
}