Expanded the concept, needs a few touches still
This commit is contained in:
parent
ac2ef32827
commit
bffb63b540
400
enclosure.go
400
enclosure.go
|
|
@ -14,6 +14,7 @@ type EnclosureConfig struct {
|
||||||
WallHeight float64 // mm (height of walls above PCB)
|
WallHeight float64 // mm (height of walls above PCB)
|
||||||
Clearance float64 // mm (gap between PCB and enclosure wall)
|
Clearance float64 // mm (gap between PCB and enclosure wall)
|
||||||
DPI float64
|
DPI float64
|
||||||
|
OutlineBounds *Bounds // gerber coordinate bounds for drill mapping
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default enclosure values
|
// Default enclosure values
|
||||||
|
|
@ -32,7 +33,7 @@ type EnclosureResult struct {
|
||||||
|
|
||||||
// SideCutout defines a cutout on a side wall face
|
// SideCutout defines a cutout on a side wall face
|
||||||
type SideCutout struct {
|
type SideCutout struct {
|
||||||
Face string // "north", "south", "east", "west"
|
Side int // 1-indexed side number (clockwise from top)
|
||||||
X, Y float64 // Position on the face in mm (from left edge, from bottom)
|
X, Y float64 // Position on the face in mm (from left edge, from bottom)
|
||||||
Width float64 // Width in mm
|
Width float64 // Width in mm
|
||||||
Height float64 // Height in mm
|
Height float64 // Height in mm
|
||||||
|
|
@ -105,9 +106,104 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
// For the lid, we want to cover everything within the outer wall boundary
|
// For the lid, we want to cover everything within the outer wall boundary
|
||||||
// Lid pixels = wallMask[i] || boardMask[i] || clearanceMask[i]
|
// Lid pixels = wallMask[i] || boardMask[i] || clearanceMask[i]
|
||||||
// (i.e., the entire footprint of the enclosure)
|
// (i.e., the entire footprint of the enclosure)
|
||||||
|
|
||||||
size := imgW * imgH
|
size := imgW * imgH
|
||||||
|
|
||||||
|
// Pre-compute board bounding box (needed for side cutout detection and removal tabs)
|
||||||
|
minBX, minBY := imgW, imgH
|
||||||
|
maxBX, maxBY := 0, 0
|
||||||
|
boardCenterX, boardCenterY := 0.0, 0.0
|
||||||
|
boardCount := 0
|
||||||
|
for y := 0; y < imgH; y++ {
|
||||||
|
for x := 0; x < imgW; x++ {
|
||||||
|
if boardMask[y*imgW+x] {
|
||||||
|
boardCenterX += float64(x)
|
||||||
|
boardCenterY += float64(y)
|
||||||
|
boardCount++
|
||||||
|
if x < minBX {
|
||||||
|
minBX = x
|
||||||
|
}
|
||||||
|
if x > maxBX {
|
||||||
|
maxBX = x
|
||||||
|
}
|
||||||
|
if y < minBY {
|
||||||
|
minBY = y
|
||||||
|
}
|
||||||
|
if y > maxBY {
|
||||||
|
maxBY = y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build wall-cutout mask from side cutouts
|
||||||
|
// For each side cutout, determine which wall pixels to subtract
|
||||||
|
wallCutoutMask := make([]bool, size)
|
||||||
|
if len(sideCutouts) > 0 && cfg.OutlineBounds != nil {
|
||||||
|
// Board bounding box in pixels
|
||||||
|
for y := 0; y < imgH; y++ {
|
||||||
|
for x := 0; x < imgW; x++ {
|
||||||
|
idx := y*imgW + x
|
||||||
|
if !(wallMask[idx] && !clearanceMask[idx] && !boardMask[idx]) {
|
||||||
|
continue // not a wall pixel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which side this wall pixel belongs to
|
||||||
|
// Find distance to each side of the board bounding box
|
||||||
|
dTop := math.Abs(float64(y) - float64(minBY))
|
||||||
|
dBottom := math.Abs(float64(y) - float64(maxBY))
|
||||||
|
dLeft := math.Abs(float64(x) - float64(minBX))
|
||||||
|
dRight := math.Abs(float64(x) - float64(maxBX))
|
||||||
|
|
||||||
|
sideNum := 0
|
||||||
|
minDist := dTop
|
||||||
|
sideNum = 1 // top
|
||||||
|
if dRight < minDist {
|
||||||
|
minDist = dRight
|
||||||
|
sideNum = 2 // right
|
||||||
|
}
|
||||||
|
if dBottom < minDist {
|
||||||
|
minDist = dBottom
|
||||||
|
sideNum = 3 // bottom
|
||||||
|
}
|
||||||
|
if dLeft < minDist {
|
||||||
|
sideNum = 4 // left
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position along the side in mm
|
||||||
|
var posAlongSide float64
|
||||||
|
var zPos float64
|
||||||
|
switch sideNum {
|
||||||
|
case 1: // top — position = X distance from left board edge
|
||||||
|
posAlongSide = float64(x-minBX) * pixelToMM
|
||||||
|
zPos = 0 // all Z heights for walls
|
||||||
|
case 2: // right — position = Y distance from top board edge
|
||||||
|
posAlongSide = float64(y-minBY) * pixelToMM
|
||||||
|
zPos = 0
|
||||||
|
case 3: // bottom — position = X distance from left board edge
|
||||||
|
posAlongSide = float64(x-minBX) * pixelToMM
|
||||||
|
zPos = 0
|
||||||
|
case 4: // left — position = Y distance from top board edge
|
||||||
|
posAlongSide = float64(y-minBY) * pixelToMM
|
||||||
|
zPos = 0
|
||||||
|
}
|
||||||
|
_ = zPos
|
||||||
|
|
||||||
|
// Check all cutouts for this side
|
||||||
|
for _, c := range sideCutouts {
|
||||||
|
if c.Side != sideNum {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if this pixel's position falls within the cutout X range
|
||||||
|
if posAlongSide >= c.X && posAlongSide <= c.X+c.Width {
|
||||||
|
wallCutoutMask[idx] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("Wall cutout mask: applied %d side cutouts\n", len(sideCutouts))
|
||||||
|
}
|
||||||
|
|
||||||
// Generate walls using RLE
|
// Generate walls using RLE
|
||||||
for y := 0; y < imgH; y++ {
|
for y := 0; y < imgH; y++ {
|
||||||
runStart := -1
|
runStart := -1
|
||||||
|
|
@ -135,6 +231,114 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now subtract side cutout regions from the walls
|
||||||
|
// For each cutout, we remove wall material in the Z range [cutout.Y, cutout.Y+cutout.H]
|
||||||
|
// by NOT generating boxes in that region. Since we already generated full-height walls,
|
||||||
|
// we rebuild wall columns where cutouts exist with gaps.
|
||||||
|
if len(sideCutouts) > 0 {
|
||||||
|
var cutoutEncTris [][3]Point
|
||||||
|
for y := 0; y < imgH; y++ {
|
||||||
|
runStart := -1
|
||||||
|
for x := 0; x <= imgW; x++ {
|
||||||
|
isCutWall := false
|
||||||
|
if x < imgW {
|
||||||
|
idx := y*imgW + x
|
||||||
|
isCutWall = wallCutoutMask[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCutWall {
|
||||||
|
if runStart == -1 {
|
||||||
|
runStart = x
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if runStart != -1 {
|
||||||
|
// This run of wall pixels has cutouts — find which cutout
|
||||||
|
midX := (runStart + x) / 2
|
||||||
|
midIdx := y*imgW + midX
|
||||||
|
_ = midIdx
|
||||||
|
|
||||||
|
// Find the dominant side and cutout for this run
|
||||||
|
dTop := math.Abs(float64(y) - float64(minBY))
|
||||||
|
dBottom := math.Abs(float64(y) - float64(maxBY))
|
||||||
|
dLeft := math.Abs(float64(midX) - float64(minBX))
|
||||||
|
dRight := math.Abs(float64(midX) - float64(maxBX))
|
||||||
|
|
||||||
|
sideNum := 1
|
||||||
|
minDist := dTop
|
||||||
|
if dRight < minDist {
|
||||||
|
minDist = dRight
|
||||||
|
sideNum = 2
|
||||||
|
}
|
||||||
|
if dBottom < minDist {
|
||||||
|
minDist = dBottom
|
||||||
|
sideNum = 3
|
||||||
|
}
|
||||||
|
if dLeft < minDist {
|
||||||
|
sideNum = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
bx := float64(runStart) * pixelToMM
|
||||||
|
by2 := float64(y) * pixelToMM
|
||||||
|
bw := float64(x-runStart) * pixelToMM
|
||||||
|
bh := pixelToMM
|
||||||
|
|
||||||
|
// Find the matching cutout for this side
|
||||||
|
for _, c := range sideCutouts {
|
||||||
|
if c.Side != sideNum {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Wall below cutout: from 0 to cutout.Y
|
||||||
|
if c.Y > 0.1 {
|
||||||
|
addBoxAtZ(&cutoutEncTris, bx, by2, 0, bw, bh, c.Y)
|
||||||
|
}
|
||||||
|
// Wall above cutout: from cutout.Y+cutout.H to totalH
|
||||||
|
cutTop := c.Y + c.Height
|
||||||
|
if cutTop < totalH-0.1 {
|
||||||
|
addBoxAtZ(&cutoutEncTris, bx, by2, cutTop, bw, bh, totalH-cutTop)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
runStart = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replace full-height walls with cutout walls
|
||||||
|
// First remove the original full-height boxes for cutout pixels
|
||||||
|
// (They were already added above, so we need to rebuild)
|
||||||
|
// Simpler approach: rebuild encTris without cutout regions, then add partial walls
|
||||||
|
var newEncTris [][3]Point
|
||||||
|
// Re-generate walls, skipping cutout pixels
|
||||||
|
for y := 0; y < imgH; y++ {
|
||||||
|
runStart := -1
|
||||||
|
for x := 0; x <= imgW; x++ {
|
||||||
|
isWallPixel := false
|
||||||
|
if x < imgW {
|
||||||
|
idx := y*imgW + x
|
||||||
|
isWallPixel = wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] && !wallCutoutMask[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if isWallPixel {
|
||||||
|
if runStart == -1 {
|
||||||
|
runStart = x
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if runStart != -1 {
|
||||||
|
bx := float64(runStart) * pixelToMM
|
||||||
|
by2 := float64(y) * pixelToMM
|
||||||
|
bw := float64(x-runStart) * pixelToMM
|
||||||
|
bb := pixelToMM
|
||||||
|
AddBox(&newEncTris, bx, by2, bw, bb, totalH)
|
||||||
|
runStart = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add the partial (cut) wall sections
|
||||||
|
newEncTris = append(newEncTris, cutoutEncTris...)
|
||||||
|
encTris = newEncTris
|
||||||
|
}
|
||||||
|
|
||||||
// Lid: cover the entire enclosure footprint at the top
|
// Lid: 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
|
||||||
|
|
@ -198,6 +402,59 @@ 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)
|
// Snap ledges: on the inside of the walls (at the clearance boundary)
|
||||||
// These are pixels that are in clearanceMask but adjacent to wallMask
|
// These are pixels that are in clearanceMask but adjacent to wallMask
|
||||||
|
|
@ -254,7 +511,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
isTrayPixel := false
|
isTrayPixel := false
|
||||||
if x < imgW {
|
if x < imgW {
|
||||||
idx := y*imgW + x
|
idx := y*imgW + x
|
||||||
isTrayPixel = clearanceMask[idx] || boardMask[idx]
|
isTrayPixel = (clearanceMask[idx] || boardMask[idx]) && !pegMask[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
if isTrayPixel {
|
if isTrayPixel {
|
||||||
|
|
@ -355,31 +612,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
// Removal tabs: INTERNAL — thin finger grips that sit inside the tray cavity
|
// Removal tabs: INTERNAL — thin finger grips that sit inside the tray cavity
|
||||||
// User can push on them from below to pop the tray out
|
// User can push on them from below to pop the tray out
|
||||||
fmt.Println("Adding internal removal tabs...")
|
fmt.Println("Adding internal removal tabs...")
|
||||||
boardCenterX, boardCenterY := 0.0, 0.0
|
// (uses pre-computed board bounding box: minBX, minBY, maxBX, maxBY, boardCenterY)
|
||||||
boardCount := 0
|
|
||||||
minBX, minBY := imgW, imgH
|
|
||||||
maxBX, maxBY := 0, 0
|
|
||||||
for y := 0; y < imgH; y++ {
|
|
||||||
for x := 0; x < imgW; x++ {
|
|
||||||
if boardMask[y*imgW+x] {
|
|
||||||
boardCenterX += float64(x)
|
|
||||||
boardCenterY += float64(y)
|
|
||||||
boardCount++
|
|
||||||
if x < minBX {
|
|
||||||
minBX = x
|
|
||||||
}
|
|
||||||
if x > maxBX {
|
|
||||||
maxBX = x
|
|
||||||
}
|
|
||||||
if y < minBY {
|
|
||||||
minBY = y
|
|
||||||
}
|
|
||||||
if y > maxBY {
|
|
||||||
maxBY = y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if boardCount > 0 {
|
if boardCount > 0 {
|
||||||
boardCenterY /= float64(boardCount)
|
boardCenterY /= float64(boardCount)
|
||||||
tabCenterY := boardCenterY * pixelToMM
|
tabCenterY := boardCenterY * pixelToMM
|
||||||
|
|
@ -394,32 +627,72 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
addBoxAtZ(&trayTris, rightInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH)
|
addBoxAtZ(&trayTris, rightInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embossed lip: a thin raised ridge around the full tray perimeter
|
// Embossed lip: a raised ridge around the tray perimeter, 0.5mm thick
|
||||||
// This lip mates against the inside face of the enclosure walls for a tight fit
|
// This lip mates against a recess in the enclosure for a tight snap fit
|
||||||
fmt.Println("Adding embossed lip...")
|
fmt.Println("Adding embossed lip...")
|
||||||
lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening
|
lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening
|
||||||
lipW := 0.6 // thin lip wall
|
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 y := 1; y < imgH-1; y++ {
|
||||||
runStart := -1
|
for x := 1; x < imgW-1; x++ {
|
||||||
for x := 0; x <= imgW; x++ {
|
|
||||||
isLipPixel := false
|
|
||||||
if x > 0 && x < imgW-1 {
|
|
||||||
idx := y*imgW + x
|
idx := y*imgW + x
|
||||||
if clearanceMask[idx] && !boardMask[idx] {
|
if clearanceMask[idx] && !boardMask[idx] {
|
||||||
// Lip sits at the outer edge of the clearance zone (touching the wall)
|
|
||||||
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
|
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
|
||||||
ni := (y+d[1])*imgW + (x + d[0])
|
ni := (y+d[1])*imgW + (x + d[0])
|
||||||
if ni >= 0 && ni < size {
|
if ni >= 0 && ni < size {
|
||||||
if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] {
|
if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] {
|
||||||
isLipPixel = true
|
lipCoreMask[idx] = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isLipPixel {
|
// 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 {
|
if runStart == -1 {
|
||||||
runStart = x
|
runStart = x
|
||||||
}
|
}
|
||||||
|
|
@ -429,13 +702,62 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
||||||
by := float64(y) * pixelToMM
|
by := float64(y) * pixelToMM
|
||||||
bw := float64(x-runStart) * pixelToMM
|
bw := float64(x-runStart) * pixelToMM
|
||||||
bh := pixelToMM
|
bh := pixelToMM
|
||||||
_ = lipW // lip width is one pixel at this DPI
|
|
||||||
addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, lipH)
|
addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, lipH)
|
||||||
runStart = -1
|
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GerberJob represents a KiCad .gbrjob file
|
||||||
|
type GerberJob struct {
|
||||||
|
Header struct {
|
||||||
|
GenerationSoftware struct {
|
||||||
|
Vendor string `json:"Vendor"`
|
||||||
|
Application string `json:"Application"`
|
||||||
|
Version string `json:"Version"`
|
||||||
|
} `json:"GenerationSoftware"`
|
||||||
|
} `json:"Header"`
|
||||||
|
GeneralSpecs struct {
|
||||||
|
ProjectId struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
} `json:"ProjectId"`
|
||||||
|
Size struct {
|
||||||
|
X float64 `json:"X"`
|
||||||
|
Y float64 `json:"Y"`
|
||||||
|
} `json:"Size"`
|
||||||
|
BoardThickness float64 `json:"BoardThickness"`
|
||||||
|
} `json:"GeneralSpecs"`
|
||||||
|
FilesAttributes []struct {
|
||||||
|
Path string `json:"Path"`
|
||||||
|
FileFunction string `json:"FileFunction"`
|
||||||
|
FilePolarity string `json:"FilePolarity"`
|
||||||
|
} `json:"FilesAttributes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GerberJobResult contains the auto-discovered file assignments
|
||||||
|
type GerberJobResult struct {
|
||||||
|
ProjectName string
|
||||||
|
BoardWidth float64 // mm
|
||||||
|
BoardHeight float64 // mm
|
||||||
|
BoardThickness float64 // mm
|
||||||
|
EdgeCutsFile string // Profile
|
||||||
|
FabFile string // AssemblyDrawing,Top
|
||||||
|
CourtyardFile string // matches courtyard naming
|
||||||
|
SoldermaskFile string // matches mask naming
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseGerberJob parses a .gbrjob file and returns auto-discovered file mappings
|
||||||
|
func ParseGerberJob(filename string) (*GerberJobResult, error) {
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read gbrjob: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var job GerberJob
|
||||||
|
if err := json.Unmarshal(data, &job); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse gbrjob JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &GerberJobResult{
|
||||||
|
ProjectName: job.GeneralSpecs.ProjectId.Name,
|
||||||
|
BoardWidth: job.GeneralSpecs.Size.X,
|
||||||
|
BoardHeight: job.GeneralSpecs.Size.Y,
|
||||||
|
BoardThickness: job.GeneralSpecs.BoardThickness,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map FileFunction to our layer types
|
||||||
|
for _, f := range job.FilesAttributes {
|
||||||
|
fn := strings.ToLower(f.FileFunction)
|
||||||
|
path := f.Path
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case fn == "profile":
|
||||||
|
result.EdgeCutsFile = path
|
||||||
|
case strings.HasPrefix(fn, "assemblydrawing"):
|
||||||
|
// F.Fab = AssemblyDrawing,Top
|
||||||
|
if strings.Contains(fn, "top") {
|
||||||
|
result.FabFile = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also match by filename patterns for courtyard/mask
|
||||||
|
lp := strings.ToLower(path)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lp, "courtyard") || strings.Contains(lp, "courty"):
|
||||||
|
if strings.Contains(lp, "f_courty") || strings.Contains(lp, "f.courty") || strings.Contains(lp, "-f_courty") {
|
||||||
|
result.CourtyardFile = path
|
||||||
|
}
|
||||||
|
case strings.Contains(lp, "mask") || strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask"):
|
||||||
|
if strings.Contains(lp, "f_mask") || strings.Contains(lp, "f.mask") || strings.Contains(lp, "-f_mask") {
|
||||||
|
result.SoldermaskFile = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("GerberJob: project=%s, board=%.1fx%.1fmm, thickness=%.1fmm\n",
|
||||||
|
result.ProjectName, result.BoardWidth, result.BoardHeight, result.BoardThickness)
|
||||||
|
fmt.Printf(" EdgeCuts: %s\n", result.EdgeCutsFile)
|
||||||
|
fmt.Printf(" F.Fab: %s\n", result.FabFile)
|
||||||
|
fmt.Printf(" F.Courtyard: %s\n", result.CourtyardFile)
|
||||||
|
fmt.Printf(" F.Mask: %s\n", result.SoldermaskFile)
|
||||||
|
|
||||||
|
if result.EdgeCutsFile == "" {
|
||||||
|
return nil, fmt.Errorf("no Edge.Cuts (Profile) layer found in gbrjob")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
117
main.go
117
main.go
|
|
@ -681,15 +681,11 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
uuid := randomID()
|
uuid := randomID()
|
||||||
|
|
||||||
// Parse params
|
// Parse params
|
||||||
pcbThickness, _ := strconv.ParseFloat(r.FormValue("pcbThickness"), 64)
|
|
||||||
wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64)
|
wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64)
|
||||||
wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64)
|
wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64)
|
||||||
clearance, _ := strconv.ParseFloat(r.FormValue("clearance"), 64)
|
clearance, _ := strconv.ParseFloat(r.FormValue("clearance"), 64)
|
||||||
dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64)
|
dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64)
|
||||||
|
|
||||||
if pcbThickness == 0 {
|
|
||||||
pcbThickness = DefaultPCBThickness
|
|
||||||
}
|
|
||||||
if wallThickness == 0 {
|
if wallThickness == 0 {
|
||||||
wallThickness = DefaultEncWallThick
|
wallThickness = DefaultEncWallThick
|
||||||
}
|
}
|
||||||
|
|
@ -700,7 +696,36 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
clearance = DefaultClearance
|
clearance = DefaultClearance
|
||||||
}
|
}
|
||||||
if dpi == 0 {
|
if dpi == 0 {
|
||||||
dpi = 500
|
dpi = 600
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle GerberJob file (required)
|
||||||
|
gbrjobFile, gbrjobHeader, err := r.FormFile("gbrjob")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Gerber job file (.gbrjob) is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer gbrjobFile.Close()
|
||||||
|
|
||||||
|
gbrjobPath := filepath.Join(tempDir, uuid+"_"+gbrjobHeader.Filename)
|
||||||
|
jf, err := os.Create(gbrjobPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error saving file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.Copy(jf, gbrjobFile)
|
||||||
|
jf.Close()
|
||||||
|
|
||||||
|
jobResult, err := ParseGerberJob(gbrjobPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Error parsing gbrjob: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill PCB thickness from job file
|
||||||
|
pcbThickness := jobResult.BoardThickness
|
||||||
|
if pcbThickness == 0 {
|
||||||
|
pcbThickness = DefaultPCBThickness
|
||||||
}
|
}
|
||||||
|
|
||||||
ecfg := EnclosureConfig{
|
ecfg := EnclosureConfig{
|
||||||
|
|
@ -711,22 +736,33 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
DPI: dpi,
|
DPI: dpi,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Outline File (required)
|
// Handle uploaded gerber files (multi-select)
|
||||||
outlineFile, outlineHeader, err := r.FormFile("outline")
|
// Save all gerbers, then match to layers from job file
|
||||||
|
gerberFiles := r.MultipartForm.File["gerbers"]
|
||||||
|
savedGerbers := make(map[string]string) // filename → saved path
|
||||||
|
for _, fh := range gerberFiles {
|
||||||
|
f, err := fh.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Board outline gerber is required", http.StatusBadRequest)
|
continue
|
||||||
return
|
}
|
||||||
|
savePath := filepath.Join(tempDir, uuid+"_"+fh.Filename)
|
||||||
|
sf, err := os.Create(savePath)
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
io.Copy(sf, f)
|
||||||
|
sf.Close()
|
||||||
|
f.Close()
|
||||||
|
savedGerbers[fh.Filename] = savePath
|
||||||
}
|
}
|
||||||
defer outlineFile.Close()
|
|
||||||
|
|
||||||
outlinePath := filepath.Join(tempDir, uuid+"_outline"+filepath.Ext(outlineHeader.Filename))
|
// Find the outline (Edge.Cuts) gerber
|
||||||
of, err := os.Create(outlinePath)
|
outlinePath, ok := savedGerbers[jobResult.EdgeCutsFile]
|
||||||
if err != nil {
|
if !ok {
|
||||||
http.Error(w, "Server error saving file", http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Edge.Cuts file '%s' not found in uploaded gerbers. Upload all .gbr files.", jobResult.EdgeCutsFile), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
io.Copy(of, outlineFile)
|
|
||||||
of.Close()
|
|
||||||
|
|
||||||
// Handle PTH Drill File (optional)
|
// Handle PTH Drill File (optional)
|
||||||
var drillHoles []DrillHole
|
var drillHoles []DrillHole
|
||||||
|
|
@ -786,6 +822,10 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
outlineBounds := outlineGf.CalculateBounds()
|
outlineBounds := outlineGf.CalculateBounds()
|
||||||
|
|
||||||
|
// Save actual board dimensions before adding margins
|
||||||
|
actualBoardW := outlineBounds.MaxX - outlineBounds.MinX
|
||||||
|
actualBoardH := outlineBounds.MaxY - outlineBounds.MinY
|
||||||
|
|
||||||
// Add margin for enclosure walls
|
// Add margin for enclosure walls
|
||||||
margin := ecfg.WallThickness + ecfg.Clearance + 5.0
|
margin := ecfg.WallThickness + ecfg.Clearance + 5.0
|
||||||
outlineBounds.MinX -= margin
|
outlineBounds.MinX -= margin
|
||||||
|
|
@ -795,18 +835,12 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Render outline to image
|
// Render outline to image
|
||||||
fmt.Println("Rendering outline...")
|
fmt.Println("Rendering outline...")
|
||||||
|
ecfg.OutlineBounds = &outlineBounds
|
||||||
outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds)
|
outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds)
|
||||||
|
|
||||||
// Handle F.Courtyard Gerber (optional) — for lid cutouts
|
// Auto-discover and render F.Courtyard from job file
|
||||||
var courtyardImg image.Image
|
var courtyardImg image.Image
|
||||||
courtyardFile, courtyardHeader, err := r.FormFile("courtyard")
|
if courtPath, ok := savedGerbers[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" {
|
||||||
if err == nil {
|
|
||||||
defer courtyardFile.Close()
|
|
||||||
courtPath := filepath.Join(tempDir, uuid+"_courtyard"+filepath.Ext(courtyardHeader.Filename))
|
|
||||||
cf, err := os.Create(courtPath)
|
|
||||||
if err == nil {
|
|
||||||
io.Copy(cf, courtyardFile)
|
|
||||||
cf.Close()
|
|
||||||
courtGf, err := ParseGerber(courtPath)
|
courtGf, err := ParseGerber(courtPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Could not parse courtyard gerber: %v", err)
|
log.Printf("Warning: Could not parse courtyard gerber: %v", err)
|
||||||
|
|
@ -815,17 +849,10 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds)
|
courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Handle F.Mask (Soldermask) Gerber (optional) — for minimum pad cutouts
|
// Auto-discover and render F.Mask from job file
|
||||||
var soldermaskImg image.Image
|
var soldermaskImg image.Image
|
||||||
maskFile, maskHeader, err := r.FormFile("soldermask")
|
if maskPath, ok := savedGerbers[jobResult.SoldermaskFile]; ok && jobResult.SoldermaskFile != "" {
|
||||||
if err == nil {
|
|
||||||
defer maskFile.Close()
|
|
||||||
maskPath := filepath.Join(tempDir, uuid+"_mask"+filepath.Ext(maskHeader.Filename))
|
|
||||||
mf, err := os.Create(maskPath)
|
|
||||||
if err == nil {
|
|
||||||
io.Copy(mf, maskFile)
|
|
||||||
mf.Close()
|
|
||||||
maskGf, err := ParseGerber(maskPath)
|
maskGf, err := ParseGerber(maskPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Could not parse soldermask gerber: %v", err)
|
log.Printf("Warning: Could not parse soldermask gerber: %v", err)
|
||||||
|
|
@ -834,6 +861,18 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds)
|
soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also try F.Fab as fallback courtyard (many boards have F.Fab but not F.Courtyard)
|
||||||
|
if courtyardImg == nil && jobResult.FabFile != "" {
|
||||||
|
if fabPath, ok := savedGerbers[jobResult.FabFile]; ok {
|
||||||
|
fabGf, err := ParseGerber(fabPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Could not parse fab gerber: %v", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Rendering F.Fab layer as courtyard fallback...")
|
||||||
|
courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate enclosure (no side cutouts yet — added in preview flow)
|
// Generate enclosure (no side cutouts yet — added in preview flow)
|
||||||
|
|
@ -845,8 +884,8 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
DrillHoles: filteredHoles,
|
DrillHoles: filteredHoles,
|
||||||
Config: ecfg,
|
Config: ecfg,
|
||||||
OutlineBounds: outlineBounds,
|
OutlineBounds: outlineBounds,
|
||||||
BoardW: float64(outlineImg.Bounds().Max.X) * (25.4 / ecfg.DPI),
|
BoardW: actualBoardW,
|
||||||
BoardH: float64(outlineImg.Bounds().Max.Y) * (25.4 / ecfg.DPI),
|
BoardH: actualBoardH,
|
||||||
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
|
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
|
||||||
}
|
}
|
||||||
sessionsMu.Lock()
|
sessionsMu.Lock()
|
||||||
|
|
@ -968,7 +1007,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
cutoutsJSON := r.FormValue("sideCutouts")
|
cutoutsJSON := r.FormValue("sideCutouts")
|
||||||
if cutoutsJSON != "" && cutoutsJSON != "[]" {
|
if cutoutsJSON != "" && cutoutsJSON != "[]" {
|
||||||
var rawCutouts []struct {
|
var rawCutouts []struct {
|
||||||
Face string `json:"face"`
|
Side int `json:"side"`
|
||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
W float64 `json:"w"`
|
W float64 `json:"w"`
|
||||||
|
|
@ -980,7 +1019,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
} else {
|
} else {
|
||||||
for _, rc := range rawCutouts {
|
for _, rc := range rawCutouts {
|
||||||
sideCutouts = append(sideCutouts, SideCutout{
|
sideCutouts = append(sideCutouts, SideCutout{
|
||||||
Face: rc.Face,
|
Side: rc.Side,
|
||||||
X: rc.X,
|
X: rc.X,
|
||||||
Y: rc.Y,
|
Y: rc.Y,
|
||||||
Width: rc.W,
|
Width: rc.W,
|
||||||
|
|
|
||||||
BIN
pcb-to-stencil
BIN
pcb-to-stencil
Binary file not shown.
|
|
@ -4,13 +4,13 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PCB Tools by kennycoder</title>
|
<title>PCB Tools by kennycoder + pszsh</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>PCB Tools by kennycoder</h1>
|
<h1>PCB Tools by kennycoder + pszsh</h1>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="stencil">Stencil</button>
|
<button class="tab active" data-tab="stencil">Stencil</button>
|
||||||
|
|
@ -65,77 +65,58 @@
|
||||||
<!-- Tab 2: Enclosure -->
|
<!-- Tab 2: Enclosure -->
|
||||||
<div class="tab-content" id="tab-enclosure">
|
<div class="tab-content" id="tab-enclosure">
|
||||||
<form action="/upload-enclosure" method="post" enctype="multipart/form-data">
|
<form action="/upload-enclosure" method="post" enctype="multipart/form-data">
|
||||||
<div class="form-group tooltip-wrap">
|
<div class="form-group">
|
||||||
<label for="enc-outline">Board Outline Gerber (Required)</label>
|
<label for="enc-gbrjob">Gerber Job File (Required) <span class="help-btn"
|
||||||
<input type="file" id="enc-outline" name="outline" accept=".gbr,.gko,.gm1" required>
|
onclick="document.getElementById('help-gbrjob').classList.toggle('visible')">(?)</span></label>
|
||||||
<div class="tooltip">Layers to export for Gerbers
|
<input type="file" id="enc-gbrjob" name="gbrjob" accept=".gbrjob" required>
|
||||||
<hr>• Edge.Cuts (board outline)
|
<div class="hint">Auto-detects board layers, dimensions, and PCB thickness.</div>
|
||||||
|
<div class="help-popup" id="help-gbrjob">
|
||||||
|
<div class="help-popup-close"
|
||||||
|
onclick="document.getElementById('help-gbrjob').classList.remove('visible')">✕</div>
|
||||||
|
<img src="/static/screenshot_gerber_output_dialogue.png" alt="KiCad plot settings">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="enc-gerbers">Gerber Files (Required)</label>
|
||||||
|
<input type="file" id="enc-gerbers" name="gerbers" accept=".gbr,.gko,.gm1" multiple required>
|
||||||
|
<div class="hint">Select all exported .gbr files from the same folder.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group tooltip-wrap">
|
<div class="form-group tooltip-wrap">
|
||||||
<label for="enc-drill">PTH Drill File (Optional)</label>
|
<label for="enc-drill">PTH Drill File (Optional)</label>
|
||||||
<input type="file" id="enc-drill" name="drill" accept=".drl,.xln,.txt">
|
<input type="file" id="enc-drill" name="drill" accept=".drl,.xln,.txt">
|
||||||
<div class="hint">Component through-holes (vias auto-filtered).</div>
|
<div class="hint">Component through-holes (vias auto-filtered).</div>
|
||||||
<div class="tooltip">Layers to export for DRL
|
<div class="tooltip">Use the <b>PTH</b> file from KiCad's drill export.</div>
|
||||||
<hr>• Use the <b>PTH</b> file (Plated Through-Hole)<br>• Vias are automatically filtered out
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group tooltip-wrap">
|
<div class="form-group tooltip-wrap">
|
||||||
<label for="enc-npth">NPTH Drill File (Optional)</label>
|
<label for="enc-npth">NPTH Drill File (Optional)</label>
|
||||||
<input type="file" id="enc-npth" name="npth" accept=".drl,.xln,.txt">
|
<input type="file" id="enc-npth" name="npth" accept=".drl,.xln,.txt">
|
||||||
<div class="hint">Mounting holes — become pegs in enclosure.</div>
|
<div class="hint">Mounting holes — become alignment pegs.</div>
|
||||||
<div class="tooltip">Layers to export for DRL
|
<div class="tooltip">Use the <b>NPTH</b> file — these become alignment pegs.</div>
|
||||||
<hr>• Use the <b>NPTH</b> file (Non-Plated Through-Hole)<br>• These become alignment pegs
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group tooltip-wrap">
|
|
||||||
<label for="enc-courtyard">F.Courtyard Gerber (Optional)</label>
|
|
||||||
<input type="file" id="enc-courtyard" name="courtyard" accept=".gbr">
|
|
||||||
<div class="hint">Component outlines — used for lid cutouts.</div>
|
|
||||||
<div class="tooltip">Layers to export for Gerbers
|
|
||||||
<hr>• <b>F.Courtyard</b> (front courtyard)<br>• ☑ Exclude DNP footprints in KiCad plot
|
|
||||||
dialog<br>• Cutouts generated where components exist
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group tooltip-wrap">
|
|
||||||
<label for="enc-mask">F.Mask Gerber (Optional)</label>
|
|
||||||
<input type="file" id="enc-mask" name="soldermask" accept=".gbr">
|
|
||||||
<div class="hint">Soldermask openings — minimum pad cutouts.</div>
|
|
||||||
<div class="tooltip">Layers to export for Gerbers
|
|
||||||
<hr>• <b>F.Mask</b> (front soldermask)<br>• Shows exact pad areas that need cutouts<br>• ☑
|
|
||||||
Exclude DNP footprints in KiCad plot dialog
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
|
||||||
<label for="enc-pcbThickness">PCB Thickness (mm)</label>
|
|
||||||
<input type="number" id="enc-pcbThickness" name="pcbThickness" value="1.6" step="0.1">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="enc-wallThickness">Wall Thickness (mm)</label>
|
<label for="enc-wallThickness">Wall Thickness (mm)</label>
|
||||||
<input type="number" id="enc-wallThickness" name="wallThickness" value="1.5" step="0.1">
|
<input type="number" id="enc-wallThickness" name="wallThickness" value="1.5" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="enc-wallHeight">Wall Height (mm)</label>
|
<label for="enc-wallHeight">Wall Height (mm)</label>
|
||||||
<input type="number" id="enc-wallHeight" name="wallHeight" value="10.0" step="0.5">
|
<input type="number" id="enc-wallHeight" name="wallHeight" value="10.0" step="0.5">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="enc-clearance">Clearance (mm)</label>
|
<label for="enc-clearance">Clearance (mm)</label>
|
||||||
<input type="number" id="enc-clearance" name="clearance" value="0.3" step="0.05">
|
<input type="number" id="enc-clearance" name="clearance" value="0.3" step="0.05">
|
||||||
<div class="hint">Gap between PCB edge and enclosure wall.</div>
|
<div class="hint">Gap between PCB edge and enclosure wall.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="enc-dpi">DPI</label>
|
<label for="enc-dpi">DPI</label>
|
||||||
<input type="number" id="enc-dpi" name="dpi" value="500" step="100">
|
<input type="number" id="enc-dpi" name="dpi" value="600" step="100">
|
||||||
|
<div class="hint">Lower = smaller file. 600 recommended.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="submit-btn">Generate Enclosure</button>
|
<button type="submit" class="submit-btn">Generate Enclosure</button>
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,12 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.face-tab {
|
.face-tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
@ -175,6 +177,55 @@
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-preset {
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-preset:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coord-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coord-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-center {
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #f9fafb;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-center:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
.unit-note {
|
.unit-note {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|
@ -188,7 +239,7 @@
|
||||||
<div class="container preview-container">
|
<div class="container preview-container">
|
||||||
<h1>Enclosure Preview</h1>
|
<h1>Enclosure Preview</h1>
|
||||||
|
|
||||||
<!-- Top-down board view -->
|
<!-- Top-down board view with numbered side labels -->
|
||||||
<div class="board-canvas-wrap">
|
<div class="board-canvas-wrap">
|
||||||
<canvas id="boardCanvas" width="600" height="400"></canvas>
|
<canvas id="boardCanvas" width="600" height="400"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -212,24 +263,31 @@
|
||||||
|
|
||||||
<!-- Side cutout editor -->
|
<!-- Side cutout editor -->
|
||||||
<div class="side-editor" id="sideEditor">
|
<div class="side-editor" id="sideEditor">
|
||||||
<div class="face-tabs" id="faceTabs">
|
<div class="face-tabs" id="faceTabs"></div>
|
||||||
<div class="face-tab active" data-face="north">North</div>
|
|
||||||
<div class="face-tab" data-face="east">East</div>
|
|
||||||
<div class="face-tab" data-face="south">South</div>
|
|
||||||
<div class="face-tab" data-face="west">West</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="side-canvas-wrap" id="sideCanvasWrap">
|
<div class="side-canvas-wrap" id="sideCanvasWrap">
|
||||||
<canvas id="sideCanvas" width="700" height="200"></canvas>
|
<canvas id="sideCanvas" width="700" height="200"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="preset-row">
|
||||||
|
<button type="button" class="btn-preset" id="btnPresetUSBC">⚡ USB-C (9 × 3.26mm r=1.3)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="coord-row">
|
<div class="coord-row">
|
||||||
<div class="form-group">
|
<div class="coord-field">
|
||||||
|
<div class="coord-label-row">
|
||||||
<label for="cutX">X (mm)</label>
|
<label for="cutX">X (mm)</label>
|
||||||
|
<button type="button" class="btn-center" id="btnCenterX" title="Center horizontally">⟷
|
||||||
|
center</button>
|
||||||
|
</div>
|
||||||
<input type="number" id="cutX" value="0" step="0.01">
|
<input type="number" id="cutX" value="0" step="0.01">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="coord-field">
|
||||||
|
<div class="coord-label-row">
|
||||||
<label for="cutY">Y (mm)</label>
|
<label for="cutY">Y (mm)</label>
|
||||||
|
<button type="button" class="btn-center" id="btnCenterY" title="Center vertically">⟷
|
||||||
|
center</button>
|
||||||
|
</div>
|
||||||
<input type="number" id="cutY" value="0" step="0.01">
|
<input type="number" id="cutY" value="0" step="0.01">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -244,7 +302,7 @@
|
||||||
<div class="coord-row">
|
<div class="coord-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="cutR">Corner Radius (mm)</label>
|
<label for="cutR">Corner Radius (mm)</label>
|
||||||
<input type="number" id="cutR" value="0.8" step="0.01">
|
<input type="number" id="cutR" value="1.3" step="0.01">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"></div>
|
<div class="form-group"></div>
|
||||||
<div class="form-group"></div>
|
<div class="form-group"></div>
|
||||||
|
|
@ -269,36 +327,113 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Session data loaded from server
|
|
||||||
var sessionData = null;
|
|
||||||
var sideCutouts = [];
|
var sideCutouts = [];
|
||||||
var currentFace = 'north';
|
var currentSide = 1;
|
||||||
var dragStart = null;
|
var dragStart = null;
|
||||||
var dragCurrent = null;
|
var dragCurrent = null;
|
||||||
|
|
||||||
// Board dimensions from session (set by server-rendered JSON)
|
// Board dimensions from server
|
||||||
var boardInfo = {{.BoardInfoJSON }};
|
var boardInfo = {{.BoardInfoJSON }};
|
||||||
var sessionId = '{{.SessionID}}';
|
var sessionId = '{{.SessionID}}';
|
||||||
|
|
||||||
document.getElementById('sessionId').value = sessionId;
|
document.getElementById('sessionId').value = sessionId;
|
||||||
|
|
||||||
|
// Define sides as numbered segments (clockwise from top)
|
||||||
|
// For rectangular boards: Side 1=top, 2=right, 3=bottom, 4=left
|
||||||
|
// Future: server could pass actual polygon segments for irregular boards
|
||||||
|
var sides = [
|
||||||
|
{ num: 1, label: 'Side 1 (Top)', length: boardInfo.boardW, pos: 'top' },
|
||||||
|
{ num: 2, label: 'Side 2 (Right)', length: boardInfo.boardH, pos: 'right' },
|
||||||
|
{ num: 3, label: 'Side 3 (Bottom)', length: boardInfo.boardW, pos: 'bottom' },
|
||||||
|
{ num: 4, label: 'Side 4 (Left)', length: boardInfo.boardH, pos: 'left' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build side tabs dynamically
|
||||||
|
var tabsContainer = document.getElementById('faceTabs');
|
||||||
|
sides.forEach(function (side, i) {
|
||||||
|
var tab = document.createElement('div');
|
||||||
|
tab.className = 'face-tab' + (i === 0 ? ' active' : '');
|
||||||
|
tab.dataset.side = side.num;
|
||||||
|
tab.textContent = 'Side ' + side.num;
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.face-tab').forEach(function (t) { t.classList.remove('active'); });
|
||||||
|
tab.classList.add('active');
|
||||||
|
currentSide = side.num;
|
||||||
|
drawSideFace();
|
||||||
|
});
|
||||||
|
tabsContainer.appendChild(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Colors for side labels
|
||||||
|
var sideColors = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
|
||||||
|
|
||||||
// Initialize board canvas
|
// Initialize board canvas
|
||||||
var boardCanvas = document.getElementById('boardCanvas');
|
var boardCanvas = document.getElementById('boardCanvas');
|
||||||
var boardCtx = boardCanvas.getContext('2d');
|
var boardCtx = boardCanvas.getContext('2d');
|
||||||
|
|
||||||
// Load and draw the board preview image
|
// Board image position (set after load for label drawing)
|
||||||
|
var boardRect = { x: 0, y: 0, w: 0, h: 0 };
|
||||||
|
|
||||||
var boardImg = new Image();
|
var boardImg = new Image();
|
||||||
boardImg.onload = function () {
|
boardImg.onload = function () {
|
||||||
var scale = Math.min(boardCanvas.width / boardImg.width, boardCanvas.height / boardImg.height);
|
drawBoardWithLabels();
|
||||||
|
};
|
||||||
|
boardImg.src = '/preview-image/' + sessionId;
|
||||||
|
|
||||||
|
function drawBoardWithLabels() {
|
||||||
|
var ctx = boardCtx;
|
||||||
|
var scale = Math.min(boardCanvas.width / boardImg.width, boardCanvas.height / boardImg.height) * 0.75;
|
||||||
var w = boardImg.width * scale;
|
var w = boardImg.width * scale;
|
||||||
var h = boardImg.height * scale;
|
var h = boardImg.height * scale;
|
||||||
var x = (boardCanvas.width - w) / 2;
|
var x = (boardCanvas.width - w) / 2;
|
||||||
var y = (boardCanvas.height - h) / 2;
|
var y = (boardCanvas.height - h) / 2;
|
||||||
boardCtx.fillStyle = '#1a1a2e';
|
boardRect = { x: x, y: y, w: w, h: h };
|
||||||
boardCtx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
|
||||||
boardCtx.drawImage(boardImg, x, y, w, h);
|
ctx.fillStyle = '#1a1a2e';
|
||||||
};
|
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
||||||
boardImg.src = '/preview-image/' + sessionId;
|
ctx.drawImage(boardImg, x, y, w, h);
|
||||||
|
|
||||||
|
// Draw numbered side labels around the board
|
||||||
|
ctx.font = 'bold 13px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
var labelPad = 18;
|
||||||
|
|
||||||
|
sides.forEach(function (side) {
|
||||||
|
var color = sideColors[(side.num - 1) % sideColors.length];
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
var lx, ly;
|
||||||
|
switch (side.pos) {
|
||||||
|
case 'top':
|
||||||
|
lx = x + w / 2; ly = y - labelPad;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, y - 1); ctx.lineTo(x + w, y - 1); ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
lx = x + w + labelPad; ly = y + h / 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x + w + 1, y); ctx.lineTo(x + w + 1, y + h); ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
lx = x + w / 2; ly = y + h + labelPad;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, y + h + 1); ctx.lineTo(x + w, y + h + 1); ctx.stroke();
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
lx = x - labelPad; ly = y + h / 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x - 1, y); ctx.lineTo(x - 1, y + h); ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw circled number
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(lx, ly, 12, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.fillText(side.num, lx, ly + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Side cutout checkbox toggle
|
// Side cutout checkbox toggle
|
||||||
document.getElementById('optSideCutout').addEventListener('change', function () {
|
document.getElementById('optSideCutout').addEventListener('change', function () {
|
||||||
|
|
@ -311,24 +446,10 @@
|
||||||
document.getElementById('conformInput').value = this.checked ? 'true' : 'false';
|
document.getElementById('conformInput').value = this.checked ? 'true' : 'false';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Face tabs
|
// Get face dimensions in mm for current side
|
||||||
document.querySelectorAll('.face-tab').forEach(function (tab) {
|
|
||||||
tab.addEventListener('click', function () {
|
|
||||||
document.querySelectorAll('.face-tab').forEach(function (t) { t.classList.remove('active'); });
|
|
||||||
tab.classList.add('active');
|
|
||||||
currentFace = tab.dataset.face;
|
|
||||||
drawSideFace();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get face dimensions in mm
|
|
||||||
function getFaceDims() {
|
function getFaceDims() {
|
||||||
var info = boardInfo;
|
var side = sides.find(function (s) { return s.num === currentSide; });
|
||||||
if (currentFace === 'north' || currentFace === 'south') {
|
return { width: side ? side.length : boardInfo.boardW, height: boardInfo.totalH };
|
||||||
return { width: info.boardW, height: info.totalH };
|
|
||||||
} else {
|
|
||||||
return { width: info.boardH, height: info.totalH };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw side face
|
// Draw side face
|
||||||
|
|
@ -336,28 +457,34 @@
|
||||||
var canvas = document.getElementById('sideCanvas');
|
var canvas = document.getElementById('sideCanvas');
|
||||||
var ctx = canvas.getContext('2d');
|
var ctx = canvas.getContext('2d');
|
||||||
var dims = getFaceDims();
|
var dims = getFaceDims();
|
||||||
|
var side = sides.find(function (s) { return s.num === currentSide; });
|
||||||
|
|
||||||
// Scale to fit canvas
|
var scaleX = (canvas.width - 40) / dims.width;
|
||||||
var scaleX = (canvas.width - 20) / dims.width;
|
var scaleY = (canvas.height - 30) / dims.height;
|
||||||
var scaleY = (canvas.height - 20) / dims.height;
|
|
||||||
var scale = Math.min(scaleX, scaleY);
|
var scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
var offX = (canvas.width - dims.width * scale) / 2;
|
var offX = (canvas.width - dims.width * scale) / 2;
|
||||||
var offY = (canvas.height - dims.height * scale) / 2;
|
var offY = 10;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// Draw wall face
|
// Draw wall face
|
||||||
ctx.fillStyle = '#d1d5db';
|
ctx.fillStyle = '#d1d5db';
|
||||||
ctx.strokeStyle = '#6b7280';
|
ctx.strokeStyle = sideColors[(currentSide - 1) % sideColors.length];
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 2;
|
||||||
ctx.fillRect(offX, offY, dims.width * scale, dims.height * scale);
|
ctx.fillRect(offX, offY, dims.width * scale, dims.height * scale);
|
||||||
ctx.strokeRect(offX, offY, dims.width * scale, dims.height * scale);
|
ctx.strokeRect(offX, offY, dims.width * scale, dims.height * scale);
|
||||||
|
|
||||||
// Draw existing cutouts for this face
|
// Side label
|
||||||
|
ctx.fillStyle = sideColors[(currentSide - 1) % sideColors.length];
|
||||||
|
ctx.font = 'bold 11px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(side ? side.label : 'Side ' + currentSide, offX, offY - 2);
|
||||||
|
|
||||||
|
// Draw existing cutouts for this side
|
||||||
ctx.fillStyle = '#1a1a2e';
|
ctx.fillStyle = '#1a1a2e';
|
||||||
sideCutouts.forEach(function (c) {
|
sideCutouts.forEach(function (c) {
|
||||||
if (c.face !== currentFace) return;
|
if (c.side !== currentSide) return;
|
||||||
drawRoundedRect(ctx, offX + c.x * scale, offY + (dims.height - c.y - c.h) * scale,
|
drawRoundedRect(ctx, offX + c.x * scale, offY + (dims.height - c.y - c.h) * scale,
|
||||||
c.w * scale, c.h * scale, c.r * scale);
|
c.w * scale, c.h * scale, c.r * scale);
|
||||||
});
|
});
|
||||||
|
|
@ -365,13 +492,14 @@
|
||||||
// Draw drag preview
|
// Draw drag preview
|
||||||
if (dragStart && dragCurrent) {
|
if (dragStart && dragCurrent) {
|
||||||
ctx.fillStyle = 'rgba(37, 99, 235, 0.3)';
|
ctx.fillStyle = 'rgba(37, 99, 235, 0.3)';
|
||||||
ctx.strokeStyle = 'var(--primary)';
|
ctx.strokeStyle = '#2563eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
var x1 = Math.min(dragStart.x, dragCurrent.x);
|
var x1 = Math.min(dragStart.x, dragCurrent.x);
|
||||||
var y1 = Math.min(dragStart.y, dragCurrent.y);
|
var y1 = Math.min(dragStart.y, dragCurrent.y);
|
||||||
var w = Math.abs(dragCurrent.x - dragStart.x);
|
var dw = Math.abs(dragCurrent.x - dragStart.x);
|
||||||
var h = Math.abs(dragCurrent.y - dragStart.y);
|
var dh = Math.abs(dragCurrent.y - dragStart.y);
|
||||||
ctx.fillRect(x1, y1, w, h);
|
ctx.fillRect(x1, y1, dw, dh);
|
||||||
ctx.strokeRect(x1, y1, w, h);
|
ctx.strokeRect(x1, y1, dw, dh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw mm grid labels
|
// Draw mm grid labels
|
||||||
|
|
@ -381,7 +509,7 @@
|
||||||
var step = Math.ceil(dims.width / 10);
|
var step = Math.ceil(dims.width / 10);
|
||||||
for (var mm = 0; mm <= dims.width; mm += step) {
|
for (var mm = 0; mm <= dims.width; mm += step) {
|
||||||
var px = offX + mm * scale;
|
var px = offX + mm * scale;
|
||||||
ctx.fillText(mm + '', px, canvas.height - 2);
|
ctx.fillText(mm + '', px, offY + dims.height * scale + 14);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,37 +533,44 @@
|
||||||
var sideCanvas = document.getElementById('sideCanvas');
|
var sideCanvas = document.getElementById('sideCanvas');
|
||||||
sideCanvas.addEventListener('mousedown', function (e) {
|
sideCanvas.addEventListener('mousedown', function (e) {
|
||||||
var rect = sideCanvas.getBoundingClientRect();
|
var rect = sideCanvas.getBoundingClientRect();
|
||||||
dragStart = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
var sx = (e.clientX - rect.left) * (sideCanvas.width / rect.width);
|
||||||
|
var sy = (e.clientY - rect.top) * (sideCanvas.height / rect.height);
|
||||||
|
dragStart = { x: sx, y: sy };
|
||||||
dragCurrent = null;
|
dragCurrent = null;
|
||||||
});
|
});
|
||||||
sideCanvas.addEventListener('mousemove', function (e) {
|
sideCanvas.addEventListener('mousemove', function (e) {
|
||||||
if (!dragStart) return;
|
if (!dragStart) return;
|
||||||
var rect = sideCanvas.getBoundingClientRect();
|
var rect = sideCanvas.getBoundingClientRect();
|
||||||
dragCurrent = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
dragCurrent = {
|
||||||
|
x: (e.clientX - rect.left) * (sideCanvas.width / rect.width),
|
||||||
|
y: (e.clientY - rect.top) * (sideCanvas.height / rect.height)
|
||||||
|
};
|
||||||
drawSideFace();
|
drawSideFace();
|
||||||
});
|
});
|
||||||
sideCanvas.addEventListener('mouseup', function (e) {
|
sideCanvas.addEventListener('mouseup', function (e) {
|
||||||
if (!dragStart || !dragCurrent) { dragStart = null; return; }
|
if (!dragStart || !dragCurrent) { dragStart = null; return; }
|
||||||
var rect = sideCanvas.getBoundingClientRect();
|
var rect = sideCanvas.getBoundingClientRect();
|
||||||
dragCurrent = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
dragCurrent = {
|
||||||
|
x: (e.clientX - rect.left) * (sideCanvas.width / rect.width),
|
||||||
|
y: (e.clientY - rect.top) * (sideCanvas.height / rect.height)
|
||||||
|
};
|
||||||
|
|
||||||
// Convert pixel coords to mm
|
|
||||||
var dims = getFaceDims();
|
var dims = getFaceDims();
|
||||||
var scaleX = (sideCanvas.width - 20) / dims.width;
|
var scaleX = (sideCanvas.width - 40) / dims.width;
|
||||||
var scaleY = (sideCanvas.height - 20) / dims.height;
|
var scaleY = (sideCanvas.height - 30) / dims.height;
|
||||||
var scale = Math.min(scaleX, scaleY);
|
var scale = Math.min(scaleX, scaleY);
|
||||||
var offX = (sideCanvas.width - dims.width * scale) / 2;
|
var offX = (sideCanvas.width - dims.width * scale) / 2;
|
||||||
var offY = (sideCanvas.height - dims.height * scale) / 2;
|
var offY = 10;
|
||||||
|
|
||||||
var x1 = Math.min(dragStart.x, dragCurrent.x);
|
var x1 = Math.min(dragStart.x, dragCurrent.x);
|
||||||
var y1 = Math.min(dragStart.y, dragCurrent.y);
|
var y1 = Math.min(dragStart.y, dragCurrent.y);
|
||||||
var w = Math.abs(dragCurrent.x - dragStart.x);
|
var dw = Math.abs(dragCurrent.x - dragStart.x);
|
||||||
var h = Math.abs(dragCurrent.y - dragStart.y);
|
var dh = Math.abs(dragCurrent.y - dragStart.y);
|
||||||
|
|
||||||
var mmX = (x1 - offX) / scale;
|
var mmX = (x1 - offX) / scale;
|
||||||
var mmY = dims.height - (y1 + h - offY) / scale;
|
var mmY = dims.height - (y1 + dh - offY) / scale;
|
||||||
var mmW = w / scale;
|
var mmW = dw / scale;
|
||||||
var mmH = h / scale;
|
var mmH = dh / scale;
|
||||||
|
|
||||||
if (mmW > 0.5 && mmH > 0.5) {
|
if (mmW > 0.5 && mmH > 0.5) {
|
||||||
document.getElementById('cutX').value = mmX.toFixed(2);
|
document.getElementById('cutX').value = mmX.toFixed(2);
|
||||||
|
|
@ -449,15 +584,41 @@
|
||||||
drawSideFace();
|
drawSideFace();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// USB-C Preset button
|
||||||
|
document.getElementById('btnPresetUSBC').addEventListener('click', function () {
|
||||||
|
document.getElementById('cutW').value = '9';
|
||||||
|
document.getElementById('cutH').value = '3.26';
|
||||||
|
document.getElementById('cutR').value = '1.3';
|
||||||
|
drawSideFace();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizontal center button (centers X along face width)
|
||||||
|
document.getElementById('btnCenterX').addEventListener('click', function () {
|
||||||
|
var dims = getFaceDims();
|
||||||
|
var w = parseFloat(document.getElementById('cutW').value) || 0;
|
||||||
|
var x = (dims.width - w) / 2;
|
||||||
|
document.getElementById('cutX').value = x.toFixed(2);
|
||||||
|
drawSideFace();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vertical center button (centers Y along face height)
|
||||||
|
document.getElementById('btnCenterY').addEventListener('click', function () {
|
||||||
|
var dims = getFaceDims();
|
||||||
|
var h = parseFloat(document.getElementById('cutH').value) || 0;
|
||||||
|
var y = (dims.height - h) / 2;
|
||||||
|
document.getElementById('cutY').value = y.toFixed(2);
|
||||||
|
drawSideFace();
|
||||||
|
});
|
||||||
|
|
||||||
// Add cutout button
|
// Add cutout button
|
||||||
document.getElementById('btnAddCutout').addEventListener('click', function () {
|
document.getElementById('btnAddCutout').addEventListener('click', function () {
|
||||||
var c = {
|
var c = {
|
||||||
face: currentFace,
|
side: currentSide,
|
||||||
x: parseFloat(document.getElementById('cutX').value) || 0,
|
x: parseFloat(document.getElementById('cutX').value) || 0,
|
||||||
y: parseFloat(document.getElementById('cutY').value) || 0,
|
y: parseFloat(document.getElementById('cutY').value) || 0,
|
||||||
w: parseFloat(document.getElementById('cutW').value) || 9,
|
w: parseFloat(document.getElementById('cutW').value) || 9,
|
||||||
h: parseFloat(document.getElementById('cutH').value) || 3.5,
|
h: parseFloat(document.getElementById('cutH').value) || 3.5,
|
||||||
r: parseFloat(document.getElementById('cutR').value) || 0.8
|
r: parseFloat(document.getElementById('cutR').value) || 1.3
|
||||||
};
|
};
|
||||||
sideCutouts.push(c);
|
sideCutouts.push(c);
|
||||||
updateCutoutList();
|
updateCutoutList();
|
||||||
|
|
@ -470,13 +631,13 @@
|
||||||
sideCutouts.forEach(function (c, i) {
|
sideCutouts.forEach(function (c, i) {
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.className = 'cutout-item';
|
div.className = 'cutout-item';
|
||||||
div.innerHTML = '<span>' + c.face.toUpperCase() + ': ' +
|
var color = sideColors[(c.side - 1) % sideColors.length];
|
||||||
|
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span> ' +
|
||||||
c.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' +
|
c.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' +
|
||||||
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
|
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
|
||||||
'</span><button onclick="removeCutout(' + i + ')">✕</button>';
|
'<button onclick="removeCutout(' + i + ')">✕</button>';
|
||||||
list.appendChild(div);
|
list.appendChild(div);
|
||||||
});
|
});
|
||||||
// Update hidden form field
|
|
||||||
document.getElementById('sideCutoutsInput').value = JSON.stringify(sideCutouts);
|
document.getElementById('sideCutoutsInput').value = JSON.stringify(sideCutouts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PCB Tools by kennycoder</title>
|
<title>PCB Tools by kennycoder + pszsh</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
|
|
@ -246,3 +246,62 @@ input[type="file"] {
|
||||||
.tooltip-wrap:hover .tooltip {
|
.tooltip-wrap:hover .tooltip {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-btn:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-popup {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-popup.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-popup img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-popup-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
z-index: 2;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-popup-close:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue