Everything I wanted to add pretty much works now.
This commit is contained in:
parent
bffb63b540
commit
448987d97b
650
enclosure.go
650
enclosure.go
|
|
@ -51,63 +51,92 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
imgH := bounds.Max.Y
|
imgH := bounds.Max.Y
|
||||||
|
|
||||||
// Use ComputeWallMask to get the board shape and wall around it
|
// Use ComputeWallMask to get the board shape and wall around it
|
||||||
// WallThickness for enclosure = clearance + wall thickness
|
// WallThickness for enclosure = clearance + 2 * wall thickness
|
||||||
totalWallMM := cfg.Clearance + cfg.WallThickness
|
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)
|
fmt.Printf("Computing board shape (wall=%.1fmm)...\n", totalWallMM)
|
||||||
wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM)
|
wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM) // wallMask is now an int slice
|
||||||
|
|
||||||
// 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)
|
// Determine the actual enclosure boundary = wall | board (expanded by clearance)
|
||||||
// wallMask = pixels that are the wall
|
// wallMask = pixels that are the wall
|
||||||
// boardMask = pixels inside the board outline
|
// 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
|
// Total height of the enclosure (from bottom of tray to top of lid)
|
||||||
// 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
|
pcbT := cfg.PCBThickness
|
||||||
totalH := cfg.WallHeight + pcbT + trayFloor // total enclosure height
|
trayFloor := pcbT + 0.5 // Tray floor is 0.5mm thick, sits below PCB
|
||||||
lidThick := cfg.WallThickness // lid thickness at top
|
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
|
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)
|
// Pre-compute board bounding box (needed for side cutout detection and removal tabs)
|
||||||
minBX, minBY := imgW, imgH
|
minBX, minBY := imgW, imgH
|
||||||
maxBX, maxBY := 0, 0
|
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
|
// Build wall-cutout mask from side cutouts
|
||||||
// For each side cutout, determine which wall pixels to subtract
|
// For each side cutout, determine which wall pixels to subtract
|
||||||
wallCutoutMask := make([]bool, size)
|
wallCutoutMask := make([]bool, size)
|
||||||
|
|
@ -143,7 +223,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
for y := 0; y < imgH; y++ {
|
for y := 0; y < imgH; y++ {
|
||||||
for x := 0; x < imgW; x++ {
|
for x := 0; x < imgW; x++ {
|
||||||
idx := y*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
|
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))
|
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++ {
|
for y := 0; y < imgH; y++ {
|
||||||
runStart := -1
|
runStartX := -1
|
||||||
|
curIsInner := false
|
||||||
|
curIsSnap := false
|
||||||
for x := 0; x <= imgW; x++ {
|
for x := 0; x <= imgW; x++ {
|
||||||
isWallPixel := false
|
isWallPx := false
|
||||||
|
isInnerWall := false
|
||||||
|
isSnapGroove := false
|
||||||
if x < imgW {
|
if x < imgW {
|
||||||
idx := y*imgW + x
|
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 isWallPx {
|
||||||
if runStart == -1 {
|
if runStartX == -1 {
|
||||||
runStart = x
|
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 {
|
} else {
|
||||||
if runStart != -1 {
|
if runStartX != -1 {
|
||||||
bx := float64(runStart) * pixelToMM
|
bx := float64(runStartX) * pixelToMM
|
||||||
by := float64(y) * pixelToMM
|
by := float64(y) * pixelToMM
|
||||||
bw := float64(x-runStart) * pixelToMM
|
bw := float64(x-runStartX) * pixelToMM
|
||||||
bh := 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
|
isWallPixel := false
|
||||||
if x < imgW {
|
if x < imgW {
|
||||||
idx := y*imgW + x
|
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 {
|
if isWallPixel {
|
||||||
|
|
@ -327,8 +456,8 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
bx := float64(runStart) * pixelToMM
|
bx := float64(runStart) * pixelToMM
|
||||||
by2 := float64(y) * pixelToMM
|
by2 := float64(y) * pixelToMM
|
||||||
bw := float64(x-runStart) * pixelToMM
|
bw := float64(x-runStart) * pixelToMM
|
||||||
bb := pixelToMM
|
bh := pixelToMM
|
||||||
AddBox(&newEncTris, bx, by2, bw, bb, totalH)
|
AddBox(&newEncTris, bx, by2, bw, bh, totalH)
|
||||||
runStart = -1
|
runStart = -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -339,6 +468,8 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
encTris = newEncTris
|
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: cover the entire enclosure footprint at the top
|
||||||
// Lid pixels = any pixel in wallMask OR clearanceMask OR boardMask
|
// Lid pixels = any pixel in wallMask OR clearanceMask OR boardMask
|
||||||
// Subtract courtyard regions (component footprints) from the lid
|
// Subtract courtyard regions (component footprints) from the lid
|
||||||
|
|
@ -380,7 +511,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
isLidPixel := false
|
isLidPixel := false
|
||||||
if x < imgW {
|
if x < imgW {
|
||||||
idx := y*imgW + x
|
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
|
// Cut lid where combined cutout exists inside the board area
|
||||||
isCutout := combinedCutout[idx] && boardMask[idx]
|
isCutout := combinedCutout[idx] && boardMask[idx]
|
||||||
isLidPixel = inFootprint && !isCutout
|
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
|
// (Peg calculations moved above)
|
||||||
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)
|
// TRAY (bottom — conforms to board shape)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
var trayTris [][3]Point
|
|
||||||
fmt.Println("Generating edge-cut conforming tray...")
|
fmt.Println("Generating edge-cut conforming tray...")
|
||||||
|
|
||||||
// Tray floor: covers the cavity area (clearanceMask + boardMask)
|
|
||||||
for y := 0; y < imgH; y++ {
|
for y := 0; y < imgH; y++ {
|
||||||
runStart := -1
|
runStartX := -1
|
||||||
|
curIsWall := false
|
||||||
|
curIsBump := false
|
||||||
for x := 0; x <= imgW; x++ {
|
for x := 0; x <= imgW; x++ {
|
||||||
isTrayPixel := false
|
isTrayFloor := false
|
||||||
|
isTrayWall := false
|
||||||
|
isTrayBump := false
|
||||||
if x < imgW {
|
if x < imgW {
|
||||||
idx := y*imgW + x
|
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 isTrayFloor {
|
||||||
if runStart == -1 {
|
if runStartX == -1 {
|
||||||
runStart = x
|
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 {
|
} else {
|
||||||
if runStart != -1 {
|
if runStartX != -1 {
|
||||||
bx := float64(runStart) * pixelToMM
|
bx := float64(runStartX) * pixelToMM
|
||||||
by := float64(y) * pixelToMM
|
by := float64(y) * pixelToMM
|
||||||
bw := float64(x-runStart) * pixelToMM
|
bw := float64(x-runStartX) * pixelToMM
|
||||||
bh := pixelToMM
|
bh := pixelToMM
|
||||||
AddBox(&trayTris, bx, by, bw, bh, trayFloor)
|
|
||||||
runStart = -1
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PCB support rim: inner edge of clearance zone (adjacent to board)
|
// (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.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))
|
fmt.Printf("Enclosure: %d triangles, Tray: %d triangles\n", len(encTris), len(trayTris))
|
||||||
|
|
||||||
_ = math.Pi // keep math import for Phase 2 cylindrical pegs
|
_ = math.Pi // keep math import for Phase 2 cylindrical pegs
|
||||||
|
|
|
||||||
55
gerber.go
55
gerber.go
|
|
@ -17,7 +17,7 @@ const (
|
||||||
ApertureCircle = "C"
|
ApertureCircle = "C"
|
||||||
ApertureRect = "R"
|
ApertureRect = "R"
|
||||||
ApertureObround = "O"
|
ApertureObround = "O"
|
||||||
// Add macros later if needed
|
AperturePolygon = "P"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Aperture struct {
|
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)
|
drawObround(img, x, y, w, h, c)
|
||||||
}
|
}
|
||||||
return
|
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
|
// Check for Macros
|
||||||
|
|
@ -699,6 +723,35 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
|
||||||
drawFilledPolygon(img, vertices)
|
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
|
case 20: // Vector Line
|
||||||
// Mods: Exposure, Width, StartX, StartY, EndX, EndY, Rotation
|
// Mods: Exposure, Width, StartX, StartY, EndX, EndY, Rotation
|
||||||
if len(prim.Modifiers) >= 7 {
|
if len(prim.Modifiers) >= 7 {
|
||||||
|
|
|
||||||
266
main.go
266
main.go
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/binary"
|
"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.
|
// 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
|
// It identifies the board area (inside the outline) and creates a wall of
|
||||||
// specified thickness around it.
|
// 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()
|
bounds := img.Bounds()
|
||||||
w := bounds.Max.X
|
w := bounds.Max.X
|
||||||
h := bounds.Max.Y
|
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 {
|
for len(wQueue) > 0 {
|
||||||
idx := wQueue[0]
|
idx := wQueue[0]
|
||||||
|
|
@ -342,14 +346,14 @@ func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([
|
||||||
nIdx := ny*w + nx
|
nIdx := ny*w + nx
|
||||||
if dist[nIdx] == -1 {
|
if dist[nIdx] == -1 {
|
||||||
dist[nIdx] = d + 1
|
dist[nIdx] = d + 1
|
||||||
isWall[nIdx] = true
|
wallDist[nIdx] = d + 1
|
||||||
wQueue = append(wQueue, nIdx)
|
wQueue = append(wQueue, nIdx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return isWall, isBoard
|
return wallDist, isBoard
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3]Point {
|
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
|
height := bounds.Max.Y
|
||||||
var triangles [][3]Point
|
var triangles [][3]Point
|
||||||
|
|
||||||
var wallMask []bool
|
var wallDist []int
|
||||||
var boardMask []bool
|
var boardMask []bool
|
||||||
if outlineImg != nil {
|
if outlineImg != nil {
|
||||||
fmt.Println("Computing wall mask...")
|
fmt.Println("Computing wall mask...")
|
||||||
wallMask, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM)
|
wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimization: Run-Length Encoding
|
// Optimization: Run-Length Encoding
|
||||||
|
|
@ -380,9 +384,9 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3
|
||||||
// Check wall
|
// Check wall
|
||||||
isWall := false
|
isWall := false
|
||||||
isInsideBoard := true
|
isInsideBoard := true
|
||||||
if wallMask != nil {
|
if wallDist != nil {
|
||||||
idx := y*width + x
|
idx := y*width + x
|
||||||
isWall = wallMask[idx]
|
isWall = wallDist[idx] >= 0
|
||||||
if boardMask != nil {
|
if boardMask != nil {
|
||||||
isInsideBoard = boardMask[idx]
|
isInsideBoard = boardMask[idx]
|
||||||
}
|
}
|
||||||
|
|
@ -450,14 +454,30 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3
|
||||||
|
|
||||||
// --- Logic ---
|
// --- Logic ---
|
||||||
|
|
||||||
func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) {
|
func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, error) {
|
||||||
outputPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".stl"
|
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)
|
// 1. Parse Gerber(s)
|
||||||
fmt.Printf("Parsing %s...\n", gerberPath)
|
fmt.Printf("Parsing %s...\n", gerberPath)
|
||||||
gf, err := ParseGerber(gerberPath)
|
gf, err := ParseGerber(gerberPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("error parsing gerber: %v", err)
|
return nil, fmt.Errorf("error parsing gerber: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var outlineGf *GerberFile
|
var outlineGf *GerberFile
|
||||||
|
|
@ -465,7 +485,7 @@ func processPCB(gerberPath, outlinePath string, cfg Config) (string, error) {
|
||||||
fmt.Printf("Parsing outline %s...\n", outlinePath)
|
fmt.Printf("Parsing outline %s...\n", outlinePath)
|
||||||
outlineGf, err = ParseGerber(outlinePath)
|
outlineGf, err = ParseGerber(outlinePath)
|
||||||
if err != nil {
|
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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var triangles [][3]Point
|
||||||
|
if wantsType("stl") || wantsType("scad") {
|
||||||
// 4. Generate Mesh
|
// 4. Generate Mesh
|
||||||
fmt.Println("Generating mesh...")
|
fmt.Println("Generating mesh...")
|
||||||
triangles := GenerateMeshFromImages(img, outlineImg, cfg)
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ---
|
// --- CLI ---
|
||||||
|
|
@ -549,7 +603,7 @@ func runCLI(cfg Config, args []string) {
|
||||||
outlinePath = args[1]
|
outlinePath = args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := processPCB(gerberPath, outlinePath, cfg)
|
_, err := processPCB(gerberPath, outlinePath, cfg, []string{"stl"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error: %v", err)
|
log.Fatalf("Error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -657,15 +711,39 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process
|
// Process
|
||||||
outSTL, err := processPCB(gerberPath, outlinePath, cfg)
|
exports := r.Form["exports"]
|
||||||
|
generatedPaths, err := processPCB(gerberPath, outlinePath, cfg, exports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error processing: %v", err)
|
log.Printf("Error processing: %v", err)
|
||||||
http.Error(w, fmt.Sprintf("Error processing PCB: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Error processing PCB: %v", err), http.StatusInternalServerError)
|
||||||
return
|
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
|
// 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) {
|
func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -838,6 +916,39 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
ecfg.OutlineBounds = &outlineBounds
|
ecfg.OutlineBounds = &outlineBounds
|
||||||
outlineImg := outlineGf.Render(ecfg.DPI, &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
|
// Auto-discover and render F.Courtyard from job file
|
||||||
var courtyardImg image.Image
|
var courtyardImg image.Image
|
||||||
if courtPath, ok := savedGerbers[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" {
|
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{
|
session := &EnclosureSession{
|
||||||
|
Exports: r.Form["exports"],
|
||||||
|
OutlineGf: outlineGf,
|
||||||
OutlineImg: outlineImg,
|
OutlineImg: outlineImg,
|
||||||
CourtyardImg: courtyardImg,
|
CourtyardImg: courtyardImg,
|
||||||
SoldermaskImg: soldermaskImg,
|
SoldermaskImg: soldermaskImg,
|
||||||
|
|
@ -887,6 +998,9 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
BoardW: actualBoardW,
|
BoardW: actualBoardW,
|
||||||
BoardH: actualBoardH,
|
BoardH: actualBoardH,
|
||||||
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
|
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
|
||||||
|
MinBX: float64(minBX),
|
||||||
|
MaxBX: float64(maxBX),
|
||||||
|
BoardCenterY: boardCenterY,
|
||||||
}
|
}
|
||||||
sessionsMu.Lock()
|
sessionsMu.Lock()
|
||||||
sessions[uuid] = session
|
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")
|
tmpl, err := template.ParseFS(staticFiles, "static/result.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
|
|
@ -906,13 +1020,17 @@ func renderResult(w http.ResponseWriter, message string, files []string) {
|
||||||
data := struct {
|
data := struct {
|
||||||
Message string
|
Message string
|
||||||
Files []string
|
Files []string
|
||||||
}{Message: message, Files: files}
|
BackURL string
|
||||||
|
ZipFile string
|
||||||
|
}{Message: message, Files: files, BackURL: backURL, ZipFile: zipFile}
|
||||||
tmpl.Execute(w, data)
|
tmpl.Execute(w, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Enclosure Preview Session ---
|
// --- Enclosure Preview Session ---
|
||||||
|
|
||||||
type EnclosureSession struct {
|
type EnclosureSession struct {
|
||||||
|
Exports []string
|
||||||
|
OutlineGf *GerberFile
|
||||||
OutlineImg image.Image
|
OutlineImg image.Image
|
||||||
CourtyardImg image.Image
|
CourtyardImg image.Image
|
||||||
SoldermaskImg image.Image
|
SoldermaskImg image.Image
|
||||||
|
|
@ -922,6 +1040,9 @@ type EnclosureSession struct {
|
||||||
BoardW float64
|
BoardW float64
|
||||||
BoardH float64
|
BoardH float64
|
||||||
TotalH float64
|
TotalH float64
|
||||||
|
MinBX float64
|
||||||
|
MaxBX float64
|
||||||
|
BoardCenterY float64
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -1031,36 +1152,83 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Printf("Side cutouts: %d\n", len(sideCutouts))
|
fmt.Printf("Side cutouts: %d\n", len(sideCutouts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate enclosure
|
var generatedFiles []string
|
||||||
fmt.Println("Generating enclosure with side cutouts...")
|
|
||||||
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config,
|
|
||||||
session.CourtyardImg, session.SoldermaskImg, sideCutouts)
|
|
||||||
|
|
||||||
// Save STLs
|
// Helper to check what formats were requested
|
||||||
|
wantsType := func(t string) bool {
|
||||||
|
for _, e := range session.Exports {
|
||||||
|
if e == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always default to STL if nothing is specified
|
||||||
|
if len(session.Exports) == 0 {
|
||||||
|
session.Exports = []string{"stl"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
encPath := filepath.Join("temp", id+"_enclosure.stl")
|
||||||
trayPath := filepath.Join("temp", id+"_tray.stl")
|
trayPath := filepath.Join("temp", id+"_tray.stl")
|
||||||
|
WriteSTL(encPath, result.EnclosureTriangles)
|
||||||
fmt.Printf("Saving enclosure to %s (%d triangles)...\n", encPath, len(result.EnclosureTriangles))
|
WriteSTL(trayPath, result.TrayTriangles)
|
||||||
if err := WriteSTL(encPath, result.EnclosureTriangles); err != nil {
|
generatedFiles = append(generatedFiles, filepath.Base(encPath), filepath.Base(trayPath))
|
||||||
http.Error(w, fmt.Sprintf("Error writing enclosure STL: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Saving tray to %s (%d triangles)...\n", trayPath, len(result.TrayTriangles))
|
// Process SCAD
|
||||||
if err := WriteSTL(trayPath, result.TrayTriangles); err != nil {
|
if wantsType("scad") {
|
||||||
http.Error(w, fmt.Sprintf("Error writing tray STL: %v", err), http.StatusInternalServerError)
|
fmt.Println("Generating Native SCAD scripts...")
|
||||||
return
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up session
|
// Process SVG
|
||||||
sessionsMu.Lock()
|
if wantsType("svg") && session.OutlineGf != nil {
|
||||||
delete(sessions, id)
|
fmt.Println("Generating SVG vector outline...")
|
||||||
sessionsMu.Unlock()
|
svgPath := filepath.Join("temp", id+"_outline.svg")
|
||||||
|
WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds)
|
||||||
|
generatedFiles = append(generatedFiles, filepath.Base(svgPath))
|
||||||
|
}
|
||||||
|
|
||||||
renderResult(w, "Your enclosure has been generated successfully.", []string{
|
// Process PNG
|
||||||
filepath.Base(encPath),
|
if wantsType("png") && session.OutlineImg != nil {
|
||||||
filepath.Base(trayPath),
|
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) {
|
func downloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
BIN
pcb-to-stencil
BIN
pcb-to-stencil
Binary file not shown.
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,28 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>PCB Tools by kennycoder + pszsh</h1>
|
<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">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="stencil">Stencil</button>
|
<button class="tab active" data-tab="stencil">Stencil</button>
|
||||||
<button class="tab" data-tab="enclosure">Enclosure</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) {
|
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';
|
document.getElementById('loading').style.display = 'block';
|
||||||
var btn = form.querySelector('.submit-btn');
|
var btn = form.querySelector('.submit-btn');
|
||||||
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerText = 'Processing...';
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,12 @@
|
||||||
<input type="hidden" name="sessionId" id="sessionId">
|
<input type="hidden" name="sessionId" id="sessionId">
|
||||||
<input type="hidden" name="sideCutouts" id="sideCutoutsInput">
|
<input type="hidden" name="sideCutouts" id="sideCutoutsInput">
|
||||||
<input type="hidden" name="conformToEdge" id="conformInput" value="true">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,25 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Success!</h2>
|
<h2>Success!</h2>
|
||||||
<p>{{.Message}}</p>
|
<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">
|
<ul class="download-list">
|
||||||
{{range .Files}}
|
{{range .Files}}
|
||||||
<li><a href="/download/{{.}}" class="btn">Download {{.}}</a></li>
|
<li><a href="/download/{{.}}" class="btn">Download {{.}}</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,92 @@ h2 {
|
||||||
margin-top: 0;
|
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 */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue