KiCad

The layers of a board are planex2d faces, which can be written as a KiCad board and read back.

[1]:
import itertools
import math
import planex as px

A minimal example

The example is a round board: a disc of radius 20 mm as the board outline and a copper layer of radius 15 mm with a hole of radius 2 mm at (5, 0). It needs only planex2d and writes disc.kicad_pcb into the working directory:

[3]:
outline = px.QuickSketch().Circle(20).Face()
copper = px.QuickSketch().Circle(15).Circle(5, 0, 2).Face()
px.WriteKiCad("disc.kicad_pcb", {"F.Cu": copper, "Edge.Cuts": outline}, unit=px.mm)

board = px.FromKiCad("disc.kicad_pcb")
print(list(board), "copper", 1e6 * board["F.Cu"].mass, "mm², exact", math.pi * (15**2 - 2**2))
['F.Cu', 'B.Cu', 'Edge.Cuts'] copper 694.2919764433442 mm², exact 694.2919764433443

The copper layer comes back as the same face, in meters.

A real board

px.FromKiCad reads a KiCad board (.kicad_pcb, KiCad 9 and later) as a dict of shapes, one per layer. The copper layers come first, from the top, with their faces named by net and their drill holes cut out. The board outline Edge.Cuts follows as a face named "profile", and then every other layer that holds something, such as silkscreen, solder mask, paste and courtyards, as KiCad prints it. Given a stackup, it builds the whole board in 3D instead. px.WriteKiCad writes shapes back as a board. Everything is in meters with y pointing up, as in the rest of planex2d, while KiCad works in millimeters with y pointing down.

The board here is the pic_programmer demo from KiCad’s own demo projects, demos/pic_programmer in the KiCad repository, licensed CC BY-SA 4.0 by the KiCad project. The board file and the pictures on this page made from it are under the same license. Download: pic_programmer.kicad_pcb.

[4]:
board = px.FromKiCad("pic_programmer.kicad_pcb")
for name, layer in board.items():
    print(f"{name:10} {len(layer.faces):4} faces, {1e6 * layer.mass:9.2f} mm²")
/workspace/planex/documentation/fenicsx/lib/python3.12/site-packages/planex/_ids.py:70: PlanexWarning: FromKiCad: 223 texts left out (not supported yet)
  r = f(*args, **kwargs)
F.Cu        232 faces,    762.68 mm²
B.Cu        126 faces,  11819.33 mm²
Edge.Cuts     1 faces,  15589.59 mm²
F.SilkS      72 faces,    371.79 mm²
B.SilkS       1 faces,      1.15 mm²
F.Mask      245 faces,    836.04 mm²
B.Mask      246 faces,    839.72 mm²
Cmts.User     6 faces,     24.32 mm²
F.CrtYd      62 faces,    147.59 mm²
B.CrtYd       1 faces,      0.58 mm²
F.Fab        60 faces,    200.20 mm²

Text is not read yet, and FromKiCad reports how much it left out. The edges of holes through copper are named "pth" (plated, for vias and plated pads) or "npth" (unplated), and every hole through the whole board is also cut out of the outline.

[5]:
fcu, bcu, outline = board["F.Cu"], board["B.Cu"], board["Edge.Cuts"]
(x0, y0), (x1, y1) = outline.bounding_box
print("board", round(1e3 * (x1 - x0), 1), "x", round(1e3 * (y1 - y0), 1), "mm,", len(outline.faces[0].inner),
      "holes through it")
for name, layer in (("F.Cu", fcu), ("B.Cu", bcu)):
    nets = {f.name for f in layer.faces if f.name}
    print(name, len(nets), "nets,", len(layer.edges(name="pth")), "edges of plated holes")
board 160.0 x 99.1 mm, 251 holes through it
F.Cu 111 nets, 244 edges of plated holes
B.Cu 111 nets, 244 edges of plated holes

Both copper layers

The two copper layers are drawn in copper: the top layer (Figure 1a) and the bottom layer (Figure 1b), which is mostly one ground pour. The board outline is dashed.

F.Cu, the top layer
(a) F.Cu, the top layer
B.Cu, the bottom layer
(b) B.Cu, the bottom layer
Figure 1

What the board looks like

The other layers hold what KiCad prints there. The solder mask layer F.Mask holds the openings in the mask, with every pad grown by its mask margin and joined where pads come closer than the board’s minimum mask width. The silkscreen F.SilkS holds the lines and outlines as wide as they are drawn. Laid over each other, they show the board as it comes from the manufacturer, with the copper seen through the openings (Figure 2a), and the top of the PIC’s socket (Figure 2b).

F.Mask and F.SilkS over the outline
(a) F.Mask and F.SilkS over the outline
The top of the PIC's socket
(b) The top of the PIC's socket
Figure 2

Copper per net

The net is the name of the face, so everything that planex2d measures can be grouped by net.

[8]:
def copper_by_net(layer):
    area = {}
    for f in layer.faces:
        net = f.name or "(no net)"
        area[net] = area.get(net, 0.0) + 1e6 * f.mass
    return area


for name, layer in (("F.Cu", fcu), ("B.Cu", bcu)):
    largest = sorted(copper_by_net(layer).items(), key=lambda kv: -kv[1])[:5]
    print(f"{name}: " + ", ".join(f"{net} {area:.1f} mm²" for net, area in largest))
F.Cu: GND 98.3 mm², /DATA-RB7 68.9 mm², /CLOCK-RB6 56.9 mm², VCC 50.2 mm², /pic_sockets/VCC_PIC 44.6 mm²
B.Cu: GND 10673.7 mm², /pic_sockets/VCC_PIC 137.9 mm², /VPP/MCLR 131.8 mm², VCC 120.0 mm², /CLOCK-RB6 76.8 mm²

