package main import ( "crypto/rand" "embed" "encoding/binary" "encoding/hex" "encoding/json" "flag" "fmt" "html/template" "image" "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) ([]bool, []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) } } isWall := make([]bool, size) 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 isWall[nIdx] = true wQueue = append(wQueue, nIdx) } } } } return isWall, 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 wallMask []bool var boardMask []bool if outlineImg != nil { fmt.Println("Computing wall mask...") wallMask, 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 wallMask != nil { idx := y*width + x isWall = wallMask[idx] 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) (string, error) { outputPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".stl" // 1. Parse Gerber(s) fmt.Printf("Parsing %s...\n", gerberPath) gf, err := ParseGerber(gerberPath) if err != nil { return "", 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 "", 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() } } // 4. Generate Mesh fmt.Println("Generating mesh...") triangles := GenerateMeshFromImages(img, outlineImg, cfg) // 5. Save STL fmt.Printf("Saving to %s (%d triangles)...\n", outputPath, len(triangles)) err = WriteSTL(outputPath, triangles) if err != nil { return "", fmt.Errorf("error writing STL: %v", err) } return outputPath, nil } // --- CLI --- func runCLI(cfg Config, args []string) { if len(args) < 1 { fmt.Println("Usage: go run main.go [options] [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) 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 outSTL, err := processPCB(gerberPath, outlinePath, cfg) if err != nil { log.Printf("Error processing: %v", err) http.Error(w, fmt.Sprintf("Error processing PCB: %v", err), http.StatusInternalServerError) return } // Render Success renderResult(w, "Your stencil has been generated successfully.", []string{filepath.Base(outSTL)}) } 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) // 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) } } } // Generate enclosure (no side cutouts yet — added in preview flow) // Store session data for preview page session := &EnclosureSession{ OutlineImg: outlineImg, CourtyardImg: courtyardImg, SoldermaskImg: soldermaskImg, DrillHoles: filteredHoles, Config: ecfg, OutlineBounds: outlineBounds, BoardW: actualBoardW, BoardH: actualBoardH, TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0, } sessionsMu.Lock() sessions[uuid] = session sessionsMu.Unlock() // Redirect to preview page http.Redirect(w, r, "/preview?id="+uuid, http.StatusSeeOther) } func renderResult(w http.ResponseWriter, message string, files []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 }{Message: message, Files: files} tmpl.Execute(w, data) } // --- Enclosure Preview Session --- type EnclosureSession struct { OutlineImg image.Image CourtyardImg image.Image SoldermaskImg image.Image DrillHoles []DrillHole Config EnclosureConfig OutlineBounds Bounds BoardW float64 BoardH float64 TotalH float64 } 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"` }{ BoardW: session.BoardW, BoardH: session.BoardH, TotalH: session.TotalH, } 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)) } // Generate enclosure fmt.Println("Generating enclosure with side cutouts...") result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts) // Save STLs encPath := filepath.Join("temp", id+"_enclosure.stl") trayPath := filepath.Join("temp", id+"_tray.stl") fmt.Printf("Saving enclosure to %s (%d triangles)...\n", encPath, len(result.EnclosureTriangles)) if err := WriteSTL(encPath, result.EnclosureTriangles); err != nil { http.Error(w, fmt.Sprintf("Error writing enclosure STL: %v", err), http.StatusInternalServerError) return } fmt.Printf("Saving tray to %s (%d triangles)...\n", trayPath, len(result.TrayTriangles)) if err := WriteSTL(trayPath, result.TrayTriangles); err != nil { http.Error(w, fmt.Sprintf("Error writing tray STL: %v", err), http.StatusInternalServerError) return } // Clean up session sessionsMu.Lock() delete(sessions, id) sessionsMu.Unlock() renderResult(w, "Your enclosure has been generated successfully.", []string{ filepath.Base(encPath), filepath.Base(trayPath), }) } 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("/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()) } }