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 } // 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 { Face string // "north", "south", "east", "west" 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 + wall thickness totalWallMM := cfg.Clearance + cfg.WallThickness fmt.Printf("Computing board shape (wall=%.1fmm)...\n", totalWallMM) wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM) // Also compute a thinner mask for just the clearance zone (inner wall boundary) clearanceMask, _ := ComputeWallMask(outlineImg, cfg.Clearance, pixelToMM) // Determine the actual enclosure boundary = wall | board (expanded by clearance) // wallMask = pixels that are the wall // boardMask = pixels inside the board outline // clearanceMask = pixels in the clearance zone around the board // The enclosure walls are: wallMask pixels that are NOT in the clearance zone // Actually: wallMask gives us everything from board edge out to totalWall distance // clearanceMask gives us board edge out to clearance distance // Real wall = wallMask AND NOT clearanceMask AND NOT boardMask // Dimensions trayFloor := 1.0 // mm pcbT := cfg.PCBThickness totalH := cfg.WallHeight + pcbT + trayFloor // total enclosure height lidThick := cfg.WallThickness // lid thickness at top // Snap-fit dimensions snapHeight := 1.5 snapFromBottom := trayFloor + 0.3 // Tab dimensions tabW := 8.0 tabD := 6.0 tabH := 2.0 // ========================================== // ENCLOSURE (top shell — conforms to board shape) // ========================================== var encTris [][3]Point fmt.Println("Generating edge-cut conforming enclosure...") // Walls: scan through the image and create boxes for wall pixels // A pixel is "wall" if it's in wallMask but NOT in clearanceMask and NOT in boardMask // Actually simpler: wallMask already represents the OUTSIDE ring. // wallMask = pixels outside board but within thickness distance // boardMask = pixels inside the board // So wall pixels are: wallMask[i] && !boardMask[i] // But we also want to separate outer wall from inner clearance: // Outer wall = wallMask && !clearanceMask (the actual solid wall material) // Inner clearance = clearanceMask (air gap between wall and PCB) // For the enclosure walls, we want the OUTER wall portion only // Wall pixels = wallMask[i] && !clearanceMask[i] && !boardMask[i] // 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 // Generate walls using RLE 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] } if isWallPixel { if runStart == -1 { runStart = x } } else { if runStart != -1 { bx := float64(runStart) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM bh := pixelToMM AddBox(&encTris, bx, by, bw, bh, totalH) runStart = -1 } } } } // 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] || clearanceMask[idx] || 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 } } } } // Snap ledges: on the inside of the walls (at the clearance boundary) // These are pixels that are in clearanceMask but adjacent to wallMask fmt.Println("Generating snap ledges...") for y := 1; y < imgH-1; y++ { runStart := -1 for x := 0; x <= imgW; x++ { isSnapPixel := false if x > 0 && x < imgW-1 { idx := y*imgW + x if clearanceMask[idx] && !boardMask[idx] { // Check if adjacent to a wall pixel hasAdjacentWall := false 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] { hasAdjacentWall = true break } } } isSnapPixel = hasAdjacentWall } } if isSnapPixel { 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, snapFromBottom, bw, bh, snapHeight) runStart = -1 } } } } // ========================================== // TRAY (bottom — conforms to board shape) // ========================================== var trayTris [][3]Point fmt.Println("Generating edge-cut conforming tray...") // Tray floor: covers the cavity area (clearanceMask + boardMask) for y := 0; y < imgH; y++ { runStart := -1 for x := 0; x <= imgW; x++ { isTrayPixel := false if x < imgW { idx := y*imgW + x isTrayPixel = clearanceMask[idx] || boardMask[idx] } if isTrayPixel { if runStart == -1 { runStart = x } } else { if runStart != -1 { bx := float64(runStart) * pixelToMM by := float64(y) * pixelToMM bw := float64(x-runStart) * pixelToMM bh := pixelToMM AddBox(&trayTris, bx, by, bw, bh, trayFloor) runStart = -1 } } } } // PCB support rim: inner edge of clearance zone (adjacent to board) fmt.Println("Generating PCB support rim...") rimH := pcbT * 0.5 for y := 1; y < imgH-1; y++ { runStart := -1 for x := 0; x <= imgW; x++ { isRimPixel := false if x > 0 && x < imgW-1 { idx := y*imgW + x if clearanceMask[idx] && !boardMask[idx] { // Adjacent to board? 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 && boardMask[ni] { isRimPixel = true break } } } } if isRimPixel { 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(&trayTris, bx, by, trayFloor, bw, bh, rimH) runStart = -1 } } } } // Snap bumps: on the outer edge of the tray (adjacent to wall) fmt.Println("Generating snap bumps...") snapBumpH := snapHeight + 0.3 for y := 1; y < imgH-1; y++ { runStart := -1 for x := 0; x <= imgW; x++ { isBumpPixel := false if x > 0 && x < imgW-1 { idx := y*imgW + x if clearanceMask[idx] && !boardMask[idx] { // Adjacent to 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] { isBumpPixel = true break } } } } } if isBumpPixel { 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(&trayTris, bx, by, snapFromBottom-0.1, bw, bh, snapBumpH) runStart = -1 } } } } // 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 } } } } if boardCount > 0 { boardCenterY /= float64(boardCount) tabCenterY := boardCenterY * pixelToMM // Internal tabs: inside the clearance zone, extending inward // Left tab — just inside the left wall leftInner := float64(minBX)*pixelToMM - cfg.Clearance addBoxAtZ(&trayTris, leftInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH) // Right tab — just inside the right wall rightInner := float64(maxBX)*pixelToMM + cfg.Clearance - tabD 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 fmt.Println("Adding embossed lip...") lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening lipW := 0.6 // thin lip wall 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 } } } } } if isLipPixel { if runStart == -1 { runStart = x } } else { if runStart != -1 { bx := float64(runStart) * pixelToMM 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 } } } } 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 }