diff --git a/enclosure.go b/enclosure.go index c80cb00..0bbaab5 100644 --- a/enclosure.go +++ b/enclosure.go @@ -14,6 +14,7 @@ type EnclosureConfig struct { WallHeight float64 // mm (height of walls above PCB) Clearance float64 // mm (gap between PCB and enclosure wall) DPI float64 + OutlineBounds *Bounds // gerber coordinate bounds for drill mapping } // Default enclosure values @@ -32,7 +33,7 @@ type EnclosureResult struct { // SideCutout defines a cutout on a side wall face type SideCutout struct { - Face string // "north", "south", "east", "west" + Side int // 1-indexed side number (clockwise from top) X, Y float64 // Position on the face in mm (from left edge, from bottom) Width float64 // Width in mm Height float64 // Height in mm @@ -105,9 +106,104 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo // For the lid, we want to cover everything within the outer wall boundary // Lid pixels = wallMask[i] || boardMask[i] || clearanceMask[i] // (i.e., the entire footprint of the enclosure) - size := imgW * imgH + // Pre-compute board bounding box (needed for side cutout detection and removal tabs) + minBX, minBY := imgW, imgH + maxBX, maxBY := 0, 0 + boardCenterX, boardCenterY := 0.0, 0.0 + boardCount := 0 + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + if boardMask[y*imgW+x] { + boardCenterX += float64(x) + boardCenterY += float64(y) + boardCount++ + if x < minBX { + minBX = x + } + if x > maxBX { + maxBX = x + } + if y < minBY { + minBY = y + } + if y > maxBY { + maxBY = y + } + } + } + } + + // Build wall-cutout mask from side cutouts + // For each side cutout, determine which wall pixels to subtract + wallCutoutMask := make([]bool, size) + if len(sideCutouts) > 0 && cfg.OutlineBounds != nil { + // Board bounding box in pixels + for y := 0; y < imgH; y++ { + for x := 0; x < imgW; x++ { + idx := y*imgW + x + if !(wallMask[idx] && !clearanceMask[idx] && !boardMask[idx]) { + continue // not a wall pixel + } + + // Determine which side this wall pixel belongs to + // Find distance to each side of the board bounding box + dTop := math.Abs(float64(y) - float64(minBY)) + dBottom := math.Abs(float64(y) - float64(maxBY)) + dLeft := math.Abs(float64(x) - float64(minBX)) + dRight := math.Abs(float64(x) - float64(maxBX)) + + sideNum := 0 + minDist := dTop + sideNum = 1 // top + if dRight < minDist { + minDist = dRight + sideNum = 2 // right + } + if dBottom < minDist { + minDist = dBottom + sideNum = 3 // bottom + } + if dLeft < minDist { + sideNum = 4 // left + } + + // Position along the side in mm + var posAlongSide float64 + var zPos float64 + switch sideNum { + case 1: // top — position = X distance from left board edge + posAlongSide = float64(x-minBX) * pixelToMM + zPos = 0 // all Z heights for walls + case 2: // right — position = Y distance from top board edge + posAlongSide = float64(y-minBY) * pixelToMM + zPos = 0 + case 3: // bottom — position = X distance from left board edge + posAlongSide = float64(x-minBX) * pixelToMM + zPos = 0 + case 4: // left — position = Y distance from top board edge + posAlongSide = float64(y-minBY) * pixelToMM + zPos = 0 + } + _ = zPos + + // Check all cutouts for this side + for _, c := range sideCutouts { + if c.Side != sideNum { + continue + } + // Check if this pixel's position falls within the cutout X range + if posAlongSide >= c.X && posAlongSide <= c.X+c.Width { + wallCutoutMask[idx] = true + break + } + } + } + } + fmt.Printf("Wall cutout mask: applied %d side cutouts\n", len(sideCutouts)) + } + // Generate walls using RLE for y := 0; y < imgH; y++ { runStart := -1 @@ -135,6 +231,114 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo } } + // Now subtract side cutout regions from the walls + // For each cutout, we remove wall material in the Z range [cutout.Y, cutout.Y+cutout.H] + // by NOT generating boxes in that region. Since we already generated full-height walls, + // we rebuild wall columns where cutouts exist with gaps. + if len(sideCutouts) > 0 { + var cutoutEncTris [][3]Point + for y := 0; y < imgH; y++ { + runStart := -1 + for x := 0; x <= imgW; x++ { + isCutWall := false + if x < imgW { + idx := y*imgW + x + isCutWall = wallCutoutMask[idx] + } + + if isCutWall { + if runStart == -1 { + runStart = x + } + } else { + if runStart != -1 { + // This run of wall pixels has cutouts — find which cutout + midX := (runStart + x) / 2 + midIdx := y*imgW + midX + _ = midIdx + + // Find the dominant side and cutout for this run + dTop := math.Abs(float64(y) - float64(minBY)) + dBottom := math.Abs(float64(y) - float64(maxBY)) + dLeft := math.Abs(float64(midX) - float64(minBX)) + dRight := math.Abs(float64(midX) - float64(maxBX)) + + sideNum := 1 + minDist := dTop + if dRight < minDist { + minDist = dRight + sideNum = 2 + } + if dBottom < minDist { + minDist = dBottom + sideNum = 3 + } + if dLeft < minDist { + sideNum = 4 + } + + bx := float64(runStart) * pixelToMM + by2 := float64(y) * pixelToMM + bw := float64(x-runStart) * pixelToMM + bh := pixelToMM + + // Find the matching cutout for this side + for _, c := range sideCutouts { + if c.Side != sideNum { + continue + } + // Wall below cutout: from 0 to cutout.Y + if c.Y > 0.1 { + addBoxAtZ(&cutoutEncTris, bx, by2, 0, bw, bh, c.Y) + } + // Wall above cutout: from cutout.Y+cutout.H to totalH + cutTop := c.Y + c.Height + if cutTop < totalH-0.1 { + addBoxAtZ(&cutoutEncTris, bx, by2, cutTop, bw, bh, totalH-cutTop) + } + break + } + runStart = -1 + } + } + } + } + // Replace full-height walls with cutout walls + // First remove the original full-height boxes for cutout pixels + // (They were already added above, so we need to rebuild) + // Simpler approach: rebuild encTris without cutout regions, then add partial walls + var newEncTris [][3]Point + // Re-generate walls, skipping cutout pixels + for y := 0; y < imgH; y++ { + runStart := -1 + for x := 0; x <= imgW; x++ { + isWallPixel := false + if x < imgW { + idx := y*imgW + x + isWallPixel = wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] && !wallCutoutMask[idx] + } + + if isWallPixel { + if runStart == -1 { + runStart = x + } + } else { + if runStart != -1 { + bx := float64(runStart) * pixelToMM + by2 := float64(y) * pixelToMM + bw := float64(x-runStart) * pixelToMM + bb := pixelToMM + AddBox(&newEncTris, bx, by2, bw, bb, totalH) + runStart = -1 + } + } + } + } + // Add the partial (cut) wall sections + newEncTris = append(newEncTris, cutoutEncTris...) + encTris = newEncTris + } + // Lid: cover the entire enclosure footprint at the top // Lid pixels = any pixel in wallMask OR clearanceMask OR boardMask // Subtract courtyard regions (component footprints) from the lid @@ -198,6 +402,59 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo } } } + // Mounting pegs from NPTH holes: cylinders going from lid downward + pegMask := make([]bool, size) // true = peg/socket at this pixel (exclude from tray floor) + if cfg.OutlineBounds != nil { + mountingHoles := 0 + for _, h := range drillHoles { + if h.Type != DrillTypeMounting { + continue + } + mountingHoles++ + + // Convert drill mm coordinates to pixel coordinates + px := (h.X - cfg.OutlineBounds.MinX) * cfg.DPI / 25.4 + py := (h.Y - cfg.OutlineBounds.MinY) * cfg.DPI / 25.4 + + // Peg radius slightly smaller than hole for press fit + pegRadiusMM := (h.Diameter / 2) - 0.15 + pegRadiusPx := pegRadiusMM * cfg.DPI / 25.4 + // Socket radius slightly larger for easy insertion + socketRadiusPx := (h.Diameter/2 + 0.1) * cfg.DPI / 25.4 + + // Peg height: from bottom (z=0) up to lid + pegH := totalH - lidThick + + // Scan a bounding box around the hole + rInt := int(socketRadiusPx) + 2 + cx, cy := int(px), int(py) + + for dy := -rInt; dy <= rInt; dy++ { + for dx := -rInt; dx <= rInt; dx++ { + ix, iy := cx+dx, cy+dy + if ix < 0 || ix >= imgW || iy < 0 || iy >= imgH { + continue + } + dist := math.Sqrt(float64(dx*dx + dy*dy)) + + // Peg cylinder (in enclosure, from z=0 up to lid) + if dist <= pegRadiusPx { + bx := float64(ix) * pixelToMM + by := float64(iy) * pixelToMM + addBoxAtZ(&encTris, bx, by, 0, pixelToMM, pixelToMM, pegH) + } + + // Socket mask (for tray floor removal) + if dist <= socketRadiusPx { + pegMask[iy*imgW+ix] = true + } + } + } + } + if mountingHoles > 0 { + fmt.Printf("Generated %d mounting pegs\n", mountingHoles) + } + } // Snap ledges: on the inside of the walls (at the clearance boundary) // These are pixels that are in clearanceMask but adjacent to wallMask @@ -254,7 +511,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo isTrayPixel := false if x < imgW { idx := y*imgW + x - isTrayPixel = clearanceMask[idx] || boardMask[idx] + isTrayPixel = (clearanceMask[idx] || boardMask[idx]) && !pegMask[idx] } if isTrayPixel { @@ -355,31 +612,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo // Removal tabs: INTERNAL — thin finger grips that sit inside the tray cavity // User can push on them from below to pop the tray out fmt.Println("Adding internal removal tabs...") - boardCenterX, boardCenterY := 0.0, 0.0 - boardCount := 0 - minBX, minBY := imgW, imgH - maxBX, maxBY := 0, 0 - for y := 0; y < imgH; y++ { - for x := 0; x < imgW; x++ { - if boardMask[y*imgW+x] { - boardCenterX += float64(x) - boardCenterY += float64(y) - boardCount++ - if x < minBX { - minBX = x - } - if x > maxBX { - maxBX = x - } - if y < minBY { - minBY = y - } - if y > maxBY { - maxBY = y - } - } - } - } + // (uses pre-computed board bounding box: minBX, minBY, maxBX, maxBY, boardCenterY) if boardCount > 0 { boardCenterY /= float64(boardCount) tabCenterY := boardCenterY * pixelToMM @@ -394,32 +627,72 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo addBoxAtZ(&trayTris, rightInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH) } - // Embossed lip: a thin raised ridge around the full tray perimeter - // This lip mates against the inside face of the enclosure walls for a tight fit + // Embossed lip: a raised ridge around the tray perimeter, 0.5mm thick + // This lip mates against a recess in the enclosure for a tight snap fit fmt.Println("Adding embossed lip...") - lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening - lipW := 0.6 // thin lip wall + lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening + lipThickPx := int(math.Ceil(0.5 * cfg.DPI / 25.4)) // 0.5mm in pixels + if lipThickPx < 1 { + lipThickPx = 1 + } + + // Build lip mask from the adjacency rule, then dilate inward by lipThickPx + lipCoreMask := make([]bool, size) for y := 1; y < imgH-1; y++ { - runStart := -1 - for x := 0; x <= imgW; x++ { - isLipPixel := false - if x > 0 && x < imgW-1 { - idx := y*imgW + x - if clearanceMask[idx] && !boardMask[idx] { - // Lip sits at the outer edge of the clearance zone (touching the wall) - for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { - ni := (y+d[1])*imgW + (x + d[0]) - if ni >= 0 && ni < size { - if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] { - isLipPixel = true - break - } + for x := 1; x < imgW-1; x++ { + idx := y*imgW + x + if clearanceMask[idx] && !boardMask[idx] { + for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { + ni := (y+d[1])*imgW + (x + d[0]) + if ni >= 0 && ni < size { + if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] { + lipCoreMask[idx] = true + break } } } } + } + } - if isLipPixel { + // Dilate lip mask inward by lipThickPx pixels + lipMask := make([]bool, size) + copy(lipMask, lipCoreMask) + for iter := 1; iter < lipThickPx; iter++ { + nextMask := make([]bool, size) + copy(nextMask, lipMask) + for y := 1; y < imgH-1; y++ { + for x := 1; x < imgW-1; x++ { + idx := y*imgW + x + if lipMask[idx] { + continue // already in lip + } + if !clearanceMask[idx] || boardMask[idx] { + continue // must be in clearance zone + } + // Adjacent to existing lip pixel? + for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { + ni := (y+d[1])*imgW + (x + d[0]) + if ni >= 0 && ni < size && lipMask[ni] { + nextMask[idx] = true + break + } + } + } + } + lipMask = nextMask + } + + // Generate lip boxes + for y := 0; y < imgH; y++ { + runStart := -1 + for x := 0; x <= imgW; x++ { + isLipPx := false + if x < imgW { + isLipPx = lipMask[y*imgW+x] + } + + if isLipPx { if runStart == -1 { runStart = x } @@ -429,13 +702,62 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo by := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM bh := pixelToMM - _ = lipW // lip width is one pixel at this DPI addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, lipH) runStart = -1 } } } } + + // Add matching recess in enclosure for the lip (0.25mm deep groove) + // Recess sits at the inner face of the enclosure wall, where the lip enters + fmt.Println("Adding lip recess in enclosure...") + recessDepth := 0.25 + recessH := lipH + 0.5 // slightly taller than lip for easy entry + for y := 0; y < imgH; y++ { + runStart := -1 + for x := 0; x <= imgW; x++ { + // Recess = wall pixels adjacent to the lip (inner face of wall) + isRecess := false + if x > 0 && x < imgW-1 { + idx := y*imgW + x + if wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] { + for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { + ni := (y+d[1])*imgW + (x + d[0]) + if ni >= 0 && ni < size && lipMask[ni] { + isRecess = true + break + } + } + } + } + + if isRecess { + if runStart == -1 { + runStart = x + } + } else { + if runStart != -1 { + // Subtract recess from enclosure wall by NOT generating here + // Instead, generate wall with gap at recess height + bx := float64(runStart) * pixelToMM + by := float64(y) * pixelToMM + bw := float64(x-runStart) * pixelToMM + bh := pixelToMM + // Wall below recess + if trayFloor > 0.05 { + addBoxAtZ(&encTris, bx, by, 0, bw, bh, trayFloor) + } + // Thinner wall in recess zone (subtract recessDepth from thickness) + // This is handled by just not filling the recess area + _ = recessDepth + // Wall above recess + addBoxAtZ(&encTris, bx, by, trayFloor+recessH, bw, bh, totalH-(trayFloor+recessH)) + runStart = -1 + } + } + } + } fmt.Printf("Enclosure: %d triangles, Tray: %d triangles\n", len(encTris), len(trayTris)) _ = math.Pi // keep math import for Phase 2 cylindrical pegs diff --git a/gbrjob.go b/gbrjob.go new file mode 100644 index 0000000..7a8a229 --- /dev/null +++ b/gbrjob.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// GerberJob represents a KiCad .gbrjob file +type GerberJob struct { + Header struct { + GenerationSoftware struct { + Vendor string `json:"Vendor"` + Application string `json:"Application"` + Version string `json:"Version"` + } `json:"GenerationSoftware"` + } `json:"Header"` + GeneralSpecs struct { + ProjectId struct { + Name string `json:"Name"` + } `json:"ProjectId"` + Size struct { + X float64 `json:"X"` + Y float64 `json:"Y"` + } `json:"Size"` + BoardThickness float64 `json:"BoardThickness"` + } `json:"GeneralSpecs"` + FilesAttributes []struct { + Path string `json:"Path"` + FileFunction string `json:"FileFunction"` + FilePolarity string `json:"FilePolarity"` + } `json:"FilesAttributes"` +} + +// GerberJobResult contains the auto-discovered file assignments +type GerberJobResult struct { + ProjectName string + BoardWidth float64 // mm + BoardHeight float64 // mm + BoardThickness float64 // mm + EdgeCutsFile string // Profile + FabFile string // AssemblyDrawing,Top + CourtyardFile string // matches courtyard naming + SoldermaskFile string // matches mask naming +} + +// ParseGerberJob parses a .gbrjob file and returns auto-discovered file mappings +func ParseGerberJob(filename string) (*GerberJobResult, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read gbrjob: %w", err) + } + + var job GerberJob + if err := json.Unmarshal(data, &job); err != nil { + return nil, fmt.Errorf("parse gbrjob JSON: %w", err) + } + + result := &GerberJobResult{ + ProjectName: job.GeneralSpecs.ProjectId.Name, + BoardWidth: job.GeneralSpecs.Size.X, + BoardHeight: job.GeneralSpecs.Size.Y, + BoardThickness: job.GeneralSpecs.BoardThickness, + } + + // Map FileFunction to our layer types + for _, f := range job.FilesAttributes { + fn := strings.ToLower(f.FileFunction) + path := f.Path + + switch { + case fn == "profile": + result.EdgeCutsFile = path + case strings.HasPrefix(fn, "assemblydrawing"): + // F.Fab = AssemblyDrawing,Top + if strings.Contains(fn, "top") { + result.FabFile = path + } + } + + // Also match by filename patterns for courtyard/mask + lp := strings.ToLower(path) + switch { + case strings.Contains(lp, "courtyard") || strings.Contains(lp, "courty"): + if strings.Contains(lp, "f_courty") || strings.Contains(lp, "f.courty") || strings.Contains(lp, "-f_courty") { + result.CourtyardFile = path + } + case strings.Contains(lp, "mask") || strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask"): + if strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask") || strings.Contains(lp, "-f_mask") { + result.SoldermaskFile = path + } + } + } + + fmt.Printf("GerberJob: project=%s, board=%.1fx%.1fmm, thickness=%.1fmm\n", + result.ProjectName, result.BoardWidth, result.BoardHeight, result.BoardThickness) + fmt.Printf(" EdgeCuts: %s\n", result.EdgeCutsFile) + fmt.Printf(" F.Fab: %s\n", result.FabFile) + fmt.Printf(" F.Courtyard: %s\n", result.CourtyardFile) + fmt.Printf(" F.Mask: %s\n", result.SoldermaskFile) + + if result.EdgeCutsFile == "" { + return nil, fmt.Errorf("no Edge.Cuts (Profile) layer found in gbrjob") + } + + return result, nil +} diff --git a/main.go b/main.go index 344e043..f2eb58d 100644 --- a/main.go +++ b/main.go @@ -681,15 +681,11 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { uuid := randomID() // Parse params - pcbThickness, _ := strconv.ParseFloat(r.FormValue("pcbThickness"), 64) 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 pcbThickness == 0 { - pcbThickness = DefaultPCBThickness - } if wallThickness == 0 { wallThickness = DefaultEncWallThick } @@ -700,7 +696,36 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { clearance = DefaultClearance } if dpi == 0 { - dpi = 500 + 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{ @@ -711,22 +736,33 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { DPI: dpi, } - // Handle Outline File (required) - outlineFile, outlineHeader, err := r.FormFile("outline") - if err != nil { - http.Error(w, "Board outline gerber is required", http.StatusBadRequest) - return + // 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 } - defer outlineFile.Close() - outlinePath := filepath.Join(tempDir, uuid+"_outline"+filepath.Ext(outlineHeader.Filename)) - of, err := os.Create(outlinePath) - if err != nil { - http.Error(w, "Server error saving file", http.StatusInternalServerError) + // 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 } - io.Copy(of, outlineFile) - of.Close() // Handle PTH Drill File (optional) var drillHoles []DrillHole @@ -786,6 +822,10 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { 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 @@ -795,43 +835,42 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { // Render outline to image fmt.Println("Rendering outline...") + ecfg.OutlineBounds = &outlineBounds outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds) - // Handle F.Courtyard Gerber (optional) — for lid cutouts + // Auto-discover and render F.Courtyard from job file var courtyardImg image.Image - courtyardFile, courtyardHeader, err := r.FormFile("courtyard") - if err == nil { - defer courtyardFile.Close() - courtPath := filepath.Join(tempDir, uuid+"_courtyard"+filepath.Ext(courtyardHeader.Filename)) - cf, err := os.Create(courtPath) - if err == nil { - io.Copy(cf, courtyardFile) - cf.Close() - 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) - } + 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) } } - // Handle F.Mask (Soldermask) Gerber (optional) — for minimum pad cutouts + + // Auto-discover and render F.Mask from job file var soldermaskImg image.Image - maskFile, maskHeader, err := r.FormFile("soldermask") - if err == nil { - defer maskFile.Close() - maskPath := filepath.Join(tempDir, uuid+"_mask"+filepath.Ext(maskHeader.Filename)) - mf, err := os.Create(maskPath) - if err == nil { - io.Copy(mf, maskFile) - mf.Close() - maskGf, err := ParseGerber(maskPath) + 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 soldermask gerber: %v", err) + log.Printf("Warning: Could not parse fab gerber: %v", err) } else { - fmt.Println("Rendering soldermask layer...") - soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds) + fmt.Println("Rendering F.Fab layer as courtyard fallback...") + courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds) } } } @@ -845,8 +884,8 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) { DrillHoles: filteredHoles, Config: ecfg, OutlineBounds: outlineBounds, - BoardW: float64(outlineImg.Bounds().Max.X) * (25.4 / ecfg.DPI), - BoardH: float64(outlineImg.Bounds().Max.Y) * (25.4 / ecfg.DPI), + BoardW: actualBoardW, + BoardH: actualBoardH, TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0, } sessionsMu.Lock() @@ -968,7 +1007,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { cutoutsJSON := r.FormValue("sideCutouts") if cutoutsJSON != "" && cutoutsJSON != "[]" { var rawCutouts []struct { - Face string `json:"face"` + Side int `json:"side"` X float64 `json:"x"` Y float64 `json:"y"` W float64 `json:"w"` @@ -980,7 +1019,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) { } else { for _, rc := range rawCutouts { sideCutouts = append(sideCutouts, SideCutout{ - Face: rc.Face, + Side: rc.Side, X: rc.X, Y: rc.Y, Width: rc.W, diff --git a/pcb-to-stencil b/pcb-to-stencil index a795a90..6f7ce38 100755 Binary files a/pcb-to-stencil and b/pcb-to-stencil differ diff --git a/static/index.html b/static/index.html index 8a25bac..db2a2d4 100644 --- a/static/index.html +++ b/static/index.html @@ -4,13 +4,13 @@ - PCB Tools by kennycoder + PCB Tools by kennycoder + pszsh
-

PCB Tools by kennycoder

+

PCB Tools by kennycoder + pszsh

@@ -65,77 +65,58 @@
-
- - -
Layers to export for Gerbers -
• Edge.Cuts (board outline) +
+ + +
Auto-detects board layers, dimensions, and PCB thickness.
+
+
+ KiCad plot settings
+
+ + +
Select all exported .gbr files from the same folder.
+
+
Component through-holes (vias auto-filtered).
-
Layers to export for DRL -
• Use the PTH file (Plated Through-Hole)
• Vias are automatically filtered out -
+
Use the PTH file from KiCad's drill export.
-
Mounting holes — become pegs in enclosure.
-
Layers to export for DRL -
• Use the NPTH file (Non-Plated Through-Hole)
• These become alignment pegs -
-
-
- - -
Component outlines — used for lid cutouts.
-
Layers to export for Gerbers -
F.Courtyard (front courtyard)
• ☑ Exclude DNP footprints in KiCad plot - dialog
• Cutouts generated where components exist -
-
-
- - -
Soldermask openings — minimum pad cutouts.
-
Layers to export for Gerbers -
F.Mask (front soldermask)
• Shows exact pad areas that need cutouts
• ☑ - Exclude DNP footprints in KiCad plot dialog -
+
Mounting holes — become alignment pegs.
+
Use the NPTH file — these become alignment pegs.
-
- - -
-
- -
+
+ +
Gap between PCB edge and enclosure wall.
-
- -
- + +
Lower = smaller file. 600 recommended.
-
diff --git a/static/preview.html b/static/preview.html index 09f87c4..d77b48f 100644 --- a/static/preview.html +++ b/static/preview.html @@ -72,10 +72,12 @@ display: flex; gap: 0; margin-bottom: 0.75rem; + flex-wrap: wrap; } .face-tab { flex: 1; + min-width: 60px; padding: 0.4rem; text-align: center; border: 1px solid var(--border); @@ -175,6 +177,55 @@ background: #f3f4f6; } + .preset-row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + .btn-preset { + padding: 0.35rem 0.7rem; + border: 1px solid #3b82f6; + border-radius: 4px; + background: #eff6ff; + color: #1d4ed8; + cursor: pointer; + font-size: 0.75rem; + font-weight: 500; + } + + .btn-preset:hover { + background: #dbeafe; + } + + .coord-field { + display: flex; + flex-direction: column; + } + + .coord-label-row { + display: flex; + align-items: center; + gap: 0.3rem; + margin-bottom: 0.2rem; + } + + .btn-center { + padding: 0.1rem 0.35rem; + border: 1px solid #d1d5db; + border-radius: 3px; + background: #f9fafb; + cursor: pointer; + font-size: 0.65rem; + color: #6b7280; + line-height: 1; + } + + .btn-center:hover { + background: #e5e7eb; + color: #374151; + } + .unit-note { font-size: 0.7rem; color: #9ca3af; @@ -188,7 +239,7 @@

Enclosure Preview

- +
@@ -212,24 +263,31 @@
-
-
North
-
East
-
South
-
West
-
+
+
+ +
+
-
- +
+
+ + +
-
- +
+
+ + +
@@ -244,7 +302,7 @@
- +
@@ -269,36 +327,113 @@