Everything I wanted to add pretty much works now.

This commit is contained in:
pszsh 2026-02-21 06:42:08 -08:00
parent bffb63b540
commit 448987d97b
10 changed files with 1187 additions and 457 deletions

View File

@ -51,63 +51,92 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
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
// 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)
// Also compute a thinner mask for just the clearance zone (inner wall boundary)
clearanceMask, _ := ComputeWallMask(outlineImg, cfg.Clearance, pixelToMM)
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 = pixels in the clearance zone around the board
// 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
}
// 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
// Total height of the enclosure (from bottom of tray to top of lid)
pcbT := cfg.PCBThickness
totalH := cfg.WallHeight + pcbT + trayFloor // total enclosure height
lidThick := cfg.WallThickness // lid thickness at top
trayFloor := pcbT + 0.5 // Tray floor is 0.5mm thick, sits below PCB
totalH := trayFloor + cfg.WallHeight + lidThick
// 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
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
@ -135,6 +164,57 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
}
}
// === 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)
@ -143,7 +223,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
for y := 0; y < imgH; y++ {
for x := 0; x < imgW; x++ {
idx := y*imgW + x
if !(wallMask[idx] && !clearanceMask[idx] && !boardMask[idx]) {
if !(wallMask[idx] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !boardMask[idx]) {
continue // not a wall pixel
}
@ -204,28 +284,77 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
fmt.Printf("Wall cutout mask: applied %d side cutouts\n", len(sideCutouts))
}
// Generate walls using RLE
// 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++ {
runStart := -1
runStartX := -1
curIsInner := false
curIsSnap := false
for x := 0; x <= imgW; x++ {
isWallPixel := false
isWallPx := false
isInnerWall := false
isSnapGroove := false
if x < imgW {
idx := y*imgW + x
isWallPixel = wallMask[idx] && !clearanceMask[idx] && !boardMask[idx]
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 isWallPixel {
if runStart == -1 {
runStart = x
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 runStart != -1 {
bx := float64(runStart) * pixelToMM
if runStartX != -1 {
bx := float64(runStartX) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bw := float64(x-runStartX) * pixelToMM
bh := pixelToMM
AddBox(&encTris, bx, by, bw, bh, totalH)
runStart = -1
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
}
}
}
@ -315,7 +444,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
isWallPixel := false
if x < imgW {
idx := y*imgW + x
isWallPixel = wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] && !wallCutoutMask[idx]
isWallPixel = wallMask[idx] > clearanceDistPx && wallMask[idx] <= encWallOuterPx && !boardMask[idx] && !wallCutoutMask[idx]
}
if isWallPixel {
@ -327,8 +456,8 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
bx := float64(runStart) * pixelToMM
by2 := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bb := pixelToMM
AddBox(&newEncTris, bx, by2, bw, bb, totalH)
bh := pixelToMM
AddBox(&newEncTris, bx, by2, bw, bh, totalH)
runStart = -1
}
}
@ -339,6 +468,8 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
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
@ -380,7 +511,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
isLidPixel := false
if x < imgW {
idx := y*imgW + x
inFootprint := wallMask[idx] || clearanceMask[idx] || boardMask[idx]
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
@ -402,362 +533,83 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
}
}
}
// 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
}
}
}
}
// (Peg calculations moved above)
// ==========================================
// 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
runStartX := -1
curIsWall := false
curIsBump := false
for x := 0; x <= imgW; x++ {
isTrayPixel := false
isTrayFloor := false
isTrayWall := false
isTrayBump := false
if x < imgW {
idx := y*imgW + x
isTrayPixel = (clearanceMask[idx] || boardMask[idx]) && !pegMask[idx]
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 isTrayPixel {
if runStart == -1 {
runStart = x
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 runStart != -1 {
bx := float64(runStart) * pixelToMM
if runStartX != -1 {
bx := float64(runStartX) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bw := float64(x-runStartX) * 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
}
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)
}
}
}
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
runStartX = -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
}
}
}
}
// (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

View File

