Fork with bugfixes and additional features. Init. Commit

This commit is contained in:
pszsh 2026-02-20 23:01:19 -08:00
parent 3ddd36fd26
commit ac2ef32827
10 changed files with 2028 additions and 87 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
./temp/*
temp/

189
drill.go Normal file
View File

@ -0,0 +1,189 @@
package main
import (
"bufio"
"math"
"os"
"regexp"
"strconv"
"strings"
)
// DrillHoleType classifies a drill hole by function
type DrillHoleType int
const (
DrillTypeUnknown DrillHoleType = iota
DrillTypeVia // ViaDrill — ignore for enclosure
DrillTypeComponent // ComponentDrill — component leads
DrillTypeMounting // Mounting holes (from NPTH)
)
// DrillHole represents a single drill hole with position, diameter, and type
type DrillHole struct {
X, Y float64 // Position in mm
Diameter float64 // Diameter in mm
Type DrillHoleType // Classified by TA.AperFunction
ToolNum int // Tool number (T1, T2, etc.)
}
// ParseDrill parses an Excellon drill file and returns hole positions
func ParseDrill(filename string) ([]DrillHole, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
var holes []DrillHole
type toolInfo struct {
diameter float64
holeType DrillHoleType
}
tools := make(map[int]toolInfo)
currentTool := 0
inHeader := true
units := "MM"
isNPTH := false
// Format spec
formatDec := 0
// Pending aperture function for the next tool definition
pendingType := DrillTypeUnknown
scanner := bufio.NewScanner(file)
reToolDef := regexp.MustCompile(`^T(\d+)C([\d.]+)`)
reToolSelect := regexp.MustCompile(`^T(\d+)$`)
reCoord := regexp.MustCompile(`^X([+-]?[\d.]+)Y([+-]?[\d.]+)`)
reAperFunc := regexp.MustCompile(`TA\.AperFunction,(\w+),(\w+),(\w+)`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Check file function for NPTH
if strings.Contains(line, "TF.FileFunction") {
if strings.Contains(line, "NonPlated") || strings.Contains(line, "NPTH") {
isNPTH = true
}
}
// Parse TA.AperFunction comments (appears before tool definition)
if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "G04") {
m := reAperFunc.FindStringSubmatch(line)
if len(m) >= 4 {
funcType := m[3]
switch funcType {
case "ViaDrill":
pendingType = DrillTypeVia
case "ComponentDrill":
pendingType = DrillTypeComponent
default:
pendingType = DrillTypeUnknown
}
}
// Also check for format spec
if strings.HasPrefix(line, ";FORMAT=") {
re := regexp.MustCompile(`\{(\d+):(\d+)\}`)
fm := re.FindStringSubmatch(line)
if len(fm) == 3 {
formatDec, _ = strconv.Atoi(fm[2])
}
}
continue
}
// Detect header end
if line == "%" || line == "M95" {
inHeader = false
continue
}
// Units
if strings.Contains(line, "METRIC") || line == "M71" {
units = "MM"
continue
}
if strings.Contains(line, "INCH") || line == "M72" {
units = "IN"
continue
}
// Tool definitions (in header): T01C0.300
if inHeader {
m := reToolDef.FindStringSubmatch(line)
if len(m) == 3 {
toolNum, _ := strconv.Atoi(m[1])
dia, _ := strconv.ParseFloat(m[2], 64)
ht := pendingType
// If this is an NPTH file and type is unknown, classify as mounting
if isNPTH && ht == DrillTypeUnknown {
ht = DrillTypeMounting
}
tools[toolNum] = toolInfo{diameter: dia, holeType: ht}
pendingType = DrillTypeUnknown // Reset
continue
}
}
// Tool selection: T01
m := reToolSelect.FindStringSubmatch(line)
if len(m) == 2 {
toolNum, _ := strconv.Atoi(m[1])
currentTool = toolNum
continue
}
// End of file
if line == "M30" || line == "M00" {
break
}
// Coordinate: X123456Y789012
mc := reCoord.FindStringSubmatch(line)
if len(mc) == 3 && currentTool != 0 {
x := parseExcellonCoord(mc[1], formatDec)
y := parseExcellonCoord(mc[2], formatDec)
ti := tools[currentTool]
dia := ti.diameter
// Convert inches to mm if needed
if units == "IN" {
x *= 25.4
y *= 25.4
if dia < 1.0 {
dia *= 25.4
}
}
holes = append(holes, DrillHole{
X: x,
Y: y,
Diameter: dia,
Type: ti.holeType,
ToolNum: currentTool,
})
}
}
return holes, nil
}
func parseExcellonCoord(s string, fmtDec int) float64 {
if strings.Contains(s, ".") {
val, _ := strconv.ParseFloat(s, 64)
return val
}
val, _ := strconv.ParseFloat(s, 64)
if fmtDec > 0 {
return val / math.Pow(10, float64(fmtDec))
}
return val / 1000.0
}

576
enclosure.go Normal file
View File

@ -0,0 +1,576 @@
package main
import (
"fmt"
"image"
"image/color"
"math"
)
// EnclosureConfig holds parameters for enclosure generation
type EnclosureConfig struct {
PCBThickness float64 // mm
WallThickness float64 // mm
WallHeight float64 // mm (height of walls above PCB)
Clearance float64 // mm (gap between PCB and enclosure wall)
DPI float64
}
// Default enclosure values
const (
DefaultPCBThickness = 1.6
DefaultEncWallHeight = 10.0
DefaultEncWallThick = 1.5
DefaultClearance = 0.3
)
// EnclosureResult contains the generated meshes
type EnclosureResult struct {
EnclosureTriangles [][3]Point
TrayTriangles [][3]Point
}
// SideCutout defines a cutout on a side wall face
type SideCutout struct {
Face string // "north", "south", "east", "west"
X, Y float64 // Position on the face in mm (from left edge, from bottom)
Width float64 // Width in mm
Height float64 // Height in mm
CornerRadius float64 // Corner radius in mm (0 for square)
}
// GenerateEnclosure creates enclosure + tray meshes from a board outline image and drill holes.
// The enclosure walls conform to the actual board outline shape.
// courtyardImg is optional — if provided, component courtyard regions are cut from the lid (flood-filled).
// soldermaskImg is optional — if provided, soldermask pad openings are also cut from the lid.
func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg EnclosureConfig, courtyardImg image.Image, soldermaskImg image.Image, sideCutouts []SideCutout) *EnclosureResult {
pixelToMM := 25.4 / cfg.DPI
bounds := outlineImg.Bounds()
imgW := bounds.Max.X
imgH := bounds.Max.Y
// Use ComputeWallMask to get the board shape and wall around it
// WallThickness for enclosure = clearance + wall thickness
totalWallMM := cfg.Clearance + cfg.WallThickness
fmt.Printf("Computing board shape (wall=%.1fmm)...\n", totalWallMM)
wallMask, boardMask := ComputeWallMask(outlineImg, totalWallMM, pixelToMM)
// Also compute a thinner mask for just the clearance zone (inner wall boundary)
clearanceMask, _ := ComputeWallMask(outlineImg, cfg.Clearance, pixelToMM)
// Determine the actual enclosure boundary = wall | board (expanded by clearance)
// wallMask = pixels that are the wall
// boardMask = pixels inside the board outline
// clearanceMask = pixels in the clearance zone around the board
// The enclosure walls are: wallMask pixels that are NOT in the clearance zone
// Actually: wallMask gives us everything from board edge out to totalWall distance
// clearanceMask gives us board edge out to clearance distance
// Real wall = wallMask AND NOT clearanceMask AND NOT boardMask
// Dimensions
trayFloor := 1.0 // mm
pcbT := cfg.PCBThickness
totalH := cfg.WallHeight + pcbT + trayFloor // total enclosure height
lidThick := cfg.WallThickness // lid thickness at top
// Snap-fit dimensions
snapHeight := 1.5
snapFromBottom := trayFloor + 0.3
// Tab dimensions
tabW := 8.0
tabD := 6.0
tabH := 2.0
// ==========================================
// ENCLOSURE (top shell — conforms to board shape)
// ==========================================
var encTris [][3]Point
fmt.Println("Generating edge-cut conforming enclosure...")
// Walls: scan through the image and create boxes for wall pixels
// A pixel is "wall" if it's in wallMask but NOT in clearanceMask and NOT in boardMask
// Actually simpler: wallMask already represents the OUTSIDE ring.
// wallMask = pixels outside board but within thickness distance
// boardMask = pixels inside the board
// So wall pixels are: wallMask[i] && !boardMask[i]
// But we also want to separate outer wall from inner clearance:
// Outer wall = wallMask && !clearanceMask (the actual solid wall material)
// Inner clearance = clearanceMask (air gap between wall and PCB)
// For the enclosure walls, we want the OUTER wall portion only
// Wall pixels = wallMask[i] && !clearanceMask[i] && !boardMask[i]
// For the lid, we want to cover everything within the outer wall boundary
// Lid pixels = wallMask[i] || boardMask[i] || clearanceMask[i]
// (i.e., the entire footprint of the enclosure)
size := imgW * imgH
// Generate walls using RLE
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]
}
if isWallPixel {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
bx := float64(runStart) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
AddBox(&encTris, bx, by, bw, bh, totalH)
runStart = -1
}
}
}
}
// 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
fmt.Println("Generating lid...")
// Build courtyard cutout mask using flood-fill
courtyardMask := buildCutoutMask(courtyardImg, imgW, imgH, true) // flood-fill closed outlines
if courtyardImg != nil {
cutoutCount := 0
for _, v := range courtyardMask {
if v {
cutoutCount++
}
}
fmt.Printf("Courtyard cutout (flood-filled): %d pixels\n", cutoutCount)
}
// Build soldermask cutout mask (direct pixel match, no flood-fill)
soldermaskMask := buildCutoutMask(soldermaskImg, imgW, imgH, false)
if soldermaskImg != nil {
cutoutCount := 0
for _, v := range soldermaskMask {
if v {
cutoutCount++
}
}
fmt.Printf("Soldermask cutout: %d pixels\n", cutoutCount)
}
// Combined cutout: union of courtyard (filled) and soldermask
combinedCutout := make([]bool, size)
for i := 0; i < size; i++ {
combinedCutout[i] = courtyardMask[i] || soldermaskMask[i]
}
for y := 0; y < imgH; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
isLidPixel := false
if x < imgW {
idx := y*imgW + x
inFootprint := wallMask[idx] || clearanceMask[idx] || boardMask[idx]
// Cut lid where combined cutout exists inside the board area
isCutout := combinedCutout[idx] && boardMask[idx]
isLidPixel = inFootprint && !isCutout
}
if isLidPixel {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
bx := float64(runStart) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
addBoxAtZ(&encTris, bx, by, totalH-lidThick, bw, bh, lidThick)
runStart = -1
}
}
}
}
// Snap ledges: on the inside of the walls (at the clearance boundary)
// These are pixels that are in clearanceMask but adjacent to wallMask
fmt.Println("Generating snap ledges...")
for y := 1; y < imgH-1; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
isSnapPixel := false
if x > 0 && x < imgW-1 {
idx := y*imgW + x
if clearanceMask[idx] && !boardMask[idx] {
// Check if adjacent to a wall pixel
hasAdjacentWall := false
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
ni := (y+d[1])*imgW + (x + d[0])
if ni >= 0 && ni < size {
if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] {
hasAdjacentWall = true
break
}
}
}
isSnapPixel = hasAdjacentWall
}
}
if isSnapPixel {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
bx := float64(runStart) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
addBoxAtZ(&encTris, bx, by, snapFromBottom, bw, bh, snapHeight)
runStart = -1
}
}
}
}
// ==========================================
// TRAY (bottom — conforms to board shape)
// ==========================================
var trayTris [][3]Point
fmt.Println("Generating edge-cut conforming tray...")
// Tray floor: covers the cavity area (clearanceMask + boardMask)
for y := 0; y < imgH; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
isTrayPixel := false
if x < imgW {
idx := y*imgW + x
isTrayPixel = clearanceMask[idx] || boardMask[idx]
}
if isTrayPixel {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
bx := float64(runStart) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
AddBox(&trayTris, bx, by, bw, bh, trayFloor)
runStart = -1
}
}
}
}
// PCB support rim: inner edge of clearance zone (adjacent to board)
fmt.Println("Generating PCB support rim...")
rimH := pcbT * 0.5
for y := 1; y < imgH-1; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
isRimPixel := false
if x > 0 && x < imgW-1 {
idx := y*imgW + x
if clearanceMask[idx] && !boardMask[idx] {
// Adjacent to board?
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
ni := (y+d[1])*imgW + (x + d[0])
if ni >= 0 && ni < size && boardMask[ni] {
isRimPixel = true
break
}
}
}
}
if isRimPixel {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
bx := float64(runStart) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
addBoxAtZ(&trayTris, bx, by, trayFloor, bw, bh, rimH)
runStart = -1
}
}
}
}
// Snap bumps: on the outer edge of the tray (adjacent to wall)
fmt.Println("Generating snap bumps...")
snapBumpH := snapHeight + 0.3
for y := 1; y < imgH-1; y++ {
runStart := -1
for x := 0; x <= imgW; x++ {
isBumpPixel := false
if x > 0 && x < imgW-1 {
idx := y*imgW + x
if clearanceMask[idx] && !boardMask[idx] {
// Adjacent to wall?
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
ni := (y+d[1])*imgW + (x + d[0])
if ni >= 0 && ni < size {
if wallMask[ni] && !clearanceMask[ni] && !boardMask[ni] {
isBumpPixel = true
break
}
}
}
}
}
if isBumpPixel {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
bx := float64(runStart) * pixelToMM
by := float64(y) * pixelToMM
bw := float64(x-runStart) * pixelToMM
bh := pixelToMM
addBoxAtZ(&trayTris, bx, by, snapFromBottom-0.1, bw, bh, snapBumpH)
runStart = -1
}
}
}
}
// Removal tabs: INTERNAL — thin finger grips that sit inside the tray cavity
// User can push on them from below to pop the tray out
fmt.Println("Adding internal removal tabs...")
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
}
}
}
}
if boardCount > 0 {
boardCenterY /= float64(boardCount)
tabCenterY := boardCenterY * pixelToMM
// Internal tabs: inside the clearance zone, extending inward
// Left tab — just inside the left wall
leftInner := float64(minBX)*pixelToMM - cfg.Clearance
addBoxAtZ(&trayTris, leftInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH)
// Right tab — just inside the right wall
rightInner := float64(maxBX)*pixelToMM + cfg.Clearance - tabD
addBoxAtZ(&trayTris, rightInner, tabCenterY-tabW/2, trayFloor, tabD, tabW, tabH)
}
// Embossed lip: a thin raised ridge around the full tray perimeter
// This lip mates against the inside face of the enclosure walls for a tight 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
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
}
}
}
}
}
if isLipPixel {
if runStart == -1 {
runStart = x
}
} else {
if runStart != -1 {
bx := float64(runStart) * pixelToMM
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
}
}
}
}
fmt.Printf("Enclosure: %d triangles, Tray: %d triangles\n", len(encTris), len(trayTris))
_ = math.Pi // keep math import for Phase 2 cylindrical pegs
return &EnclosureResult{
EnclosureTriangles: encTris,
TrayTriangles: trayTris,
}
}
// addBoxAtZ creates a box at a specific Z offset
func addBoxAtZ(triangles *[][3]Point, x, y, z, w, h, zHeight float64) {
x0, y0 := x, y
x1, y1 := x+w, y+h
z0, z1 := z, z+zHeight
p000 := Point{x0, y0, z0}
p100 := Point{x1, y0, z0}
p110 := Point{x1, y1, z0}
p010 := Point{x0, y1, z0}
p001 := Point{x0, y0, z1}
p101 := Point{x1, y0, z1}
p111 := Point{x1, y1, z1}
p011 := Point{x0, y1, z1}
addQuad := func(a, b, c, d Point) {
*triangles = append(*triangles, [3]Point{a, b, c})
*triangles = append(*triangles, [3]Point{c, d, a})
}
addQuad(p000, p010, p110, p100) // Bottom
addQuad(p101, p111, p011, p001) // Top
addQuad(p000, p100, p101, p001) // Front
addQuad(p100, p110, p111, p101) // Right
addQuad(p110, p010, p011, p111) // Back
addQuad(p010, p000, p001, p011) // Left
}
// buildCutoutMask creates a boolean mask from an image.
// If floodFill is true, it flood-fills from the edges to find closed regions.
func buildCutoutMask(img image.Image, w, h int, floodFill bool) []bool {
size := w * h
mask := make([]bool, size)
if img == nil {
return mask
}
// First: build raw pixel mask from the image
bounds := img.Bounds()
rawPixels := make([]bool, size)
for y := 0; y < h && y < bounds.Max.Y; y++ {
for x := 0; x < w && x < bounds.Max.X; x++ {
r, g, b, _ := img.At(x+bounds.Min.X, y+bounds.Min.Y).RGBA()
gray := color.GrayModel.Convert(color.NRGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 255}).(color.Gray)
if gray.Y > 128 {
rawPixels[y*w+x] = true
}
}
}
if !floodFill {
// Direct mode: raw pixels are the mask
return rawPixels
}
// Flood-fill mode: fill from edges to find exterior, invert to get interiors
// Exterior = everything reachable from edges without crossing a white pixel
exterior := floodFillExterior(rawPixels, w, h)
// Interior = NOT exterior AND NOT raw pixel (the outline itself)
// Actually, interior = NOT exterior (includes both outline pixels and filled regions)
for i := 0; i < size; i++ {
mask[i] = !exterior[i]
}
return mask
}
// floodFillExterior marks all pixels reachable from the image edges
// without crossing a white (true) pixel as exterior
func floodFillExterior(pixels []bool, w, h int) []bool {
size := w * h
exterior := make([]bool, size)
// BFS queue starting from all edge pixels that are not white
queue := make([]int, 0, w*2+h*2)
for x := 0; x < w; x++ {
// Top edge
if !pixels[x] {
exterior[x] = true
queue = append(queue, x)
}
// Bottom edge
idx := (h-1)*w + x
if !pixels[idx] {
exterior[idx] = true
queue = append(queue, idx)
}
}
for y := 0; y < h; y++ {
// Left edge
idx := y * w
if !pixels[idx] {
exterior[idx] = true
queue = append(queue, idx)
}
// Right edge
idx = y*w + (w - 1)
if !pixels[idx] {
exterior[idx] = true
queue = append(queue, idx)
}
}
// BFS
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
x := cur % w
y := cur / w
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
nx, ny := x+d[0], y+d[1]
if nx >= 0 && nx < w && ny >= 0 && ny < h {
ni := ny*w + nx
if !exterior[ni] && !pixels[ni] {
exterior[ni] = true
queue = append(queue, ni)
}
}
}
}
return exterior
}

View File

@ -198,6 +198,12 @@ func ParseGerber(filename string) (*GerberFile, error) {
} else if part == "G03" {
// Counter-clockwise circular interpolation
gf.Commands = append(gf.Commands, GerberCommand{Type: "G03"})
} else if part == "G36" {
// Region fill start
gf.Commands = append(gf.Commands, GerberCommand{Type: "G36"})
} else if part == "G37" {
// Region fill end
gf.Commands = append(gf.Commands, GerberCommand{Type: "G37"})
}
continue
}
@ -411,6 +417,8 @@ func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image {
curX, curY := 0.0, 0.0
curDCode := 0
interpolationMode := "G01" // Default linear
inRegion := false
var regionVertices [][2]int
for _, cmd := range gf.Commands {
if cmd.Type == "APERTURE" {
@ -421,6 +429,20 @@ func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image {
interpolationMode = cmd.Type
continue
}
if cmd.Type == "G36" {
inRegion = true
regionVertices = nil
continue
}
if cmd.Type == "G37" {
// End region: fill the collected polygon
if len(regionVertices) >= 3 {
drawFilledPolygon(img, regionVertices)
}
inRegion = false
regionVertices = nil
continue
}
prevX, prevY := curX, curY
if cmd.X != nil {
@ -430,6 +452,59 @@ func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image {
curY = *cmd.Y
}
// In region mode, collect contour vertices instead of drawing
if inRegion {
if cmd.Type == "MOVE" {
// D02 in region: start a new contour
px, py := toPix(curX, curY)
regionVertices = append(regionVertices, [2]int{px, py})
} else if cmd.Type == "DRAW" {
if interpolationMode == "G01" {
// Linear segment: add endpoint
px, py := toPix(curX, curY)
regionVertices = append(regionVertices, [2]int{px, py})
} else {
// Arc segment: sample points along the arc
iVal := 0.0
jVal := 0.0
if cmd.I != nil {
iVal = *cmd.I
}
if cmd.J != nil {
jVal = *cmd.J
}
centerX := prevX + iVal
centerY := prevY + jVal
radius := math.Sqrt(iVal*iVal + jVal*jVal)
startAngle := math.Atan2(prevY-centerY, prevX-centerX)
endAngle := math.Atan2(curY-centerY, curX-centerX)
if interpolationMode == "G03" {
if endAngle <= startAngle {
endAngle += 2 * math.Pi
}
} else {
if startAngle <= endAngle {
startAngle += 2 * math.Pi
}
}
arcLen := math.Abs(endAngle-startAngle) * radius
steps := int(arcLen * scale * 2)
if steps < 10 {
steps = 10
}
for s := 1; s <= steps; s++ {
t := float64(s) / float64(steps)
angle := startAngle + t*(endAngle-startAngle)
ax := centerX + radius*math.Cos(angle)
ay := centerY + radius*math.Sin(angle)
px, py := toPix(ax, ay)
regionVertices = append(regionVertices, [2]int{px, py})
}
}
}
continue
}
if cmd.Type == "FLASH" {
// Draw Aperture at curX, curY
ap, ok := gf.State.Apertures[curDCode]
@ -447,7 +522,6 @@ func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image {
gf.drawLine(img, x1, y1, x2, y2, ap, scale, white)
} else {
// Circular Interpolation (G02/G03)
// I and J are offsets from start point (prevX, prevY) to center
iVal := 0.0
jVal := 0.0
if cmd.I != nil {
@ -537,7 +611,9 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
if w > h {
// Horizontal
rectW := w - h
if rectW < 0 { rectW = 0 } // Should be impossible if w > h
if rectW < 0 {
rectW = 0
} // Should be impossible if w > h
// Center Rect
r := image.Rect(x-rectW/2, y-h/2, x+rectW/2, y+h/2)
@ -550,7 +626,9 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
} else {
// Vertical
rectH := h - w
if rectH < 0 { rectH = 0 }
if rectH < 0 {
rectH = 0
}
// Center Rect
r := image.Rect(x-w/2, y-rectH/2, x+w/2, y+rectH/2)
@ -618,7 +696,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
}
// Draw filled polygon using scanline algorithm
drawFilledPolygon(img, vertices, c)
drawFilledPolygon(img, vertices)
}
}
case 20: // Vector Line
@ -659,7 +737,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
{x + int((endX-perpX)*scale), y - int((endY-perpY)*scale)},
}
drawFilledPolygon(img, vertices, c)
drawFilledPolygon(img, vertices)
}
}
case 21: // Center Line (Rect)
@ -702,7 +780,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
}
// Draw as polygon
drawFilledPolygon(img, vertices, c)
drawFilledPolygon(img, vertices)
}
}
}
@ -720,7 +798,7 @@ func drawCircle(img *image.RGBA, x0, y0, r int) {
}
}
func drawFilledPolygon(img *image.RGBA, vertices [][2]int, c image.Image) {
func drawFilledPolygon(img *image.RGBA, vertices [][2]int) {
if len(vertices) < 3 {
return
}

365
main.go
View File

@ -5,6 +5,7 @@ import (
"embed"
"encoding/binary"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"html/template"
@ -18,6 +19,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
)
// --- Configuration ---
@ -392,7 +394,7 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3
h = cfg.WallHeight
} else if isStencilSolid {
if isInsideBoard {
h = cfg.StencilHeight
h = cfg.WallHeight
}
}
@ -581,6 +583,11 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// Parse the multipart form BEFORE reading FormValue.
// Without this, FormValue can't see fields in a multipart/form-data body,
// so all numeric parameters silently fall back to defaults.
r.ParseMultipartForm(32 << 20)
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
@ -658,15 +665,365 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) {
}
// Render Success
renderResult(w, "Your stencil has been generated successfully.", []string{filepath.Base(outSTL)})
}
func enclosureUploadHandler(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(32 << 20)
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tempDir := filepath.Join(".", "temp")
os.MkdirAll(tempDir, 0755)
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
}
if wallHeight == 0 {
wallHeight = DefaultEncWallHeight
}
if clearance == 0 {
clearance = DefaultClearance
}
if dpi == 0 {
dpi = 500
}
ecfg := EnclosureConfig{
PCBThickness: pcbThickness,
WallThickness: wallThickness,
WallHeight: wallHeight,
Clearance: clearance,
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
}
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)
return
}
io.Copy(of, outlineFile)
of.Close()
// Handle PTH Drill File (optional)
var drillHoles []DrillHole
drillFile, drillHeader, err := r.FormFile("drill")
if err == nil {
defer drillFile.Close()
drillPath := filepath.Join(tempDir, uuid+"_drill"+filepath.Ext(drillHeader.Filename))
df, err := os.Create(drillPath)
if err == nil {
io.Copy(df, drillFile)
df.Close()
holes, err := ParseDrill(drillPath)
if err != nil {
log.Printf("Warning: Could not parse PTH drill file: %v", err)
} else {
drillHoles = append(drillHoles, holes...)
fmt.Printf("Parsed %d PTH drill holes\n", len(holes))
}
}
}
// Handle NPTH Drill File (optional)
npthFile, npthHeader, err := r.FormFile("npth")
if err == nil {
defer npthFile.Close()
npthPath := filepath.Join(tempDir, uuid+"_npth"+filepath.Ext(npthHeader.Filename))
nf, err := os.Create(npthPath)
if err == nil {
io.Copy(nf, npthFile)
nf.Close()
holes, err := ParseDrill(npthPath)
if err != nil {
log.Printf("Warning: Could not parse NPTH drill file: %v", err)
} else {
drillHoles = append(drillHoles, holes...)
fmt.Printf("Parsed %d NPTH drill holes\n", len(holes))
}
}
}
// Filter out vias — only keep component and mounting holes
var filteredHoles []DrillHole
for _, h := range drillHoles {
if h.Type != DrillTypeVia {
filteredHoles = append(filteredHoles, h)
}
}
fmt.Printf("After filtering: %d holes (vias removed)\n", len(filteredHoles))
// Parse outline gerber
fmt.Printf("Parsing outline %s...\n", outlinePath)
outlineGf, err := ParseGerber(outlinePath)
if err != nil {
http.Error(w, fmt.Sprintf("Error parsing outline: %v", err), http.StatusInternalServerError)
return
}
outlineBounds := outlineGf.CalculateBounds()
// Add margin for enclosure walls
margin := ecfg.WallThickness + ecfg.Clearance + 5.0
outlineBounds.MinX -= margin
outlineBounds.MinY -= margin
outlineBounds.MaxX += margin
outlineBounds.MaxY += margin
// Render outline to image
fmt.Println("Rendering outline...")
outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds)
// Handle F.Courtyard Gerber (optional) — for lid cutouts
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)
}
}
}
// Handle F.Mask (Soldermask) Gerber (optional) — for minimum pad cutouts
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 err != nil {
log.Printf("Warning: Could not parse soldermask gerber: %v", err)
} else {
fmt.Println("Rendering soldermask layer...")
soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds)
}
}
}
// Generate enclosure (no side cutouts yet — added in preview flow)
// Store session data for preview page
session := &EnclosureSession{
OutlineImg: outlineImg,
CourtyardImg: courtyardImg,
SoldermaskImg: soldermaskImg,
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),
TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0,
}
sessionsMu.Lock()
sessions[uuid] = session
sessionsMu.Unlock()
// Redirect to preview page
http.Redirect(w, r, "/preview?id="+uuid, http.StatusSeeOther)
}
func renderResult(w http.ResponseWriter, message string, files []string) {
tmpl, err := template.ParseFS(staticFiles, "static/result.html")
if err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
data := struct{ Filename string }{Filename: filepath.Base(outSTL)}
data := struct {
Message string
Files []string
}{Message: message, Files: files}
tmpl.Execute(w, data)
}
// --- Enclosure Preview Session ---
type EnclosureSession struct {
OutlineImg image.Image
CourtyardImg image.Image
SoldermaskImg image.Image
DrillHoles []DrillHole
Config EnclosureConfig
OutlineBounds Bounds
BoardW float64
BoardH float64
TotalH float64
}
var (
sessions = make(map[string]*EnclosureSession)
sessionsMu sync.Mutex
)
func previewHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
sessionsMu.Lock()
session, ok := sessions[id]
sessionsMu.Unlock()
if !ok {
http.Error(w, "Session not found. Please re-upload your files.", http.StatusNotFound)
return
}
boardInfo := struct {
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
TotalH float64 `json:"totalH"`
}{
BoardW: session.BoardW,
BoardH: session.BoardH,
TotalH: session.TotalH,
}
boardJSON, _ := json.Marshal(boardInfo)
tmpl, err := template.ParseFS(staticFiles, "static/preview.html")
if err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
return
}
data := struct {
SessionID string
BoardInfoJSON template.JS
}{
SessionID: id,
BoardInfoJSON: template.JS(boardJSON),
}
tmpl.Execute(w, data)
}
func previewImageHandler(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 {
http.NotFound(w, r)
return
}
id := parts[2]
sessionsMu.Lock()
session, ok := sessions[id]
sessionsMu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
png.Encode(w, session.OutlineImg)
}
func generateEnclosureHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
r.ParseForm()
id := r.FormValue("sessionId")
sessionsMu.Lock()
session, ok := sessions[id]
sessionsMu.Unlock()
if !ok {
http.Error(w, "Session expired. Please re-upload your files.", http.StatusNotFound)
return
}
// Parse side cutouts from JSON
var sideCutouts []SideCutout
cutoutsJSON := r.FormValue("sideCutouts")
if cutoutsJSON != "" && cutoutsJSON != "[]" {
var rawCutouts []struct {
Face string `json:"face"`
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
H float64 `json:"h"`
R float64 `json:"r"`
}
if err := json.Unmarshal([]byte(cutoutsJSON), &rawCutouts); err != nil {
log.Printf("Warning: could not parse side cutouts: %v", err)
} else {
for _, rc := range rawCutouts {
sideCutouts = append(sideCutouts, SideCutout{
Face: rc.Face,
X: rc.X,
Y: rc.Y,
Width: rc.W,
Height: rc.H,
CornerRadius: rc.R,
})
}
}
fmt.Printf("Side cutouts: %d\n", len(sideCutouts))
}
// Generate enclosure
fmt.Println("Generating enclosure with side cutouts...")
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config,
session.CourtyardImg, session.SoldermaskImg, sideCutouts)
// Save STLs
encPath := filepath.Join("temp", id+"_enclosure.stl")
trayPath := filepath.Join("temp", id+"_tray.stl")
fmt.Printf("Saving enclosure to %s (%d triangles)...\n", encPath, len(result.EnclosureTriangles))
if err := WriteSTL(encPath, result.EnclosureTriangles); err != nil {
http.Error(w, fmt.Sprintf("Error writing enclosure STL: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("Saving tray to %s (%d triangles)...\n", trayPath, len(result.TrayTriangles))
if err := WriteSTL(trayPath, result.TrayTriangles); err != nil {
http.Error(w, fmt.Sprintf("Error writing tray STL: %v", err), http.StatusInternalServerError)
return
}
// Clean up session
sessionsMu.Lock()
delete(sessions, id)
sessionsMu.Unlock()
renderResult(w, "Your enclosure has been generated successfully.", []string{
filepath.Base(encPath),
filepath.Base(trayPath),
})
}
func downloadHandler(w http.ResponseWriter, r *http.Request) {
vars := strings.Split(r.URL.Path, "/")
if len(vars) < 3 {
@ -699,6 +1056,10 @@ func runServer(port string) {
http.HandleFunc("/", indexHandler)
http.HandleFunc("/upload", uploadHandler)
http.HandleFunc("/upload-enclosure", enclosureUploadHandler)
http.HandleFunc("/preview", previewHandler)
http.HandleFunc("/preview-image/", previewImageHandler)
http.HandleFunc("/generate-enclosure", generateEnclosureHandler)
http.HandleFunc("/download/", downloadHandler)
fmt.Printf("Starting server on http://0.0.0.0:%s\n", port)

BIN
pcb-to-stencil Executable file

Binary file not shown.

View File

@ -1,26 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gerber to Stencil converter</title>
<title>PCB Tools by kennycoder</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>PCB to Stencil Converter by kennycoder</h1>
<h1>PCB Tools by kennycoder</h1>
<div class="tabs">
<button class="tab active" data-tab="stencil">Stencil</button>
<button class="tab" data-tab="enclosure">Enclosure</button>
</div>
<!-- Tab 1: Stencil -->
<div class="tab-content active" id="tab-stencil">
<form action="/upload" method="post" enctype="multipart/form-data">
<div class="form-group">
<div class="form-group tooltip-wrap">
<label for="gerber">Solder Paste Gerber File (Required)</label>
<input type="file" id="gerber" name="gerber" accept=".gbr,.gtp,.gbp" required>
<div class="tooltip">Layers to export for Gerbers
<hr>• F.Paste (front paste stencil)<br>• B.Paste (back paste stencil)
</div>
<div class="form-group">
</div>
<div class="form-group tooltip-wrap">
<label for="outline">Board Outline Gerber (Optional)</label>
<input type="file" id="outline" name="outline" accept=".gbr,.gko,.gm1">
<div class="hint">Upload this to automatically crop and generate walls.</div>
<div class="tooltip">Layers to export for Gerbers
<hr>• Edge.Cuts (board outline)
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-row">
<div class="form-group">
<label for="height">Stencil Height (mm)</label>
<input type="number" id="height" name="height" value="0.16" step="0.01">
@ -31,7 +47,7 @@
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-row">
<div class="form-group">
<label for="wallHeight">Wall Height (mm)</label>
<input type="number" id="wallHeight" name="wallHeight" value="2.0" step="0.1">
@ -42,8 +58,89 @@
</div>
</div>
<button type="submit" id="submit-btn">Convert to STL</button>
<button type="submit" class="submit-btn">Convert to STL</button>
</form>
</div>
<!-- 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>
</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>
<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>
<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 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">
</div>
<div class="form-group"></div>
</div>
<button type="submit" class="submit-btn">Generate Enclosure</button>
</form>
</div>
<div id="loading">
<div class="spinner"></div>
@ -52,11 +149,26 @@
</div>
<script>
document.querySelector('form').addEventListener('submit', function() {
// Tab switching
document.querySelectorAll('.tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function (tc) { tc.classList.remove('active'); });
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
// Loading spinner on submit
document.querySelectorAll('form').forEach(function (form) {
form.addEventListener('submit', function () {
document.getElementById('loading').style.display = 'block';
document.getElementById('submit-btn').disabled = true;
document.getElementById('submit-btn').innerText = 'Converting...';
var btn = form.querySelector('.submit-btn');
btn.disabled = true;
btn.innerText = 'Processing...';
});
});
</script>
</body>
</html>

499
static/preview.html Normal file
View File

@ -0,0 +1,499 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enclosure Preview — PCB Tools</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.preview-container {
max-width: 800px;
}
.board-canvas-wrap {
background: #1a1a2e;
border-radius: 8px;
padding: 12px;
margin-bottom: 1rem;
text-align: center;
}
.board-canvas-wrap canvas {
max-width: 100%;
border-radius: 4px;
}
.option-group {
margin-bottom: 1rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 6px;
border: 1px solid var(--border);
}
.option-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
margin: 0;
}
.option-group input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
}
.option-hint {
font-size: 0.75rem;
color: #6b7280;
margin-left: 26px;
margin-top: 0.25rem;
}
.side-editor {
display: none;
margin-top: 1rem;
padding: 1rem;
background: #f0f1f5;
border-radius: 8px;
border: 1px solid var(--border);
}
.side-editor.active {
display: block;
}
.face-tabs {
display: flex;
gap: 0;
margin-bottom: 0.75rem;
}
.face-tab {
flex: 1;
padding: 0.4rem;
text-align: center;
border: 1px solid var(--border);
background: white;
cursor: pointer;
font-size: 0.8rem;
}
.face-tab:first-child {
border-radius: 4px 0 0 4px;
}
.face-tab:last-child {
border-radius: 0 4px 4px 0;
}
.face-tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.side-canvas-wrap {
background: #e5e7eb;
border-radius: 6px;
padding: 8px;
margin-bottom: 0.75rem;
position: relative;
cursor: crosshair;
}
.side-canvas-wrap canvas {
width: 100%;
display: block;
border-radius: 4px;
}
.coord-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.coord-row label {
font-size: 0.75rem;
margin-bottom: 0.2rem;
}
.coord-row input {
font-size: 0.85rem;
padding: 0.3rem;
}
.cutout-list {
max-height: 120px;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.cutout-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0.5rem;
background: white;
border-radius: 4px;
margin-bottom: 0.3rem;
font-size: 0.8rem;
}
.cutout-item button {
background: #ef4444;
color: white;
border: none;
border-radius: 4px;
padding: 0.15rem 0.4rem;
cursor: pointer;
font-size: 0.75rem;
}
.btn-row {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.8rem;
}
.btn-small:hover {
background: #f3f4f6;
}
.unit-note {
font-size: 0.7rem;
color: #9ca3af;
text-align: right;
margin-top: 0.2rem;
}
</style>
</head>
<body>
<div class="container preview-container">
<h1>Enclosure Preview</h1>
<!-- Top-down board view -->
<div class="board-canvas-wrap">
<canvas id="boardCanvas" width="600" height="400"></canvas>
</div>
<!-- Options -->
<div class="option-group">
<label>
<input type="checkbox" id="optConform" checked>
Conform to edge cuts
</label>
<div class="option-hint">Enclosure walls follow the board outline shape instead of a rectangular box.</div>
</div>
<div class="option-group">
<label>
<input type="checkbox" id="optSideCutout">
Add side cutout (USB-C, connectors)
</label>
<div class="option-hint">Place rounded-rectangle cutouts on enclosure side walls.</div>
</div>
<!-- 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="side-canvas-wrap" id="sideCanvasWrap">
<canvas id="sideCanvas" width="700" height="200"></canvas>
</div>
<div class="coord-row">
<div class="form-group">
<label for="cutX">X (mm)</label>
<input type="number" id="cutX" value="0" step="0.01">
</div>
<div class="form-group">
<label for="cutY">Y (mm)</label>
<input type="number" id="cutY" value="0" step="0.01">
</div>
<div class="form-group">
<label for="cutW">Width (mm)</label>
<input type="number" id="cutW" value="9.0" step="0.01">
</div>
<div class="form-group">
<label for="cutH">Height (mm)</label>
<input type="number" id="cutH" value="3.5" step="0.01">
</div>
</div>
<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">
</div>
<div class="form-group"></div>
<div class="form-group"></div>
<div class="form-group">
<div class="unit-note">All values in mm (0.01mm precision)</div>
</div>
</div>
<div class="btn-row">
<button class="btn-small" id="btnAddCutout">+ Add Cutout</button>
</div>
<div class="cutout-list" id="cutoutList"></div>
</div>
<form id="generateForm" method="POST" action="/generate-enclosure">
<input type="hidden" name="sessionId" id="sessionId">
<input type="hidden" name="sideCutouts" id="sideCutoutsInput">
<input type="hidden" name="conformToEdge" id="conformInput" value="true">
<button type="submit" class="submit-btn">Generate Enclosure</button>
</form>
</div>
<script>
// Session data loaded from server
var sessionData = null;
var sideCutouts = [];
var currentFace = 'north';
var dragStart = null;
var dragCurrent = null;
// Board dimensions from session (set by server-rendered JSON)
var boardInfo = {{.BoardInfoJSON }};
var sessionId = '{{.SessionID}}';
document.getElementById('sessionId').value = sessionId;
// Initialize board canvas
var boardCanvas = document.getElementById('boardCanvas');
var boardCtx = boardCanvas.getContext('2d');
// Load and draw the board preview image
var boardImg = new Image();
boardImg.onload = function () {
var scale = Math.min(boardCanvas.width / boardImg.width, boardCanvas.height / boardImg.height);
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;
// Side cutout checkbox toggle
document.getElementById('optSideCutout').addEventListener('change', function () {
document.getElementById('sideEditor').classList.toggle('active', this.checked);
if (this.checked) drawSideFace();
});
// Conform checkbox
document.getElementById('optConform').addEventListener('change', function () {
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
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 };
}
}
// Draw side face
function drawSideFace() {
var canvas = document.getElementById('sideCanvas');
var ctx = canvas.getContext('2d');
var dims = getFaceDims();
// Scale to fit canvas
var scaleX = (canvas.width - 20) / dims.width;
var scaleY = (canvas.height - 20) / dims.height;
var scale = Math.min(scaleX, scaleY);
var offX = (canvas.width - dims.width * scale) / 2;
var offY = (canvas.height - dims.height * scale) / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw wall face
ctx.fillStyle = '#d1d5db';
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
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
ctx.fillStyle = '#1a1a2e';
sideCutouts.forEach(function (c) {
if (c.face !== currentFace) return;
drawRoundedRect(ctx, offX + c.x * scale, offY + (dims.height - c.y - c.h) * scale,
c.w * scale, c.h * scale, c.r * scale);
});
// Draw drag preview
if (dragStart && dragCurrent) {
ctx.fillStyle = 'rgba(37, 99, 235, 0.3)';
ctx.strokeStyle = 'var(--primary)';
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);
}
// Draw mm grid labels
ctx.fillStyle = '#9ca3af';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
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);
}
}
function drawRoundedRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
ctx.fill();
}
// Mouse drag on side canvas
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 };
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 };
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 };
// Convert pixel coords to mm
var dims = getFaceDims();
var scaleX = (sideCanvas.width - 20) / dims.width;
var scaleY = (sideCanvas.height - 20) / 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 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 mmX = (x1 - offX) / scale;
var mmY = dims.height - (y1 + h - offY) / scale;
var mmW = w / scale;
var mmH = h / scale;
if (mmW > 0.5 && mmH > 0.5) {
document.getElementById('cutX').value = mmX.toFixed(2);
document.getElementById('cutY').value = mmY.toFixed(2);
document.getElementById('cutW').value = mmW.toFixed(2);
document.getElementById('cutH').value = mmH.toFixed(2);
}
dragStart = null;
dragCurrent = null;
drawSideFace();
});
// Add cutout button
document.getElementById('btnAddCutout').addEventListener('click', function () {
var c = {
face: currentFace,
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
};
sideCutouts.push(c);
updateCutoutList();
drawSideFace();
});
function updateCutoutList() {
var list = document.getElementById('cutoutList');
list.innerHTML = '';
sideCutouts.forEach(function (c, i) {
var div = document.createElement('div');
div.className = 'cutout-item';
div.innerHTML = '<span>' + c.face.toUpperCase() + ': ' +
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>';
list.appendChild(div);
});
// Update hidden form field
document.getElementById('sideCutoutsInput').value = JSON.stringify(sideCutouts);
}
window.removeCutout = function (i) {
sideCutouts.splice(i, 1);
updateCutoutList();
drawSideFace();
};
// Form submit
document.getElementById('generateForm').addEventListener('submit', function () {
document.getElementById('sideCutoutsInput').value = JSON.stringify(sideCutouts);
var btn = this.querySelector('.submit-btn');
btn.disabled = true;
btn.innerText = 'Generating...';
});
</script>
</body>
</html>

View File

@ -1,17 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gerber to Stencil converter</title>
<title>PCB Tools by kennycoder</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="card">
<h2>Success!</h2>
<p>Your stencil has been generated successfully.</p>
<a href="/download/{{.Filename}}" class="btn">Download STL</a>
<a href="/" class="btn secondary">Convert Another</a>
<p>{{.Message}}</p>
<ul class="download-list">
{{range .Files}}
<li><a href="/download/{{.}}" class="btn">Download {{.}}</a></li>
{{end}}
</ul>
<a href="/" class="btn secondary">Back</a>
</div>
</body>
</html>

View File

@ -6,6 +6,7 @@
--text: #1f2937;
--border: #e5e7eb;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg);
@ -17,6 +18,7 @@ body {
margin: 0;
padding: 20px;
}
.container {
background-color: var(--card-bg);
padding: 2rem;
@ -25,6 +27,7 @@ body {
width: 100%;
max-width: 500px;
}
.card {
background-color: var(--card-bg);
padding: 2rem;
@ -34,6 +37,7 @@ body {
max-width: 400px;
width: 100%;
}
h1 {
margin-top: 0;
margin-bottom: 1.5rem;
@ -41,19 +45,73 @@ h1 {
font-size: 1.5rem;
color: var(--text);
}
h2 {
color: #059669;
margin-top: 0;
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border-bottom: 2px solid var(--border);
}
.tab {
flex: 1;
padding: 0.6rem 1rem;
border: none;
background: none;
font-size: 0.95rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
width: auto;
margin-top: 0;
}
.tab:hover {
color: var(--primary);
background: none;
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
background: none;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Form */
.form-group {
margin-bottom: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
input[type="text"],
input[type="number"],
input[type="file"] {
@ -64,12 +122,14 @@ input[type="file"] {
box-sizing: border-box;
font-size: 1rem;
}
.hint {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}
button {
.submit-btn {
width: 100%;
background-color: var(--primary);
color: white;
@ -82,18 +142,22 @@ button {
transition: background-color 0.2s;
margin-top: 1rem;
}
button:hover {
.submit-btn:hover {
background-color: var(--primary-hover);
}
button:disabled {
.submit-btn:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
#loading {
display: none;
text-align: center;
margin-top: 1rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid var(--primary);
@ -103,10 +167,18 @@ button:disabled {
animation: spin 1s linear infinite;
margin: 0 auto 0.5rem auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Result page buttons */
.btn {
display: inline-block;
background: var(--primary);
@ -117,14 +189,60 @@ button:disabled {
margin-top: 1rem;
transition: 0.2s;
}
.btn:hover {
background: var(--primary-hover);
}
.secondary {
background: #e5e7eb;
color: #374151;
margin-left: 0.5rem;
}
.secondary:hover {
background: #d1d5db;
}
.download-list {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.download-list li {
margin-bottom: 0.5rem;
}
/* Tooltips */
.tooltip-wrap {
position: relative;
}
.tooltip-wrap .tooltip {
display: none;
position: absolute;
left: 0;
top: 100%;
z-index: 10;
background: #1f2937;
color: #f3f4f6;
padding: 0.6rem 0.8rem;
border-radius: 6px;
font-size: 0.8rem;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
pointer-events: none;
margin-top: 0.25rem;
}
.tooltip-wrap .tooltip hr {
border: none;
border-top: 1px solid #4b5563;
margin: 0.3rem 0;
}
.tooltip-wrap:hover .tooltip {
display: block;
}