751 lines
22 KiB
Go
751 lines
22 KiB
Go
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
|
|
}
|