pcb-to-stencil/enclosure.go

1218 lines
35 KiB
Go

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
}