package main import ( "fmt" "image" "image/color" "math" ) // EnclosureConfig holds parameters for enclosure generation type EnclosureConfig struct { PCBThickness float64 `json:"pcbThickness"` WallThickness float64 `json:"wallThickness"` WallHeight float64 `json:"wallHeight"` Clearance float64 `json:"clearance"` DPI float64 `json:"dpi"` OutlineBounds *Bounds `json:"-"` } // Default enclosure values const ( DefaultPCBThickness = 1.6 DefaultEncWallHeight = 10.0 DefaultEncWallThick = 1.5 DefaultClearance = 0.3 ) // EnclosureResult contains the generated meshes type EnclosureResult struct { EnclosureTriangles [][3]Point TrayTriangles [][3]Point } // SideCutout defines a cutout on a side wall face type SideCutout struct { Side int `json:"side"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"w"` Height float64 `json:"h"` CornerRadius float64 `json:"r"` Layer string `json:"l"` } // LidCutout defines a cutout on the lid or tray plane (top/bottom flat surfaces) type LidCutout struct { ID int `json:"id"` Plane string `json:"plane"` // "lid" or "tray" MinX float64 `json:"minX"` // gerber mm coordinates MinY float64 `json:"minY"` MaxX float64 `json:"maxX"` MaxY float64 `json:"maxY"` IsDado bool `json:"isDado"` Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut } // Cutout is the unified cutout type — replaces separate SideCutout/LidCutout. type Cutout struct { ID string `json:"id"` Surface string `json:"surface"` // "top", "bottom", "side" SideNum int `json:"sideNum"` // only when Surface="side" X float64 `json:"x"` // side: mm along side; top/bottom: gerber mm minX Y float64 `json:"y"` // side: mm height from PCB; top/bottom: gerber mm minY Width float64 `json:"w"` Height float64 `json:"h"` CornerRadius float64 `json:"r"` IsDado bool `json:"isDado"` Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut SourceLayer string `json:"sourceLayer"` // "F" or "B" for side cutouts } // CutoutToSideCutout converts a unified Cutout (surface="side") to legacy SideCutout func CutoutToSideCutout(c Cutout) SideCutout { return SideCutout{ Side: c.SideNum, X: c.X, Y: c.Y, Width: c.Width, Height: c.Height, CornerRadius: c.CornerRadius, Layer: c.SourceLayer, } } // CutoutToLidCutout converts a unified Cutout (surface="top"/"bottom") to legacy LidCutout func CutoutToLidCutout(c Cutout) LidCutout { plane := "lid" if c.Surface == "bottom" { plane = "tray" } return LidCutout{ Plane: plane, MinX: c.X, MinY: c.Y, MaxX: c.X + c.Width, MaxY: c.Y + c.Height, IsDado: c.IsDado, Depth: c.Depth, } } // SplitCutouts partitions unified cutouts into side and lid slices for SCAD/STL generation. func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) { var sides []SideCutout var lids []LidCutout for _, c := range cutouts { switch c.Surface { case "side": sides = append(sides, CutoutToSideCutout(c)) case "top", "bottom": lids = append(lids, CutoutToLidCutout(c)) } } return sides, lids } // BoardSide represents a physical straight edge of the board outline type BoardSide struct { Num int `json:"num"` Label string `json:"label"` Length float64 `json:"length"` StartX float64 `json:"startX"` StartY float64 `json:"startY"` EndX float64 `json:"endX"` EndY float64 `json:"endY"` Angle float64 `json:"angle"` // Angle in radians of the normal vector pushing OUT of the board } func perpendicularDistance(pt, lineStart, lineEnd [2]float64) float64 { dx := lineEnd[0] - lineStart[0] dy := lineEnd[1] - lineStart[1] // Normalize line vector mag := math.Sqrt(dx*dx + dy*dy) if mag == 0 { return math.Sqrt((pt[0]-lineStart[0])*(pt[0]-lineStart[0]) + (pt[1]-lineStart[1])*(pt[1]-lineStart[1])) } dx /= mag dy /= mag // Vector from lineStart to pt px := pt[0] - lineStart[0] py := pt[1] - lineStart[1] // Cross product gives perpendicular distance return math.Abs(px*dy - py*dx) } func simplifyPolygonRDP(points [][2]float64, epsilon float64) [][2]float64 { if len(points) < 3 { return points } dmax := 0.0 index := 0 end := len(points) - 1 for i := 1; i < end; i++ { d := perpendicularDistance(points[i], points[0], points[end]) if d > dmax { index = i dmax = d } } if dmax > epsilon { recResults1 := simplifyPolygonRDP(points[:index+1], epsilon) recResults2 := simplifyPolygonRDP(points[index:], epsilon) result := append([][2]float64{}, recResults1[:len(recResults1)-1]...) result = append(result, recResults2...) return result } return [][2]float64{points[0], points[end]} } func ExtractBoardSides(poly [][2]float64) []BoardSide { if len(poly) < 3 { return nil } // Determine "center" of polygon to find outward normals cx, cy := 0.0, 0.0 for _, p := range poly { cx += p[0] cy += p[1] } cx /= float64(len(poly)) cy /= float64(len(poly)) // Ensure the polygon is closed for RDP, if it isn't already if poly[0][0] != poly[len(poly)-1][0] || poly[0][1] != poly[len(poly)-1][1] { poly = append(poly, poly[0]) } simplified := simplifyPolygonRDP(poly, 0.2) // 0.2mm tolerance fmt.Printf("[DEBUG] ExtractBoardSides: poly points = %d, simplified points = %d\n", len(poly), len(simplified)) var sides []BoardSide sideNum := 1 for i := 0; i < len(simplified)-1; i++ { p1 := simplified[i] p2 := simplified[i+1] dx := p2[0] - p1[0] dy := p2[1] - p1[1] length := math.Sqrt(dx*dx + dy*dy) // Only keep substantial straight edges (e.g. > 4mm) if length > 4.0 { // Calculate outward normal angle // The segment path vector is (dx, dy). Normal is either (-dy, dx) or (dy, -dx) nx := dy ny := -dx // Dot product with center->midpoint to check if it points out midX := (p1[0] + p2[0]) / 2.0 midY := (p1[1] + p2[1]) / 2.0 vx := midX - cx vy := midY - cy if nx*vx+ny*vy < 0 { nx = -nx ny = -ny } angle := math.Atan2(ny, nx) sides = append(sides, BoardSide{ Num: sideNum, Label: fmt.Sprintf("Side %d (%.1fmm)", sideNum, length), Length: length, StartX: p1[0], StartY: p1[1], EndX: p2[0], EndY: p2[1], Angle: angle, }) sideNum++ } } return sides } // ExtractBoardSidesFromMask traces the outer boundary of a boolean mask // and simplifies it into BoardSides. This perfectly matches the 3D generation. func ExtractBoardSidesFromMask(mask []bool, imgW, imgH int, pixelToMM float64, bounds *Bounds) []BoardSide { // Find top-leftmost pixel of mask startX, startY := -1, -1 outer: for y := 0; y < imgH; y++ { for x := 0; x < imgW; x++ { if mask[y*imgW+x] { startX, startY = x, y break outer } } } if startX == -1 { return nil } // Moore-neighbor boundary tracing var boundary [][2]int dirs := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}} curX, curY := startX, startY boundary = append(boundary, [2]int{curX, curY}) // Initial previous neighbor direction (up/west of top-left is empty) pDir := 6 for { found := false for i := 0; i < 8; i++ { // Scan clockwise starting from dir after the previous background pixel testDir := (pDir + 1 + i) % 8 nx, ny := curX+dirs[testDir][0], curY+dirs[testDir][1] if nx >= 0 && nx < imgW && ny >= 0 && ny < imgH && mask[ny*imgW+nx] { curX, curY = nx, ny boundary = append(boundary, [2]int{curX, curY}) // The new background pixel is opposite to the direction we found the solid one pDir = (testDir + 4) % 8 found = true break } } if !found { break // Isolated pixel } // Stop when we return to the start and moved in the same direction if curX == startX && curY == startY { break } // Failsafe for complex shapes if len(boundary) > imgW*imgH { break } } // Convert boundary pixels to Gerber mm coordinates var poly [][2]float64 for _, p := range boundary { px := float64(p[0])*pixelToMM + bounds.MinX // Image Y=0 is MaxY in Gerber space py := bounds.MaxY - float64(p[1])*pixelToMM poly = append(poly, [2]float64{px, py}) } sides := ExtractBoardSides(poly) fmt.Printf("[DEBUG] ExtractBoardSidesFromMask: mask size=%dx%d, boundary pixels=%d, sides extracted=%d\n", imgW, imgH, len(boundary), len(sides)) if len(sides) == 0 && len(poly) > 0 { fmt.Printf("[DEBUG] poly[0]=%v, poly[n/2]=%v, poly[last]=%v\n", poly[0], poly[len(poly)/2], poly[len(poly)-1]) } return sides } // GenerateEnclosure creates enclosure + tray meshes from a board outline image and drill holes. // The enclosure walls conform to the actual board outline shape. // courtyardImg is optional — if provided, component courtyard regions are cut from the lid (flood-filled). // soldermaskImg is optional — if provided, soldermask pad openings are also cut from the lid. func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg EnclosureConfig, courtyardImg image.Image, soldermaskImg image.Image, sideCutouts []SideCutout, boardSides []BoardSide) *EnclosureResult { pixelToMM := 25.4 / cfg.DPI bounds := outlineImg.Bounds() imgW := bounds.Max.X imgH := bounds.Max.Y // Use ComputeWallMask to get the board shape and wall around it // WallThickness for enclosure = clearance + 2 * wall thickness clearance := cfg.Clearance wt := cfg.WallThickness lidThick := wt snapHeight := 2.5 totalWallMM := clearance + 2.0*wt fmt.Printf("Computing board shape (wall=%.1fmm)...\n", totalWallMM) wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM) // wallMask is now an int slice // Determine the actual enclosure boundary = wall | board (expanded by clearance) // wallMask = pixels that are the wall // boardMask = pixels inside the board outline // clearanceMask is just an expansion of boardMask using distance logic up to clearance // However, we already have wallDist which measures distance OUTWARD from board clearanceDistPx := int(clearance * cfg.DPI / 25.4) trayWallOuterPx := int((clearance + wt) * cfg.DPI / 25.4) encWallOuterPx := int((clearance + 2.0*wt) * cfg.DPI / 25.4) snapDepthPx := int(0.5 * cfg.DPI / 25.4) if snapDepthPx < 1 { snapDepthPx = 1 } // Total height of the enclosure (from bottom of tray to top of lid) pcbT := cfg.PCBThickness trayFloor := pcbT + 0.5 // Tray floor is 0.5mm thick, sits below PCB totalH := trayFloor + cfg.WallHeight + lidThick size := imgW * imgH var encTris [][3]Point var trayTris [][3]Point // 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) } } // 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 } } } } // === APPLY PRY SLOT CUTOUTS TO WALL MASK BEFORE MESHING === // We want 8mm wide by 1.5mm deep slots in the left and right exterior walls if boardCount > 0 { pryWPx := int(8.0 * cfg.DPI / 25.4) pryDPx := int(1.5 * cfg.DPI / 25.4) if pryWPx < 1 { pryWPx = 1 } if pryDPx < 1 { pryDPx = 1 } centerYPx := int(boardCenterY / float64(boardCount)) leftXPx := minBX rightXPx := maxBX // For the left side, we clear the wall mask from minBX-wallPx up to minBX-wallPx+pryDPx for y := centerYPx - pryWPx/2; y <= centerYPx+pryWPx/2; y++ { if y < 0 || y >= imgH { continue } // Find outer edge of wall on the left for x := 0; x < leftXPx; x++ { idx := y*imgW + x if wallMask[idx] > clearanceDistPx { // Blank out the outermost pryDPx pixels of the wall for dx := 0; dx < pryDPx; dx++ { if x+dx < imgW { wallMask[y*imgW+(x+dx)] = -1 } } break // Only do the outer edge } } // Find outer edge of wall on the right (search backwards) for x := imgW - 1; x > rightXPx; x-- { idx := y*imgW + x if wallMask[idx] > clearanceDistPx { // Blank out the outermost pryDPx pixels of the wall for dx := 0; dx < pryDPx; dx++ { if x-dx >= 0 { wallMask[y*imgW+(x-dx)] = -1 } } break // Only do the outer edge } } } } // 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] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !boardMask[idx]) { continue // not a wall pixel } // Determine which side this wall pixel belongs to bx := float64(x)*pixelToMM + cfg.OutlineBounds.MinX by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM sideNum := -1 minDist := math.MaxFloat64 var posAlongSide float64 for _, bs := range boardSides { dx := bs.EndX - bs.StartX dy := bs.EndY - bs.StartY lenSq := dx*dx + dy*dy if lenSq == 0 { continue } t := ((bx-bs.StartX)*dx + (by-bs.StartY)*dy) / lenSq tClamp := math.Max(0, math.Min(1, t)) projX := bs.StartX + tClamp*dx projY := bs.StartY + tClamp*dy dist := math.Sqrt((bx-projX)*(bx-projX) + (by-projY)*(by-projY)) if dist < minDist { minDist = dist sideNum = bs.Num posAlongSide = t * bs.Length } } // 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)) } // ENCLOSURE (top shell — conforms to board shape) // ========================================== fmt.Println("Generating edge-cut conforming enclosure...") // The Enclosure Wall sits on top of the Tray Floor (starts at Z = trayFloor) // Inner Wall (above snapHeight) = `clearanceDistPx` to `trayWallOuterPx` // Outer Wall (full height) = `trayWallOuterPx` to `encWallOuterPx` for y := 0; y < imgH; y++ { runStartX := -1 curIsInner := false curIsSnap := false for x := 0; x <= imgW; x++ { isWallPx := false isInnerWall := false isSnapGroove := false if x < imgW { idx := y*imgW + x dist := wallMask[idx] if dist > clearanceDistPx && dist <= encWallOuterPx && !boardMask[idx] && !pegMask[idx] { isWallPx = true if dist <= trayWallOuterPx { isInnerWall = true } else if dist <= trayWallOuterPx+snapDepthPx { isSnapGroove = true } } } if isWallPx { if runStartX == -1 { runStartX = x curIsInner = isInnerWall curIsSnap = isSnapGroove } else if isInnerWall != curIsInner || isSnapGroove != curIsSnap { // boundary between inner, outer, and snap groove bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStartX) * pixelToMM bh := pixelToMM isSpringRelief := false if !curIsInner && bx >= float64(minBX)*pixelToMM-clearance-wt-1.0 && bx <= float64(maxBX)*pixelToMM+clearance+wt+1.0 { // Check if the current pixel run constitutes either the left or right clip relief pryWMM := 8.0 by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM leftClipX := float64(minBX)*pixelToMM - clearance - wt rightClipX := float64(maxBX)*pixelToMM + clearance + wt if by >= by_center-pryWMM/2.0-0.5 && by <= by_center+pryWMM/2.0+0.5 { if math.Abs(bx-leftClipX) <= 1.5 || math.Abs(bx-rightClipX) <= 1.5 { isSpringRelief = true } } } if curIsInner { addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) } else { if isSpringRelief { // For relief wall cut, omit the bottom solid wall material from the tray floor addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight+1.0, bw, bh, totalH-(trayFloor+snapHeight+1.0)) } else if curIsSnap { // Snap groove: remove material from (trayFloor+snapHeight-0.7) to (trayFloor+snapHeight-0.1) addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) } else { // Outer wall addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) } } runStartX = x curIsInner = isInnerWall curIsSnap = isSnapGroove } } else { if runStartX != -1 { bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStartX) * pixelToMM bh := pixelToMM isSpringRelief := false if !curIsInner && bx >= float64(minBX)*pixelToMM-clearance-wt-1.0 && bx <= float64(maxBX)*pixelToMM+clearance+wt+1.0 { pryWMM := 8.0 by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM leftClipX := float64(minBX)*pixelToMM - clearance - wt rightClipX := float64(maxBX)*pixelToMM + clearance + wt if by >= by_center-pryWMM/2.0-0.5 && by <= by_center+pryWMM/2.0+0.5 { if math.Abs(bx-leftClipX) <= 1.5 || math.Abs(bx-rightClipX) <= 1.5 { isSpringRelief = true } } } if curIsInner { addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) } else { if isSpringRelief { // For relief wall cut, omit the bottom solid wall material from the tray floor addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight+1.0, bw, bh, totalH-(trayFloor+snapHeight+1.0)) } else if curIsSnap { addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, snapHeight-0.7) addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight-0.1, bw, bh, totalH-(trayFloor+snapHeight-0.1)) } else { addBoxAtZ(&encTris, bx, by, trayFloor, bw, bh, totalH-trayFloor) } } runStartX = -1 } } } } // 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 bx := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX by := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM sideNum := -1 minDist := math.MaxFloat64 var bestPosAlongSide float64 for _, bs := range boardSides { dx := bs.EndX - bs.StartX dy := bs.EndY - bs.StartY lenSq := dx*dx + dy*dy if lenSq == 0 { continue } t := ((bx-bs.StartX)*dx + (by-bs.StartY)*dy) / lenSq tClamp := math.Max(0, math.Min(1, t)) projX := bs.StartX + tClamp*dx projY := bs.StartY + tClamp*dy dist := math.Sqrt((bx-projX)*(bx-projX) + (by-projY)*(by-projY)) if dist < minDist { minDist = dist sideNum = bs.Num bestPosAlongSide = t * bs.Length } } bx2 := 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 } minZ, maxZ := cutoutZBounds(c, bestPosAlongSide) minZ += trayFloor + pcbT maxZ += trayFloor + pcbT // Wall below cutout: from trayFloor to minZ (preserve enclosure floor) if minZ > trayFloor+0.3 { addBoxAtZ(&cutoutEncTris, bx2, by2, trayFloor, bw, bh, minZ-trayFloor) } // Wall above cutout: from maxZ to totalH if maxZ < totalH-0.05 { addBoxAtZ(&cutoutEncTris, bx2, by2, maxZ, bw, bh, totalH-maxZ) } 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] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !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 bh := pixelToMM addBoxAtZ(&newEncTris, bx, by2, trayFloor, bw, bh, totalH-trayFloor) runStart = -1 } } } } // Add the partial (cut) wall sections newEncTris = append(newEncTris, cutoutEncTris...) encTris = newEncTris } // Note: We handled pry slots by cropping the wallMask before running the generation. // 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 fmt.Println("Generating lid...") // Build courtyard cutout mask using flood-fill courtyardMask := buildCutoutMask(courtyardImg, imgW, imgH, true) // flood-fill closed outlines if courtyardImg != nil { cutoutCount := 0 for _, v := range courtyardMask { if v { cutoutCount++ } } fmt.Printf("Courtyard cutout (flood-filled): %d pixels\n", cutoutCount) } // Build soldermask cutout mask (direct pixel match, no flood-fill) soldermaskMask := buildCutoutMask(soldermaskImg, imgW, imgH, false) if soldermaskImg != nil { cutoutCount := 0 for _, v := range soldermaskMask { if v { cutoutCount++ } } fmt.Printf("Soldermask cutout: %d pixels\n", cutoutCount) } // Combined cutout: union of courtyard (filled) and soldermask combinedCutout := make([]bool, size) for i := 0; i < size; i++ { combinedCutout[i] = courtyardMask[i] || soldermaskMask[i] } for y := 0; y < imgH; y++ { runStart := -1 for x := 0; x <= imgW; x++ { isLidPixel := false if x < imgW { idx := y*imgW + x inFootprint := (wallMask[idx] >= 0 && wallMask[idx] <= encWallOuterPx) || boardMask[idx] // Cut lid where combined cutout exists inside the board area isCutout := combinedCutout[idx] && boardMask[idx] isLidPixel = inFootprint && !isCutout } if isLidPixel { if runStart == -1 { runStart = x } } else { if runStart != -1 { bx := float64(runStart) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM bh := pixelToMM addBoxAtZ(&encTris, bx, by, totalH-lidThick, bw, bh, lidThick) runStart = -1 } } } } // (Peg calculations moved above) // ========================================== // TRAY (bottom — conforms to board shape) // ========================================== fmt.Println("Generating edge-cut conforming tray...") for y := 0; y < imgH; y++ { runStartX := -1 curIsWall := false for x := 0; x <= imgW; x++ { isTrayFloor := false isTrayWall := false if x < imgW { idx := y*imgW + x if !pegMask[idx] { dist := wallMask[idx] // Tray Floor covers everything up to encWallOuterPx if (dist >= 0 && dist <= encWallOuterPx) || boardMask[idx] { isTrayFloor = true } // Tray Wall goes from clearance to trayWallOuterPx if dist > clearanceDistPx && dist <= trayWallOuterPx && !boardMask[idx] { isTrayWall = true } } } if isTrayFloor { if runStartX == -1 { runStartX = x curIsWall = isTrayWall } else if isTrayWall != curIsWall { bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStartX) * pixelToMM bh := pixelToMM addBoxAtZ(&trayTris, bx, by, 0, bw, bh, trayFloor) wallBase := trayFloor wallH := snapHeight // Evaluate cutout limits if this pixel run falls into a cutout mask isCutout := false for testX := runStartX; testX < x; testX++ { if wallCutoutMask[y*imgW+testX] { isCutout = true break } } if isCutout && len(sideCutouts) > 0 { midX := (runStartX + x) / 2 bxMid := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX byMid := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM sideNum := -1 minDist := math.MaxFloat64 var bestPosAlongSide float64 for _, bs := range boardSides { dx := bs.EndX - bs.StartX dy := bs.EndY - bs.StartY lenSq := dx*dx + dy*dy if lenSq == 0 { continue } t := ((bxMid-bs.StartX)*dx + (byMid-bs.StartY)*dy) / lenSq tClamp := math.Max(0, math.Min(1, t)) projX := bs.StartX + tClamp*dx projY := bs.StartY + tClamp*dy dist := math.Sqrt((bxMid-projX)*(bxMid-projX) + (byMid-projY)*(byMid-projY)) if dist < minDist { minDist = dist sideNum = bs.Num bestPosAlongSide = t * bs.Length } } for _, c := range sideCutouts { if c.Side == sideNum { minZ, _ := cutoutZBounds(c, bestPosAlongSide) minZ += trayFloor + pcbT // Tray wall goes up to trayFloor + snapHeight. If minZ is lower, truncate it. if minZ < trayFloor+wallH { wallH = minZ - trayFloor if wallH < 0 { wallH = 0 } } break } } } if curIsWall && wallH > 0.05 { addBoxAtZ(&trayTris, bx, by, wallBase, bw, bh, wallH) } runStartX = x curIsWall = isTrayWall } } else { if runStartX != -1 { bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStartX) * pixelToMM bh := pixelToMM addBoxAtZ(&trayTris, bx, by, 0, bw, bh, trayFloor) wallBase := trayFloor wallH := snapHeight // Evaluate cutout limits if this pixel run falls into a cutout mask isCutout := false for testX := runStartX; testX < x; testX++ { if wallCutoutMask[y*imgW+testX] { isCutout = true break } } if isCutout && len(sideCutouts) > 0 { midX := (runStartX + x) / 2 bxMid := float64(midX)*pixelToMM + cfg.OutlineBounds.MinX byMid := cfg.OutlineBounds.MaxY - float64(y)*pixelToMM sideNum := -1 minDist := math.MaxFloat64 var bestPosAlongSide float64 for _, bs := range boardSides { dx := bs.EndX - bs.StartX dy := bs.EndY - bs.StartY lenSq := dx*dx + dy*dy if lenSq == 0 { continue } t := ((bxMid-bs.StartX)*dx + (byMid-bs.StartY)*dy) / lenSq tClamp := math.Max(0, math.Min(1, t)) projX := bs.StartX + tClamp*dx projY := bs.StartY + tClamp*dy dist := math.Sqrt((bxMid-projX)*(bxMid-projX) + (byMid-projY)*(byMid-projY)) if dist < minDist { minDist = dist sideNum = bs.Num bestPosAlongSide = t * bs.Length } } for _, c := range sideCutouts { if c.Side == sideNum { minZ, _ := cutoutZBounds(c, bestPosAlongSide) minZ += trayFloor + pcbT if minZ < trayFloor+wallH { wallH = minZ - trayFloor if wallH < 0 { wallH = 0 } } break } } } if curIsWall && wallH > 0.05 { addBoxAtZ(&trayTris, bx, by, wallBase, bw, bh, wallH) } runStartX = -1 } } } } // Add Pry Clips to the Tray to sit under the Enclosure Pry Slots if boardCount > 0 { pryWMM := 8.0 pryDMM := 1.0 clipH := 0.8 leftX := float64(minBX)*pixelToMM - clearance - wt rightX := float64(maxBX)*pixelToMM + clearance + wt by_center := float64(int(boardCenterY/float64(boardCount))) * pixelToMM // Z coordinates: trayFloor + snapHeight - clipH ensures the clip finishes flush with the top of the tray wall addBoxAtZ(&trayTris, leftX-pryDMM, by_center-pryWMM/2.0, trayFloor+snapHeight-clipH, pryDMM, pryWMM, clipH) addBoxAtZ(&trayTris, rightX, by_center-pryWMM/2.0, trayFloor+snapHeight-clipH, pryDMM, pryWMM, clipH) } _ = math.Pi // keep math import for Phase 2 cylindrical pegs // Shift meshes to origin so the exported STL is centered offsetX := float64(imgW) * pixelToMM / 2.0 offsetY := float64(imgH) * pixelToMM / 2.0 for i := range encTris { for j := 0; j < 3; j++ { encTris[i][j].X -= offsetX encTris[i][j].Y -= offsetY } } for i := range trayTris { for j := 0; j < 3; j++ { trayTris[i][j].X -= offsetX trayTris[i][j].Y -= offsetY } } return &EnclosureResult{ EnclosureTriangles: encTris, TrayTriangles: trayTris, } } // addBoxAtZ creates a box at a specific Z offset func addBoxAtZ(triangles *[][3]Point, x, y, z, w, h, zHeight float64) { x0, y0 := x, y x1, y1 := x+w, y+h z0, z1 := z, z+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 } // buildCutoutMask creates a boolean mask from an image. // If floodFill is true, it flood-fills from the edges to find closed regions. func buildCutoutMask(img image.Image, w, h int, floodFill bool) []bool { size := w * h mask := make([]bool, size) if img == nil { return mask } // First: build raw pixel mask from the image bounds := img.Bounds() rawPixels := make([]bool, size) for y := 0; y < h && y < bounds.Max.Y; y++ { for x := 0; x < w && x < bounds.Max.X; x++ { r, g, b, _ := img.At(x+bounds.Min.X, y+bounds.Min.Y).RGBA() gray := color.GrayModel.Convert(color.NRGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}).(color.Gray) if gray.Y > 128 { rawPixels[y*w+x] = true } } } if !floodFill { // Direct mode: raw pixels are the mask return rawPixels } // Flood-fill mode: fill from edges to find exterior, invert to get interiors // Exterior = everything reachable from edges without crossing a white pixel exterior := floodFillExterior(rawPixels, w, h) // Interior = NOT exterior AND NOT raw pixel (the outline itself) // Actually, interior = NOT exterior (includes both outline pixels and filled regions) for i := 0; i < size; i++ { mask[i] = !exterior[i] } return mask } // floodFillExterior marks all pixels reachable from the image edges // without crossing a white (true) pixel as exterior func floodFillExterior(pixels []bool, w, h int) []bool { size := w * h exterior := make([]bool, size) // BFS queue starting from all edge pixels that are not white queue := make([]int, 0, w*2+h*2) for x := 0; x < w; x++ { // Top edge if !pixels[x] { exterior[x] = true queue = append(queue, x) } // Bottom edge idx := (h-1)*w + x if !pixels[idx] { exterior[idx] = true queue = append(queue, idx) } } for y := 0; y < h; y++ { // Left edge idx := y * w if !pixels[idx] { exterior[idx] = true queue = append(queue, idx) } // Right edge idx = y*w + (w - 1) if !pixels[idx] { exterior[idx] = true queue = append(queue, idx) } } // BFS for len(queue) > 0 { cur := queue[0] queue = queue[1:] x := cur % w y := cur / w for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { nx, ny := x+d[0], y+d[1] if nx >= 0 && nx < w && ny >= 0 && ny < h { ni := ny*w + nx if !exterior[ni] && !pixels[ni] { exterior[ni] = true queue = append(queue, ni) } } } } return exterior } // cutoutZBounds calculates the accurate Z bounds taking into account corner radii func cutoutZBounds(c SideCutout, posAlongSide float64) (float64, float64) { minZ := c.Y maxZ := c.Y + c.Height if c.CornerRadius > 0 { r := c.CornerRadius localX := posAlongSide - c.X if localX < r { dx := r - localX dy := r - math.Sqrt(math.Max(0, r*r-dx*dx)) minZ += dy maxZ -= dy } else if localX > c.Width-r { dx := localX - (c.Width - r) dy := r - math.Sqrt(math.Max(0, r*r-dx*dx)) minZ += dy maxZ -= dy } } return minZ, maxZ }