pcb-to-stencil/enclosure.go

899 lines
25 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 + 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
// 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
}
}
}
}
// 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] && !clearanceMask[idx] && !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))
}
// 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
}
}
}
}
// 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] && !clearanceMask[idx] && !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
bb := pixelToMM
AddBox(&newEncTris, bx, by2, bw, bb, totalH)
runStart = -1
}
}
}
}
// Add the partial (cut) wall sections
newEncTris = append(newEncTris, cutoutEncTris...)
encTris = newEncTris
}
// 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
}
}
}
}
// 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)
}
}
// 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]) && !pegMask[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...")
// (uses pre-computed board bounding box: minBX, minBY, maxBX, maxBY, boardCenterY)
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 raised ridge around the tray perimeter, 0.5mm thick
// This lip mates against a recess in the enclosure for a tight snap fit
fmt.Println("Adding embossed lip...")
lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening
lipThickPx := int(math.Ceil(0.5 * cfg.DPI / 25.4)) // 0.5mm in pixels
if lipThickPx < 1 {
lipThickPx = 1
}
// Build lip mask from the adjacency rule, then dilate inward by lipThickPx
lipCoreMask := make([]bool, size)
for y := 1; y < imgH-1; y++ {
for x := 1; x < imgW-1; x++ {
idx := y*imgW + x
if clearanceMask[idx] && !boardMask[idx] {
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] {
lipCoreMask[idx] = true
break
}
}
}
}
}
}
// Dilate lip mask inward by lipThickPx pixels
lipMask := make([]bool, size)
copy(lipMask, lipCoreMask)
for iter := 1; iter < lipThickPx; iter++ {
nextMask := make([]bool, size)
copy(nextMask, lipMask)
for y := 1; y < imgH-1; y++ {
for x := 1; x < imgW-1; x++ {
idx := y*imgW + x
if lipMask[idx] {
continue // already in lip
}
if !clearanceMask[idx] || boardMask[idx] {
continue // must be in clearance zone
}
// Adjacent to existing lip pixel?
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 && lipMask[ni] {
nextMask[idx] = true
break
}
}
}
}
lipMask = nextMask
}
// Generate lip boxes
for y := 0; y < imgH; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
isLipPx := false
if x < imgW {
isLipPx = lipMask[y*imgW+x]
}
if isLipPx {
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, lipH)
runStart = -1
}
}
}
}
// Add matching recess in enclosure for the lip (0.25mm deep groove)
// Recess sits at the inner face of the enclosure wall, where the lip enters
fmt.Println("Adding lip recess in enclosure...")
recessDepth := 0.25
recessH := lipH + 0.5 // slightly taller than lip for easy entry
for y := 0; y < imgH; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
// Recess = wall pixels adjacent to the lip (inner face of wall)
isRecess := false
if x > 0 && x < imgW-1 {
idx := y*imgW + x
if wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] {
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 && lipMask[ni] {
isRecess = true
break
}
}
}
}
if isRecess {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
// Subtract recess from enclosure wall by NOT generating here
// Instead, generate wall with gap at recess height
bx := float64(runStart) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
// Wall below recess
if trayFloor > 0.05 {
addBoxAtZ(&encTris, bx, by, 0, bw, bh, trayFloor)
}
// Thinner wall in recess zone (subtract recessDepth from thickness)
// This is handled by just not filling the recess area
_ = recessDepth
// Wall above recess
addBoxAtZ(&encTris, bx, by, trayFloor+recessH, bw, bh, totalH-(trayFloor+recessH))
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
}