Expanded the concept, needs a few touches still

This commit is contained in:
pszsh 2026-02-21 05:21:17 -08:00
parent ac2ef32827
commit bffb63b540
9 changed files with 884 additions and 214 deletions

View File

@ -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
lipH := pcbT + 1.5 // extends above tray floor to grip the enclosure opening
lipThickPx := int(math.Ceil(0.5 * cfg.DPI / 25.4)) // 0.5mm in pixels
if lipThickPx < 1 {
lipThickPx = 1
}
// Build lip mask from the adjacency rule, then dilate inward by lipThickPx
lipCoreMask := make([]bool, size)
for y := 1; y < imgH-1; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
isLipPixel := false
if x > 0 && x < imgW-1 {
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
break
}
for x := 1; x < imgW-1; x++ {
idx := y*imgW + x
if clearanceMask[idx] && !boardMask[idx] {
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
ni := (y+d[1])*imgW + (x + d[0])
if ni >= 0 && ni < size {
if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] {
lipCoreMask[idx] = true
break
}
}
}
}
}
}
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

108
gbrjob.go Normal file
View File

@ -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
}

139
main.go
View File

@ -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")
if err != nil {
http.Error(w, "Board outline gerber is required", http.StatusBadRequest)
return
// 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 {
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,43 +835,42 @@ 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()
courtGf, err := ParseGerber(courtPath)
if err != nil {
log.Printf("Warning: Could not parse courtyard gerber: %v", err)
} else {
fmt.Println("Rendering courtyard layer...")
courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds)
}
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)
} else {
fmt.Println("Rendering courtyard layer...")
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()
maskGf, err := ParseGerber(maskPath)
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)
} else {
fmt.Println("Rendering soldermask layer...")
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 soldermask gerber: %v", err)
log.Printf("Warning: Could not parse fab gerber: %v", err)
} else {
fmt.Println("Rendering soldermask layer...")
soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds)
fmt.Println("Rendering F.Fab layer as courtyard fallback...")
courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds)
}
}
}
@ -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,

Binary file not shown.

View File

@ -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>

View File

@ -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">
<label for="cutX">X (mm)</label>
<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">
<label for="cutY">Y (mm)</label>
<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>&nbsp; ' +
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);
}

View File

@ -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

View File

@ -245,4 +245,63 @@ 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;
}