Fork with bugfixes and additional features. Init. Commit
This commit is contained in:
parent
3ddd36fd26
commit
ac2ef32827
|
|
@ -1 +1,2 @@
|
||||||
./temp/*
|
./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" {
|
} else if part == "G03" {
|
||||||
// Counter-clockwise circular interpolation
|
// Counter-clockwise circular interpolation
|
||||||
gf.Commands = append(gf.Commands, GerberCommand{Type: "G03"})
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -411,6 +417,8 @@ func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image {
|
||||||
curX, curY := 0.0, 0.0
|
curX, curY := 0.0, 0.0
|
||||||
curDCode := 0
|
curDCode := 0
|
||||||
interpolationMode := "G01" // Default linear
|
interpolationMode := "G01" // Default linear
|
||||||
|
inRegion := false
|
||||||
|
var regionVertices [][2]int
|
||||||
|
|
||||||
for _, cmd := range gf.Commands {
|
for _, cmd := range gf.Commands {
|
||||||
if cmd.Type == "APERTURE" {
|
if cmd.Type == "APERTURE" {
|
||||||
|
|
@ -421,6 +429,20 @@ func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image {
|
||||||
interpolationMode = cmd.Type
|
interpolationMode = cmd.Type
|
||||||
continue
|
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
|
prevX, prevY := curX, curY
|
||||||
if cmd.X != nil {
|
if cmd.X != nil {
|
||||||
|
|
@ -430,6 +452,59 @@ func (gf *GerberFile) Render(dpi float64, bounds *Bounds) image.Image {
|
||||||
curY = *cmd.Y
|
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" {
|
if cmd.Type == "FLASH" {
|
||||||
// Draw Aperture at curX, curY
|
// Draw Aperture at curX, curY
|
||||||
ap, ok := gf.State.Apertures[curDCode]
|
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)
|
gf.drawLine(img, x1, y1, x2, y2, ap, scale, white)
|
||||||
} else {
|
} else {
|
||||||
// Circular Interpolation (G02/G03)
|
// Circular Interpolation (G02/G03)
|
||||||
// I and J are offsets from start point (prevX, prevY) to center
|
|
||||||
iVal := 0.0
|
iVal := 0.0
|
||||||
jVal := 0.0
|
jVal := 0.0
|
||||||
if cmd.I != nil {
|
if cmd.I != nil {
|
||||||
|
|
@ -537,7 +611,9 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale
|
||||||
if w > h {
|
if w > h {
|
||||||
// Horizontal
|
// Horizontal
|
||||||
rectW := w - h
|
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
|
// Center Rect
|
||||||
r := image.Rect(x-rectW/2, y-h/2, x+rectW/2, y+h/2)
|
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 {
|
} else {
|
||||||
// Vertical
|
// Vertical
|
||||||
rectH := h - w
|
rectH := h - w
|
||||||
if rectH < 0 { rectH = 0 }
|
if rectH < 0 {
|
||||||
|
rectH = 0
|
||||||
|
}
|
||||||
|
|
||||||
// Center Rect
|
// Center Rect
|
||||||
r := image.Rect(x-w/2, y-rectH/2, x+w/2, y+rectH/2)
|
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
|
// Draw filled polygon using scanline algorithm
|
||||||
drawFilledPolygon(img, vertices, c)
|
drawFilledPolygon(img, vertices)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 20: // Vector Line
|
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)},
|
{x + int((endX-perpX)*scale), y - int((endY-perpY)*scale)},
|
||||||
}
|
}
|
||||||
|
|
||||||
drawFilledPolygon(img, vertices, c)
|
drawFilledPolygon(img, vertices)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 21: // Center Line (Rect)
|
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
|
// 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 {
|
if len(vertices) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
365
main.go
365
main.go
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
|
@ -18,6 +19,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Configuration ---
|
// --- Configuration ---
|
||||||
|
|
@ -392,7 +394,7 @@ func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3
|
||||||
h = cfg.WallHeight
|
h = cfg.WallHeight
|
||||||
} else if isStencilSolid {
|
} else if isStencilSolid {
|
||||||
if isInsideBoard {
|
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) {
|
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" {
|
if r.Method != "POST" {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|
@ -658,15 +665,365 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Success
|
// 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")
|
tmpl, err := template.ParseFS(staticFiles, "static/result.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := struct{ Filename string }{Filename: filepath.Base(outSTL)}
|
data := struct {
|
||||||
|
Message string
|
||||||
|
Files []string
|
||||||
|
}{Message: message, Files: files}
|
||||||
tmpl.Execute(w, data)
|
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) {
|
func downloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := strings.Split(r.URL.Path, "/")
|
vars := strings.Split(r.URL.Path, "/")
|
||||||
if len(vars) < 3 {
|
if len(vars) < 3 {
|
||||||
|
|
@ -699,6 +1056,10 @@ func runServer(port string) {
|
||||||
|
|
||||||
http.HandleFunc("/", indexHandler)
|
http.HandleFunc("/", indexHandler)
|
||||||
http.HandleFunc("/upload", uploadHandler)
|
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)
|
http.HandleFunc("/download/", downloadHandler)
|
||||||
|
|
||||||
fmt.Printf("Starting server on http://0.0.0.0:%s\n", port)
|
fmt.Printf("Starting server on http://0.0.0.0:%s\n", port)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,26 +1,42 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Gerber to Stencil converter</title>
|
<title>PCB Tools by kennycoder</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>PCB 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">
|
<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>
|
<label for="gerber">Solder Paste Gerber File (Required)</label>
|
||||||
<input type="file" id="gerber" name="gerber" accept=".gbr,.gtp,.gbp" required>
|
<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>
|
||||||
<div class="form-group">
|
</div>
|
||||||
|
<div class="form-group tooltip-wrap">
|
||||||
<label for="outline">Board Outline Gerber (Optional)</label>
|
<label for="outline">Board Outline Gerber (Optional)</label>
|
||||||
<input type="file" id="outline" name="outline" accept=".gbr,.gko,.gm1">
|
<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="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>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="height">Stencil Height (mm)</label>
|
<label for="height">Stencil Height (mm)</label>
|
||||||
<input type="number" id="height" name="height" value="0.16" step="0.01">
|
<input type="number" id="height" name="height" value="0.16" step="0.01">
|
||||||
|
|
@ -31,7 +47,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="wallHeight">Wall Height (mm)</label>
|
<label for="wallHeight">Wall Height (mm)</label>
|
||||||
<input type="number" id="wallHeight" name="wallHeight" value="2.0" step="0.1">
|
<input type="number" id="wallHeight" name="wallHeight" value="2.0" step="0.1">
|
||||||
|
|
@ -42,8 +58,89 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" id="submit-btn">Convert to STL</button>
|
<button type="submit" class="submit-btn">Convert to STL</button>
|
||||||
</form>
|
</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 id="loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|
@ -52,11 +149,26 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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('loading').style.display = 'block';
|
||||||
document.getElementById('submit-btn').disabled = true;
|
var btn = form.querySelector('.submit-btn');
|
||||||
document.getElementById('submit-btn').innerText = 'Converting...';
|
btn.disabled = true;
|
||||||
|
btn.innerText = 'Processing...';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Gerber to Stencil converter</title>
|
<title>PCB Tools by kennycoder</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Success!</h2>
|
<h2>Success!</h2>
|
||||||
<p>Your stencil has been generated successfully.</p>
|
<p>{{.Message}}</p>
|
||||||
<a href="/download/{{.Filename}}" class="btn">Download STL</a>
|
<ul class="download-list">
|
||||||
<a href="/" class="btn secondary">Convert Another</a>
|
{{range .Files}}
|
||||||
|
<li><a href="/download/{{.}}" class="btn">Download {{.}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<a href="/" class="btn secondary">Back</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
128
static/style.css
128
static/style.css
|
|
@ -6,6 +6,7 @@
|
||||||
--text: #1f2937;
|
--text: #1f2937;
|
||||||
--border: #e5e7eb;
|
--border: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
|
|
@ -17,6 +18,7 @@ body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
@ -25,6 +27,7 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
@ -34,6 +37,7 @@ body {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
|
@ -41,19 +45,73 @@ h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
color: #059669;
|
color: #059669;
|
||||||
margin-top: 0;
|
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 {
|
.form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
|
|
@ -64,12 +122,14 @@ input[type="file"] {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
|
.submit-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
|
|
@ -82,18 +142,22 @@ button {
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
button:hover {
|
|
||||||
|
.submit-btn:hover {
|
||||||
background-color: var(--primary-hover);
|
background-color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
button:disabled {
|
|
||||||
|
.submit-btn:disabled {
|
||||||
background-color: #9ca3af;
|
background-color: #9ca3af;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
#loading {
|
#loading {
|
||||||
display: none;
|
display: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
border: 4px solid #f3f3f3;
|
border: 4px solid #f3f3f3;
|
||||||
border-top: 4px solid var(--primary);
|
border-top: 4px solid var(--primary);
|
||||||
|
|
@ -103,10 +167,18 @@ button:disabled {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
margin: 0 auto 0.5rem auto;
|
margin: 0 auto 0.5rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% {
|
||||||
100% { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result page buttons */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
|
|
@ -117,14 +189,60 @@ button:disabled {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary:hover {
|
.secondary:hover {
|
||||||
background: #d1d5db;
|
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