diff --git a/.gitignore b/.gitignore index 2077be6..a670ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -./temp/* \ No newline at end of file +./temp/* +temp/ \ No newline at end of file diff --git a/drill.go b/drill.go new file mode 100644 index 0000000..6c2bd66 --- /dev/null +++ b/drill.go @@ -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 +} diff --git a/enclosure.go b/enclosure.go new file mode 100644 index 0000000..c80cb00 --- /dev/null +++ b/enclosure.go @@ -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 +} diff --git a/gerber.go b/gerber.go index a0b2964..6765b60 100644 --- a/gerber.go +++ b/gerber.go @@ -134,20 +134,20 @@ func ParseGerber(filename string) (*GerberFile, error) { if mLine == "" { continue } - + // Check if this line ends the macro definition // Standard allows ending with *% at end of last primitive OR a separate line with % trimmedLine := strings.TrimSpace(mLine) if trimmedLine == "%" { break } - + endsWithPercent := strings.HasSuffix(mLine, "*%") - + // Remove trailing *% or just * mLine = strings.TrimSuffix(mLine, "*%") mLine = strings.TrimSuffix(mLine, "*") - + // Parse primitive parts := strings.Split(mLine, ",") if len(parts) > 0 && parts[0] != "" { @@ -161,7 +161,7 @@ func ParseGerber(filename string) (*GerberFile, error) { primitives = append(primitives, MacroPrimitive{Code: code, Modifiers: mods}) } } - + // If line ended with *%, macro definition is complete if endsWithPercent { break @@ -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 } @@ -268,7 +274,7 @@ func (gf *GerberFile) parseCoordinate(valStr string, fmtSpec struct{ Integer, De // with variable substitution from aperture modifiers func evaluateMacroExpression(expr string, params []float64) float64 { expr = strings.TrimSpace(expr) - + // Handle simple addition (e.g., "$1+$1") if strings.Contains(expr, "+") { parts := strings.Split(expr, "+") @@ -278,7 +284,7 @@ func evaluateMacroExpression(expr string, params []float64) float64 { } return result } - + // Handle simple subtraction (e.g., "$1-$2") if strings.Contains(expr, "-") && !strings.HasPrefix(expr, "-") { parts := strings.Split(expr, "-") @@ -288,7 +294,7 @@ func evaluateMacroExpression(expr string, params []float64) float64 { return left - right } } - + // Handle variable substitution (e.g., "$1", "$2") if strings.HasPrefix(expr, "$") { varNum, err := strconv.Atoi(expr[1:]) @@ -297,7 +303,7 @@ func evaluateMacroExpression(expr string, params []float64) float64 { } return 0.0 } - + // Handle literal numbers val, _ := strconv.ParseFloat(expr, 64) return val @@ -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,12 +611,14 @@ 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) draw.Draw(target, r, color, image.Point{}, draw.Src) - + // Left Circle drawCircle(target, x-rectW/2, y, h/2) // Right Circle @@ -550,14 +626,16 @@ 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) draw.Draw(target, r, color, image.Point{}, draw.Src) - + // Top Circle (Y decreases upwards in image coords usually, but here we treat y as center) - // Note: In our coordinate system, y is center. + // Note: In our coordinate system, y is center. drawCircle(target, x, y-rectH/2, w/2) // Bottom Circle drawCircle(target, x, y+rectH/2, w/2) @@ -601,24 +679,24 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale // Need at least numVertices * 2 coordinates + rotation if len(prim.Modifiers) >= 2+numVertices*2+1 { rotation := evaluateMacroExpression(prim.Modifiers[2+numVertices*2], ap.Modifiers) - + // Extract vertices vertices := make([][2]int, numVertices) for i := 0; i < numVertices; i++ { vx := evaluateMacroExpression(prim.Modifiers[2+i*2], ap.Modifiers) vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], ap.Modifiers) - + // Apply rotation vx, vy = rotatePoint(vx, vy, rotation) - + px := int(vx * scale) py := int(vy * scale) - + vertices[i] = [2]int{x + px, y - py} } - + // Draw filled polygon using scanline algorithm - drawFilledPolygon(img, vertices, c) + drawFilledPolygon(img, vertices) } } case 20: // Vector Line @@ -635,22 +713,22 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale endX := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers) endY := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers) rotation := evaluateMacroExpression(prim.Modifiers[6], ap.Modifiers) - + // Apply rotation to start and end points startX, startY = rotatePoint(startX, startY, rotation) endX, endY = rotatePoint(endX, endY, rotation) - + // Calculate the rectangle representing the line // The line goes from (startX, startY) to (endX, endY) with width dx := endX - startX dy := endY - startY length := math.Sqrt(dx*dx + dy*dy) - + if length > 0 { // Perpendicular vector for width perpX := -dy / length * width / 2 perpY := dx / length * width / 2 - + // Four corners of the rectangle vertices := [][2]int{ {x + int((startX-perpX)*scale), y - int((startY-perpY)*scale)}, @@ -658,8 +736,8 @@ 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) @@ -678,7 +756,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale // Calculate the four corners of the rectangle (centered at origin) halfW := width / 2 halfH := height / 2 - + // Four corners before rotation corners := [][2]float64{ {-halfW, -halfH}, @@ -686,7 +764,7 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale {halfW, halfH}, {-halfW, halfH}, } - + // Apply rotation and translation vertices := make([][2]int, 4) for i, corner := range corners { @@ -700,9 +778,9 @@ func (gf *GerberFile) drawAperture(img *image.RGBA, x, y int, ap Aperture, scale py := int(ry * scale) vertices[i] = [2]int{x + px, y - py} } - + // 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 } diff --git a/main.go b/main.go index e71d946..344e043 100644 --- a/main.go +++ b/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) diff --git a/pcb-to-stencil b/pcb-to-stencil new file mode 100755 index 0000000..a795a90 Binary files /dev/null and b/pcb-to-stencil differ diff --git a/static/index.html b/static/index.html index 508da2d..8a25bac 100644 --- a/static/index.html +++ b/static/index.html @@ -1,49 +1,146 @@ + - Gerber to Stencil converter + PCB Tools by kennycoder +
-

PCB to Stencil Converter by kennycoder

-
-
- - -
-
- - -
Upload this to automatically crop and generate walls.
-
- -
-
- - -
-
- - -
-
+

PCB Tools by kennycoder

-
-
- - -
-
- - -
-
+
+ + +
- -
+ +
+
+
+ + +
Layers to export for Gerbers +
• F.Paste (front paste stencil)
• B.Paste (back paste stencil) +
+
+
+ + +
Upload this to automatically crop and generate walls.
+
Layers to export for Gerbers +
• Edge.Cuts (board outline) +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Layers to export for Gerbers +
• Edge.Cuts (board outline) +
+
+
+ + +
Component through-holes (vias auto-filtered).
+
Layers to export for DRL +
• Use the PTH file (Plated Through-Hole)
• Vias are automatically filtered out +
+
+
+ + +
Mounting holes — become pegs in enclosure.
+
Layers to export for DRL +
• Use the NPTH file (Non-Plated Through-Hole)
• These become alignment pegs +
+
+
+ + +
Component outlines — used for lid cutouts.
+
Layers to export for Gerbers +
F.Courtyard (front courtyard)
• ☑ Exclude DNP footprints in KiCad plot + dialog
• Cutouts generated where components exist +
+
+
+ + +
Soldermask openings — minimum pad cutouts.
+
Layers to export for Gerbers +
F.Mask (front soldermask)
• Shows exact pad areas that need cutouts
• ☑ + Exclude DNP footprints in KiCad plot dialog +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
Gap between PCB edge and enclosure wall.
+
+
+ +
+
+ + +
+
+
+ + +
+
@@ -52,11 +149,26 @@
+ \ No newline at end of file diff --git a/static/preview.html b/static/preview.html new file mode 100644 index 0000000..09f87c4 --- /dev/null +++ b/static/preview.html @@ -0,0 +1,499 @@ + + + + + + + Enclosure Preview — PCB Tools + + + + + +
+

Enclosure Preview

+ + +
+ +
+ + +
+ +
Enclosure walls follow the board outline shape instead of a rectangular box.
+
+ +
+ +
Place rounded-rectangle cutouts on enclosure side walls.
+
+ + +
+
+
North
+
East
+
South
+
West
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
All values in mm (0.01mm precision)
+
+
+ +
+ +
+ +
+
+ +
+ + + + +
+
+ + + + + \ No newline at end of file diff --git a/static/result.html b/static/result.html index 325c864..c69edc5 100644 --- a/static/result.html +++ b/static/result.html @@ -1,17 +1,24 @@ + - Gerber to Stencil converter + PCB Tools by kennycoder +

Success!

-

Your stencil has been generated successfully.

- Download STL - Convert Another +

{{.Message}}

+ + Back
+ \ No newline at end of file diff --git a/static/style.css b/static/style.css index 1386404..ca37361 100644 --- a/static/style.css +++ b/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,15 +27,17 @@ body { width: 100%; max-width: 500px; } + .card { background-color: var(--card-bg); padding: 2rem; border-radius: 12px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); text-align: center; 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; + 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; } \ No newline at end of file