This commit is contained in:
pszsh 2026-02-24 13:50:06 -08:00
parent 66ea1db755
commit 01f450e45e
9 changed files with 751 additions and 81 deletions

View File

@ -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

Binary file not shown.

BIN
bin/pcb-to-stencil.exe Normal file

Binary file not shown.

View File

@ -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
}
}

67
main.go
View File

@ -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)

652
scad.go
View File

@ -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")
}

View File

@ -64,10 +64,18 @@
<label for="height">Stencil Height (mm)</label>
<input type="number" id="height" name="height" value="0.16" step="0.01">
</div>
<div class="form-group">
<label for="lineWidth">Nozzle Line Width (mm)</label>
<input type="number" id="lineWidth" name="lineWidth" value="0.42" step="0.01">
<div class="hint">Pad sizes snap to multiples of this.</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="dpi">DPI</label>
<input type="number" id="dpi" name="dpi" value="1000" step="100">
</div>
<div class="form-group"></div>
</div>
<div class="form-row">
@ -195,6 +203,7 @@
var config = {
stencilHeight: document.getElementById('height').value,
stencilDpi: document.getElementById('dpi').value,
stencilLineWidth: document.getElementById('lineWidth').value,
stencilWallHeight: document.getElementById('wallHeight').value,
stencilWallThickness: document.getElementById('wallThickness').value,
@ -219,6 +228,7 @@
if (config) {
if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight;
if (config.stencilDpi) document.getElementById('dpi').value = config.stencilDpi;
if (config.stencilLineWidth) document.getElementById('lineWidth').value = config.stencilLineWidth;
if (config.stencilWallHeight) document.getElementById('wallHeight').value = config.stencilWallHeight;
if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness;

View File

@ -359,7 +359,13 @@
<label for="cutR">Corner Radius (mm)</label>
<input type="number" id="cutR" value="1.3" step="0.01">
</div>
<div class="form-group"></div>
<div class="form-group">
<label for="cutLayer">Board Layer</label>
<select id="cutLayer" style="font-size:0.85rem; padding:0.3rem;">
<option value="F">F (top)</option>
<option value="B">B (bottom)</option>
</select>
</div>
<div class="form-group"></div>
<div class="form-group">
<div class="unit-note">All values in mm (0.01mm precision)</div>
@ -824,7 +830,8 @@
y: parseFloat(document.getElementById('cutY').value) || 0,
w: parseFloat(document.getElementById('cutW').value) || 9,
h: parseFloat(document.getElementById('cutH').value) || 3.5,
r: parseFloat(document.getElementById('cutR').value) || 1.3
r: parseFloat(document.getElementById('cutR').value) || 1.3,
l: document.getElementById('cutLayer').value || 'F'
};
sideCutouts.push(c);
updateCutoutList();
@ -838,7 +845,9 @@
var div = document.createElement('div');
div.className = 'cutout-item';
var color = sideColors[(c.side - 1) % sideColors.length];
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span>&nbsp; ' +
var layerLabel = (c.l === 'B') ? 'B' : 'F';
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span> ' +
'<span style="background:#555;color:#fff;padding:0 3px;border-radius:2px;font-size:0.75rem;">' + layerLabel + '</span>&nbsp; ' +
c.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' +
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
'<button onclick="removeCutout(' + i + ')"></button>';
@ -1017,6 +1026,7 @@
var cutX = bestPosX - (9.0 / 2);
document.getElementById('cutX').value = cutX.toFixed(2);
document.getElementById('cutY').value = '0.00';
// Default to F layer for auto-aligned cutouts (user can change before adding)
currentSide = closestSide.num;
document.getElementById('btnAddCutout').click();

56
svg.go
View File

@ -69,12 +69,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
}
if inRegion {
if cmd.Type == "MOVE" || cmd.Type == "DRAW" && interpolationMode == "G01" {
if cmd.Type == "MOVE" || (cmd.Type == "DRAW" && interpolationMode == "G01") {
regionVertices = append(regionVertices, [2]float64{curX, curY})
} else if cmd.Type == "DRAW" && (interpolationMode == "G02" || interpolationMode == "G03") {
// We don't have perfect analytic translation to SVG path for region arcs yet.
// We can just output the line for now, or approximate it as before.
// For SVG, we can just output line segments just like we did for image processing.
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
@ -82,29 +79,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
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) // 10 segments per mm
if steps < 10 {
steps = 10
}
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)
regionVertices = append(regionVertices, [2]float64{ax, ay})
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
for _, pt := range arcPts {
regionVertices = append(regionVertices, pt)
}
}
continue
@ -136,15 +113,26 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
jVal = *cmd.J
}
// SVG path Arc
rx, ry := math.Sqrt(iVal*iVal+jVal*jVal), math.Sqrt(iVal*iVal+jVal*jVal)
sweep := 1 // G03 CCW -> SVG path sweep up due to inverted Y
if interpolationMode == "G02" {
sweep = 0
// SVG path arc (Y-axis inverted: G02 CW -> CCW in SVG, G03 CCW -> CW in SVG)
r := math.Sqrt(iVal*iVal + jVal*jVal)
acx, acy := prevX+iVal, prevY+jVal
sa := math.Atan2(prevY-acy, prevX-acx)
ea := math.Atan2(curY-acy, curX-acx)
var arcSpan float64
if interpolationMode == "G03" {
if ea <= sa { ea += 2 * math.Pi }
arcSpan = ea - sa
} else {
if sa <= ea { sa += 2 * math.Pi }
arcSpan = sa - ea
}
largeArc := 0
if arcSpan > math.Pi { largeArc = 1 }
sweep := 1 // G03 CCW Gerber -> CW SVG
if interpolationMode == "G02" { sweep = 0 }
fmt.Fprintf(f, `<path d="M %f %f A %f %f 0 0 %d %f %f" stroke-width="%f" fill="none" stroke-linecap="round"/>`+"\n",
toSVGX(prevX), toSVGY(prevY), rx, ry, sweep, toSVGX(curX), toSVGY(curY), w)
fmt.Fprintf(f, `<path d="M %f %f A %f %f 0 %d %d %f %f" stroke-width="%f" fill="none" stroke-linecap="round"/>` + "\n",
toSVGX(prevX), toSVGY(prevY), r, r, largeArc, sweep, toSVGX(curX), toSVGY(curY), w)
}
}
}