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)
|
||||
Clearance float64 // mm (gap between PCB and enclosure wall)
|
||||
DPI float64
|
||||
OutlineBounds *Bounds // gerber coordinate bounds for drill mapping
|
||||
}
|
||||
|
||||
// Default enclosure values
|
||||
|
|
@ -32,7 +33,7 @@ type EnclosureResult struct {
|
|||
|
||||
// SideCutout defines a cutout on a side wall face
|
||||
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)
|
||||
Width float64 // Width 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
|
||||
// Lid pixels = wallMask[i] || boardMask[i] || clearanceMask[i]
|
||||
// (i.e., the entire footprint of the enclosure)
|
||||
|
||||
size := imgW * imgH
|
||||
|
||||
// Pre-compute board bounding box (needed for side cutout detection and removal tabs)
|
||||
minBX, minBY := imgW, imgH
|
||||
maxBX, maxBY := 0, 0
|
||||
boardCenterX, boardCenterY := 0.0, 0.0
|
||||
boardCount := 0
|
||||
for y := 0; y < imgH; y++ {
|
||||
for x := 0; x < imgW; x++ {
|
||||
if boardMask[y*imgW+x] {
|
||||
boardCenterX += float64(x)
|
||||
boardCenterY += float64(y)
|
||||
boardCount++
|
||||
if x < minBX {
|
||||
minBX = x
|
||||
}
|
||||
if x > maxBX {
|
||||
maxBX = x
|
||||
}
|
||||
if y < minBY {
|
||||
minBY = y
|
||||
}
|
||||
if y > maxBY {
|
||||
maxBY = y
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build wall-cutout mask from side cutouts
|
||||
// For each side cutout, determine which wall pixels to subtract
|
||||
wallCutoutMask := make([]bool, size)
|
||||
if len(sideCutouts) > 0 && cfg.OutlineBounds != nil {
|
||||
// Board bounding box in pixels
|
||||
for y := 0; y < imgH; y++ {
|
||||
for x := 0; x < imgW; x++ {
|
||||
idx := y*imgW + x
|
||||
if !(wallMask[idx] && !clearanceMask[idx] && !boardMask[idx]) {
|
||||
continue // not a wall pixel
|
||||
}
|
||||
|
||||
// Determine which side this wall pixel belongs to
|
||||
// Find distance to each side of the board bounding box
|
||||
dTop := math.Abs(float64(y) - float64(minBY))
|
||||
dBottom := math.Abs(float64(y) - float64(maxBY))
|
||||
dLeft := math.Abs(float64(x) - float64(minBX))
|
||||
dRight := math.Abs(float64(x) - float64(maxBX))
|
||||
|
||||
sideNum := 0
|
||||
minDist := dTop
|
||||
sideNum = 1 // top
|
||||
if dRight < minDist {
|
||||
minDist = dRight
|
||||
sideNum = 2 // right
|
||||
}
|
||||
if dBottom < minDist {
|
||||
minDist = dBottom
|
||||
sideNum = 3 // bottom
|
||||
}
|
||||
if dLeft < minDist {
|
||||
sideNum = 4 // left
|
||||
}
|
||||
|
||||
// Position along the side in mm
|
||||
var posAlongSide float64
|
||||
var zPos float64
|
||||
switch sideNum {
|
||||
case 1: // top — position = X distance from left board edge
|
||||
posAlongSide = float64(x-minBX) * pixelToMM
|
||||
zPos = 0 // all Z heights for walls
|
||||
case 2: // right — position = Y distance from top board edge
|
||||
posAlongSide = float64(y-minBY) * pixelToMM
|
||||
zPos = 0
|
||||
case 3: // bottom — position = X distance from left board edge
|
||||
posAlongSide = float64(x-minBX) * pixelToMM
|
||||
zPos = 0
|
||||
case 4: // left — position = Y distance from top board edge
|
||||
posAlongSide = float64(y-minBY) * pixelToMM
|
||||
zPos = 0
|
||||
}
|
||||
_ = zPos
|
||||
|
||||
// Check all cutouts for this side
|
||||
for _, c := range sideCutouts {
|
||||
if c.Side != sideNum {
|
||||
continue
|
||||
}
|
||||
// Check if this pixel's position falls within the cutout X range
|
||||
if posAlongSide >= c.X && posAlongSide <= c.X+c.Width {
|
||||
wallCutoutMask[idx] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("Wall cutout mask: applied %d side cutouts\n", len(sideCutouts))
|
||||
}
|
||||
|
||||
// Generate walls using RLE
|
||||
for y := 0; y < imgH; y++ {
|
||||
runStart := -1
|
||||
|
|
@ -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 pixels = any pixel in wallMask OR clearanceMask OR boardMask
|
||||
// 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)
|
||||
// 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
|
||||
if x < imgW {
|
||||
idx := y*imgW + x
|
||||
isTrayPixel = clearanceMask[idx] || boardMask[idx]
|
||||
isTrayPixel = (clearanceMask[idx] || boardMask[idx]) && !pegMask[idx]
|
||||
}
|
||||
|
||||
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
|
||||
// User can push on them from below to pop the tray out
|
||||
fmt.Println("Adding internal removal tabs...")
|
||||
boardCenterX, boardCenterY := 0.0, 0.0
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// (uses pre-computed board bounding box: minBX, minBY, maxBX, maxBY, boardCenterY)
|
||||
if boardCount > 0 {
|
||||
boardCenterY /= float64(boardCount)
|
||||
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)
|
||||
}
|
||||
|
||||
// Embossed lip: a thin raised ridge around the full tray perimeter
|
||||
// This lip mates against the inside face of the enclosure walls for a tight fit
|
||||
// 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
|
||||
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++ {
|
||||
runStart := -1
|
||||
for x := 0; x <= imgW; x++ {
|
||||
isLipPixel := false
|
||||
if x > 0 && x < imgW-1 {
|
||||
for x := 1; x < imgW-1; x++ {
|
||||
idx := y*imgW + x
|
||||
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}} {
|
||||
ni := (y+d[1])*imgW + (x + d[0])
|
||||
if ni >= 0 && ni < size {
|
||||
if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] {
|
||||
isLipPixel = true
|
||||
lipCoreMask[idx] = true
|
||||
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 {
|
||||
runStart = x
|
||||
}
|
||||
|
|
@ -429,13 +702,62 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
|||
by := float64(y) * pixelToMM
|
||||
bw := float64(x-runStart) * pixelToMM
|
||||
bh := pixelToMM
|
||||
_ = lipW // lip width is one pixel at this DPI
|
||||
addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, lipH)
|
||||
runStart = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add matching recess in enclosure for the lip (0.25mm deep groove)
|
||||
// Recess sits at the inner face of the enclosure wall, where the lip enters
|
||||
fmt.Println("Adding lip recess in enclosure...")
|
||||
recessDepth := 0.25
|
||||
recessH := lipH + 0.5 // slightly taller than lip for easy entry
|
||||
for y := 0; y < imgH; y++ {
|
||||
runStart := -1
|
||||
for x := 0; x <= imgW; x++ {
|
||||
// Recess = wall pixels adjacent to the lip (inner face of wall)
|
||||
isRecess := false
|
||||
if x > 0 && x < imgW-1 {
|
||||
idx := y*imgW + x
|
||||
if wallMask[idx] && !clearanceMask[idx] && !boardMask[idx] {
|
||||
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
|
||||
ni := (y+d[1])*imgW + (x + d[0])
|
||||
if ni >= 0 && ni < size && lipMask[ni] {
|
||||
isRecess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isRecess {
|
||||
if runStart == -1 {
|
||||
runStart = x
|
||||
}
|
||||
} else {
|
||||
if runStart != -1 {
|
||||
// Subtract recess from enclosure wall by NOT generating here
|
||||
// Instead, generate wall with gap at recess height
|
||||
bx := float64(runStart) * pixelToMM
|
||||
by := float64(y) * pixelToMM
|
||||
bw := float64(x-runStart) * pixelToMM
|
||||
bh := pixelToMM
|
||||
// Wall below recess
|
||||
if trayFloor > 0.05 {
|
||||
addBoxAtZ(&encTris, bx, by, 0, bw, bh, trayFloor)
|
||||
}
|
||||
// Thinner wall in recess zone (subtract recessDepth from thickness)
|
||||
// This is handled by just not filling the recess area
|
||||
_ = recessDepth
|
||||
// Wall above recess
|
||||
addBoxAtZ(&encTris, bx, by, trayFloor+recessH, bw, bh, totalH-(trayFloor+recessH))
|
||||
runStart = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("Enclosure: %d triangles, Tray: %d triangles\n", len(encTris), len(trayTris))
|
||||
|
||||
_ = math.Pi // keep math import for Phase 2 cylindrical pegs
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
// Parse params
|
||||
pcbThickness, _ := strconv.ParseFloat(r.FormValue("pcbThickness"), 64)
|
||||
wallThickness, _ := strconv.ParseFloat(r.FormValue("wallThickness"), 64)
|
||||
wallHeight, _ := strconv.ParseFloat(r.FormValue("wallHeight"), 64)
|
||||
clearance, _ := strconv.ParseFloat(r.FormValue("clearance"), 64)
|
||||
dpi, _ := strconv.ParseFloat(r.FormValue("dpi"), 64)
|
||||
|
||||
if pcbThickness == 0 {
|
||||
pcbThickness = DefaultPCBThickness
|
||||
}
|
||||
if wallThickness == 0 {
|
||||
wallThickness = DefaultEncWallThick
|
||||
}
|
||||
|
|
@ -700,7 +696,36 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|||
clearance = DefaultClearance
|
||||
}
|
||||
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{
|
||||
|
|
@ -711,22 +736,33 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|||
DPI: dpi,
|
||||
}
|
||||
|
||||
// Handle Outline File (required)
|
||||
outlineFile, outlineHeader, err := r.FormFile("outline")
|
||||
// Handle uploaded gerber files (multi-select)
|
||||
// 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 {
|
||||
http.Error(w, "Board outline gerber is required", http.StatusBadRequest)
|
||||
return
|
||||
continue
|
||||
}
|
||||
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))
|
||||
of, err := os.Create(outlinePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Server error saving file", http.StatusInternalServerError)
|
||||
// Find the outline (Edge.Cuts) gerber
|
||||
outlinePath, ok := savedGerbers[jobResult.EdgeCutsFile]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("Edge.Cuts file '%s' not found in uploaded gerbers. Upload all .gbr files.", jobResult.EdgeCutsFile), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
io.Copy(of, outlineFile)
|
||||
of.Close()
|
||||
|
||||
// Handle PTH Drill File (optional)
|
||||
var drillHoles []DrillHole
|
||||
|
|
@ -786,6 +822,10 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
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
|
||||
margin := ecfg.WallThickness + ecfg.Clearance + 5.0
|
||||
outlineBounds.MinX -= margin
|
||||
|
|
@ -795,18 +835,12 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Render outline to image
|
||||
fmt.Println("Rendering outline...")
|
||||
ecfg.OutlineBounds = &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
|
||||
courtyardFile, courtyardHeader, err := r.FormFile("courtyard")
|
||||
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()
|
||||
if courtPath, ok := savedGerbers[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" {
|
||||
courtGf, err := ParseGerber(courtPath)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle F.Mask (Soldermask) Gerber (optional) — for minimum pad cutouts
|
||||
|
||||
// Auto-discover and render F.Mask from job file
|
||||
var soldermaskImg image.Image
|
||||
maskFile, maskHeader, err := r.FormFile("soldermask")
|
||||
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()
|
||||
if maskPath, ok := savedGerbers[jobResult.SoldermaskFile]; ok && jobResult.SoldermaskFile != "" {
|
||||
maskGf, err := ParseGerber(maskPath)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
|
@ -845,8 +884,8 @@ func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|||
DrillHoles: filteredHoles,
|
||||
Config: ecfg,
|
||||
OutlineBounds: outlineBounds,
|
||||
BoardW: float64(outlineImg.Bounds().Max.X) * (25.4 / ecfg.DPI),
|
||||
BoardH: float64(outlineImg.Bounds().Max.Y) * (25.4 / ecfg.DPI),
|
||||
BoardW: actualBoardW,
|
||||
BoardH: actualBoardH,
|
||||
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
|
||||
}
|
||||
sessionsMu.Lock()
|
||||
|
|
@ -968,7 +1007,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
|||
cutoutsJSON := r.FormValue("sideCutouts")
|
||||
if cutoutsJSON != "" && cutoutsJSON != "[]" {
|
||||
var rawCutouts []struct {
|
||||
Face string `json:"face"`
|
||||
Side int `json:"side"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
W float64 `json:"w"`
|
||||
|
|
@ -980,7 +1019,7 @@ func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
|
|||
} else {
|
||||
for _, rc := range rawCutouts {
|
||||
sideCutouts = append(sideCutouts, SideCutout{
|
||||
Face: rc.Face,
|
||||
Side: rc.Side,
|
||||
X: rc.X,
|
||||
Y: rc.Y,
|
||||
Width: rc.W,
|
||||
|
|
|
|||
BIN
pcb-to-stencil
BIN
pcb-to-stencil
Binary file not shown.
|
|
@ -4,13 +4,13 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>PCB Tools by kennycoder</h1>
|
||||
<h1>PCB Tools by kennycoder + pszsh</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="stencil">Stencil</button>
|
||||
|
|
@ -65,77 +65,58 @@
|
|||
<!-- Tab 2: Enclosure -->
|
||||
<div class="tab-content" id="tab-enclosure">
|
||||
<form action="/upload-enclosure" method="post" enctype="multipart/form-data">
|
||||
<div class="form-group tooltip-wrap">
|
||||
<label for="enc-outline">Board Outline Gerber (Required)</label>
|
||||
<input type="file" id="enc-outline" name="outline" accept=".gbr,.gko,.gm1" required>
|
||||
<div class="tooltip">Layers to export for Gerbers
|
||||
<hr>• Edge.Cuts (board outline)
|
||||
<div class="form-group">
|
||||
<label for="enc-gbrjob">Gerber Job File (Required) <span class="help-btn"
|
||||
onclick="document.getElementById('help-gbrjob').classList.toggle('visible')">(?)</span></label>
|
||||
<input type="file" id="enc-gbrjob" name="gbrjob" accept=".gbrjob" required>
|
||||
<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 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">
|
||||
<label for="enc-drill">PTH Drill File (Optional)</label>
|
||||
<input type="file" id="enc-drill" name="drill" accept=".drl,.xln,.txt">
|
||||
<div class="hint">Component through-holes (vias auto-filtered).</div>
|
||||
<div class="tooltip">Layers to export for DRL
|
||||
<hr>• Use the <b>PTH</b> file (Plated Through-Hole)<br>• Vias are automatically filtered out
|
||||
</div>
|
||||
<div class="tooltip">Use the <b>PTH</b> file from KiCad's drill export.</div>
|
||||
</div>
|
||||
<div class="form-group tooltip-wrap">
|
||||
<label for="enc-npth">NPTH Drill File (Optional)</label>
|
||||
<input type="file" id="enc-npth" name="npth" accept=".drl,.xln,.txt">
|
||||
<div class="hint">Mounting holes — become pegs in enclosure.</div>
|
||||
<div class="tooltip">Layers to export for DRL
|
||||
<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 class="hint">Mounting holes — become alignment pegs.</div>
|
||||
<div class="tooltip">Use the <b>NPTH</b> file — these become alignment pegs.</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label for="enc-wallThickness">Wall Thickness (mm)</label>
|
||||
<input type="number" id="enc-wallThickness" name="wallThickness" value="1.5" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="enc-wallHeight">Wall Height (mm)</label>
|
||||
<input type="number" id="enc-wallHeight" name="wallHeight" value="10.0" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="enc-clearance">Clearance (mm)</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<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 class="form-group"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn">Generate Enclosure</button>
|
||||
|
|
|
|||
|
|
@ -72,10 +72,12 @@
|
|||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.face-tab {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
padding: 0.4rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -175,6 +177,55 @@
|
|||
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 {
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
|
|
@ -188,7 +239,7 @@
|
|||
<div class="container preview-container">
|
||||
<h1>Enclosure Preview</h1>
|
||||
|
||||
<!-- Top-down board view -->
|
||||
<!-- Top-down board view with numbered side labels -->
|
||||
<div class="board-canvas-wrap">
|
||||
<canvas id="boardCanvas" width="600" height="400"></canvas>
|
||||
</div>
|
||||
|
|
@ -212,24 +263,31 @@
|
|||
|
||||
<!-- Side cutout editor -->
|
||||
<div class="side-editor" id="sideEditor">
|
||||
<div class="face-tabs" id="faceTabs">
|
||||
<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="face-tabs" id="faceTabs"></div>
|
||||
|
||||
<div class="side-canvas-wrap" id="sideCanvasWrap">
|
||||
<canvas id="sideCanvas" width="700" height="200"></canvas>
|
||||
</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="form-group">
|
||||
<div class="coord-field">
|
||||
<div class="coord-label-row">
|
||||
<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">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="coord-field">
|
||||
<div class="coord-label-row">
|
||||
<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">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
@ -244,7 +302,7 @@
|
|||
<div class="coord-row">
|
||||
<div class="form-group">
|
||||
<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 class="form-group"></div>
|
||||
<div class="form-group"></div>
|
||||
|
|
@ -269,36 +327,113 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// Session data loaded from server
|
||||
var sessionData = null;
|
||||
var sideCutouts = [];
|
||||
var currentFace = 'north';
|
||||
var currentSide = 1;
|
||||
var dragStart = null;
|
||||
var dragCurrent = null;
|
||||
|
||||
// Board dimensions from session (set by server-rendered JSON)
|
||||
// Board dimensions from server
|
||||
var boardInfo = {{.BoardInfoJSON }};
|
||||
var sessionId = '{{.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
|
||||
var boardCanvas = document.getElementById('boardCanvas');
|
||||
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();
|
||||
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 h = boardImg.height * scale;
|
||||
var x = (boardCanvas.width - w) / 2;
|
||||
var y = (boardCanvas.height - h) / 2;
|
||||
boardCtx.fillStyle = '#1a1a2e';
|
||||
boardCtx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
||||
boardCtx.drawImage(boardImg, x, y, w, h);
|
||||
};
|
||||
boardImg.src = '/preview-image/' + sessionId;
|
||||
boardRect = { x: x, y: y, w: w, h: h };
|
||||
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(0, 0, boardCanvas.width, boardCanvas.height);
|
||||
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
|
||||
document.getElementById('optSideCutout').addEventListener('change', function () {
|
||||
|
|
@ -311,24 +446,10 @@
|
|||
document.getElementById('conformInput').value = this.checked ? 'true' : 'false';
|
||||
});
|
||||
|
||||
// Face tabs
|
||||
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
|
||||
// Get face dimensions in mm for current side
|
||||
function getFaceDims() {
|
||||
var info = boardInfo;
|
||||
if (currentFace === 'north' || currentFace === 'south') {
|
||||
return { width: info.boardW, height: info.totalH };
|
||||
} else {
|
||||
return { width: info.boardH, height: info.totalH };
|
||||
}
|
||||
var side = sides.find(function (s) { return s.num === currentSide; });
|
||||
return { width: side ? side.length : boardInfo.boardW, height: boardInfo.totalH };
|
||||
}
|
||||
|
||||
// Draw side face
|
||||
|
|
@ -336,28 +457,34 @@
|
|||
var canvas = document.getElementById('sideCanvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var dims = getFaceDims();
|
||||
var side = sides.find(function (s) { return s.num === currentSide; });
|
||||
|
||||
// Scale to fit canvas
|
||||
var scaleX = (canvas.width - 20) / dims.width;
|
||||
var scaleY = (canvas.height - 20) / dims.height;
|
||||
var scaleX = (canvas.width - 40) / dims.width;
|
||||
var scaleY = (canvas.height - 30) / dims.height;
|
||||
var scale = Math.min(scaleX, scaleY);
|
||||
|
||||
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);
|
||||
|
||||
// Draw wall face
|
||||
ctx.fillStyle = '#d1d5db';
|
||||
ctx.strokeStyle = '#6b7280';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = sideColors[(currentSide - 1) % sideColors.length];
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fillRect(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';
|
||||
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,
|
||||
c.w * scale, c.h * scale, c.r * scale);
|
||||
});
|
||||
|
|
@ -365,13 +492,14 @@
|
|||
// Draw drag preview
|
||||
if (dragStart && dragCurrent) {
|
||||
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 y1 = Math.min(dragStart.y, dragCurrent.y);
|
||||
var w = Math.abs(dragCurrent.x - dragStart.x);
|
||||
var h = Math.abs(dragCurrent.y - dragStart.y);
|
||||
ctx.fillRect(x1, y1, w, h);
|
||||
ctx.strokeRect(x1, y1, w, h);
|
||||
var dw = Math.abs(dragCurrent.x - dragStart.x);
|
||||
var dh = Math.abs(dragCurrent.y - dragStart.y);
|
||||
ctx.fillRect(x1, y1, dw, dh);
|
||||
ctx.strokeRect(x1, y1, dw, dh);
|
||||
}
|
||||
|
||||
// Draw mm grid labels
|
||||
|
|
@ -381,7 +509,7 @@
|
|||
var step = Math.ceil(dims.width / 10);
|
||||
for (var mm = 0; mm <= dims.width; mm += step) {
|
||||
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');
|
||||
sideCanvas.addEventListener('mousedown', function (e) {
|
||||
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;
|
||||
});
|
||||
sideCanvas.addEventListener('mousemove', function (e) {
|
||||
if (!dragStart) return;
|
||||
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();
|
||||
});
|
||||
sideCanvas.addEventListener('mouseup', function (e) {
|
||||
if (!dragStart || !dragCurrent) { dragStart = null; return; }
|
||||
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 scaleX = (sideCanvas.width - 20) / dims.width;
|
||||
var scaleY = (sideCanvas.height - 20) / dims.height;
|
||||
var scaleX = (sideCanvas.width - 40) / dims.width;
|
||||
var scaleY = (sideCanvas.height - 30) / dims.height;
|
||||
var scale = Math.min(scaleX, scaleY);
|
||||
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 y1 = Math.min(dragStart.y, dragCurrent.y);
|
||||
var w = Math.abs(dragCurrent.x - dragStart.x);
|
||||
var h = Math.abs(dragCurrent.y - dragStart.y);
|
||||
var dw = Math.abs(dragCurrent.x - dragStart.x);
|
||||
var dh = Math.abs(dragCurrent.y - dragStart.y);
|
||||
|
||||
var mmX = (x1 - offX) / scale;
|
||||
var mmY = dims.height - (y1 + h - offY) / scale;
|
||||
var mmW = w / scale;
|
||||
var mmH = h / scale;
|
||||
var mmY = dims.height - (y1 + dh - offY) / scale;
|
||||
var mmW = dw / scale;
|
||||
var mmH = dh / scale;
|
||||
|
||||
if (mmW > 0.5 && mmH > 0.5) {
|
||||
document.getElementById('cutX').value = mmX.toFixed(2);
|
||||
|
|
@ -449,15 +584,41 @@
|
|||
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
|
||||
document.getElementById('btnAddCutout').addEventListener('click', function () {
|
||||
var c = {
|
||||
face: currentFace,
|
||||
side: currentSide,
|
||||
x: parseFloat(document.getElementById('cutX').value) || 0,
|
||||
y: parseFloat(document.getElementById('cutY').value) || 0,
|
||||
w: parseFloat(document.getElementById('cutW').value) || 9,
|
||||
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);
|
||||
updateCutoutList();
|
||||
|
|
@ -470,13 +631,13 @@
|
|||
sideCutouts.forEach(function (c, i) {
|
||||
var div = document.createElement('div');
|
||||
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.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);
|
||||
});
|
||||
// Update hidden form field
|
||||
document.getElementById('sideCutoutsInput').value = JSON.stringify(sideCutouts);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
</head>
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
|
|
@ -246,3 +246,62 @@ input[type="file"] {
|
|||
.tooltip-wrap:hover .tooltip {
|
||||
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