@ -17,7 +17,7 @@ const (
ApertureCircle = "C"
ApertureRect = "R"
ApertureObround = "O"
// Add macros later if needed
AperturePolygon = "P"
)
type Aperture struct {
@ -644,6 +644,30 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
drawObround(img, x, y, w, h, c)
}
return
case AperturePolygon: // P
// Modifiers: [0] diameter, [1] vertices, [2] rotation (optional)
if len(ap.Modifiers) >= 2 {
diameter := ap.Modifiers[0]
numVertices := int(ap.Modifiers[1])
rotation := 0.0
if len(ap.Modifiers) >= 3 {
rotation = ap.Modifiers[2]
}
if numVertices >= 3 {
radius := (diameter * scale) / 2
vertices := make([][2]int, numVertices)
for i := 0; i < numVertices; i++ {
angleDeg := rotation + float64(i)*360.0/float64(numVertices)
angleRad := angleDeg * math.Pi / 180.0
px := int(radius * math.Cos(angleRad))
py := int(radius * math.Sin(angleRad))
vertices[i] = [2]int{x + px, y - py}
}
drawFilledPolygon(img, vertices)
}
}
return
}
// Check for Macros
@ -699,6 +723,35 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
drawFilledPolygon(img, vertices)
}
}
case 5: // Regular Polygon
// Mods: Exposure, NumVertices, CenterX, CenterY, Diameter, Rotation
if len(prim.Modifiers) >= 6 {
exposure := evaluateMacroExpression(prim.Modifiers[0], ap.Modifiers)
if exposure == 0 {
break
}
numVertices := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers))
if numVertices >= 3 {
cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers)
cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers)
diameter := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers)
rotation := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers)
radius := (diameter * scale) / 2
pxCenter := int(cx * scale)
pyCenter := int(cy * scale)
vertices := make([][2]int, numVertices)
for i := 0; i < numVertices; i++ {
angleDeg := rotation + float64(i)*360.0/float64(numVertices)
angleRad := angleDeg * math.Pi / 180.0
px := int(radius * math.Cos(angleRad))
py := int(radius * math.Sin(angleRad))
vertices[i] = [2]int{x + pxCenter + px, y - pyCenter - py}
}
drawFilledPolygon(img, vertices)
}
}
case 20: // Vector Line
// Mods: Exposure, Width, StartX, StartY, EndX, EndY, Rotation
if len(prim.Modifiers) >= 7 {

274
main.go
View File

@ -1,6 +1,7 @@
package main
import (
"archive/zip"
"crypto/rand"
"embed"
"encoding/binary"
@ -140,7 +141,7 @@ func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) {
// ComputeWallMask generates a mask for the wall based on the outline image.
// It identifies the board area (inside the outline) and creates a wall of
// specified thickness around it.
func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]bool, []bool) {
func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]int, []bool) {
bounds := img.Bounds()
w := bounds.Max.X
h := bounds.Max.Y
@ -322,7 +323,10 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([
}
}
isWall := make([]bool, size)
wallDist := make([]int, size)
for i := range wallDist {
wallDist[i] = -1
}
for len(wQueue) > 0 {
idx := wQueue[0]
@ -342,14 +346,14 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([
nIdx := ny*w + nx
if dist[nIdx] == -1 {
dist[nIdx] = d + 1
isWall[nIdx] = true
wallDist[nIdx] = d + 1
wQueue = append(wQueue, nIdx)
}
}
}
}
return isWall, isBoard
return wallDist, isBoard
}
func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3]Point {
@ -359,11 +363,11 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3
height := bounds.Max.Y
var triangles [][3]Point
var wallMask []bool
var wallDist []int
var boardMask []bool
if outlineImg != nil {
fmt.Println("Computing wall mask...")
wallMask, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM)
wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM)
}
// Optimization: Run-Length Encoding
@ -380,9 +384,9 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3
// Check wall
isWall := false
isInsideBoard := true
if wallMask != nil {
if wallDist != nil {
idx := y*width + x
isWall = wallMask[idx]
isWall = wallDist[idx] >= 0
if boardMask != nil {
isInsideBoard = boardMask[idx]
}
@ -450,14 +454,30 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3
// --- Logic ---
func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) {
outputPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".stl"
func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, error) {
baseName := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath))
var generatedFiles []string
// Helper to check what formats were requested
wantsType := func(t string) bool {
for _, e := range exports {
if e == t {
return true
}
}
return false
}
// Always default to STL if nothing is specified
if len(exports) == 0 {
exports = []string{"stl"}
}
// 1. Parse Gerber(s)
fmt.Printf("Parsing %s...\n", gerberPath)
gf, err := ParseGerber(gerberPath)
if err != nil {
return "", fmt.Errorf("error parsing gerber: %v", err)
return nil, fmt.Errorf("error parsing gerber: %v", err)
}
var outlineGf *GerberFile
@ -465,7 +485,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) {
fmt.Printf("Parsing outline %s...\n", outlinePath)
outlineGf, err = ParseGerber(outlinePath)
if err != nil {
return "", fmt.Errorf("error parsing outline gerber: %v", err)
return nil, fmt.Errorf("error parsing outline gerber: %v", err)
}
}
@ -518,18 +538,52 @@ func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) {
}
}
// 4. Generate Mesh
fmt.Println("Generating mesh...")
triangles := GenerateMeshFromImages(img, outlineImg, cfg)
// 5. Save STL
fmt.Printf("Saving to %s (%d triangles)...\n", outputPath, len(triangles))
err = WriteSTL(outputPath, triangles)
if err != nil {
return "", fmt.Errorf("error writing STL: %v", err)
var triangles [][3]Point
if wantsType("stl") || wantsType("scad") {
// 4. Generate Mesh
fmt.Println("Generating mesh...")
triangles = GenerateMeshFromImages(img, outlineImg, cfg)
}
return outputPath, nil
// 5. Output based on requested formats
if wantsType("stl") {
outputFilename := baseName + ".stl"
fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles))
if err := WriteSTL(outputFilename, triangles); err != nil {
return nil, fmt.Errorf("error writing stl: %v", err)
}
generatedFiles = append(generatedFiles, outputFilename)
}
if wantsType("svg") {
outputFilename := baseName + ".svg"
fmt.Printf("Saving to %s (SVG)...\n", outputFilename)
if err := WriteSVG(outputFilename, gf, &bounds); err != nil {
return nil, fmt.Errorf("error writing svg: %v", err)
}
generatedFiles = append(generatedFiles, outputFilename)
}
if wantsType("png") {
outputFilename := baseName + ".png"
fmt.Printf("Saving to %s (PNG)...\n", outputFilename)
if f, err := os.Create(outputFilename); err == nil {
png.Encode(f, img)
f.Close()
generatedFiles = append(generatedFiles, outputFilename)
}
}
if wantsType("scad") {
outputFilename := baseName + ".scad"
fmt.Printf("Saving to %s (SCAD)...\n", outputFilename)
if err := WriteSCAD(outputFilename, triangles); err != nil {
return nil, fmt.Errorf("error writing scad: %v", err)
}
generatedFiles = append(generatedFiles, outputFilename)
}
return generatedFiles, nil
}
// --- CLI ---
@ -549,7 +603,7 @@ func runCLI(cfg Config, args []string) {
outlinePath = args[1]
}
_, err := processPCB(gerberPath, outlinePath, cfg)
_, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"})
if err != nil {
log.Fatalf("Error: %v", err)
}
@ -657,15 +711,39 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) {
}
// Process
outSTL, err := processPCB(gerberPath, outlinePath, cfg)
exports := r.Form["exports"]
generatedPaths, err := processPCB(gerberPath, outlinePath, cfg, exports)
if err != nil {
log.Printf("Error processing: %v", err)
http.Error(w, fmt.Sprintf("Error processing PCB: %v", err), http.StatusInternalServerError)
return
}
var generatedFiles []string
for _, p := range generatedPaths {
generatedFiles = append(generatedFiles, filepath.Base(p))
}
// Generate Master Zip if more than 1 file
var zipFile string
if len(generatedFiles) > 1 {
zipPath := filepath.Join(tempDir, uuid+"_all_exports.zip")
zf, err := os.Create(zipPath)
if err == nil {
zw := zip.NewWriter(zf)
for _, fn := range generatedFiles {
fw, _ := zw.Create(fn)
fb, _ := os.ReadFile(filepath.Join(tempDir, fn))
fw.Write(fb)
}
zw.Close()
zf.Close()
zipFile = filepath.Base(zipPath)
}
}
// Render Success
renderResult(w, "Your stencil has been generated successfully.", []string{filepath.Base(outSTL)})
renderResult(w, "Your stencil has been generated successfully.", generatedFiles, "/", zipFile)
}
func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
@ -838,6 +916,39 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
ecfg.OutlineBounds = &outlineBounds
outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds)
// Compute board box and count from the rendered image
minBX, minBY, maxBX, maxBY := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y, -1, -1
var boardCenterY float64
var boardCount int
wallMaskInt, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4)
_ = wallMaskInt // Not used here, but we need boardMask
imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y
for y := 0; y < imgH; y++ {
for x := 0; x < imgW; x++ {
if boardMask[y*imgW+x] {
if x < minBX {
minBX = x
}
if y < minBY {
minBY = y
}
if x > maxBX {
maxBX = x
}
if y > maxBY {
maxBY = y
}
boardCenterY += float64(y)
boardCount++
}
}
}
if boardCount > 0 {
boardCenterY /= float64(boardCount)
}
// Auto-discover and render F.Courtyard from job file
var courtyardImg image.Image
if courtPath, ok := savedGerbers[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" {
@ -875,9 +986,9 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Generate enclosure (no side cutouts yet — added in preview flow)
// Store session data for preview page
session := &EnclosureSession{
Exports: r.Form["exports"],
OutlineGf: outlineGf,
OutlineImg: outlineImg,
CourtyardImg: courtyardImg,
SoldermaskImg: soldermaskImg,
@ -887,6 +998,9 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
BoardW: actualBoardW,
BoardH: actualBoardH,
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
MinBX: float64(minBX),
MaxBX: float64(maxBX),
BoardCenterY: boardCenterY,
}
sessionsMu.Lock()
sessions[uuid] = session
@ -897,7 +1011,7 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
}
func renderResult(w http.ResponseWriter, message string, files []string) {
func renderResult(w http.ResponseWriter, message string, files []string, backURL string, zipFile string) {
tmpl, err := template.ParseFS(staticFiles, "static/result.html")
if err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
@ -906,13 +1020,17 @@ func renderResult(w http.ResponseWriter, message string, files []string) {
data := struct {
Message string
Files []string
}{Message: message, Files: files}
BackURL string
ZipFile string
}{Message: message, Files: files, BackURL: backURL, ZipFile: zipFile}
tmpl.Execute(w, data)
}
// --- Enclosure Preview Session ---
type EnclosureSession struct {
Exports []string
OutlineGf *GerberFile
OutlineImg image.Image
CourtyardImg image.Image
SoldermaskImg image.Image
@ -922,6 +1040,9 @@ type EnclosureSession struct {
BoardW float64
BoardH float64
TotalH float64
MinBX float64
MaxBX float64
BoardCenterY float64
}
var (
@ -1031,36 +1152,83 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Side cutouts: %d\n", len(sideCutouts))
}
// Generate enclosure
fmt.Println("Generating enclosure with side cutouts...")
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config,
session.CourtyardImg, session.SoldermaskImg, sideCutouts)
var generatedFiles []string
// Save STLs
encPath := filepath.Join("temp", id+"_enclosure.stl")
trayPath := filepath.Join("temp", id+"_tray.stl")
fmt.Printf("Saving enclosure to %s (%d triangles)...\n", encPath, len(result.EnclosureTriangles))
if err := WriteSTL(encPath, result.EnclosureTriangles); err != nil {
http.Error(w, fmt.Sprintf("Error writing enclosure STL: %v", err), http.StatusInternalServerError)
return
// Helper to check what formats were requested
wantsType := func(t string) bool {
for _, e := range session.Exports {
if e == t {
return true
}
}
return false
}
fmt.Printf("Saving tray to %s (%d triangles)...\n", trayPath, len(result.TrayTriangles))
if err := WriteSTL(trayPath, result.TrayTriangles); err != nil {
http.Error(w, fmt.Sprintf("Error writing tray STL: %v", err), http.StatusInternalServerError)
return
// Always default to STL if nothing is specified
if len(session.Exports) == 0 {
session.Exports = []string{"stl"}
}
// Clean up session
sessionsMu.Lock()
delete(sessions, id)
sessionsMu.Unlock()
// Process STL
if wantsType("stl") {
fmt.Println("Generating STLs...")
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts)
encPath := filepath.Join("temp", id+"_enclosure.stl")
trayPath := filepath.Join("temp", id+"_tray.stl")
WriteSTL(encPath, result.EnclosureTriangles)
WriteSTL(trayPath, result.TrayTriangles)
generatedFiles = append(generatedFiles, filepath.Base(encPath), filepath.Base(trayPath))
}
renderResult(w, "Your enclosure has been generated successfully.", []string{
filepath.Base(encPath),
filepath.Base(trayPath),
})
// Process SCAD
if wantsType("scad") {
fmt.Println("Generating Native SCAD scripts...")
scadPathEnc := filepath.Join("temp", id+"_enclosure.scad")
scadPathTray := filepath.Join("temp", id+"_tray.scad")
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.MinBX, session.MaxBX, session.BoardCenterY)
WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, session.MinBX, session.MaxBX, session.BoardCenterY)
generatedFiles = append(generatedFiles, filepath.Base(scadPathEnc), filepath.Base(scadPathTray))
}
// Process SVG
if wantsType("svg") && session.OutlineGf != nil {
fmt.Println("Generating SVG vector outline...")
svgPath := filepath.Join("temp", id+"_outline.svg")
WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds)
generatedFiles = append(generatedFiles, filepath.Base(svgPath))
}
// Process PNG
if wantsType("png") && session.OutlineImg != nil {
fmt.Println("Generating PNG raster outline...")
pngPath := filepath.Join("temp", id+"_outline.png")
fImg, _ := os.Create(pngPath)
png.Encode(fImg, session.OutlineImg)
fImg.Close()
generatedFiles = append(generatedFiles, filepath.Base(pngPath))
}
// Generate Master Zip if more than 1 file
var zipFile string
if len(generatedFiles) > 1 {
zipPath := filepath.Join("temp", id+"_all_exports.zip")
zf, err := os.Create(zipPath)
if err == nil {
zw := zip.NewWriter(zf)
for _, fn := range generatedFiles {
fw, _ := zw.Create(fn)
fb, _ := os.ReadFile(filepath.Join("temp", fn))
fw.Write(fb)
}
zw.Close()
zf.Close()
zipFile = filepath.Base(zipPath)
}
}
// We intentionally do NOT delete the session here so the user can hit "Back for Adjustments"
renderResult(w, "Your files have been generated successfully.", generatedFiles, "/preview?id="+id, zipFile)
}
func downloadHandler(w http.ResponseWriter, r *http.Request) {

Binary file not shown.

256
scad.go Normal file
View File

@ -0,0 +1,256 @@
package main
import (
"fmt"
"math"
"os"
)
func WriteSCAD(filename string, triangles [][3]Point) error {
// Fallback/legacy mesh WriteSCAD
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "// Generated by pcb-to-stencil\npolyhedron(\n points=[\n")
for i, t := range triangles {
fmt.Fprintf(f, " [%f, %f, %f], [%f, %f, %f], [%f, %f, %f]", t[0].X, t[0].Y, t[0].Z, t[1].X, t[1].Y, t[1].Z, t[2].X, t[2].Y, t[2].Z)
if i < len(triangles)-1 {
fmt.Fprintf(f, ",\n")
} else {
fmt.Fprintf(f, "\n")
}
}
fmt.Fprintf(f, " ],\n faces=[\n")
for i := 0; i < len(triangles); i++ {
idx := i * 3
fmt.Fprintf(f, " [%d, %d, %d]", idx, idx+1, idx+2)
if i < len(triangles)-1 {
fmt.Fprintf(f, ",\n")
} else {
fmt.Fprintf(f, "\n")
}
}
fmt.Fprintf(f, " ]\n);\n")
return nil
}
// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon
func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
var points [][2]float64
curX, curY := 0.0, 0.0
interpolationMode := "G01"
for _, cmd := range gf.Commands {
if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" {
interpolationMode = cmd.Type
continue
}
prevX, prevY := curX, curY
if cmd.X != nil {
curX = *cmd.X
}
if cmd.Y != nil {
curY = *cmd.Y
}
if cmd.Type == "DRAW" {
if len(points) == 0 {
points = append(points, [2]float64{prevX, prevY})
}
if interpolationMode == "G01" {
points = append(points, [2]float64{curX, curY})
} else {
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
}
if cmd.J != nil {
jVal = *cmd.J
}
centerX, centerY := prevX+iVal, prevY+jVal
radius := math.Sqrt(iVal*iVal + jVal*jVal)
startAngle := math.Atan2(prevY-centerY, prevX-centerX)
endAngle := math.Atan2(curY-centerY, curX-centerX)
if interpolationMode == "G03" {
if endAngle <= startAngle {
endAngle += 2 * math.Pi
}
} else {
if startAngle <= endAngle {
startAngle += 2 * math.Pi
}
}
arcLen := math.Abs(endAngle-startAngle) * radius
steps := int(arcLen * 10)
if steps < 5 {
steps = 5
}
for s := 1; s <= steps; s++ {
t := float64(s) / float64(steps)
a := startAngle + t*(endAngle-startAngle)
ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a)
points = append(points, [2]float64{ax, ay})
}
}
}
}
return points
}
// WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code
func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, minBX, maxBX, boardCenterY float64) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n")
fmt.Fprintf(f, "$fn = 60;\n\n")
// 1. Output the Board Polygon Module
fmt.Fprintf(f, "module board_polygon() {\n polygon(points=[\n")
for i, v := range outlineVertices {
fmt.Fprintf(f, " [%f, %f]", v[0], v[1])
if i < len(outlineVertices)-1 {
fmt.Fprintf(f, ",\n")
}
}
fmt.Fprintf(f, "\n ]);\n}\n\n")
// Dimensions
clearance := cfg.Clearance
wt := cfg.WallThickness
lidThick := wt
snapHeight := 2.5
trayFloor := 1.5
pcbT := cfg.PCBThickness
totalH := cfg.WallHeight + pcbT + trayFloor
lipH := pcbT + 1.5
// Create Peg and Socket helper
fmt.Fprintf(f, "module mounting_pegs(isSocket) {\n")
for _, h := range holes {
if h.Type == DrillTypeMounting {
r := (h.Diameter / 2.0) - 0.15
if isTray {
// We subtract sockets from the tray floor
r = (h.Diameter / 2.0) + 0.1
fmt.Fprintf(f, " translate([%f, %f, -1]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, trayFloor+2)
} else {
fmt.Fprintf(f, " translate([%f, %f, 0]) cylinder(r=%f, h=%f);\n", h.X, h.Y, r, totalH-lidThick)
}
}
}
fmt.Fprintf(f, "}\n\n")
// Print Side Cutouts module
fmt.Fprintf(f, "module side_cutouts() {\n")
for _, c := range cutouts {
// Cutouts are relative to board.
x, y, z := 0.0, 0.0, c.Height/2+trayFloor+pcbT
w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls
if c.Side == 0 { // Top
y = outlineVertices[0][1] + 10 // rough outside pos
x = c.X
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, w, d, h)
} else if c.Side == 1 { // Right
x = maxBX
y = c.Y
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, d, w, h)
} else if c.Side == 2 { // Bottom
y = outlineVertices[0][1] - 10
x = c.X
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, w, d, h)
} else if c.Side == 3 { // Left
x = minBX
y = c.Y
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", x, y, z, d, w, h)
}
}
fmt.Fprintf(f, "}\n\n")
// Print Pry Slots Module
fmt.Fprintf(f, "module pry_slots() {\n")
pryW := 8.0
pryD := 1.5
fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", minBX-clearance-wt+pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3)
fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt-pryD/2, boardCenterY, pryD*2, pryW, snapHeight*3)
fmt.Fprintf(f, "}\n\n")
if isTray {
// --- TRAY ---
fmt.Fprintf(f, "// --- TRAY ---\n")
fmt.Fprintf(f, "difference() {\n")
fmt.Fprintf(f, " union() {\n")
fmt.Fprintf(f, " // Tray Floor (extends to clearance + 2*wt so it is flush with enclosure outside)\n")
fmt.Fprintf(f, " linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, clearance+2*wt)
fmt.Fprintf(f, " // Tray Inner Wall (thickness wt)\n")
fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor, snapHeight)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance)
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " // Snap Bumps (on outside of tray wall)\n")
fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor+snapHeight-0.6, 0.4)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.4)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt)
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " // Mounting Pegs\n")
for _, hole := range holes {
if hole.Type != DrillTypeMounting {
continue
}
pegRadius := (hole.Diameter / 2.0) - 0.15
fmt.Fprintf(f, " translate([%f,%f,0]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, totalH-lidThick, pegRadius)
}
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " // Subtract Lip Recess (for easy opening)\n")
fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) difference() {\n", trayFloor+lipH+0.5)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt+1.0)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+2*wt-0.5) // Assuming lipCut is 0.5mm
fmt.Fprintf(f, " }\n")
// Remove peg holes from floor
for _, hole := range holes {
if hole.Type != DrillTypeMounting {
continue
}
socketRadius := (hole.Diameter / 2.0) + 0.1
fmt.Fprintf(f, " translate([%f,%f,-1]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, trayFloor+2, socketRadius)
}
fmt.Fprintf(f, " pry_slots();\n")
fmt.Fprintf(f, " side_cutouts();\n")
fmt.Fprintf(f, "}\n\n")
} else {
// --- ENCLOSURE ---
fmt.Fprintf(f, "// --- ENCLOSURE ---\n")
fmt.Fprintf(f, "difference() {\n")
fmt.Fprintf(f, " union() {\n")
fmt.Fprintf(f, " // Outer Enclosure block (accommodates Tray Wall + Enclosure Wall)\n")
fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor, totalH-trayFloor, clearance+2*wt)
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " // Subtract Inner Cavity (Base clearance around board)\n")
fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", totalH-lidThick+1, clearance)
fmt.Fprintf(f, " // Subtract Tray Recess (Accommodates Tray Wall)\n")
fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor+snapHeight+0.2, clearance+wt+0.15)
fmt.Fprintf(f, " // Subtract Snap Groove\n")
fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor+snapHeight-0.7, 0.6)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.5)
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt)
fmt.Fprintf(f, " }\n")
fmt.Fprintf(f, " pry_slots();\n")
fmt.Fprintf(f, " side_cutouts();\n")
fmt.Fprintf(f, "}\n")
fmt.Fprintf(f, "mounting_pegs(false);\n")
}
return nil
}

View File

@ -12,6 +12,28 @@
<div class="container">
<h1>PCB Tools by kennycoder + pszsh</h1>
<div class="menu-bar">
<div class="menu-item">
<span>File</span>
<div class="menu-dropdown">
<a href="#" onclick="saveConfig()">Save Configuration</a>
<div class="menu-divider"></div>
<div id="recentsList" class="menu-recents">
<span class="menu-recents-title">Saved Configs</span>
</div>
</div>
</div>
<div class="menu-item">
<span>Export</span>
<div class="menu-dropdown export-options">
<label><input type="checkbox" name="export-stl" value="stl" checked> STL (3D Mesh)</label>
<label><input type="checkbox" name="export-scad" value="scad"> SCAD (Native OpenSCAD)</label>
<label><input type="checkbox" name="export-svg" value="svg"> SVG (2D Vector)</label>
<label><input type="checkbox" name="export-png" value="png"> PNG (2D Raster)</label>
</div>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="stencil">Stencil</button>
<button class="tab" data-tab="enclosure">Enclosure</button>
@ -140,15 +162,96 @@
});
});
// Loading spinner on submit
// Include export config in form submissions
document.querySelectorAll('form').forEach(function (form) {
form.addEventListener('submit', function () {
form.addEventListener('submit', function (e) {
// Clear old hidden inputs to avoid duplicates on re-submission
form.querySelectorAll('input.dynamic-export').forEach(function (el) { el.remove(); });
// Read checkboxes and inject as hidden inputs
document.querySelectorAll('.export-options input[type="checkbox"]').forEach(function (cb) {
if (cb.checked) {
var hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'exports';
hidden.value = cb.value;
hidden.className = 'dynamic-export';
form.appendChild(hidden);
}
});
document.getElementById('loading').style.display = 'block';
var btn = form.querySelector('.submit-btn');
btn.disabled = true;
btn.innerText = 'Processing...';
if (btn) {
btn.disabled = true;
btn.innerText = 'Processing...';
}
});
});
// Config state management
function saveConfig() {
var config = {
stencilHeight: document.getElementById('height').value,
stencilDpi: document.getElementById('dpi').value,
stencilWallHeight: document.getElementById('wallHeight').value,
stencilWallThickness: document.getElementById('wallThickness').value,
encWallThickness: document.getElementById('enc-wallThickness').value,
encWallHeight: document.getElementById('enc-wallHeight').value,
encClearance: document.getElementById('enc-clearance').value,
encDpi: document.getElementById('enc-dpi').value
};
var name = prompt("Save configuration as:", "My Config " + new Date().toLocaleTimeString());
if (name) {
var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}');
recents[name] = config;
localStorage.setItem('pcbConfigs', JSON.stringify(recents));
updateRecents();
}
}
function loadConfig(name) {
var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}');
var config = recents[name];
if (config) {
if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight;
if (config.stencilDpi) document.getElementById('dpi').value = config.stencilDpi;
if (config.stencilWallHeight) document.getElementById('wallHeight').value = config.stencilWallHeight;
if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness;
if (config.encWallThickness) document.getElementById('enc-wallThickness').value = config.encWallThickness;
if (config.encWallHeight) document.getElementById('enc-wallHeight').value = config.encWallHeight;
if (config.encClearance) document.getElementById('enc-clearance').value = config.encClearance;
if (config.encDpi) document.getElementById('enc-dpi').value = config.encDpi;
}
}
function updateRecents() {
var recents = JSON.parse(localStorage.getItem('pcbConfigs') || '{}');
var list = document.getElementById('recentsList');
if (!list) return;
list.innerHTML = '<span class="menu-recents-title">Saved Configs</span>';
for (var name in recents) {
var a = document.createElement('a');
a.className = 'menu-recents-item';
a.innerText = name;
a.href = "#";
a.onclick = (function (n) {
return function (e) {
e.preventDefault();
loadConfig(n);
};
})(name);
list.appendChild(a);
}
}
document.addEventListener('DOMContentLoaded', updateRecents);
// Removed old triggerExport since we now use multi-select checkboxes
</script>
</body>

