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).
|
- Supports Aperture Macros (AM) with rotation (e.g., rounded rectangles).
|
||||||
- Automatically crops the output to the PCB bounds.
|
- Automatically crops the output to the PCB bounds.
|
||||||
- Generates a 3D STL mesh optimized for 3D printing.
|
- 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
|
## 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.
|
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
|
### Web Interface
|
||||||
|
|
||||||
To start the 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
|
## How it Works
|
||||||
|
|
||||||
1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws).
|
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 layer into a high-resolution internal image.
|
2. **Rendering**: It renders the PCB outline and layers 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.
|
3. **Path Extraction**: Board edges are traced and simplified to generate 2.5D geometry.
|
||||||
4. **Export**: The mesh is saved as a binary STL file.
|
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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -38,6 +38,7 @@ type SideCutout struct {
|
||||||
Width float64 // Width in mm
|
Width float64 // Width in mm
|
||||||
Height float64 // Height in mm
|
Height float64 // Height in mm
|
||||||
CornerRadius float64 // Corner radius in mm (0 for square)
|
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
|
// 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
|
minZ += trayFloor + pcbT
|
||||||
maxZ += trayFloor + pcbT
|
maxZ += trayFloor + pcbT
|
||||||
|
|
||||||
// Wall below cutout: from 0 to minZ
|
// Wall below cutout: from trayFloor to minZ (preserve enclosure floor)
|
||||||
if minZ > 0.05 {
|
if minZ > trayFloor+0.3 {
|
||||||
addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, minZ)
|
addBoxAtZ(&cutoutEncTris, bx2, by2, trayFloor, bw, bh, minZ-trayFloor)
|
||||||
}
|
}
|
||||||
// Wall above cutout: from maxZ to totalH
|
// Wall above cutout: from maxZ to totalH
|
||||||
if maxZ < totalH-0.05 {
|
if maxZ < totalH-0.05 {
|
||||||
|
|
@ -697,7 +698,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
by2 := float64(y) * pixelToMM
|
by2 := float64(y) * pixelToMM
|
||||||
bw := float64(x-runStart) * pixelToMM
|
bw := float64(x-runStart) * pixelToMM
|
||||||
bh := pixelToMM
|
bh := pixelToMM
|
||||||
AddBox(&newEncTris, bx, by2, bw, bh, totalH)
|
addBoxAtZ(&newEncTris, bx, by2, trayFloor, bw, bh, totalH-trayFloor)
|
||||||
runStart = -1
|
runStart = -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
main.go
67
main.go
|
|
@ -31,6 +31,7 @@ type Config struct {
|
||||||
StencilHeight float64
|
StencilHeight float64
|
||||||
WallHeight float64
|
WallHeight float64
|
||||||
WallThickness float64
|
WallThickness float64
|
||||||
|
LineWidth float64
|
||||||
DPI float64
|
DPI float64
|
||||||
KeepPNG bool
|
KeepPNG bool
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +139,7 @@ func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) {
|
||||||
addQuad(p010, p000, p001, p011) // Left
|
addQuad(p010, p000, p001, p011) // Left
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Meshing Logic (Optimized) ---
|
// --- Meshing Logic ---
|
||||||
|
|
||||||
// ComputeWallMask generates a mask for the wall based on the outline image.
|
// 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
|
// 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}
|
dx := []int{0, 0, 1, -1}
|
||||||
dy := []int{1, -1, 0, 0}
|
dy := []int{1, -1, 0, 0}
|
||||||
|
|
||||||
// 1. Identify Outline Pixels (White)
|
// Identify Outline Pixels (White)
|
||||||
isOutline := make([]bool, size)
|
isOutline := make([]bool, size)
|
||||||
outlineQueue := []int{}
|
outlineQueue := []int{}
|
||||||
for i := 0; i < size; i++ {
|
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
|
// Dilate Outline to close gaps
|
||||||
// We dilate by a small amount (e.g. 0.5mm) to ensure the outline is closed.
|
// Dilates the outline by 0.5mm to ensure a closed boundary.
|
||||||
gapClosingMM := 0.5
|
gapClosingMM := 0.5
|
||||||
gapClosingPixels := int(gapClosingMM / pixelToMM)
|
gapClosingPixels := int(gapClosingMM / pixelToMM)
|
||||||
if gapClosingPixels < 1 {
|
if gapClosingPixels < 1 {
|
||||||
|
|
@ -188,7 +189,7 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([
|
||||||
dilatedOutline := make([]bool, size)
|
dilatedOutline := make([]bool, size)
|
||||||
copy(dilatedOutline, isOutline)
|
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))
|
dQueue := make([]int, len(outlineQueue))
|
||||||
copy(dQueue, 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)
|
isOutside := make([]bool, size)
|
||||||
// Start from (0,0) - assumed to be outside due to padding
|
// Start from (0,0) - assumed to be outside due to padding
|
||||||
if !dilatedOutline[0] {
|
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)
|
// Restore Board Shape by eroding expansion back to original boundary
|
||||||
// We dilated the outline, so "Outside" stopped 'gapClosingPixels' away from the real board edge.
|
// Resets distance for expansion to touch the real board edge.
|
||||||
// We need to expand "Outside" inwards by 'gapClosingPixels' to touch the real board edge.
|
// Board area is then identified as the inverse of the outside area.
|
||||||
// Then "Board" = !Outside.
|
|
||||||
|
|
||||||
// Reset dist for Outside expansion
|
// Reset dist for Outside expansion
|
||||||
for i := 0; i < size; i++ {
|
for i := 0; i < size; i++ {
|
||||||
|
|
@ -299,10 +299,8 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([
|
||||||
isBoard[i] = !isOutsideExpanded[i]
|
isBoard[i] = !isOutsideExpanded[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Generate Wall
|
// Generate Wall geometry by expanding the Board area outwards.
|
||||||
// Wall is generated by expanding Board outwards.
|
// The wall is positioned strictly outside the board boundary.
|
||||||
// We want the wall to be strictly OUTSIDE the board.
|
|
||||||
// If we expand Board, we get pixels outside.
|
|
||||||
|
|
||||||
thicknessPixels := int(thicknessMM / pixelToMM)
|
thicknessPixels := int(thicknessMM / pixelToMM)
|
||||||
if thicknessPixels < 1 {
|
if thicknessPixels < 1 {
|
||||||
|
|
@ -475,7 +473,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([
|
||||||
exports = []string{"stl"}
|
exports = []string{"stl"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Parse Gerber(s)
|
// Parse Gerber layers for board outline and component placement
|
||||||
fmt.Printf("Parsing %s...\n", gerberPath)
|
fmt.Printf("Parsing %s...\n", gerberPath)
|
||||||
gf, err := ParseGerber(gerberPath)
|
gf, err := ParseGerber(gerberPath)
|
||||||
if err != nil {
|
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()
|
bounds := gf.CalculateBounds()
|
||||||
if outlineGf != nil {
|
if outlineGf != nil {
|
||||||
outlineBounds := outlineGf.CalculateBounds()
|
outlineBounds := outlineGf.CalculateBounds()
|
||||||
|
|
@ -516,7 +514,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([
|
||||||
bounds.MaxX += margin
|
bounds.MaxX += margin
|
||||||
bounds.MaxY += 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...")
|
fmt.Println("Rendering to internal image...")
|
||||||
img := gf.Render(cfg.DPI, &bounds)
|
img := gf.Render(cfg.DPI, &bounds)
|
||||||
|
|
||||||
|
|
@ -541,13 +539,13 @@ func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([
|
||||||
}
|
}
|
||||||
|
|
||||||
var triangles [][3]Point
|
var triangles [][3]Point
|
||||||
if wantsType("stl") || wantsType("scad") {
|
if wantsType("stl") {
|
||||||
// 4. Generate Mesh
|
// Generate triangle mesh from rasterized board data (only needed for STL output)
|
||||||
fmt.Println("Generating mesh...")
|
fmt.Println("Generating mesh...")
|
||||||
triangles = GenerateMeshFromImages(img, outlineImg, cfg)
|
triangles = GenerateMeshFromImages(img, outlineImg, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Output based on requested formats
|
// Export assets in requested file formats
|
||||||
if wantsType("stl") {
|
if wantsType("stl") {
|
||||||
outputFilename := baseName + ".stl"
|
outputFilename := baseName + ".stl"
|
||||||
fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles))
|
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") {
|
if wantsType("scad") {
|
||||||
outputFilename := baseName + ".scad"
|
outputFilename := baseName + ".scad"
|
||||||
fmt.Printf("Saving to %s (SCAD)...\n", outputFilename)
|
fmt.Printf("Saving to %s (Native SCAD)...\n", outputFilename)
|
||||||
if err := WriteSCAD(outputFilename, triangles); err != nil {
|
if err := WriteStencilSCAD(outputFilename, gf, outlineGf, cfg, &bounds); err != nil {
|
||||||
return nil, fmt.Errorf("error writing scad: %v", err)
|
return nil, fmt.Errorf("error writing scad: %v", err)
|
||||||
}
|
}
|
||||||
generatedFiles = append(generatedFiles, outputFilename)
|
generatedFiles = append(generatedFiles, outputFilename)
|
||||||
|
|
@ -639,9 +637,8 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadHandler(w http.ResponseWriter, r *http.Request) {
|
func uploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse the multipart form BEFORE reading FormValue.
|
// Multipart form parsing is required to correctly extract numeric fields
|
||||||
// Without this, FormValue can't see fields in a multipart/form-data body,
|
// and layer parameters before they fall back to default values.
|
||||||
// so all numeric parameters silently fall back to defaults.
|
|
||||||
r.ParseMultipartForm(32 << 20)
|
r.ParseMultipartForm(32 << 20)
|
||||||
|
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
|
|
@ -660,6 +657,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64)
|
dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64)
|
||||||
wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64)
|
wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64)
|
||||||
wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64)
|
wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64)
|
||||||
|
lineWidth, _ := strconv.ParseFloat(r.FormValue("lineWidth"), 64)
|
||||||
|
|
||||||
if height == 0 {
|
if height == 0 {
|
||||||
height = DefaultStencilHeight
|
height = DefaultStencilHeight
|
||||||
|
|
@ -678,6 +676,7 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
StencilHeight: height,
|
StencilHeight: height,
|
||||||
WallHeight: wallHeight,
|
WallHeight: wallHeight,
|
||||||
WallThickness: wallThickness,
|
WallThickness: wallThickness,
|
||||||
|
LineWidth: lineWidth,
|
||||||
DPI: dpi,
|
DPI: dpi,
|
||||||
KeepPNG: false,
|
KeepPNG: false,
|
||||||
}
|
}
|
||||||
|
|
@ -816,8 +815,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
DPI: dpi,
|
DPI: dpi,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle uploaded gerber files (multi-select)
|
// Store uploaded Gerber files and coordinate with the job description
|
||||||
// Save all gerbers, then match to layers from job file
|
|
||||||
gerberFiles := r.MultipartForm.File["gerbers"]
|
gerberFiles := r.MultipartForm.File["gerbers"]
|
||||||
savedGerbers := make(map[string]string) // filename → saved path
|
savedGerbers := make(map[string]string) // filename → saved path
|
||||||
for _, fh := range gerberFiles {
|
for _, fh := range gerberFiles {
|
||||||
|
|
@ -924,7 +922,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var boardCount int
|
var boardCount int
|
||||||
|
|
||||||
wallMaskInt, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4)
|
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
|
imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y
|
||||||
for y := 0; y < imgH; y++ {
|
for y := 0; y < imgH; y++ {
|
||||||
|
|
@ -1235,11 +1233,16 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
W float64 `json:"w"`
|
W float64 `json:"w"`
|
||||||
H float64 `json:"h"`
|
H float64 `json:"h"`
|
||||||
R float64 `json:"r"`
|
R float64 `json:"r"`
|
||||||
|
L string `json:"l"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(cutoutsJSON), &rawCutouts); err != nil {
|
if err := json.Unmarshal([]byte(cutoutsJSON), &rawCutouts); err != nil {
|
||||||
log.Printf("Warning: could not parse side cutouts: %v", err)
|
log.Printf("Warning: could not parse side cutouts: %v", err)
|
||||||
} else {
|
} else {
|
||||||
for _, rc := range rawCutouts {
|
for _, rc := range rawCutouts {
|
||||||
|
layer := rc.L
|
||||||
|
if layer == "" {
|
||||||
|
layer = "F"
|
||||||
|
}
|
||||||
sideCutouts = append(sideCutouts, SideCutout{
|
sideCutouts = append(sideCutouts, SideCutout{
|
||||||
Side: rc.Side,
|
Side: rc.Side,
|
||||||
X: rc.X,
|
X: rc.X,
|
||||||
|
|
@ -1247,6 +1250,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
Width: rc.W,
|
Width: rc.W,
|
||||||
Height: rc.H,
|
Height: rc.H,
|
||||||
CornerRadius: rc.R,
|
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)
|
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]
|
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, "..") {
|
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") {
|
||||||
http.Error(w, "Invalid filename", http.StatusBadRequest)
|
http.Error(w, "Invalid filename", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -1358,8 +1362,7 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServer(port string) {
|
func runServer(port string) {
|
||||||
// Serve static files (CSS, etc.)
|
// Serve static assets from the embedded filesystem
|
||||||
// This will serve files under /static/ from the embedded fs
|
|
||||||
http.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
http.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
||||||
|
|
||||||
http.HandleFunc("/", indexHandler)
|
http.HandleFunc("/", indexHandler)
|
||||||
|
|
|
||||||
652
scad.go
652
scad.go
|
|
@ -6,6 +6,16 @@ import (
|
||||||
"os"
|
"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 {
|
func WriteSCAD(filename string, triangles [][3]Point) error {
|
||||||
// Fallback/legacy mesh WriteSCAD
|
// Fallback/legacy mesh WriteSCAD
|
||||||
f, err := os.Create(filename)
|
f, err := os.Create(filename)
|
||||||
|
|
@ -37,6 +47,440 @@ func WriteSCAD(filename string, triangles [][3]Point) error {
|
||||||
return nil
|
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
|
// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon
|
||||||
func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
||||||
var strokes [][][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.
|
// 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
|
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
|
dx := bs.EndX - bs.StartX
|
||||||
dy := bs.EndY - bs.StartY
|
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, " 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")
|
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
|
centerX := cfg.OutlineBounds.MinX + (cfg.OutlineBounds.MaxX-cfg.OutlineBounds.MinX)/2.0
|
||||||
centerY := cfg.OutlineBounds.MinY + (cfg.OutlineBounds.MaxY-cfg.OutlineBounds.MinY)/2.0
|
centerY := cfg.OutlineBounds.MinY + (cfg.OutlineBounds.MaxY-cfg.OutlineBounds.MinY)/2.0
|
||||||
fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY)
|
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, " translate([%f,%f,-1]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, trayFloor+2, socketRadius)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f, " side_cutouts();\n")
|
fmt.Fprintf(f, " 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, "}\n")
|
||||||
fmt.Fprintf(f, "pry_clips();\n\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, " 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, " // Vertical relief slots for the tray clips to slide into\n")
|
||||||
fmt.Fprintf(f, " clipZ = %f;\n", trayFloor+snapHeight)
|
reliefClipZ := 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)
|
reliefH := reliefClipZ + 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)
|
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")
|
fmt.Fprintf(f, " pry_slots();\n")
|
||||||
|
|
||||||
|
// Port cutouts – only these go through the full wall to the outside
|
||||||
fmt.Fprintf(f, " side_cutouts();\n")
|
fmt.Fprintf(f, " 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, "}\n")
|
||||||
fmt.Fprintf(f, "mounting_pegs(false);\n")
|
fmt.Fprintf(f, "mounting_pegs(false);\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,18 @@
|
||||||
<label for="height">Stencil Height (mm)</label>
|
<label for="height">Stencil Height (mm)</label>
|
||||||
<input type="number" id="height" name="height" value="0.16" step="0.01">
|
<input type="number" id="height" name="height" value="0.16" step="0.01">
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="dpi">DPI</label>
|
<label for="dpi">DPI</label>
|
||||||
<input type="number" id="dpi" name="dpi" value="1000" step="100">
|
<input type="number" id="dpi" name="dpi" value="1000" step="100">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|
@ -195,6 +203,7 @@
|
||||||
var config = {
|
var config = {
|
||||||
stencilHeight: document.getElementById('height').value,
|
stencilHeight: document.getElementById('height').value,
|
||||||
stencilDpi: document.getElementById('dpi').value,
|
stencilDpi: document.getElementById('dpi').value,
|
||||||
|
stencilLineWidth: document.getElementById('lineWidth').value,
|
||||||
stencilWallHeight: document.getElementById('wallHeight').value,
|
stencilWallHeight: document.getElementById('wallHeight').value,
|
||||||
stencilWallThickness: document.getElementById('wallThickness').value,
|
stencilWallThickness: document.getElementById('wallThickness').value,
|
||||||
|
|
||||||
|
|
@ -219,6 +228,7 @@
|
||||||
if (config) {
|
if (config) {
|
||||||
if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight;
|
if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight;
|
||||||
if (config.stencilDpi) document.getElementById('dpi').value = config.stencilDpi;
|
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.stencilWallHeight) document.getElementById('wallHeight').value = config.stencilWallHeight;
|
||||||
if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness;
|
if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,13 @@
|
||||||
<label for="cutR">Corner Radius (mm)</label>
|
<label for="cutR">Corner Radius (mm)</label>
|
||||||
<input type="number" id="cutR" value="1.3" step="0.01">
|
<input type="number" id="cutR" value="1.3" step="0.01">
|
||||||
</div>
|
</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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="unit-note">All values in mm (0.01mm precision)</div>
|
<div class="unit-note">All values in mm (0.01mm precision)</div>
|
||||||
|
|
@ -824,7 +830,8 @@
|
||||||
y: parseFloat(document.getElementById('cutY').value) || 0,
|
y: parseFloat(document.getElementById('cutY').value) || 0,
|
||||||
w: parseFloat(document.getElementById('cutW').value) || 9,
|
w: parseFloat(document.getElementById('cutW').value) || 9,
|
||||||
h: parseFloat(document.getElementById('cutH').value) || 3.5,
|
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);
|
sideCutouts.push(c);
|
||||||
updateCutoutList();
|
updateCutoutList();
|
||||||
|
|
@ -838,7 +845,9 @@
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.className = 'cutout-item';
|
div.className = 'cutout-item';
|
||||||
var color = sideColors[(c.side - 1) % sideColors.length];
|
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.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' +
|
||||||
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
|
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
|
||||||
'<button onclick="removeCutout(' + i + ')">✕</button>';
|
'<button onclick="removeCutout(' + i + ')">✕</button>';
|
||||||
|
|
@ -1017,6 +1026,7 @@
|
||||||
var cutX = bestPosX - (9.0 / 2);
|
var cutX = bestPosX - (9.0 / 2);
|
||||||
document.getElementById('cutX').value = cutX.toFixed(2);
|
document.getElementById('cutX').value = cutX.toFixed(2);
|
||||||
document.getElementById('cutY').value = '0.00';
|
document.getElementById('cutY').value = '0.00';
|
||||||
|
// Default to F layer for auto-aligned cutouts (user can change before adding)
|
||||||
|
|
||||||
currentSide = closestSide.num;
|
currentSide = closestSide.num;
|
||||||
document.getElementById('btnAddCutout').click();
|
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 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})
|
regionVertices = append(regionVertices, [2]float64{curX, curY})
|
||||||
} else if cmd.Type == "DRAW" && (interpolationMode == "G02" || interpolationMode == "G03") {
|
} 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
|
iVal, jVal := 0.0, 0.0
|
||||||
if cmd.I != nil {
|
if cmd.I != nil {
|
||||||
iVal = *cmd.I
|
iVal = *cmd.I
|
||||||
|
|
@ -82,29 +79,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
|
||||||
if cmd.J != nil {
|
if cmd.J != nil {
|
||||||
jVal = *cmd.J
|
jVal = *cmd.J
|
||||||
}
|
}
|
||||||
centerX, centerY := prevX+iVal, prevY+jVal
|
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
|
||||||
radius := math.Sqrt(iVal*iVal + jVal*jVal)
|
for _, pt := range arcPts {
|
||||||
startAngle := math.Atan2(prevY-centerY, prevX-centerX)
|
regionVertices = append(regionVertices, pt)
|
||||||
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})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
@ -136,15 +113,26 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
|
||||||
jVal = *cmd.J
|
jVal = *cmd.J
|
||||||
}
|
}
|
||||||
|
|
||||||
// SVG path Arc
|
// SVG path arc (Y-axis inverted: G02 CW -> CCW in SVG, G03 CCW -> CW in SVG)
|
||||||
rx, ry := math.Sqrt(iVal*iVal+jVal*jVal), math.Sqrt(iVal*iVal+jVal*jVal)
|
r := math.Sqrt(iVal*iVal + jVal*jVal)
|
||||||
sweep := 1 // G03 CCW -> SVG path sweep up due to inverted Y
|
acx, acy := prevX+iVal, prevY+jVal
|
||||||
if interpolationMode == "G02" {
|
sa := math.Atan2(prevY-acy, prevX-acx)
|
||||||
sweep = 0
|
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",
|
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), rx, ry, sweep, toSVGX(curX), toSVGY(curY), w)
|
toSVGX(prevX), toSVGY(prevY), r, r, largeArc, sweep, toSVGX(curX), toSVGY(curY), w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue