Fork with bugfixes and additional features. Init. Commit
This commit is contained in:
parent
3ddd36fd26
commit
ac2ef32827
|
|
@ -1 +1,2 @@
|
|||
./temp/*
|
||||
temp/
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
92
gerber.go
92
gerber.go
|
|
@ -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
365
main.go
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
128
static/style.css
128
static/style.css
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue