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