View File

@ -322,7 +322,12 @@
<input type="hidden" name="sessionId" id="sessionId">
<input type="hidden" name="sideCutouts" id="sideCutoutsInput">
<input type="hidden" name="conformToEdge" id="conformInput" value="true">
<button type="submit" class="submit-btn">Generate Enclosure</button>
<div style="display: flex; gap: 10px; margin-top: 15px;">
<a href="/" class="btn secondary"
style="flex: 1; text-align: center; text-decoration: none; padding: 10px; border-radius: 4px; border: 1px solid var(--border-color); color: var(--text-color);">Go
Back</a>
<button type="submit" class="submit-btn" style="flex: 1; margin-top: 0;">Generate</button>
</div>
</form>
</div>

View File

@ -12,12 +12,25 @@
<div class="card">
<h2>Success!</h2>
<p>{{.Message}}</p>
{{if .ZipFile}}
<a href="/download/{{.ZipFile}}" class="btn"
style="display: block; width: 100%; margin-bottom: 20px; font-weight: bold; font-size: 1.1em; padding: 15px;">Download
All (ZIP)</a>
<h3
style="margin-bottom: 10px; font-size: 0.9em; text-transform: uppercase; color: var(--border-color); letter-spacing: 1px;">
Individual Files</h3>
{{end}}
<ul class="download-list">
{{range .Files}}
<li><a href="/download/{{.}}" class="btn">Download {{.}}</a></li>
{{end}}
</ul>
<a href="/" class="btn secondary">Back</a>
{{if .BackURL}}
<a href="{{.BackURL}}" class="btn secondary" style="background-color: #6b7280; color: white;">Back for
Adjustments</a>
{{end}}
<a href="/" class="btn secondary">Start Over</a>
</div>
</body>