A clearance check

Measure(net, to=other) returns the distance between the faces of two nets and the two closest points. Only nets whose bounding boxes come within a millimeter of each other are measured. The project’s default net class requires 0.25 mm between copper of different nets.

[9]:
def boxes(layer):
    out = {}
    for f in layer.faces:
        if f.name:
            (a, b), (c, d) = f.bounding_box
            u0, v0, u1, v1 = out.get(f.name, (a, b, c, d))
            out[f.name] = (min(u0, a), min(v0, b), max(u1, c), max(v1, d))
    return out


def gaps(layer, near=1e-3):
    box = boxes(layer)

    def close(p, q):
        return (p[0] - near < q[2] and q[0] - near < p[2]
                and p[1] - near < q[3] and q[1] - near < p[3])

    pairs = itertools.combinations(sorted(box), 2)
    return {(a, b): layer.Measure(a, to=b)
            for a, b in pairs if close(box[a], box[b])}


rule = 0.25e-3
found = {}
for name, layer in (("F.Cu", fcu), ("B.Cu", bcu)):
    found[name] = gaps(layer)
    (a, b), m = min(found[name].items(), key=lambda kv: kv[1]["distance"])
    print(name, len(found[name]), "pairs, closest", a, "and", b, round(1e3 * m["distance"], 4), "mm")
    for (a, b), m in found[name].items():
        if m["distance"] < rule:
            print("    under 0.25 mm:", a, "and", b, round(1e3 * m["distance"], 4), "mm,",
                  round(1e6 * (rule - m["distance"]), 1), "µm short")
F.Cu 522 pairs, closest /pic_sockets/VCC_PIC and Net-(Q3-B) 0.3614 mm
B.Cu 557 pairs, closest /pic_sockets/VCC_PIC and VCC 0.2496 mm
    under 0.25 mm: /pic_sockets/VCC_PIC and VCC 0.2496 mm, 0.4 µm short

The closest spot is on the bottom layer, shown with the two nets colored (Figure 3). It is JP1, a solder jumper made of two triangular pads that are bridged with a blob of solder when the jumper is to be closed, so their gap is narrow on purpose.

[10]:
(a, b), m = min(found["B.Cu"].items(), key=lambda kv: kv[1]["distance"])
p, q = (point[:2] for point in m["points"])
print(a, "and", b, round(1e3 * m["distance"], 4), "mm")
/pic_sockets/VCC_PIC and VCC 0.2496 mm
/pic_sockets/VCC_PIC blue, VCC red: 0.2496 mm apart
Figure 3: /pic_sockets/VCC_PIC blue, VCC red: 0.2496 mm apart

The board in 3D

With a stackup instead of a layer name, FromKiCad returns the board as one glued OpenCASCADE shape. Every dielectric is the outline extruded between its two copper layers and drilled, the hole walls are faces named "pth" and "npth", and the copper layers are faces at their heights, named by net (the copper itself has no thickness). The stackup is a list of dielectric thicknesses in millimeters from the top, a manufacturer’s table from px.stackups (such as px.stackups.jlc7628), or px.stackups.kicad, the stackup stored in the board file, here a 1.51 mm core.

[12]:
from ngsolve_webgpu.jupyter import Draw

solid = px.FromKiCad("pic_programmer.kicad_pcb", px.stackups.kicad)
walls = {n: sum(1 for f in solid.faces if f.name == n) for n in ("pth", "npth")}
print("solids", [s.name for s in solid.solids], len(solid.faces), "faces")
print("hole walls:", walls["pth"], "plated,", walls["npth"], "unplated")
Draw(solid, settings={"Objects": {"Edges": False}})
/workspace/planex/documentation/fenicsx/lib/python3.12/site-packages/planex/_ids.py:70: PlanexWarning: FromKiCad: 19 texts left out (not supported yet)
  r = f(*args, **kwargs)
solids ['dielectric_1'] 713 faces
hole walls: 244 plated, 7 unplated
3D view
▶ Click to interact

Writing a board

px.WriteKiCad takes a dict of layers with the same names and writes a KiCad 9 board. Every face becomes a filled polygon with exact lines and arcs, and the outline becomes lines, arcs and circles on Edge.Cuts. Holes whose edges are named "pth" become vias, round "npth" holes become mounting holes, and faces connected by a plated hole become one net. The copper read back from the copy:

[13]:
px.WriteKiCad("pic_copy.kicad_pcb", {"F.Cu": fcu, "B.Cu": bcu, "Edge.Cuts": outline})
copy = px.FromKiCad("pic_copy.kicad_pcb")
for name in ("F.Cu", "B.Cu", "Edge.Cuts"):
    a, b = board[name].mass, copy[name].mass
    faces = len(copy[name].faces)
    print(f"{name:9} {1e6 * a:.6f} mm² -> {1e6 * b:.6f} mm², {faces} faces")
F.Cu      762.675890 mm² -> 762.675804 mm², 232 faces
B.Cu      11819.329444 mm² -> 11819.329411 mm², 126 faces
Edge.Cuts 15589.587138 mm² -> 15589.587138 mm², 1 faces

Boards from other programs

A board that is not a .kicad_pcb file (Altium, Eagle, PADS, CADSTAR, Fabmaster, P-CAD, SolidWorks PCB) is first converted by KiCad’s own command line tool, kicad-cli pcb import, of KiCad 10 or later, and then read as above. FromKiCad finds the tool through the environment variable PLANEX_KICAD_CLI, on the PATH, or where KiCad installs it on macOS and Windows. Board files older than KiCad 9 raise an error with a hint to open them in KiCad 9 or later and save them.