package main import ( "fmt" "image" "image/color" "math" ) // EnclosureConfig holds parameters for enclosure generation type EnclosureConfig struct { PCBThickness float64 // mm WallThickness float64 // mm 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 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 // 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 CornerRadius float64 // Corner radius in mm (0 for square) } // 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) *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 // 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)) } // 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 if curIsInner { addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) } 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 if curIsInner { addBoxAtZ(&encTris, bx, by, trayFloor+snapHeight, bw, bh, totalH-(trayFloor+snapHeight)) } 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 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] > 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 AddBox(&newEncTris, bx, by2, bw, bh, totalH) 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 curIsBump := false for x := 0; x <= imgW; x++ { isTrayFloor := false isTrayWall := false isTrayBump := 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 } // Tray Bumps sit on the outside of the Tray Wall if dist > trayWallOuterPx && dist <= trayWallOuterPx+snapDepthPx && !boardMask[idx] { isTrayBump = true } } } if isTrayFloor { if runStartX == -1 { runStartX = x curIsWall = isTrayWall curIsBump = isTrayBump } else if isTrayWall != curIsWall || isTrayBump != curIsBump { bx := float64(runStartX) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStartX) * pixelToMM bh := pixelToMM addBoxAtZ(&trayTris, bx, by, 0, bw, bh, trayFloor) if curIsWall { addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, snapHeight) } else if curIsBump { // Adds a small 0.4mm bump on the outside of the wall addBoxAtZ(&trayTris, bx, by, trayFloor+snapHeight-0.6, bw, bh, 0.4) } runStartX = x curIsWall = isTrayWall curIsBump = isTrayBump } } 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) if curIsWall { addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, snapHeight) } else if curIsBump { addBoxAtZ(&trayTris, bx, by, trayFloor+snapHeight-0.6, bw, bh, 0.4) } runStartX = -1 } } } } // (Old PCB support rim, snap bump, embossed lip, and removal tab loops have been permanently removed because the Tray geometry forms a flush fitting bottom shoe-box lid interface) fmt.Printf("Enclosure: %d triangles, Tray: %d triangles\n", len(encTris), len(trayTris)) _ = math.Pi // keep math import for Phase 2 cylindrical pegs 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 }