1415 lines
36 KiB
Go
1415 lines
36 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"crypto/rand"
|
|
"embed"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// --- Configuration ---
|
|
|
|
type Config struct {
|
|
StencilHeight float64
|
|
WallHeight float64
|
|
WallThickness float64
|
|
DPI float64
|
|
KeepPNG bool
|
|
}
|
|
|
|
// Default values
|
|
const (
|
|
DefaultStencilHeight = 0.16
|
|
DefaultWallHeight = 2.0
|
|
DefaultWallThickness = 1.0
|
|
DefaultDPI = 1000.0
|
|
)
|
|
|
|
// --- STL Helpers ---
|
|
|
|
type Point struct {
|
|
X, Y, Z float64
|
|
}
|
|
|
|
func WriteSTL(filename string, triangles [][3]Point) error {
|
|
f, err := os.Create(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
// Write Binary STL Header (80 bytes)
|
|
header := make([]byte, 80)
|
|
copy(header, "Generated by pcb-to-stencil")
|
|
if _, err := f.Write(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write Number of Triangles (4 bytes uint32)
|
|
count := uint32(len(triangles))
|
|
if err := binary.Write(f, binary.LittleEndian, count); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write Triangles
|
|
// Each triangle is 50 bytes:
|
|
// Normal (3 floats = 12 bytes)
|
|
// Vertex 1 (3 floats = 12 bytes)
|
|
// Vertex 2 (3 floats = 12 bytes)
|
|
// Vertex 3 (3 floats = 12 bytes)
|
|
// Attribute byte count (2 bytes uint16)
|
|
|
|
// Buffer for a single triangle to minimize syscalls
|
|
buf := make([]byte, 50)
|
|
|
|
for _, t := range triangles {
|
|
// Normal (0,0,0)
|
|
binary.LittleEndian.PutUint32(buf[0:4], math.Float32bits(0))
|
|
binary.LittleEndian.PutUint32(buf[4:8], math.Float32bits(0))
|
|
binary.LittleEndian.PutUint32(buf[8:12], math.Float32bits(0))
|
|
|
|
// Vertex 1
|
|
binary.LittleEndian.PutUint32(buf[12:16], math.Float32bits(float32(t[0].X)))
|
|
binary.LittleEndian.PutUint32(buf[16:20], math.Float32bits(float32(t[0].Y)))
|
|
binary.LittleEndian.PutUint32(buf[20:24], math.Float32bits(float32(t[0].Z)))
|
|
|
|
// Vertex 2
|
|
binary.LittleEndian.PutUint32(buf[24:28], math.Float32bits(float32(t[1].X)))
|
|
binary.LittleEndian.PutUint32(buf[28:32], math.Float32bits(float32(t[1].Y)))
|
|
binary.LittleEndian.PutUint32(buf[32:36], math.Float32bits(float32(t[1].Z)))
|
|
|
|
// Vertex 3
|
|
binary.LittleEndian.PutUint32(buf[36:40], math.Float32bits(float32(t[2].X)))
|
|
binary.LittleEndian.PutUint32(buf[40:44], math.Float32bits(float32(t[2].Y)))
|
|
binary.LittleEndian.PutUint32(buf[44:48], math.Float32bits(float32(t[2].Z)))
|
|
|
|
// Attribute byte count (0)
|
|
binary.LittleEndian.PutUint16(buf[48:50], 0)
|
|
|
|
if _, err := f.Write(buf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) {
|
|
x0, y0 := x, y
|
|
x1, y1 := x+w, y+h
|
|
z0, z1 := 0.0, zHeight
|
|
|
|
p000 := Point{x0, y0, z0}
|
|
p100 := Point{x1, y0, z0}
|
|
p110 := Point{x1, y1, z0}
|
|
p010 := Point{x0, y1, z0}
|
|
p001 := Point{x0, y0, z1}
|
|
p101 := Point{x1, y0, z1}
|
|
p111 := Point{x1, y1, z1}
|
|
p011 := Point{x0, y1, z1}
|
|
|
|
addQuad := func(a, b, c, d Point) {
|
|
*triangles = append(*triangles, [3]Point{a, b, c})
|
|
*triangles = append(*triangles, [3]Point{c, d, a})
|
|
}
|
|
|
|
addQuad(p000, p010, p110, p100) // Bottom
|
|
addQuad(p101, p111, p011, p001) // Top
|
|
addQuad(p000, p100, p101, p001) // Front
|
|
addQuad(p100, p110, p111, p101) // Right
|
|
addQuad(p110, p010, p011, p111) // Back
|
|
addQuad(p010, p000, p001, p011) // Left
|
|
}
|
|
|
|
// --- Meshing Logic (Optimized) ---
|
|
|
|
// 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
|
|
// specified thickness around it.
|
|
func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]int, []bool) {
|
|
bounds := img.Bounds()
|
|
w := bounds.Max.X
|
|
h := bounds.Max.Y
|
|
size := w * h
|
|
|
|
// Helper for neighbors
|
|
dx := []int{0, 0, 1, -1}
|
|
dy := []int{1, -1, 0, 0}
|
|
|
|
// 1. Identify Outline Pixels (White)
|
|
isOutline := make([]bool, size)
|
|
outlineQueue := []int{}
|
|
for i := 0; i < size; i++ {
|
|
cx := i % w
|
|
cy := i / w
|
|
c := img.At(cx, cy)
|
|
r, _, _, _ := c.RGBA()
|
|
if r > 10000 { // White-ish
|
|
isOutline[i] = true
|
|
outlineQueue = append(outlineQueue, i)
|
|
}
|
|
}
|
|
|
|
// 2. Dilate Outline to close gaps
|
|
// We dilate by a small amount (e.g. 0.5mm) to ensure the outline is closed.
|
|
gapClosingMM := 0.5
|
|
gapClosingPixels := int(gapClosingMM / pixelToMM)
|
|
if gapClosingPixels < 1 {
|
|
gapClosingPixels = 1
|
|
}
|
|
|
|
dist := make([]int, size)
|
|
for i := 0; i < size; i++ {
|
|
if isOutline[i] {
|
|
dist[i] = 0
|
|
} else {
|
|
dist[i] = -1
|
|
}
|
|
}
|
|
|
|
// BFS for Dilation
|
|
dilatedOutline := make([]bool, size)
|
|
copy(dilatedOutline, isOutline)
|
|
|
|
// Use a separate queue for dilation to avoid modifying the original outlineQueue if we needed it
|
|
dQueue := make([]int, len(outlineQueue))
|
|
copy(dQueue, outlineQueue)
|
|
|
|
for len(dQueue) > 0 {
|
|
idx := dQueue[0]
|
|
dQueue = dQueue[1:]
|
|
|
|
d := dist[idx]
|
|
if d >= gapClosingPixels {
|
|
continue
|
|
}
|
|
|
|
cx := idx % w
|
|
cy := idx / w
|
|
|
|
for i := 0; i < 4; i++ {
|
|
nx, ny := cx+dx[i], cy+dy[i]
|
|
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
|
nIdx := ny*w + nx
|
|
if dist[nIdx] == -1 {
|
|
dist[nIdx] = d + 1
|
|
dilatedOutline[nIdx] = true
|
|
dQueue = append(dQueue, nIdx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Flood Fill "Outside" using Dilated Outline as barrier
|
|
isOutside := make([]bool, size)
|
|
// Start from (0,0) - assumed to be outside due to padding
|
|
if !dilatedOutline[0] {
|
|
isOutside[0] = true
|
|
fQueue := []int{0}
|
|
|
|
for len(fQueue) > 0 {
|
|
idx := fQueue[0]
|
|
fQueue = fQueue[1:]
|
|
|
|
cx := idx % w
|
|
cy := idx / w
|
|
|
|
for i := 0; i < 4; i++ {
|
|
nx, ny := cx+dx[i], cy+dy[i]
|
|
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
|
nIdx := ny*w + nx
|
|
if !isOutside[nIdx] && !dilatedOutline[nIdx] {
|
|
isOutside[nIdx] = true
|
|
fQueue = append(fQueue, nIdx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
|
|
// Reset dist for Outside expansion
|
|
for i := 0; i < size; i++ {
|
|
if isOutside[i] {
|
|
dist[i] = 0
|
|
} else {
|
|
dist[i] = -1
|
|
}
|
|
}
|
|
|
|
oQueue := []int{}
|
|
for i := 0; i < size; i++ {
|
|
if isOutside[i] {
|
|
oQueue = append(oQueue, i)
|
|
}
|
|
}
|
|
|
|
isOutsideExpanded := make([]bool, size)
|
|
copy(isOutsideExpanded, isOutside)
|
|
|
|
for len(oQueue) > 0 {
|
|
idx := oQueue[0]
|
|
oQueue = oQueue[1:]
|
|
|
|
d := dist[idx]
|
|
if d >= gapClosingPixels {
|
|
continue
|
|
}
|
|
|
|
cx := idx % w
|
|
cy := idx / w
|
|
|
|
for i := 0; i < 4; i++ {
|
|
nx, ny := cx+dx[i], cy+dy[i]
|
|
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
|
nIdx := ny*w + nx
|
|
if dist[nIdx] == -1 {
|
|
dist[nIdx] = d + 1
|
|
isOutsideExpanded[nIdx] = true
|
|
oQueue = append(oQueue, nIdx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Define Board
|
|
isBoard := make([]bool, size)
|
|
for i := 0; i < size; i++ {
|
|
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.
|
|
|
|
thicknessPixels := int(thicknessMM / pixelToMM)
|
|
if thicknessPixels < 1 {
|
|
thicknessPixels = 1
|
|
}
|
|
|
|
// Reset dist for Wall generation
|
|
for i := 0; i < size; i++ {
|
|
if isBoard[i] {
|
|
dist[i] = 0
|
|
} else {
|
|
dist[i] = -1
|
|
}
|
|
}
|
|
|
|
wQueue := []int{}
|
|
for i := 0; i < size; i++ {
|
|
if isBoard[i] {
|
|
wQueue = append(wQueue, i)
|
|
}
|
|
}
|
|
|
|
wallDist := make([]int, size)
|
|
for i := range wallDist {
|
|
wallDist[i] = -1
|
|
}
|
|
|
|
for len(wQueue) > 0 {
|
|
idx := wQueue[0]
|
|
wQueue = wQueue[1:]
|
|
|
|
d := dist[idx]
|
|
if d >= thicknessPixels {
|
|
continue
|
|
}
|
|
|
|
cx := idx % w
|
|
cy := idx / w
|
|
|
|
for i := 0; i < 4; i++ {
|
|
nx, ny := cx+dx[i], cy+dy[i]
|
|
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
|
nIdx := ny*w + nx
|
|
if dist[nIdx] == -1 {
|
|
dist[nIdx] = d + 1
|
|
wallDist[nIdx] = d + 1
|
|
wQueue = append(wQueue, nIdx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return wallDist, isBoard
|
|
}
|
|
|
|
func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3]Point {
|
|
pixelToMM := 25.4 / cfg.DPI
|
|
bounds := stencilImg.Bounds()
|
|
width := bounds.Max.X
|
|
height := bounds.Max.Y
|
|
var triangles [][3]Point
|
|
|
|
var wallDist []int
|
|
var boardMask []bool
|
|
if outlineImg != nil {
|
|
fmt.Println("Computing wall mask...")
|
|
wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM)
|
|
}
|
|
|
|
// Optimization: Run-Length Encoding
|
|
for y := 0; y < height; y++ {
|
|
var startX = -1
|
|
var currentHeight = 0.0
|
|
|
|
for x := 0; x < width; x++ {
|
|
// Check stencil (black = solid)
|
|
sc := stencilImg.At(x, y)
|
|
sr, sg, sb, _ := sc.RGBA()
|
|
isStencilSolid := sr < 10000 && sg < 10000 && sb < 10000
|
|
|
|
// Check wall
|
|
isWall := false
|
|
isInsideBoard := true
|
|
if wallDist != nil {
|
|
idx := y*width + x
|
|
isWall = wallDist[idx] >= 0
|
|
if boardMask != nil {
|
|
isInsideBoard = boardMask[idx]
|
|
}
|
|
}
|
|
|
|
// Determine height at this pixel
|
|
h := 0.0
|
|
if isWall {
|
|
h = cfg.WallHeight
|
|
} else if isStencilSolid {
|
|
if isInsideBoard {
|
|
h = cfg.WallHeight
|
|
}
|
|
}
|
|
|
|
if h > 0 {
|
|
if startX == -1 {
|
|
startX = x
|
|
currentHeight = h
|
|
} else if h != currentHeight {
|
|
// Height changed, end current strip and start new one
|
|
stripLen := x - startX
|
|
AddBox(
|
|
&triangles,
|
|
float64(startX)*pixelToMM,
|
|
float64(y)*pixelToMM,
|
|
float64(stripLen)*pixelToMM,
|
|
pixelToMM,
|
|
currentHeight,
|
|
)
|
|
startX = x
|
|
currentHeight = h
|
|
}
|
|
} else {
|
|
if startX != -1 {
|
|
// End of strip, generate box
|
|
stripLen := x - startX
|
|
AddBox(
|
|
&triangles,
|
|
float64(startX)*pixelToMM,
|
|
float64(y)*pixelToMM,
|
|
float64(stripLen)*pixelToMM,
|
|
pixelToMM,
|
|
currentHeight,
|
|
)
|
|
startX = -1
|
|
currentHeight = 0.0
|
|
}
|
|
}
|
|
}
|
|
if startX != -1 {
|
|
stripLen := width - startX
|
|
AddBox(
|
|
&triangles,
|
|
float64(startX)*pixelToMM,
|
|
float64(y)*pixelToMM,
|
|
float64(stripLen)*pixelToMM,
|
|
pixelToMM,
|
|
currentHeight,
|
|
)
|
|
}
|
|
}
|
|
return triangles
|
|
}
|
|
|
|
// --- Logic ---
|
|
|
|
func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, error) {
|
|
baseName := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath))
|
|
var generatedFiles []string
|
|
|
|
// Helper to check what formats were requested
|
|
wantsType := func(t string) bool {
|
|
for _, e := range exports {
|
|
if e == t {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Always default to STL if nothing is specified
|
|
if len(exports) == 0 {
|
|
exports = []string{"stl"}
|
|
}
|
|
|
|
// 1. Parse Gerber(s)
|
|
fmt.Printf("Parsing %s...\n", gerberPath)
|
|
gf, err := ParseGerber(gerberPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error parsing gerber: %v", err)
|
|
}
|
|
|
|
var outlineGf *GerberFile
|
|
if outlinePath != "" {
|
|
fmt.Printf("Parsing outline %s...\n", outlinePath)
|
|
outlineGf, err = ParseGerber(outlinePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error parsing outline gerber: %v", err)
|
|
}
|
|
}
|
|
|
|
// 2. Calculate Union Bounds
|
|
bounds := gf.CalculateBounds()
|
|
if outlineGf != nil {
|
|
outlineBounds := outlineGf.CalculateBounds()
|
|
if outlineBounds.MinX < bounds.MinX {
|
|
bounds.MinX = outlineBounds.MinX
|
|
}
|
|
if outlineBounds.MinY < bounds.MinY {
|
|
bounds.MinY = outlineBounds.MinY
|
|
}
|
|
if outlineBounds.MaxX > bounds.MaxX {
|
|
bounds.MaxX = outlineBounds.MaxX
|
|
}
|
|
if outlineBounds.MaxY > bounds.MaxY {
|
|
bounds.MaxY = outlineBounds.MaxY
|
|
}
|
|
}
|
|
|
|
// Expand bounds to accommodate wall thickness and prevent clipping
|
|
margin := cfg.WallThickness + 5.0 // mm
|
|
bounds.MinX -= margin
|
|
bounds.MinY -= margin
|
|
bounds.MaxX += margin
|
|
bounds.MaxY += margin
|
|
|
|
// 3. Render to Image(s)
|
|
fmt.Println("Rendering to internal image...")
|
|
img := gf.Render(cfg.DPI, &bounds)
|
|
|
|
var outlineImg image.Image
|
|
if outlineGf != nil {
|
|
fmt.Println("Rendering outline to internal image...")
|
|
outlineImg = outlineGf.Render(cfg.DPI, &bounds)
|
|
}
|
|
|
|
if cfg.KeepPNG {
|
|
pngPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".png"
|
|
fmt.Printf("Saving intermediate PNG to %s...\n", pngPath)
|
|
f, err := os.Create(pngPath)
|
|
if err != nil {
|
|
log.Printf("Warning: Could not create PNG file: %v", err)
|
|
} else {
|
|
if err := png.Encode(f, img); err != nil {
|
|
log.Printf("Warning: Could not encode PNG: %v", err)
|
|
}
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
var triangles [][3]Point
|
|
if wantsType("stl") || wantsType("scad") {
|
|
// 4. Generate Mesh
|
|
fmt.Println("Generating mesh...")
|
|
triangles = GenerateMeshFromImages(img, outlineImg, cfg)
|
|
}
|
|
|
|
// 5. Output based on requested formats
|
|
if wantsType("stl") {
|
|
outputFilename := baseName + ".stl"
|
|
fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles))
|
|
if err := WriteSTL(outputFilename, triangles); err != nil {
|
|
return nil, fmt.Errorf("error writing stl: %v", err)
|
|
}
|
|
generatedFiles = append(generatedFiles, outputFilename)
|
|
}
|
|
|
|
if wantsType("svg") {
|
|
outputFilename := baseName + ".svg"
|
|
fmt.Printf("Saving to %s (SVG)...\n", outputFilename)
|
|
if err := WriteSVG(outputFilename, gf, &bounds); err != nil {
|
|
return nil, fmt.Errorf("error writing svg: %v", err)
|
|
}
|
|
generatedFiles = append(generatedFiles, outputFilename)
|
|
}
|
|
|
|
if wantsType("png") {
|
|
outputFilename := baseName + ".png"
|
|
fmt.Printf("Saving to %s (PNG)...\n", outputFilename)
|
|
if f, err := os.Create(outputFilename); err == nil {
|
|
png.Encode(f, img)
|
|
f.Close()
|
|
generatedFiles = append(generatedFiles, outputFilename)
|
|
}
|
|
}
|
|
|
|
if wantsType("scad") {
|
|
outputFilename := baseName + ".scad"
|
|
fmt.Printf("Saving to %s (SCAD)...\n", outputFilename)
|
|
if err := WriteSCAD(outputFilename, triangles); err != nil {
|
|
return nil, fmt.Errorf("error writing scad: %v", err)
|
|
}
|
|
generatedFiles = append(generatedFiles, outputFilename)
|
|
}
|
|
|
|
return generatedFiles, nil
|
|
}
|
|
|
|
// --- CLI ---
|
|
|
|
func runCLI(cfg Config, args []string) {
|
|
if len(args) < 1 {
|
|
fmt.Println("Usage: go run main.go [options] <path_to_gerber_file> [path_to_outline_gerber_file]")
|
|
fmt.Println("Options:")
|
|
flag.PrintDefaults()
|
|
fmt.Println("Example: go run main.go -height=0.3 MyPCB.GTP MyPCB.GKO")
|
|
os.Exit(1)
|
|
}
|
|
|
|
gerberPath := args[0]
|
|
var outlinePath string
|
|
if len(args) > 1 {
|
|
outlinePath = args[1]
|
|
}
|
|
|
|
_, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"})
|
|
if err != nil {
|
|
log.Fatalf("Error: %v", err)
|
|
}
|
|
fmt.Println("Success! Happy printing.")
|
|
}
|
|
|
|
// --- Server ---
|
|
|
|
//go:embed static/*
|
|
var staticFiles embed.FS
|
|
|
|
func randomID() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func indexHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// Read index.html from embedded FS
|
|
content, err := staticFiles.ReadFile("static/index.html")
|
|
if err != nil {
|
|
http.Error(w, "Could not load index page", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write(content)
|
|
}
|
|
|
|
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.
|
|
r.ParseMultipartForm(32 << 20)
|
|
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Create temp dir
|
|
tempDir := filepath.Join(".", "temp")
|
|
os.MkdirAll(tempDir, 0755)
|
|
|
|
uuid := randomID()
|
|
|
|
// Parse params
|
|
height, _ := strconv.ParseFloat(r.FormValue("height"), 64)
|
|
dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64)
|
|
wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64)
|
|
wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64)
|
|
|
|
if height == 0 {
|
|
height = DefaultStencilHeight
|
|
}
|
|
if dpi == 0 {
|
|
dpi = DefaultDPI
|
|
}
|
|
if wallHeight == 0 {
|
|
wallHeight = DefaultWallHeight
|
|
}
|
|
if wallThickness == 0 {
|
|
wallThickness = DefaultWallThickness
|
|
}
|
|
|
|
cfg := Config{
|
|
StencilHeight: height,
|
|
WallHeight: wallHeight,
|
|
WallThickness: wallThickness,
|
|
DPI: dpi,
|
|
KeepPNG: false,
|
|
}
|
|
|
|
// Handle Gerber File
|
|
file, header, err := r.FormFile("gerber")
|
|
if err != nil {
|
|
http.Error(w, "Error retrieving gerber file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
gerberPath := filepath.Join(tempDir, uuid+"_paste"+filepath.Ext(header.Filename))
|
|
outFile, err := os.Create(gerberPath)
|
|
if err != nil {
|
|
http.Error(w, "Server error saving file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer outFile.Close()
|
|
io.Copy(outFile, file)
|
|
|
|
// Handle Outline File (Optional)
|
|
outlineFile, outlineHeader, err := r.FormFile("outline")
|
|
var outlinePath string
|
|
if err == nil {
|
|
defer outlineFile.Close()
|
|
outlinePath = filepath.Join(tempDir, uuid+"_outline"+filepath.Ext(outlineHeader.Filename))
|
|
outOutline, err := os.Create(outlinePath)
|
|
if err == nil {
|
|
defer outOutline.Close()
|
|
io.Copy(outOutline, outlineFile)
|
|
}
|
|
}
|
|
|
|
// Process
|
|
exports := r.Form["exports"]
|
|
generatedPaths, err := processPCB(gerberPath, outlinePath, cfg, exports)
|
|
if err != nil {
|
|
log.Printf("Error processing: %v", err)
|
|
http.Error(w, fmt.Sprintf("Error processing PCB: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var generatedFiles []string
|
|
for _, p := range generatedPaths {
|
|
generatedFiles = append(generatedFiles, filepath.Base(p))
|
|
}
|
|
|
|
// Generate Master Zip if more than 1 file
|
|
var zipFile string
|
|
if len(generatedFiles) > 1 {
|
|
zipPath := filepath.Join(tempDir, uuid+"_all_exports.zip")
|
|
zf, err := os.Create(zipPath)
|
|
if err == nil {
|
|
zw := zip.NewWriter(zf)
|
|
for _, fn := range generatedFiles {
|
|
fw, _ := zw.Create(fn)
|
|
fb, _ := os.ReadFile(filepath.Join(tempDir, fn))
|
|
fw.Write(fb)
|
|
}
|
|
zw.Close()
|
|
zf.Close()
|
|
zipFile = filepath.Base(zipPath)
|
|
}
|
|
}
|
|
|
|
// Render Success
|
|
renderResult(w, "Your stencil has been generated successfully.", generatedFiles, "/", zipFile)
|
|
}
|
|
|
|
func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseMultipartForm(32 << 20)
|
|
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
tempDir := filepath.Join(".", "temp")
|
|
os.MkdirAll(tempDir, 0755)
|
|
uuid := randomID()
|
|
|
|
// Parse params
|
|
wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64)
|
|
wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64)
|
|
clearance, _ := strconv.ParseFloat(r.FormValue("clearance"), 64)
|
|
dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64)
|
|
|
|
if wallThickness == 0 {
|
|
wallThickness = DefaultEncWallThick
|
|
}
|
|
if wallHeight == 0 {
|
|
wallHeight = DefaultEncWallHeight
|
|
}
|
|
if clearance == 0 {
|
|
clearance = DefaultClearance
|
|
}
|
|
if dpi == 0 {
|
|
dpi = 600
|
|
}
|
|
|
|
// Handle GerberJob file (required)
|
|
gbrjobFile, gbrjobHeader, err := r.FormFile("gbrjob")
|
|
if err != nil {
|
|
http.Error(w, "Gerber job file (.gbrjob) is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer gbrjobFile.Close()
|
|
|
|
gbrjobPath := filepath.Join(tempDir, uuid+"_"+gbrjobHeader.Filename)
|
|
jf, err := os.Create(gbrjobPath)
|
|
if err != nil {
|
|
http.Error(w, "Server error saving file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
io.Copy(jf, gbrjobFile)
|
|
jf.Close()
|
|
|
|
jobResult, err := ParseGerberJob(gbrjobPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Error parsing gbrjob: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Auto-fill PCB thickness from job file
|
|
pcbThickness := jobResult.BoardThickness
|
|
if pcbThickness == 0 {
|
|
pcbThickness = DefaultPCBThickness
|
|
}
|
|
|
|
ecfg := EnclosureConfig{
|
|
PCBThickness: pcbThickness,
|
|
WallThickness: wallThickness,
|
|
WallHeight: wallHeight,
|
|
Clearance: clearance,
|
|
DPI: dpi,
|
|
}
|
|
|
|
// Handle uploaded gerber files (multi-select)
|
|
// Save all gerbers, then match to layers from job file
|
|
gerberFiles := r.MultipartForm.File["gerbers"]
|
|
savedGerbers := make(map[string]string) // filename → saved path
|
|
for _, fh := range gerberFiles {
|
|
f, err := fh.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
savePath := filepath.Join(tempDir, uuid+"_"+fh.Filename)
|
|
sf, err := os.Create(savePath)
|
|
if err != nil {
|
|
f.Close()
|
|
continue
|
|
}
|
|
io.Copy(sf, f)
|
|
sf.Close()
|
|
f.Close()
|
|
savedGerbers[fh.Filename] = savePath
|
|
}
|
|
|
|
// Find the outline (Edge.Cuts) gerber
|
|
outlinePath, ok := savedGerbers[jobResult.EdgeCutsFile]
|
|
if !ok {
|
|
http.Error(w, fmt.Sprintf("Edge.Cuts file '%s' not found in uploaded gerbers. Upload all .gbr files.", jobResult.EdgeCutsFile), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Handle PTH Drill File (optional)
|
|
var drillHoles []DrillHole
|
|
drillFile, drillHeader, err := r.FormFile("drill")
|
|
if err == nil {
|
|
defer drillFile.Close()
|
|
drillPath := filepath.Join(tempDir, uuid+"_drill"+filepath.Ext(drillHeader.Filename))
|
|
df, err := os.Create(drillPath)
|
|
if err == nil {
|
|
io.Copy(df, drillFile)
|
|
df.Close()
|
|
holes, err := ParseDrill(drillPath)
|
|
if err != nil {
|
|
log.Printf("Warning: Could not parse PTH drill file: %v", err)
|
|
} else {
|
|
drillHoles = append(drillHoles, holes...)
|
|
fmt.Printf("Parsed %d PTH drill holes\n", len(holes))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle NPTH Drill File (optional)
|
|
npthFile, npthHeader, err := r.FormFile("npth")
|
|
if err == nil {
|
|
defer npthFile.Close()
|
|
npthPath := filepath.Join(tempDir, uuid+"_npth"+filepath.Ext(npthHeader.Filename))
|
|
nf, err := os.Create(npthPath)
|
|
if err == nil {
|
|
io.Copy(nf, npthFile)
|
|
nf.Close()
|
|
holes, err := ParseDrill(npthPath)
|
|
if err != nil {
|
|
log.Printf("Warning: Could not parse NPTH drill file: %v", err)
|
|
} else {
|
|
drillHoles = append(drillHoles, holes...)
|
|
fmt.Printf("Parsed %d NPTH drill holes\n", len(holes))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter out vias — only keep component and mounting holes
|
|
var filteredHoles []DrillHole
|
|
for _, h := range drillHoles {
|
|
if h.Type != DrillTypeVia {
|
|
filteredHoles = append(filteredHoles, h)
|
|
}
|
|
}
|
|
fmt.Printf("After filtering: %d holes (vias removed)\n", len(filteredHoles))
|
|
|
|
// Parse outline gerber
|
|
fmt.Printf("Parsing outline %s...\n", outlinePath)
|
|
outlineGf, err := ParseGerber(outlinePath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Error parsing outline: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
outlineBounds := outlineGf.CalculateBounds()
|
|
|
|
// Save actual board dimensions before adding margins
|
|
actualBoardW := outlineBounds.MaxX - outlineBounds.MinX
|
|
actualBoardH := outlineBounds.MaxY - outlineBounds.MinY
|
|
|
|
// Add margin for enclosure walls
|
|
margin := ecfg.WallThickness + ecfg.Clearance + 5.0
|
|
outlineBounds.MinX -= margin
|
|
outlineBounds.MinY -= margin
|
|
outlineBounds.MaxX += margin
|
|
outlineBounds.MaxY += margin
|
|
|
|
// Render outline to image
|
|
fmt.Println("Rendering outline...")
|
|
ecfg.OutlineBounds = &outlineBounds
|
|
outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds)
|
|
|
|
// Compute board box and count from the rendered image
|
|
minBX, minBY, maxBX, maxBY := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y, -1, -1
|
|
var boardCenterY float64
|
|
var boardCount int
|
|
|
|
wallMaskInt, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4)
|
|
_ = wallMaskInt // Not used here, but we need boardMask
|
|
|
|
imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y
|
|
for y := 0; y < imgH; y++ {
|
|
for x := 0; x < imgW; x++ {
|
|
if boardMask[y*imgW+x] {
|
|
if x < minBX {
|
|
minBX = x
|
|
}
|
|
if y < minBY {
|
|
minBY = y
|
|
}
|
|
if x > maxBX {
|
|
maxBX = x
|
|
}
|
|
if y > maxBY {
|
|
maxBY = y
|
|
}
|
|
boardCenterY += float64(y)
|
|
boardCount++
|
|
}
|
|
}
|
|
}
|
|
if boardCount > 0 {
|
|
boardCenterY /= float64(boardCount)
|
|
}
|
|
|
|
// Auto-discover and render F.Courtyard from job file
|
|
var courtyardImg image.Image
|
|
if courtPath, ok := savedGerbers[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" {
|
|
courtGf, err := ParseGerber(courtPath)
|
|
if err != nil {
|
|
log.Printf("Warning: Could not parse courtyard gerber: %v", err)
|
|
} else {
|
|
fmt.Println("Rendering courtyard layer...")
|
|
courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds)
|
|
}
|
|
}
|
|
|
|
// Auto-discover and render F.Mask from job file
|
|
var soldermaskImg image.Image
|
|
if maskPath, ok := savedGerbers[jobResult.SoldermaskFile]; ok && jobResult.SoldermaskFile != "" {
|
|
maskGf, err := ParseGerber(maskPath)
|
|
if err != nil {
|
|
log.Printf("Warning: Could not parse soldermask gerber: %v", err)
|
|
} else {
|
|
fmt.Println("Rendering soldermask layer...")
|
|
soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds)
|
|
}
|
|
}
|
|
|
|
// Also try F.Fab as fallback courtyard (many boards have F.Fab but not F.Courtyard)
|
|
if courtyardImg == nil && jobResult.FabFile != "" {
|
|
if fabPath, ok := savedGerbers[jobResult.FabFile]; ok {
|
|
fabGf, err := ParseGerber(fabPath)
|
|
if err != nil {
|
|
log.Printf("Warning: Could not parse fab gerber: %v", err)
|
|
} else {
|
|
fmt.Println("Rendering F.Fab layer as courtyard fallback...")
|
|
courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds)
|
|
}
|
|
}
|
|
}
|
|
|
|
pixelToMM := 25.4 / ecfg.DPI
|
|
session := &EnclosureSession{
|
|
Exports: r.Form["exports"],
|
|
OutlineGf: outlineGf,
|
|
OutlineImg: outlineImg,
|
|
CourtyardImg: courtyardImg,
|
|
SoldermaskImg: soldermaskImg,
|
|
DrillHoles: filteredHoles,
|
|
Config: ecfg,
|
|
OutlineBounds: outlineBounds,
|
|
BoardW: actualBoardW,
|
|
BoardH: actualBoardH,
|
|
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
|
|
MinBX: float64(minBX)*pixelToMM + outlineBounds.MinX,
|
|
MaxBX: float64(maxBX)*pixelToMM + outlineBounds.MinX,
|
|
BoardCenterY: outlineBounds.MaxY - boardCenterY*pixelToMM,
|
|
Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, pixelToMM, &outlineBounds),
|
|
}
|
|
sessionsMu.Lock()
|
|
sessions[uuid] = session
|
|
sessionsMu.Unlock()
|
|
|
|
// Redirect to preview page
|
|
http.Redirect(w, r, "/preview?id="+uuid, http.StatusSeeOther)
|
|
|
|
}
|
|
|
|
func footprintUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
err := r.ParseMultipartForm(50 << 20) // 50 MB
|
|
if err != nil {
|
|
http.Error(w, "Error parsing form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
sessionID := r.FormValue("sessionId")
|
|
if sessionID == "" {
|
|
http.Error(w, "Missing sessionId", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
sessionsMu.Lock()
|
|
session, ok := sessions[sessionID]
|
|
sessionsMu.Unlock()
|
|
if !ok {
|
|
http.Error(w, "Invalid session", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
files := r.MultipartForm.File["gerbers"]
|
|
var allFootprints []Footprint
|
|
var fabGfList []*GerberFile
|
|
|
|
for _, fileHeader := range files {
|
|
f, err := fileHeader.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
b := make([]byte, 8)
|
|
rand.Read(b)
|
|
tempPath := filepath.Join("temp", fmt.Sprintf("%x_%s", b, fileHeader.Filename))
|
|
out, err := os.Create(tempPath)
|
|
if err == nil {
|
|
io.Copy(out, f)
|
|
out.Close()
|
|
gf, err := ParseGerber(tempPath)
|
|
if err == nil {
|
|
allFootprints = append(allFootprints, ExtractFootprints(gf)...)
|
|
fabGfList = append(fabGfList, gf)
|
|
}
|
|
}
|
|
f.Close()
|
|
}
|
|
|
|
// Composite Fab images into one transparent overlay
|
|
if len(fabGfList) > 0 {
|
|
bounds := session.OutlineBounds
|
|
imgW := int((bounds.MaxX - bounds.MinX) * session.Config.DPI / 25.4)
|
|
imgH := int((bounds.MaxY - bounds.MinY) * session.Config.DPI / 25.4)
|
|
if imgW > 0 && imgH > 0 {
|
|
composite := image.NewRGBA(image.Rect(0, 0, imgW, imgH))
|
|
// Initialize with pure transparency
|
|
draw.Draw(composite, composite.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
|
|
|
|
for _, gf := range fabGfList {
|
|
layerImg := gf.Render(session.Config.DPI, &bounds)
|
|
if rgba, ok := layerImg.(*image.RGBA); ok {
|
|
for y := 0; y < imgH; y++ {
|
|
for x := 0; x < imgW; x++ {
|
|
// Gerber render background is Black. White is drawn pixels.
|
|
if r, _, _, _ := rgba.At(x, y).RGBA(); r > 0x7FFF {
|
|
// Set as cyan overlay for visibility
|
|
composite.Set(x, y, color.RGBA{0, 255, 255, 180})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sessionsMu.Lock()
|
|
session.FabImg = composite
|
|
sessionsMu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Return all parsed footprints for visual selection
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(allFootprints)
|
|
}
|
|
|
|
func renderResult(w http.ResponseWriter, message string, files []string, backURL string, zipFile string) {
|
|
tmpl, err := template.ParseFS(staticFiles, "static/result.html")
|
|
if err != nil {
|
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := struct {
|
|
Message string
|
|
Files []string
|
|
BackURL string
|
|
ZipFile string
|
|
}{Message: message, Files: files, BackURL: backURL, ZipFile: zipFile}
|
|
tmpl.Execute(w, data)
|
|
}
|
|
|
|
// --- Enclosure Preview Session ---
|
|
|
|
type EnclosureSession struct {
|
|
Exports []string
|
|
OutlineGf *GerberFile
|
|
OutlineImg image.Image
|
|
CourtyardImg image.Image
|
|
SoldermaskImg image.Image
|
|
DrillHoles []DrillHole
|
|
Config EnclosureConfig
|
|
OutlineBounds Bounds
|
|
BoardW float64
|
|
BoardH float64
|
|
TotalH float64
|
|
MinBX float64
|
|
MaxBX float64
|
|
BoardCenterY float64
|
|
Sides []BoardSide
|
|
FabImg image.Image
|
|
}
|
|
|
|
var (
|
|
sessions = make(map[string]*EnclosureSession)
|
|
sessionsMu sync.Mutex
|
|
)
|
|
|
|
func previewHandler(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("id")
|
|
sessionsMu.Lock()
|
|
session, ok := sessions[id]
|
|
sessionsMu.Unlock()
|
|
if !ok {
|
|
http.Error(w, "Session not found. Please re-upload your files.", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
boardInfo := struct {
|
|
BoardW float64 `json:"boardW"`
|
|
BoardH float64 `json:"boardH"`
|
|
TotalH float64 `json:"totalH"`
|
|
Sides []BoardSide `json:"sides"`
|
|
MinX float64 `json:"minX"`
|
|
MaxY float64 `json:"maxY"`
|
|
DPI float64 `json:"dpi"`
|
|
}{
|
|
BoardW: session.BoardW,
|
|
BoardH: session.BoardH,
|
|
TotalH: session.TotalH,
|
|
Sides: session.Sides,
|
|
MinX: session.OutlineBounds.MinX,
|
|
MaxY: session.OutlineBounds.MaxY,
|
|
DPI: session.Config.DPI,
|
|
}
|
|
boardJSON, _ := json.Marshal(boardInfo)
|
|
|
|
tmpl, err := template.ParseFS(staticFiles, "static/preview.html")
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
data := struct {
|
|
SessionID string
|
|
BoardInfoJSON template.JS
|
|
}{
|
|
SessionID: id,
|
|
BoardInfoJSON: template.JS(boardJSON),
|
|
}
|
|
tmpl.Execute(w, data)
|
|
}
|
|
|
|
func previewImageHandler(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
if len(parts) < 3 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
id := parts[2]
|
|
|
|
sessionsMu.Lock()
|
|
session, ok := sessions[id]
|
|
sessionsMu.Unlock()
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "image/png")
|
|
png.Encode(w, session.OutlineImg)
|
|
}
|
|
|
|
func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
r.ParseForm()
|
|
|
|
id := r.FormValue("sessionId")
|
|
sessionsMu.Lock()
|
|
session, ok := sessions[id]
|
|
sessionsMu.Unlock()
|
|
if !ok {
|
|
http.Error(w, "Session expired. Please re-upload your files.", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Parse side cutouts from JSON
|
|
var sideCutouts []SideCutout
|
|
cutoutsJSON := r.FormValue("sideCutouts")
|
|
if cutoutsJSON != "" && cutoutsJSON != "[]" {
|
|
var rawCutouts []struct {
|
|
Side int `json:"side"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
W float64 `json:"w"`
|
|
H float64 `json:"h"`
|
|
R float64 `json:"r"`
|
|
}
|
|
if err := json.Unmarshal([]byte(cutoutsJSON), &rawCutouts); err != nil {
|
|
log.Printf("Warning: could not parse side cutouts: %v", err)
|
|
} else {
|
|
for _, rc := range rawCutouts {
|
|
sideCutouts = append(sideCutouts, SideCutout{
|
|
Side: rc.Side,
|
|
X: rc.X,
|
|
Y: rc.Y,
|
|
Width: rc.W,
|
|
Height: rc.H,
|
|
CornerRadius: rc.R,
|
|
})
|
|
}
|
|
}
|
|
fmt.Printf("Side cutouts: %d\n", len(sideCutouts))
|
|
}
|
|
|
|
var generatedFiles []string
|
|
|
|
// Helper to check what formats were requested
|
|
wantsType := func(t string) bool {
|
|
for _, e := range session.Exports {
|
|
if e == t {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Always default to STL if nothing is specified
|
|
if len(session.Exports) == 0 {
|
|
session.Exports = []string{"stl"}
|
|
}
|
|
|
|
// Process STL
|
|
if wantsType("stl") {
|
|
fmt.Println("Generating STLs...")
|
|
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts, session.Sides)
|
|
encPath := filepath.Join("temp", id+"_enclosure.stl")
|
|
trayPath := filepath.Join("temp", id+"_tray.stl")
|
|
WriteSTL(encPath, result.EnclosureTriangles)
|
|
WriteSTL(trayPath, result.TrayTriangles)
|
|
generatedFiles = append(generatedFiles, filepath.Base(encPath), filepath.Base(trayPath))
|
|
}
|
|
|
|
// Process SCAD
|
|
if wantsType("scad") {
|
|
fmt.Println("Generating Native SCAD scripts...")
|
|
scadPathEnc := filepath.Join("temp", id+"_enclosure.scad")
|
|
scadPathTray := filepath.Join("temp", id+"_tray.scad")
|
|
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
|
|
WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
|
|
WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
|
|
generatedFiles = append(generatedFiles, filepath.Base(scadPathEnc), filepath.Base(scadPathTray))
|
|
}
|
|
|
|
// Process SVG
|
|
if wantsType("svg") && session.OutlineGf != nil {
|
|
fmt.Println("Generating SVG vector outline...")
|
|
svgPath := filepath.Join("temp", id+"_outline.svg")
|
|
WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds)
|
|
generatedFiles = append(generatedFiles, filepath.Base(svgPath))
|
|
}
|
|
|
|
// Process PNG
|
|
if wantsType("png") && session.OutlineImg != nil {
|
|
fmt.Println("Generating PNG raster outline...")
|
|
pngPath := filepath.Join("temp", id+"_outline.png")
|
|
fImg, _ := os.Create(pngPath)
|
|
png.Encode(fImg, session.OutlineImg)
|
|
fImg.Close()
|
|
generatedFiles = append(generatedFiles, filepath.Base(pngPath))
|
|
}
|
|
|
|
// Generate Master Zip if more than 1 file
|
|
var zipFile string
|
|
if len(generatedFiles) > 1 {
|
|
zipPath := filepath.Join("temp", id+"_all_exports.zip")
|
|
zf, err := os.Create(zipPath)
|
|
if err == nil {
|
|
zw := zip.NewWriter(zf)
|
|
for _, fn := range generatedFiles {
|
|
fw, _ := zw.Create(fn)
|
|
fb, _ := os.ReadFile(filepath.Join("temp", fn))
|
|
fw.Write(fb)
|
|
}
|
|
zw.Close()
|
|
zf.Close()
|
|
zipFile = filepath.Base(zipPath)
|
|
}
|
|
}
|
|
|
|
// We intentionally do NOT delete the session here so the user can hit "Back for Adjustments"
|
|
renderResult(w, "Your files have been generated successfully.", generatedFiles, "/preview?id="+id, zipFile)
|
|
}
|
|
|
|
func downloadHandler(w http.ResponseWriter, r *http.Request) {
|
|
vars := strings.Split(r.URL.Path, "/")
|
|
if len(vars) < 3 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
filename := vars[2]
|
|
|
|
// Security check: ensure no path traversal
|
|
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") {
|
|
http.Error(w, "Invalid filename", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
path := filepath.Join("temp", filename)
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
http.ServeFile(w, r, path)
|
|
}
|
|
|
|
func runServer(port string) {
|
|
// Serve static files (CSS, etc.)
|
|
// This will serve files under /static/ from the embedded fs
|
|
http.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
|
|
|
http.HandleFunc("/", indexHandler)
|
|
http.HandleFunc("/upload", uploadHandler)
|
|
http.HandleFunc("/upload-enclosure", enclosureUploadHandler)
|
|
http.HandleFunc("/upload-footprints", footprintUploadHandler)
|
|
http.HandleFunc("/preview", previewHandler)
|
|
http.HandleFunc("/preview-image/", previewImageHandler)
|
|
http.HandleFunc("/generate-enclosure", generateEnclosureHandler)
|
|
http.HandleFunc("/download/", downloadHandler)
|
|
|
|
fmt.Printf("Starting server on http://0.0.0.0:%s\n", port)
|
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
}
|
|
|
|
// --- Main ---
|
|
|
|
var (
|
|
flagStencilHeight float64
|
|
flagWallHeight float64
|
|
flagWallThickness float64
|
|
flagDPI float64
|
|
flagKeepPNG bool
|
|
flagServer bool
|
|
flagPort string
|
|
)
|
|
|
|
func main() {
|
|
flag.Float64Var(&flagStencilHeight, "height", DefaultStencilHeight, "Stencil height in mm")
|
|
flag.Float64Var(&flagWallHeight, "wall-height", DefaultWallHeight, "Wall height in mm")
|
|
flag.Float64Var(&flagWallThickness, "wall-thickness", DefaultWallThickness, "Wall thickness in mm")
|
|
flag.Float64Var(&flagDPI, "dpi", DefaultDPI, "DPI for rendering (lower = smaller file, rougher curves)")
|
|
flag.BoolVar(&flagKeepPNG, "keep-png", false, "Save intermediate PNG file")
|
|
|
|
flag.BoolVar(&flagServer, "server", false, "Start in server mode")
|
|
flag.StringVar(&flagPort, "port", "8080", "Port to run the server on")
|
|
|
|
flag.Parse()
|
|
|
|
if flagServer {
|
|
runServer(flagPort)
|
|
} else {
|
|
cfg := Config{
|
|
StencilHeight: flagStencilHeight,
|
|
WallHeight: flagWallHeight,
|
|
WallThickness: flagWallThickness,
|
|
DPI: flagDPI,
|
|
KeepPNG: flagKeepPNG,
|
|
}
|
|
runCLI(cfg, flag.Args())
|
|
}
|
|
}
|