View File

@ -51,6 +51,92 @@ h2 {
margin-top: 0;
}
/* Menu Bar */
.menu-bar {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.menu-item {
position: relative;
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
color: var(--text);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.menu-item:hover {
background-color: #e5e7eb;
}
.menu-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid var(--border);
border-radius: 6px;
z-index: 100;
padding: 0.5rem 0;
}
.menu-item:hover .menu-dropdown {
display: block;
}
.menu-dropdown a {
display: block;
padding: 0.5rem 1rem;
color: var(--text);
text-decoration: none;
font-size: 0.85rem;
}
.menu-dropdown a:hover {
background-color: var(--bg);
color: var(--primary);
}
.menu-divider {
height: 1px;
background-color: var(--border);
margin: 0.4rem 0;
}
.menu-recents-title {
display: block;
padding: 0.25rem 1rem;
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
font-weight: 600;
}
.menu-recents-item {
display: block;
padding: 0.4rem 1rem;
font-size: 0.85rem;
color: var(--text);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-recents-item:hover {
background-color: var(--bg);
color: var(--primary);
}
/* Tabs */
.tabs {
display: flex;

194
svg.go Normal file
View File

@ -0,0 +1,194 @@
package main
import (
"fmt"
"math"
"os"
)
func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
b := *bounds
widthMM := b.MaxX - b.MinX
heightMM := b.MaxY - b.MinY
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
// Use mm directly for SVG
fmt.Fprintf(f, `<svg xmlns="http://www.w3.org/2000/svg" width="%fmm" height="%fmm" viewBox="0 0 %f %f">`,
widthMM, heightMM, widthMM, heightMM)
fmt.Fprintf(f, "\n<g fill=\"black\" stroke=\"black\">\n")
// Note: SVG Y-axis points down. We need to invert Y: (heightMM - (y - b.MinY))
toSVGX := func(x float64) float64 { return x - b.MinX }
toSVGY := func(y float64) float64 { return heightMM - (y - b.MinY) }
curX, curY := 0.0, 0.0
curDCode := 0
interpolationMode := "G01" // Default linear
inRegion := false
var regionVertices [][2]float64
for _, cmd := range gf.Commands {
if cmd.Type == "APERTURE" {
curDCode = *cmd.D
continue
}
if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" {
interpolationMode = cmd.Type
continue
}
if cmd.Type == "G36" {
inRegion = true
regionVertices = nil
continue
}
if cmd.Type == "G37" {
if len(regionVertices) >= 3 {
fmt.Fprintf(f, `<polygon points="`)
for _, v := range regionVertices {
fmt.Fprintf(f, "%f,%f ", toSVGX(v[0]), toSVGY(v[1]))
}
fmt.Fprintf(f, "\" fill=\"black\" stroke=\"none\"/>\n")
}
inRegion = false
regionVertices = nil
continue
}
prevX, prevY := curX, curY
if cmd.X != nil {
curX = *cmd.X
}
if cmd.Y != nil {
curY = *cmd.Y
}
if inRegion {
if cmd.Type == "MOVE" || cmd.Type == "DRAW" && interpolationMode == "G01" {
regionVertices = append(regionVertices, [2]float64{curX, curY})
} else if cmd.Type == "DRAW" && (interpolationMode == "G02" || interpolationMode == "G03") {
// We don't have perfect analytic translation to SVG path for region arcs yet.
// We can just output the line for now, or approximate it as before.
// For SVG, we can just output line segments just like we did for image processing.
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
}
if cmd.J != nil {
jVal = *cmd.J
}
centerX, centerY := prevX+iVal, prevY+jVal
radius := math.Sqrt(iVal*iVal + jVal*jVal)
startAngle := math.Atan2(prevY-centerY, prevX-centerX)
endAngle := math.Atan2(curY-centerY, curX-centerX)
if interpolationMode == "G03" {
if endAngle <= startAngle {
endAngle += 2 * math.Pi
}
} else {
if startAngle <= endAngle {
startAngle += 2 * math.Pi
}
}
arcLen := math.Abs(endAngle-startAngle) * radius
steps := int(arcLen * 10) // 10 segments per mm
if steps < 10 {
steps = 10
}
for s := 1; s <= steps; s++ {
t := float64(s) / float64(steps)
a := startAngle + t*(endAngle-startAngle)
ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a)
regionVertices = append(regionVertices, [2]float64{ax, ay})
}
}
continue
}
if cmd.Type == "FLASH" {
ap, ok := gf.State.Apertures[curDCode]
if ok {
writeSVGAperture(f, toSVGX(curX), toSVGY(curY), ap, false)
}
} else if cmd.Type == "DRAW" {
ap, ok := gf.State.Apertures[curDCode]
if ok {
// Basic stroke representation for lines
w := 0.1 // default
if len(ap.Modifiers) > 0 {
w = ap.Modifiers[0]
}
if interpolationMode == "G01" {
fmt.Fprintf(f, `<line x1="%f" y1="%f" x2="%f" y2="%f" stroke-width="%f" stroke-linecap="round"/>`+"\n",
toSVGX(prevX), toSVGY(prevY), toSVGX(curX), toSVGY(curY), w)
} else {
iVal, jVal := 0.0, 0.0
if cmd.I != nil {
iVal = *cmd.I
}
if cmd.J != nil {
jVal = *cmd.J
}
// SVG path Arc
rx, ry := math.Sqrt(iVal*iVal+jVal*jVal), math.Sqrt(iVal*iVal+jVal*jVal)
sweep := 1 // G03 CCW -> SVG path sweep up due to inverted Y
if interpolationMode == "G02" {
sweep = 0
}
fmt.Fprintf(f, `<path d="M %f %f A %f %f 0 0 %d %f %f" stroke-width="%f" fill="none" stroke-linecap="round"/>`+"\n",
toSVGX(prevX), toSVGY(prevY), rx, ry, sweep, toSVGX(curX), toSVGY(curY), w)
}
}
}
}
fmt.Fprintf(f, "</g>\n</svg>\n")
return nil
}
func writeSVGAperture(f *os.File, cx, cy float64, ap Aperture, isMacro bool) {
switch ap.Type {
case "C":
if len(ap.Modifiers) > 0 {
r := ap.Modifiers[0] / 2
fmt.Fprintf(f, `<circle cx="%f" cy="%f" r="%f" />`+"\n", cx, cy, r)
}
case "R":
if len(ap.Modifiers) >= 2 {
w, h := ap.Modifiers[0], ap.Modifiers[1]
fmt.Fprintf(f, `<rect x="%f" y="%f" width="%f" height="%f" />`+"\n", cx-w/2, cy-h/2, w, h)
}
case "O":
if len(ap.Modifiers) >= 2 {
w, h := ap.Modifiers[0], ap.Modifiers[1]
r := w / 2
if h < w {
r = h / 2
}
fmt.Fprintf(f, `<rect x="%f" y="%f" width="%f" height="%f" rx="%f" ry="%f" />`+"\n", cx-w/2, cy-h/2, w, h, r, r)
}
case "P":
if len(ap.Modifiers) >= 2 {
dia, numV := ap.Modifiers[0], int(ap.Modifiers[1])
r := dia / 2
rot := 0.0
if len(ap.Modifiers) >= 3 {
rot = ap.Modifiers[2]
}
fmt.Fprintf(f, `<polygon points="`)
for i := 0; i < numV; i++ {
a := (rot + float64(i)*360.0/float64(numV)) * math.Pi / 180.0
// SVG inverted Y means we might need minus for Sin
fmt.Fprintf(f, "%f,%f ", cx+r*math.Cos(a), cy-r*math.Sin(a))
}
fmt.Fprintf(f, `" />`+"\n")
}
}
}