Skip to content

The board project interface

What you own when you build a board with pcbkit: the folder layout, pcbkit.toml, and the hooks in routing.py.

Status: pre-release. The pcbkit.toml schema and the routing.py hooks are settled here; the contracts of the other modules are added as the commands that read them are implemented.

Layout

my-board/
  pcbkit.toml          board names, stackup, router and stitching settings, fab profile, checks
  pyproject.toml       depends on pcbkit; `pcbkit new` writes it
  design.py            the circuit: parts and nets, written with pcbkit.design
  layout.py            W, H, P = {ref: (x, y, rot)}; optional CORNER_R, HOLES, outline(), SHOTS
  routing.py           hooks for routing (below)
  silk.py              optional: labels and silkscreen choices (LABELS, CONN_LABELS, HIDE_REF,
                       KEEP_REF, extra(), title block)
  bom.py               optional: how parts are bought and shown in the BOM (MPN_OVERRIDE, ...)
  footprints.py        optional: custom symbols and footprints; footprints/ holds .kicad_mod files
  specs.py             datasheet numbers and design limits the checks read
  circuits.py          optional: SPICE deck builders
  checks/test_*.py     your own checks (pytest), written against the pcbkit.check APIs
  mutants.py           MUTANTS: planted mistakes the checks must catch
  kicad/ out/ golden/ fab/    generated by pcbkit: never edit by hand
  archive/             yours: earlier revisions
  • A command finds the project from the current directory: the nearest folder at or above it that holds a pcbkit.toml.
  • kicad/, out/, golden/ and fab/ are regenerated by pcbkit. An edit made there, including one made in the KiCad GUI, is lost on the next build. Change the Python instead. Opening the files in KiCad to look at them is fine.
  • golden/ holds the route that passed DRC (pcbkit promote writes it, pcbkit finalize rebuilds from it).
  • Coordinates in layout.py and in the routing hooks are millimetres from the board's top-left corner, with y pointing down. pcbkit adds a fixed (50, 50) mm offset to place the board on KiCad's page; the helpers below apply it, so your code never sees it.

Importing your own modules

pcbkit imports your modules by file path (pcbkit.project.import_project_module). While it does, it puts the project folder on sys.path, so routing.py can simply import layout. Every importer shares one copy of each module. Name your modules so they do not shadow an installed package (a design.py is fine, a string.py is not). An error inside your own module reaches you as an ordinary traceback through your file.

pcbkit.toml

[board]
stem = "my_board"            # KiCad file stem: kicad/my_board.kicad_pcb
title = "My Board"           # shown in the title block
rev = "A"                    # shown in the title block
fab_name = "My_Board_revA"   # prefix of the files written for the fab house

[stackup]
layers = 2
thickness_mm = 1.6
copper_mm = 0.035            # 1 oz; 2 oz is 0.070

[route]
freerouting_passes = 40
tries = 3
stall_timeout_s = 90
eco_unlock_reach_mm = 8.0

[stitch]
pitch_mm = 5.0
dense = [[10.0, 10.0, 30.0, 20.0, 2.0]]   # x0, y0, x1, y1, pitch
gap_limit_mm = 3.4

[fab]
profile = "pcbway"

[checks]
groups = ["kicad", "outputs", "fab"]

Only [board] is required, and every key in it is. Leave out any other table or key and it takes the default below.

Key Type Default Meaning
board.stem string required KiCad file stem. Letters, digits, _, - and ., no spaces, not starting with . or -.
board.title string required Title block text.
board.rev string required Revision, such as "A". Quote it: rev = 2 is an integer and is rejected.
board.fab_name string required Base name of the files written for the fab house, such as My_Board_revA_gerbers.zip. Same character rules as stem.
stackup.layers integer 2 Copper layers. Only 2 is accepted: pcbkit builds two-layer boards.
stackup.thickness_mm number 1.6 Finished board thickness.
stackup.copper_mm number 0.035 Outer copper thickness, written into the saved board's stackup. 1 oz is 0.035, 2 oz is 0.070.
route.freerouting_passes integer 40 Maximum passes in one Freerouting run (its -mp option).
route.tries integer 3 Freerouting runs pcbkit route makes before giving up when the result is not DRC-clean.
route.stall_timeout_s integer 90 Seconds to wait for Freerouting to start routing before it is killed. It can hang while loading a board.
route.eco_unlock_reach_mm number 8.0 With pcbkit route --eco, copper this close to a changed pad, and copper on the changed parts' nets, stays unlocked so Freerouting may rework it.
stitch.pitch_mm number 5.0 Grid pitch of ground-stitching vias wherever both ground pours have solid copper.
stitch.dense list of boxes [] Boxes, each [x0, y0, x1, y1, pitch] in layout millimetres with x0 < x1 and y0 < y1, where the stitching grid uses pitch instead. Use it under a switching regulator, for example.
stitch.gap_limit_mm number 3.4 Largest distance from any point with ground on both layers to the nearest ground via or plated ground pad. pcbkit adds vias until nothing is further away. Choose it a little under the limit you care about (a twentieth of a wavelength at your highest frequency, say).
fab.profile string "pcbway" Whose limits and order forms to use. "pcbway" is the only profile so far.
checks.groups list of strings ["kicad", "outputs"] Built-in check groups to run: kicad, outputs, fab, copper, circuit, esp32s3. The default groups need nothing from specs.py; add the others as you write the numbers they read.

How the file is read:

  • A number accepts a TOML integer or float (thickness_mm = 2 is fine) but never a boolean, and never inf or nan. Whole-number keys reject 2.0 and "2".
  • An unknown table or key is an error, with a "did you mean" when one is close. A typo never silently takes the default.
  • Every problem in the file is reported together, naming the file, the table and the key.

design.py

The circuit: every part, and the net on each of its pins. You write it with a few calls from pcbkit.design. pcbkit draws the schematic from it (pcbkit sch), and the board is built from the netlist KiCad exports from that schematic, so schematic and board cannot drift apart.

from __future__ import annotations

from pcbkit.design import FP, LED, R, part

BLOCK_ORDER = ["Supply", "Indicator"]
NOTES = ["My board: 3V3 in, one LED.", "The LED draws about 5 mA."]

B = "Supply"
part(
    "J1",
    "Connector_Generic:Conn_01x02",
    "3V3 in",
    FP["HDR2"],
    {"1": "+3V3", "2": "GND"},
    "Acme",
    "HDR-2",
    "Supply header: 3V3 / GND",
    B,
)

B = "Indicator"
R("R1", "330", "+3V3", "LED_A", B)
LED("D1", "Green", "LED_A", "GND", B, "GRN-0603", "Acme")

Nets are joined by name: pins that name the same net are connected, and no wire is drawn. A pin number the symbol does not have is an error. A pin of the symbol that you leave out, or give None, gets a no-connect flag, except on a part whose reference starts with # (a power flag, whose text is also hidden on the sheet) or whose symbol is in the Mechanical library.

The calls

part(ref, sym, value, fp, pins, mfr="", mpn="", desc="", block="", dnp=False, bom=True)
R(ref, value, a, b, block, mpn="", mfr="", fp=None, desc="")
C(ref, value, a, b, block, size="C0603", mpn="", mfr="", desc="")
LED(ref, color, anode, cathode, block, mpn, mfr)
  • part makes one part. ref is unique in the design. sym is Library:Name, a symbol of KiCad's stock libraries or of the project's own (see footprints.py below). pins maps pin numbers, as strings, to net names. block names the group the part is drawn in. dnp marks it do-not-populate; bom=False keeps it out of the bill of materials.
  • R makes a Device:R in the FP["R0603"] footprint, or in fp. mpn defaults to 0603 <value> 1% and desc to Resistor <value> 0603 1%.
  • C makes a Device:C whose footprint is the FP entry named by size. desc defaults to Ceramic capacitor <value> <size without its letter>, so 0603 for the default.
  • LED makes a Device:LED in FP["LED0603"], with the anode on pin 2 and the cathode on pin 1, as KiCad numbers it. desc is LED <color> 0603.
  • Positional order is part of the contract: the calls above are what existing designs write.

FP maps short keys to footprints. It is a plain dict that you can extend in your design.py (FP["HDR3"] = "my_board:Header_1x03_P2.54mm"); it starts from these entries each time the design is loaded:

Key Footprint
R0603 Resistor_SMD:R_0603_1608Metric
C0603 Capacitor_SMD:C_0603_1608Metric
C0805 Capacitor_SMD:C_0805_2012Metric
C1206 Capacitor_SMD:C_1206_3216Metric
LED0603 LED_SMD:LED_0603_1608Metric
XH2 Connector_JST:JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical
XH3 Connector_JST:JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical
XH4 Connector_JST:JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical
XH5 Connector_JST:JST_XH_B5B-XH-A_1x05_P2.50mm_Vertical
XH6 Connector_JST:JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical
XH10 Connector_JST:JST_XH_B10B-XH-A_1x10_P2.50mm_Vertical
PH3 Connector_JST:JST_PH_B3B-PH-K_1x03_P2.00mm_Vertical
ZH4 Connector_JST:JST_ZH_B4B-ZR_1x04_P1.50mm_Vertical
QWIIC Connector_JST:JST_SH_BM04B-SRSS-TB_1x04-1MP_P1.00mm_Vertical
HDR2 Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical
HDR8 Connector_PinHeader_2.54mm:PinHeader_1x08_P2.54mm_Vertical
WIRE2 Connector_Wire:SolderWire-2sqmm_1x02_P7.8mm_D2mm_OD3.9mm

Names design.py can define

All optional. The board's stem, title and revision are not here: they come from pcbkit.toml.

Name Type Default Meaning
BLOCK_ORDER list of strings the blocks, in the order the parts first use them The order the blocks are drawn on the sheet.
NOTES list of strings [] Lines of text under the blocks. The first is the heading.
BLOCK_WIDTHS dict of block to number {} Width in mm of a block that needs more or less than 130.
BLOCK_TITLES dict of block to string {} The heading of a block whose heading is not its name.
COMPANY string "" The title block's company line; left out when empty.
COMMENT string "" The title block's first comment; left out when empty.

Any other upper-case data the file defines (a pin table, say) is kept in the loaded Design.constants, for your own checks.

pcbkit refuses, naming the file and the part: a design with no parts, a reference used twice, a part with no block or in a block missing from BLOCK_ORDER, a BLOCK_ORDER entry with no parts, a BLOCK_WIDTHS or BLOCK_TITLES key that is not a block, and a name above with the wrong type.

pcbkit runs design.py afresh each time it needs the design, starting from an empty list of parts and the default FP, so nothing carries over from another board or from an earlier load. Modules your design.py imports from the project folder are run again too, so a block of parts may live in a module of its own.

footprints.py and footprints/

Most parts come from KiCad's stock libraries. A part KiCad does not have goes in the project's own library, which pcbkit sch builds into kicad/ from two places that can be used together: the hooks in footprints.py, and the .kicad_mod files in footprints/ (copied unchanged, for example a stock footprint that a newer KiCad dropped). Both are optional.

from __future__ import annotations

from pcbkit.kicad.sexp import q

LIB = "my_board"


def symbols() -> list:
    """Return the project's symbols (shortened: a real one also has pins and graphics)."""
    return [["symbol", q("Thing"), ["in_bom", "yes"]]]


def footprints() -> dict:
    """Return the project's footprints by name (shortened in the same way)."""
    return {"Pad": ["footprint", q("Pad"), ["layer", q("F.Cu")]]}
Name Returns Meaning
LIB string The library nickname: the part before the colon in my_board:Thing. Default: the board's stem. Letters, digits, _, - and ..
symbols() list of nodes Each ["symbol", q("Name"), ...], written to kicad/<LIB>.kicad_sym. Names must differ.
footprints() dict of name to node Each ["footprint", q("Name"), ...] whose name matches its key, written to kicad/<LIB>.pretty/<Name>.kicad_mod.

Nodes are the nested lists of pcbkit.kicad.sexp: q("text") marks a quoted string, a number is a number, and any other bare word is a string. A footprint that is both generated and vendored under one name is an error.

pcbkit sch writes these files into kicad/:

File What it is
<stem>.kicad_sch The schematic.
<LIB>.kicad_sym The project's symbols. Only when symbols() returns some.
<LIB>.pretty/ The project's footprints: generated, then vendored. Only when there are any.
sym-lib-table, fp-lib-table Tell KiCad where the project's libraries are. Empty when it has none.
<stem>.kicad_pro A minimal project file, written only when there is none. kicad-cli reads the two tables above only when the project file exists, and later stages save the design rules into it, so it is never overwritten.
erc.rpt The ERC report.
<stem>.net The netlist, in KiCad's S-expression format.

pcbkit overwrites the files it makes and does not delete ones it no longer makes. Delete kicad/ for a clean rebuild.

routing.py

The routing engine imports routing.py and calls the hooks below that it defines. Only prerouted is required, and it may do nothing.

Name Signature Called What it does
NETCLASSES dict[str, tuple] when rules are applied Net classes: name to (track, clearance, via_d, via_drill, patterns), all in millimetres. patterns is a list of net names with KiCad's leading /, such as "/VIN".
design_rules design_rules(ds) -> None after pcbkit's generic rules Board-specific design rules. ds is board.GetDesignSettings(); set minimums on it, for example ds.m_TrackMinWidth = api.mm(0.2).
prerouted prerouted(board, api) -> None before Freerouting Hand-route what the autorouter should not decide, such as power paths. Tracks and vias made with api.track and api.via are locked, so Freerouting routes around them.
keepouts keepouts(board, api) -> None before Freerouting Add rule areas (api.keepout): a keep-out under an antenna, a strip kept clear of signals beside a high-current pour, a region reserved for a heat sink.
gnd_links gnd_links(board, api) -> None after Freerouting, before the pours are filled Give fine-pitch ground pads their own copper where the pour's minimum width would cut their thin necks.
zones zones(board, api) -> None after gnd_links, before the pours are filled Add the ground and power pours (api.zone) and any pour keep-out.
solid_pad_refs set[str] while the pours are filled References whose ground pads connect to the pour solid, with no thermal spokes: high-current connectors, a regulator's ground.

The order within each stage follows the table: before Freerouting, rules (NETCLASSES then design_rules), prerouted, keepouts, then pcbkit's own keep-out strips along the four board edges; after it, the rules are applied again (a copied board does not carry them), the router's result is imported, then gnd_links, zones and solid_pad_refs run, and the pours are filled and stitched.

An error raised inside one of your hooks reaches you as an ordinary traceback through routing.py. A hook that is missing or has the wrong shape (prerouted absent, a NETCLASSES entry that is not five values, solid_pad_refs given as a bare string) is reported before anything runs, with the file and the hook.

What the engine assumes

  • The board's size is W and H in layout.py, in millimetres. They give the keep-out strips along the edges and the area stitching covers.
  • The ground net is called GND. Stitching vias, the gap-filling vias and solid_pad_refs find ground by that name. Stitching also needs a GND pour on each layer, so zones must make both; with no pour on a layer nothing is stitched, and that is not an error.
  • Stitching and clean-up are the same for every board. Their settings are in pcbkit.toml ([stitch]): the grid pitch, the dense boxes and the gap limit. After the pours are filled, vias that carry copper on one layer only, and tracks that end nowhere, are taken out.

The commands that run these hooks

pcbkit route [--eco PATH] [--tries N] [--passes N] starts from kicad/placed.kicad_pcb, which pcbkit build writes, and stops with a hint if that is missing. It applies the rules, runs prerouted and keepouts, adds the edge strips and exports the DSN, kicad/<stem>.dsn. Then it runs Freerouting, up to route.tries times: after each run it imports kicad/<stem>.ses, runs the post-route stage (the hooks, the pours, the stitching and the clean-up) and runs DRC. It stops at the first try with no copper problem, meaning no unconnected pad, clearance, short, crossing, hole-clearance or edge-clearance entry; it prints each try's problems by category. If no try is clean it gives up, leaves the try with the fewest problems in kicad/, and exits 1. A Freerouting run that has not started routing within route.stall_timeout_s is killed with everything it started, and counts as a failed try. Last it adds the silkscreen and shows DRC with schematic parity.

With --eco PATH, the route in PATH (a folder like golden/) is kept: its copper is copied onto the new board and locked, except copper that clashes with what changed (dropped) and copper on the changed parts' nets or within route.eco_unlock_reach_mm of them (left free). Copied ground copper is never kept, because ground comes from the pours. Freerouting then completes what is missing. If it stalls on such a board, route routes the whole board instead.

pcbkit promote copies kicad/prerouted.kicad_pcb, <stem>.ses and <stem>.dsn into golden/. It first runs DRC with schematic parity on the finished board and refuses unless there is nothing to report: golden/ holds only a route that passed. It keeps no project file: the rules come from routing.py each time and finalize writes the file again.

pcbkit finalize [--no-render] rebuilds the board from golden/: it copies the files there into kicad/, runs the post-route stage on them, adds the silkscreen, runs DRC with schematic parity and exports the fab files. If DRC finds anything it stops, exports nothing and exits 1. It needs kicad/<stem>.kicad_sch (from pcbkit build).

The stages hand each other these files, all in kicad/:

File Written by What it is
placed.kicad_pcb pcbkit build The placed board, before routing.
prerouted.kicad_pcb route The placed board with your pre-routes and keep-outs, and no autorouted copper. golden/ keeps it.
<stem>.dsn route Its Specctra export, which Freerouting reads.
<stem>.ses Freerouting The route it found. golden/ keeps it.
routed_nozones.kicad_pcb route, finalize The board with the route imported, before the pours and the clean-up.
<stem>.kicad_pcb route, finalize The finished board.
drc.rpt route, promote, finalize The latest DRC report.
freerouting.log route The router's console output from its latest run.

The api argument

api is the pcbkit.kicad.board module: pcbkit's helpers for pcbnew, so a project rarely needs to import pcbnew itself (it may). Besides unit helpers (mm, pt, to_local) it provides:

pad(board, ref, num) -> PAD                  # pad `num` of the footprint `ref`
ppos(board, ref, num) -> (x, y)              # that pad's centre, in layout mm
track(board, pts, width, netname, layer=None) -> None
                                             # a locked track through the points `pts`;
                                             # `layer` None means F_Cu
via(board, x, y, netname, d=0.8, drill=0.4) -> None
                                             # a locked through-hole via
keepout(board, x0, y0, x1, y1, tracks=True, vias=True, pours=True,
        layers=("F.Cu", "B.Cu"), pts=None) -> None
                                             # a rule area; `pts` gives a polygon instead
zone(board, netname, layer, pts, priority=0, full=False,
     min_w=0.25, clearance=0.25) -> ZONE     # a copper pour; `layer` is a pcbnew layer id
                                             # (F_Cu or B_Cu); `full` = solid pad connection
rect(x0, y0, x1, y1) -> list[(x, y)]         # a rectangle's corners, for `zone` and `keepout`
clear_spot(board, near, net="GND", via_d=0.6, drill=0.3,
           reach=2.5, gap=0.25) -> (x, y)    # nearest spot a via and its track fit

The module also has the outline and text helpers that placement uses (add_line, add_arc, add_text), N(board, name) for a net, and the engine's shields against pcbnew hazards on KiCad 10 (remove, point_in_track, set_copper). A layer argument that defaults to None means the layer the helper names in its docstring (F_Cu for track): that is how the module imports on a machine without KiCad. Any number may be a numpy scalar.

Net names passed to these helpers have no leading /: track(board, pts, 1.0, "VIN") finds the net /VIN. Only the NETCLASSES patterns carry the /, because they are KiCad's own net-name patterns.

A minimal routing.py

from __future__ import annotations

NETCLASSES = {
    "Wide": (0.6, 0.25, 0.9, 0.5, ["/VIN", "/VOUT"]),
}


def prerouted(board, api):
    """Run the input connector's positive pin to the fuse with a wide track."""
    a = api.ppos(board, "CN1", 1)
    b = api.ppos(board, "F1", 1)
    api.track(board, [a, (b[0], a[1]), b], 1.0, "VIN")


solid_pad_refs = {"CN1"}

These helper signatures are part of the contract: if one changes, this page changes with it.

layout.py and silk.py

layout.py says where everything goes on the board. pcbkit build reads it, after the schematic, and writes kicad/<stem>.kicad_pcb (and an identical kicad/placed.kicad_pcb). silk.py says what text the silkscreen pass adds; it is optional. Coordinates in both are millimetres from the board's top-left corner, with y pointing down.

layout.py

Name Type Default Meaning
W, H number required The board's width and height. With an outline function they are the board's bounding box: the silkscreen pass keeps text 0.6 mm off these edges, and parts without a position are parked below H.
P dict required Where each part goes: {ref: (x, y, rot)}. rot is in degrees, counter-clockwise as KiCad shows it.
CORNER_R number 0 Radius of the outline's corners, from 0 (square) to half the shorter side.
HOLES list [] The position (x, y) of each mounting hole.
outline function none outline(board, api) draws the board outline itself, instead of the rectangle W by H.
from __future__ import annotations

W, H = 40.0, 24.0
CORNER_R = 2.0
HOLES = [(4.0, 4.0), (36.0, 20.0)]
P = {
    "J1": (10.0, 8.0, 0),
    "R1": (26.0, 12.0, 90),
}
  • A part called H1, H2, ... (an H and a number, nothing else) is a mounting hole: it goes to HOLES[n - 1] with rotation 0, whatever P says about it. There must be a position for each. A reference such as HR1 or HS1 is an ordinary part.
  • A part of the design that has no entry in P is still put on the board, but parked below it: at x = 5, 16 mm under the bottom edge for the first, and 6 mm lower for each next. pcbkit build prints them as missing. An entry in P for a part the design does not have is ignored.
  • The outline is a rectangle on Edge.Cuts, with quarter-circle corners of radius CORNER_R. For any other shape, define outline(board, api). api is the pcbkit.kicad.board module (see "The api argument" under routing.py), so api.add_line(board, x0, y0, x1, y1) and api.add_arc(board, cx, cy, sx, sy, ex, ey) draw on Edge.Cuts in layout millimetres:
def outline(board, api):
    """Draw a board with one corner cut off."""
    api.add_line(board, 0, 0, 30, 0)
    api.add_line(board, 30, 0, 40, 10)
    api.add_line(board, 40, 10, 40, 24)
    api.add_line(board, 40, 24, 0, 24)
    api.add_line(board, 0, 24, 0, 0)
  • Each footprint is loaded from the project's own library (kicad/<LIB>.pretty, see footprints.py) when its id starts with <LIB>:, and from KiCad's stock libraries otherwise. A footprint that is not there stops the build, naming it.
  • The stackup is written into the board from [stackup]: the outer copper is copper_mm, the board is thickness_mm thick, and the FR4 core between the coppers is thickness_mm less 0.16 mm, whatever the copper weight.

silk.py

The silkscreen pass runs on the routed board (pcbkit finalize does it, after the pours are filled). It adds text in this order, and each text it adds is something the ones after it keep clear of: pads, the parts' own silkscreen and the board edge are in the way from the start.

  1. LABELS, at their spots.
  2. CONN_LABELS, beside their parts.
  3. Whatever extra(board, api) draws.
  4. A reference designator for every part, on the silkscreen where there is room round its courtyard.
  5. The assembly drawing: a reference with no room on the silkscreen goes to the fab layer (F.Fab), and one that overlaps another fab text, or sits on a through-hole pad, is moved to a clear spot or shrunk.

Values are never printed, and the board's title block is filled in.

Name Type Default Meaning
LABELS list of (text, x, y, size, rot) [] Fixed labels. x, y is the text's centre, size its height in mm and rot 0 or 90. A label whose spot is taken moves to the nearest free one within 1.5 mm; if none is free it is drawn at its spot anyway, with a warning.
CONN_LABELS dict of ref to text {} A function label beside each part named: below its courtyard, above it, or to a side, whichever is free first. A reference that is not on the board is an error.
HIDE_REF set of references set() Parts whose reference is not printed on the silkscreen but kept on F.Fab. A reference that is not on the board is a warning.
KEEP_REF set of references set() Parts whose reference text is left where its footprint has it, and whose fab texts the assembly pass does not move: for a footprint that draws its own labels. A part in both sets is an error.
extra function none extra(board, api) draws what only this board needs.
COMPANY string "" The title block's company.
COMMENTS list of strings [] The title block's comments, up to nine.
DATE string "" The title block's date. Left empty unless you set it, so that running the pass on another day does not change the board.

The title block's title and revision come from [board] in pcbkit.toml. Any other name in silk.py is yours: extra can read it.

from __future__ import annotations

from pcbkit.silk import iloc

LABELS = [("My Board", 20.0, 20.0, 1.0, 0)]
CONN_LABELS = {"J1": "3V3 / GND"}
HIDE_REF = {"H1", "H2"}
COMPANY = "Acme"
COMMENTS = ["2 layer, 1.6 mm FR4"]
DATE = "2026-01-02"


def extra(board, api):
    """Number J1's pins on the silkscreen, beside each pad."""
    for pad in api.footprints["J1"].Pads():
        x, y = iloc(pad)
        if not api.place(pad.GetNumber(), [(x + 2.0, y), (x - 2.0, y)], 0.8):
            api.warn(f"no room for the mark of J1 pin {pad.GetNumber()}")

The api argument of extra

extra is called after LABELS and CONN_LABELS, so it sees them as obstacles, and the reference designators are placed afterwards and keep clear of what it draws. api holds the state of the pass:

api.board                                    # the pcbnew board (also the first argument)
api.footprints                               # {ref: footprint} of every part on the board
api.obstacles                                # Boxes: what text keeps clear of, in layout mm
api.width, api.height                        # the board's size, W and H of layout.py
api.text(text, x, y, size=0.8, rot=0, layer=None, bold=False) -> text
                                             # add a text centred on (x, y), on F.SilkS
                                             # unless `layer` (a pcbnew layer id) says
                                             # otherwise; later texts keep clear of it
api.free(x, y, text, size, vertical=False) -> bool
                                             # would that text touch nothing?
api.place(text, candidates, size=0.8, bold=False) -> bool
                                             # add the text at the first free candidate,
                                             # each (x, y) or (x, y, rot); False if none
api.warn(message) -> None                    # report something that did not fit

pcbkit.silk also has the functions behind these, for code that wants them directly: add_text(board, text, x, y, size, rot, layer, bold, obstacles), text_box(x, y, text, size, vertical) (the box a text is taken to fill), bbox_of(item) and iloc(item) (a board item's box and position in layout millimetres), and the Boxes class. A text added with any of them counts, and is guarded, like one added with api.text. pcbkit.silk.add_text is not api.add_text of the routing hooks: its stroke is never under 0.15 mm, the least a fab house prints.

Once per board

The pass never removes a text. Run twice on one board it leaves the first run's labels where they were and draws the new ones beside them, or exactly on top of them. So apply_silk refuses a board that has had the pass: it raises SilkError (a click error: the command prints it and exits 1), says why, and writes nothing. It looks for two signs:

  • The title block already has a title. The pass writes one every time and no other stage does, so this catches a second run even after silk.py was edited and every label moved. A board titled by hand is refused too: clear the title to go on.
  • The pass would draw a text exactly on one that is already there: the same text, on the same layer, in the same place.

Run it on the board as the router left it. pcbkit finalize starts from the golden route every time, so it never meets this. Texts of the board's own that the pass does not draw on top of (a logo, a note) are fine.

Fab outputs

pcbkit finalize ends by exporting what you send to a fab house and an assembler, and the documents that go with it. pcbkit quote reads those files back and prints each number the PCBWay order form asks for, so you never count parts by hand.

What is written

An export reads the saved board kicad/<stem>.kicad_pcb, its schematic, design.py and the board height H in layout.py, and writes two folders. <fab_name> is the [board] fab_name of pcbkit.toml.

File What it is
out/fab/<fab_name>_gerbers.zip The Gerber and drill files in one zip: the file to upload.
out/fab/gerbers/ The same files, loose: the nine plotted layers, the Excellon drill files with their maps, and KiCad's job file.
out/fab/<fab_name>_BOM.csv and _BOM.xlsx The bill of materials, as CSV and as a workbook.
out/fab/<fab_name>_centroid.csv Where each BOM part sits, for the pick-and-place machine.
out/docs/<fab_name>_schematic.pdf The schematic.
out/docs/<fab_name>_assembly_top.pdf and .png The assembly drawing: board edge and each part's outline.
out/docs/<fab_name>_top_copper.pdf and _bottom_copper.pdf The copper of each side.
out/docs/<fab_name>_render_iso.png, _render_top.png and _render_bottom.png 3D renders. pcbkit finalize --no-render leaves them out.
  • Only out/fab/ and out/docs/ are emptied before an export, so a file left by an earlier run cannot end up in the zip. Anything else you keep under out/ stays.
  • The Gerbers and the centroid are measured from the bottom-left corner of the board: the export saves the board with its aux origin there. The centroid has x to the right and y up, and lists the top side only. For a surface-mount part whose anchor is more than 0.1 mm from the middle of its F.Fab outline, it gives the middle of the outline, which is what an assembler places by.
  • The workbook has one sheet, BOM, with the same rows as the CSV.

What the BOM lists

Every part that is in the bill of materials: not a power flag (a reference starting with #), not dnp=True, not bom=False. Parts with the same manufacturer part number and footprint share a line, and a part with no part number is grouped by its value. A line takes its manufacturer, description and value from the first of its parts that design.py declares, and its quantity is the sum of theirs.

Lines are sorted surface-mount first, then through-hole, each by the letters and number of the line's first reference (C2 before C10). A line is through-hole (Type THT) when its footprint has a plated through-hole pad on the board. A footprint with only SMD pads, or only unplated holes such as locating pegs, is surface-mount.

A resistor made with R() and no mpn has a stand-in part number, 0603 <value> 1%, which cannot be ordered. For these values, in an R_0603 footprint, the BOM uses a Yageo RC0603FR-07 part number instead:

Values
10, 100, 220, 300, 330, 560, 1k, 1.5k, 2.2k, 3.3k, 4.7k, 5.23k, 10k, 18k, 22k, 30.1k, 100k

A part number you wrote yourself is never replaced. Any other value keeps the stand-in until you give the part an mpn.

A fitted part whose part number is empty, is still a stand-in, or has a space in it (a real part number is one word; one with a space is a description) cannot be ordered. pcbkit.fab.bom.parts_without_mpn(parts, overrides) lists such parts, judged on what the BOM will show after the bom.py below, and export_fab returns them in FabResult.without_mpn without failing.

bom.py

Optional. Write the part number a buyer needs in design.py, and you need no bom.py. It is for when the schematic should say one thing and the BOM another, for example a generic part number in the schematic that is bought as one real part. It may define these names, all optional:

Name Type Meaning
MPN_OVERRIDE dict of an MPN to fields Keys are part numbers as written in design.py; every part with that part number gets the fields. A key no part uses is ignored.
REF_OVERRIDE dict of a reference to fields The same for one reference, and it wins over MPN_OVERRIDE. A reference that is not in the design is an error.
line function taking a Group Called for each finished BOM line. Returns None, or a dict that sets the line's value and desc.
NOT_IN_BOM list of strings Text for the workbook, one row each, under a bold "Not assembled / not in BOM:" heading. With none, the workbook has no such rows.

The fields of an override are mfr, mpn, desc (strings) and qty (a whole number of 1 or more, how many physical parts the reference stands for; default 1). A field you leave out keeps the part's own. A Group has parts (the design.py parts, in the order declared), refs, footprint (without its library), mfr, mpn, value, desc, qty and through_hole.

from __future__ import annotations

MPN_OVERRIDE = {
    "HDR 1x3 male": {  # what design.py calls the part: the BOM says what to buy
        "mfr": "Acme",
        "mpn": "AC-HDR-1X3",
        "desc": "Pin header 1x3 2.54mm vertical",
    },
}

REF_OVERRIDE = {
    "BT1": {  # one reference that is two parts: a battery holder bought as two clips
        "mfr": "Acme",
        "mpn": "AC-CLIP-18650",
        "desc": "18650 cell clip",
        "qty": 2,
    },
}

NOT_IN_BOM = ["W1 is a wire pad. H1 is a mounting hole."]


def line(group):
    """Show the three-pin headers as one kind of part, whatever each was named."""
    if group.footprint.startswith("PinHeader_1x03"):
        return {"value": "Header 3-pin"}

pcbkit reports a mistake in bom.py with what to fix: a field it does not know, a value of the wrong type, and an upper-case name that looks like a misspelling of one above.

pcbkit quote

pcbkit quote [--assembled N] [--fab-qty N] [--self-solder-tht] [--notes FILE]

Reads the files an export wrote, from out/fab/ (else fab/), and prints each value of the PCBWay order form. If they are not there it says to run pcbkit finalize.

Value Where it comes from
Layers, thickness, finish, copper weight The Gerber job file in the zip. The finish is shown as the form words it (KiCad's "HAL lead-free" is "HASL lead-free").
Board size The job file's size less one outline line width, which KiCad adds.
Min track and spacing The smallest track width and the smallest spacing in the job file's design rules, in mm and mil.
Min hole size The smallest tool in the Excellon files, plated or not.
Unique parts With --assembled: the BOM lines the assembler places. All of them, or with --self-solder-tht the surface-mount ones only.
SMD placements The sum of the surface-mount lines' quantities, per board.
BGA/QFP/QFN parts Parts whose footprint name has BGA, QFP or QFN in it (so LQFP, VQFN and LFBGA count), with their references. Pitch is not looked at.
Through-hole parts The sum of the through-hole lines' quantities and the number of references, then the references with each run of three or more numbers written as a range (J3-J9). With --self-solder-tht, 0 for the assembler and these as the ones you solder.

--assembled N adds the assembly numbers (they are per board; N is how many boards). --self-solder-tht needs --assembled. --fab-qty N is the number of bare boards and must be at least --assembled. --notes FILE checks the order notes against the form's limit of 600 characters, counting a line break as one and ignoring trailing whitespace; if they are over it, the command fails and says how many characters there are.

It also warns, after the numbers, when the copper in the Gerbers is not [stackup] copper_mm, and when the Gerber zip is older than the saved board: the numbers then describe an earlier export.

Checks: specs.py, circuits.py, checks/ and mutants.py

pcbkit check [-k EXPR] runs two kinds of check with pytest, in one run: the built-in checks of the groups listed in [checks] groups, and your own, the test_*.py files in checks/. It needs pcbnew (pcbkit setup makes the project's .venv that has it) and exits with pytest's code: 0 all passed, 1 a check failed, 5 nothing matched. -k is pytest's own expression. pcbkit mutants shows that the checks bite (below), and pcbkit report turns the last run into a report.

A built-in check reads its numbers from your specs.py, and the operating points of the circuit from your circuits.py. A group that is switched on but is missing a name it needs does not skip: the check fails, naming the name and the file ("specs.PINOUT is not defined in .../specs.py"). A table a check runs once per row of does not vanish when its table is missing either: it fails once, as missing-spec. A name documented as optional below may be left out.

The checks run in a child pytest that loads pcbkit.check.plugin by name (there is no entry point, so a plain pytest run never loads it) and ignores any other pytest configuration. To run them yourself:

pytest -p pcbkit.check.plugin --pcbkit-project . --pyargs pcbkit.check.builtin checks

Results

A run leaves out/checks/results.json (a run that selects nothing leaves an empty one); pcbkit report writes out/checks/VALIDATION.md from it. Plots made by checks go in out/checks/ too.

{"format": 1, "board": {"stem": "my_board", "title": "My Board", "rev": "A"},
 "copper_mm": 0.035, "groups": ["kicad", "outputs"], "exit_status": 1,
 "counts": {"passed": 40, "failed": 1}, "seconds": 12.3,
 "selection": {"keyword": "", "markexpr": "", "deselected": 0, "complete": true},
 "checks": {"pcbkit.check.builtin.test_kicad::test_erc_clean":
              {"group": "kicad", "outcome": "passed", "numbers": {"erc": "..."}},
            "checks/test_power.py::test_rail":
              {"group": "project", "outcome": "failed", "numbers": {},
               "message": "AssertionError: ..."}}}

A check's id is pcbkit.check.builtin.<module>::<name>[param] for a built-in one and its path from the project root for yours, whatever folder pytest was started in. outcome is passed, failed, skipped or error (a fixture or set-up that raised); a skipped check has a reason and a failed or errored one a message. numbers holds what the check recorded. selection says whether the run was cut short: after pcbkit check -k ... (or any run that left checks out) complete is false, so treat those results as partial and run the checks in full before you order boards. pcbkit report marks such a report as partial, under its counts.

Writing a check

Put test_*.py files in checks/. They are ordinary pytest tests, and these fixtures are available (session-wide unless noted):

Fixture What it is
project The pcbkit.project.Project: root, config, kicad_dir, out_dir.
specs, circuits, layout Your specs.py, circuits.py and layout.py. specs.NAME returns the value, or fails the check with a message naming the missing name; specs.get("NAME", default) and "NAME" in specs ask without failing. Your folder is on sys.path, so import specs works too.
nl The pcbkit.check.netlist.Netlist of a fresh kicad-cli export of the schematic (parts, nets, net(ref, pin), refs_on(net), kind(ref), by_kind(*kinds)). Net names have no leading "/".
board The routed board, loaded with pcbnew from kicad/<stem>.kicad_pcb.
record (per check) record(key, value) keeps a number for results.json and the report.
out_dir out/checks/, made if it is not there.

The engines the built-in checks are made of are yours to call: pcbkit.check.dc (a DC operating-point solver over the netlist: build, Circuit, Solution), pcbkit.check.spice (run and bench netlists for ngspice), pcbkit.check.copper (NetCopper, a current-flow solve over a net's real copper, and ipc2221_rise), pcbkit.check.coupling, pcbkit.check.stitching and pcbkit.check.reserved (copper in a region). pcbkit.check.plugin.spec_params(metafunc, argnames, values, ids) runs a check once per row of one of your tables:

from pcbkit.check.plugin import spec_params


def pytest_generate_tests(metafunc):
    spec_params(metafunc, "ref", lambda specs: sorted(specs.PINOUT))


def test_part_is_in_the_design(nl, ref):
    assert ref in nl.parts

Never loosen a check to make it pass, and show that it can fail: plant the mistake it is for (see mutants.py, or a test that builds a bad fixture and asserts it is caught).

Built-in groups and what they read

Names are specs.NAME unless a circuits. prefix says otherwise. Tables say what a name holds; "optional" means a check does without it (or skips, naming why).

kicad reads nothing: test_erc_clean and test_drc_clean_with_schematic_parity run kicad-cli on kicad/<stem>.kicad_sch and .kicad_pcb.

outputs reads the files in out/fab (or fab) named after [board] fab_name, the stackup from pcbkit.toml, and layout.W and layout.H. test_gerber_set_complete, test_job_file_stackup, test_outline_size, test_drill_files_match_board, test_bom_matches_schematic and test_centroid_matches_board parse the Gerbers, drill files, BOM and centroid back and compare them with the board and the schematic. test_bom_complete reads design.py and bom.py instead: it fails a fitted part a buyer cannot order (no part number, the stand-in R() makes up for a resistor, or a description with a space in it), judged after bom.py's overrides.

fab:

Name Holds
PCBWAY The fab house's limits in mm: min_annular, min_drill, min_plated_slot, silk_min_height, silk_min_stroke, min_track_2oz, min_space_2oz.
WIRE_PADS {ref: (positive pad, negative pad)} for bare pads that take a wire; each needs a "+" and a "-" on the silkscreen nearer its own pad. {} for none.
DECOUPLING Rows (ic, pin, (capacitor refs), limit mm): a listed capacitor must have a pad that close to the pin.
POWER_FOOTPRINTS One dict per part whose pads must match its datasheet: a ref, and any of tab_pad (the largest pad is this one) with tab_net, smaller (pairs (small pad, large pad)) and drill_mm ({pad: drill}).
I2C_BUSES {bus: (sda net, scl net, speed Hz, off-board devices)}, devices as (name, 7-bit address, pin capacitance pF) (the rise-time model counts every device at I2C_PIN_C; the third value is kept for your own reference).
I2C_TR_MAX, I2C_PIN_C, TRACE_C_PER_MM, I2C_CABLE_LEN, I2C_CABLE_C_PER_M The I2C rise-time model: limit per speed (s), capacitance per device pin and per mm of trace (F), cable length (m) and its capacitance per metre (F).
I2C_PULLUP_NET, I2C_DEVICE_PREFIXES Optional: the pull-up rail (default "+3V3") and the reference prefixes that count as devices on a bus (default "UA").
ASSEMBLY_TEXT_EXEMPT Optional: references whose pin labels may sit over their own through-hole pins on the assembly drawing.

copper (the copper thickness is [stackup] copper_mm, the board thickness [stackup] thickness_mm, the stitching pitch [stitch] pitch_mm):

Name Holds
RESERVED_COPPER Rows (name, layer, x0, y0, x1, y1, allowed nets): a region (layout mm, layer "top" or "bottom") only the nets named, spelled as KiCad does ("/GND"), may put copper in. Add the keep-out to routing.py too: the router only keeps clear of a region it is told about.
COUPLING Sensitive signals beside high-current or switching copper (the basis is in pcbkit/check/coupling.py): classes (class to (noise budget V, victim source ohm, dI/dt A/s)), sensitive (class to nets), aggressors (name to {"nets": [...], "kind": "bar"/"current"/"switch", "region": (x0, y0, x1, y1)}), any physical constant to change (c_per_mm, dv_dt, dv_sw, cn_default, cn_net, bar_min_w, bar_w_cap, res), and optional filters: {net: {"t_edge": s, "pin_ref": ref, "max_mm": mm}} for a node with an RC filter whose capacitor must sit near its pin.
SENSE_LOOPS Optional: measurement pairs judged by the area they enclose: dicts with name, nets (the pair), from_ref, to_ref, to_pads and max_mm2.
COPPER_SEGMENTS, COPPER_BUDGETS The high-current copper: each segment has a name, a net, terminals as (ref, pad, share[, role]) (the share of the test current that enters at that pad, negative for leaving; the last terminal is the 0 V reference) and heating (False for a segment judged on its voltage drop alone). COPPER_BUDGETS: i_cont (A, judged on the IPC-2221 rise), i_peak (A, judged on the drop), segment_drop_max_v, max_rise_c.
SUPPLY_REGULATION Optional: what the farthest load loses to supply sag and ground lift: supply and ground (segment names), label, nominal_v and max_fraction; terminals with the role "source" and "load" say where.
GROUND_NET, STITCH_BASIS Optional: the ground net (default "GND"), and {"f_hz": ..., "er_fr4": ...} for the stitching limit (default the top of the 2.4 GHz Wi-Fi band in FR4).

circuit (runs on the netlist and on circuits.scenario):

Name Holds
PINOUT {ref: {pad: datasheet function}}. Optional PIN_ALIASES maps the symbol's spelling to the datasheet's (None: an unnamed pin), and ABSENT_REFS {ref: reason} skips a part this revision does not carry. A part that is neither in the design nor listed compares nothing, with a warning.
STRESS_SCENARIOS Keyword arguments of circuits.scenario for the operating points that stress parts hardest.
RESISTOR_POWER_W {"default": watts, ref: watts}: resistors must stay under half.
CAP_VRATED Capacitor MPN to rated volts: capacitors must stay under 80 %.
LED_REFS, LED_SCENARIO, LED_I_MIN, LED_I_MAX The LEDs (a name the scenario adds stands for an off-board one), the conditions, and the current window in amps.
SUPPLY_SCENARIO, SUPPLY_PINS, SUPPLY_LIMITS Rows (ref, pin, (min V, max V)) for supply pins, and rows (what, value, "<=" or "<", limit) (or a function of the netlist that returns one) for the limits a design range must fit inside.
I2C_BUSES, I2C_IOL As in fab, and the sink current per speed (A). Optional I2C_PULLUP_NET and I2C_VDD (default 3.3).

esp32s3 (a board that carries an ESP32-S3-DevKitC-1 in sockets; the chip's own facts, RESERVED, UART0, ADC1, INPUT_ONLY, STRAPS, ESP32_VDD, ESP32_VIH, ESP32_VIL, ESP32_VOH_MIN, ESP32_PIN_ABS_MAX, ESP32_GPIO_SOURCE_MAX and ESP32_INTERNAL_PULL, are defaults in pcbkit/check/packs/esp32s3.py that you may override):

Name Holds
DEVKIT_REF, DEVKIT_GPIO The DevKit's reference, and {header pin as your symbol numbers it: GPIO}.
ANALOG_NETS The nets that carry analogue inputs: they must be on ADC1.
STRAP_SCENARIO, GPIO_SOURCE_SCENARIO Keyword arguments of circuits.scenario for judging the strapping pins at reset and the current each GPIO sources.
GPIO_SCENARIOS {id: keyword arguments}: no GPIO may exceed its absolute maximum in any of them.
OPEN_DRAIN_NETS, RAIL_3V3 Optional: nets exempt from the source-current check (default: the I2C lines of I2C_BUSES), and the 3.3 V rail the strapping pulls go to (default "+3V3").

circuits.py

Functions the circuit and esp32s3 checks call, and that your own checks may too:

Function Returns
scenario(nl, **kw) An unsolved pcbkit.check.dc.Circuit for one operating condition: start from pcbkit.check.dc.build(nl) (every resistor, LED, diode and FET of the netlist), hold the supplies with fix, add what the netlist cannot know (off-board loads, a driven pin) and return it. The checks call .solve().
i2c_devices(nl) (sda net, name, 7-bit address) for each on-board I2C device, address pins read from the netlist.

It also holds whatever SPICE decks your own checks build, with pcbkit.check.spice.run.

mutants.py

A check that has never been seen to fail is not trusted. mutants.py lists planted mistakes the checks must catch:

MUTANTS = [
    (
        "pull-up resistor missing",  # what the mistake is
        [  # edits to design.py: (text to find, text to put there)
            ('R("R7", "10k", "EN", "+3V3", B)', 'R("R7", "10k", "EN", "NC_X", B)'),
        ],
        "test_boot_state_is_safe",  # the pytest -k expression that must then fail
    ),
]

pcbkit mutants first runs the control: the checks every entry names, on an unedited scratch copy of the project, must all pass. Then each mistake is planted in design.py in a fresh scratch copy, the schematic is made again there, and the checks run (the same command as pcbkit check, so built-in and project checks are both in play). A mistake is caught when a check fails; it is missed when every check still passes or when the text to find is not in design.py (a stale entry is a miss, not a pass). The board layout is not regenerated, so a mutant tests the schematic, netlist and circuit checks; checks on the copper are shown to fail by planting the mistake in a test board. Exit codes: 0 all caught, 1 one missed, 2 the control did not pass. Other lists whose name ends in _MUTANTS are parked: counted, named, not run.

Comparing boards and taking shots

Two commands for looking at boards rather than building them. compare needs pcbnew, so run it in the project's .venv (pcbkit setup makes it); shots needs only kicad-cli and rsvg-convert, so it runs anywhere.

pcbkit compare

pcbkit compare OLD NEW [--json] [--piece-tol MM2] [--fill-tol MM2] [--top N]

Compares the routed copper of two board files and says what moved. It takes any two .kicad_pcb files, so it does not need a project: use it to check that a refactor, a new KiCad or a rebuilt route changed nothing, or to see what a re-route did.

What Measured as A difference when
Tracks how many track segments (arcs included) and their total length the counts differ, or the lengths differ by more than 0.001 mm
Vias how many vias the counts differ
Zones each zone's filled area the fill moved by --fill-tol or more, or only one board has the zone
Copper what F.Cu and B.Cu have on one board only, in pieces a piece is larger than --piece-tol
  • A zone is matched across the boards by its net, its first layer and its outline area to 0.1 mm2. Zones that share all three are one entry, and a zone's fill is added up over every layer it fills, so a pour that a router split into pieces still compares as one. A keep-out area is an entry with no fill: one that only one board has is a difference even though it moves no copper.
  • The copper comparison turns each outer layer into polygons and subtracts each board's from the other's. What is left on one board only is judged piece by piece, not as a layer total: refilling a pour twice leaves a few hundredths of a square millimetre of slivers, in pieces far smaller than any real change, and a layer total would call that a difference. The totals are printed for information. A piece's area is the area inside its outer outline, so a thin ring (a track made a little wider) counts as the whole area it surrounds: that can over-report a change, never hide one.
  • Only copper and its numbers are compared. Silkscreen, fab layers, footprint properties and the 3D model do not count, so a board that differs only in a label matches.
  • Positions are layout millimetres, so a piece reported at (15.0, 10.0) is where layout.py coordinates would put it.
  • --piece-tol (default 0.01) and --fill-tol (default 0.5) are in mm2. --top (default 8) is how many pieces the text report lists for each layer and board; --json always lists them all.

The exit code says the result: 0 the boards match, 1 they differ, 2 a file is not a KiCad board (or could not be read), so a script can tell "different" from "could not compare". A machine where pcbnew does not import gets the message every command that needs pcbnew gives, with exit code 1. With --json the report is printed as JSON and nothing else goes to standard output: identical, differences (one sentence each), tolerances, tracks, vias, zones (a fill of null means the board has no such zone) and copper (for each layer and side: the total, the number of pieces and over_tolerance, each with its area and position).

A change of one track, as the text report shows it:

pcbkit compare
  old  before.kicad_pcb
  new  after.kicad_pcb
  a copper piece over 0.01 mm2 is a difference, and so is a zone fill that moved by 0.5 mm2 or more

Tracks  old 2 (14.0 mm)  new 2 (14.0 mm)  same
Vias    old 1  new 1  same

Zone fill, mm2 (a zone is matched by net, first layer and outline area)
  net       layer  outline    old    new  change
  keep-out  F.Cu       6.0    0.0    0.0     0.0
  keep-out  F.Cu      18.0    0.0    0.0     0.0
  /GND      B.Cu     570.4  543.0  543.0     0.0

Copper on one board only (the layer totals include slivers: they are for information)
  F.Cu only in old: total 2.888 mm2 in 1 piece, 1 over 0.01 mm2  DIFFERENT
          2.888 mm2 at (15.0, 10.0)
  F.Cu only in new: total 3.135 mm2 in 1 piece, 1 over 0.01 mm2  DIFFERENT
          3.135 mm2 at (15.0, 11.0)
  B.Cu only in old: total 0.000 mm2 in 0 pieces, 0 over 0.01 mm2
  B.Cu only in new: total 0.000 mm2 in 0 pieces, 0 over 0.01 mm2

The boards differ: 2 differences.

pcbkit shots

pcbkit shots [--out DIR] [--pcb FILE] [--region NAME]... [--no-render]

Pictures of the board for a review, in the folder out/shots/ (or --out): for each region an SVG and a PNG of it, and three 3D renders, render_iso.png (angled), render_top.png and render_bottom.png. It shoots the project's board, kicad/<stem>.kicad_pcb, or the one named by --pcb. --region takes only the regions named (repeat it for several); --no-render skips the renders, which take the longest. It needs kicad-cli, and rsvg-convert for the PNGs: without rsvg-convert it stops before doing anything and says how to install it.

A region is one side of the board, cut to a box. The top view shows F.Cu, F.SilkS, F.CrtYd, F.Fab and Edge.Cuts, the bottom view the B. equivalents. The bottom view is not mirrored: a point has the same x on both sides. The colours are those of the PCB editor's colour theme, which is where kicad-cli takes them from. There are always two regions, board_top and board_bottom, the whole board at 1800 pixels wide. The project adds its own with SHOTS in layout.py.

A box is read from the origin of the SVG that kicad-cli draws, which is the top-left corner of the bounding box of the board outline. That is layout (0, 0) when the outline starts there, as the default rectangle does. An outline whose bounding box starts somewhere else shifts every box by the same amount.

SHOTS in layout.py

Optional. A dict of region name to (side, x0, y0, x1, y1), with the box in layout millimetres (from the board's top-left corner, y down, as everywhere in layout.py):

SHOTS = {
    "input": ("top", 0, 0, 30, 20),
    "input_back": ("bottom", 0, 0, 30, 20),
    "regulator": ("top", 40, 5, 60, 25, 2400),
}
Value Meaning
name The file name: input.svg and input.png. Letters, digits, _, - and ., starting with a letter, digit or _. A name starting render_ is kept for the 3D renders. A region called board_top or board_bottom replaces the whole-board shot of that name.
side "top" or "bottom".
x0, y0, x1, y1 The corners of the box, with x0 < x1 and y0 < y1, in mm.
width in pixels Optional sixth value, a whole number from 100 to 10000. The default is 1600; the height follows from the box.

Every problem in SHOTS is reported together, naming layout.py and the region, and nothing is exported until it is right.

Starting a board: pcbkit new and pcbkit setup

Two commands take you from nothing to a board that builds. pcbkit new makes the project folder from a template, and pcbkit setup makes the folder ready to run: it builds the project's own .venv on KiCad's Python and makes sure the router is there. Neither needs that .venv (setup is what makes it), so both run from any Python that has pcbkit, uvx included. Everything after them runs from the project's .venv:

pcbkit new my-board
cd my-board
pcbkit setup
.venv/bin/pcbkit build
.venv/bin/pcbkit route
.venv/bin/pcbkit promote
.venv/bin/pcbkit finalize
.venv/bin/pcbkit check
.venv/bin/pcbkit mutants

pcbkit new

pcbkit new NAME [--from blinky] [--pcbkit-source PATH]

Copies a template into the new folder NAME and names the board after it. NAME may be a path (boards/my-board): the board is named after the last part. The folder must not exist, or must be empty, and new never writes into one that holds anything. If it fails half way it takes out what it wrote.

The name is split into words at hyphens, underscores, dots and spaces, and may use letters and digits besides those, because the stem and the fab name end up in file names. A name with anything else in it is refused, naming the characters.

NAME [board] stem [board] title [board] fab_name [project] name
my-board my_board My Board My_Board_revA my-board
ESP32-carrier esp32_carrier ESP32 Carrier ESP32_Carrier_revA esp32-carrier
blinky2 blinky2 Blinky2 Blinky2_revA blinky2

What is written:

  • The template's files. The three [board] values in pcbkit.toml are replaced; its comments and every other key stay as they are. rev stays "A".
  • golden/, the template's route, with <stem>.ses and <stem>.dsn renamed to the new stem and the old stem replaced inside the three files (the session's design name, the DSN's first line, the schematic file each footprint names). pcbkit finalize looks for those names, so a board made by new can be finalized the moment it is built.
  • pyproject.toml, written for the board (below), and a README.md with the commands.

--from names the example to start from. blinky is the only one so far: see the end of this section.

The project's pyproject.toml

pcbkit setup makes the project's .venv from it. The project is not a package (package = false: uv sync installs its dependencies and nothing else), and it needs Python 3.9 or newer because KiCad's own Python is 3.9. pytest is in the list because pcbkit check runs your checks with it.

[project]
name = "my-board"
version = "0.1.0"
description = "My Board: a PCB project made with pcbkit"
requires-python = ">=3.9"
dependencies = [
    "pcbkit @ git+https://github.com/bruk-io/pcbkit",
    "pytest>=8",
]

[tool.uv]
package = false

By default pcbkit comes from its git repository. To install it from your own copy of the repository instead (to work on pcbkit, or while it is not published), give the folder you cloned it into:

pcbkit new my-board --pcbkit-source ~/src/pcbkit

or set PCBKIT_SOURCE to it, which every new then uses. The folder must be a pcbkit checkout (a pyproject.toml that names pcbkit, and a pcbkit/ package), or new stops before writing anything. The dependency is then the bare name, and uv is told where it lives, as an editable path source, so a change you make to pcbkit shows up in the project at once:

dependencies = [
    "pcbkit",
    "pytest>=8",
]

[tool.uv.sources]
pcbkit = { path = "/home/me/src/pcbkit", editable = true }

The path is absolute, so it is only good on the machine that wrote it. Commit the default form, and keep the path form for yourself.

pcbkit setup

pcbkit setup

Run it in a board project (the nearest folder at or above the current one that holds a pcbkit.toml, which must also hold a pyproject.toml): once after pcbkit new, and again after changing the dependencies. It does these things in order and says each one:

  1. Finds uv and KiCad's own Python, the interpreter that imports pcbnew (the KiCad Python line of pcbkit doctor). KiCad 10 or newer is required.
  2. Makes .venv with uv venv --python <KiCad's Python> --system-site-packages. A .venv that already imports pcbnew is kept (uv venv refuses to replace one, and running setup again must do no harm); one that does not is made again. A .venv that is not a virtual environment is never touched.
  3. Runs uv sync --python <KiCad's Python> to install the dependencies of pyproject.toml into it. The interpreter is named again because a bare uv sync rebuilds .venv on the Python that a .python-version file in the project folder (or a UV_PYTHON variable) names, without the system site packages, and pcbnew cannot be imported there. Run pcbkit setup, not a bare uv sync, to change dependencies.
  4. Checks that .venv/bin/python imports pcbnew. That is the point of all of it.
  5. Makes sure the Freerouting 1.9.0 jar is there (below).
  6. Looks for Java 17 or newer, which the router needs. A missing Java is reported with how to install it and does not make setup fail.

It ends by printing the commands to run next, as .venv/bin/pcbkit ....

Anything that goes wrong is a message that says what failed and what to do, not a traceback:

It says What to do
uv is not installed Install uv (brew install uv).
no Python that can import pcbnew was found Install KiCad 10 (brew install --cask kicad); the message lists each interpreter it tried and why it failed.
pcbnew ... is older than the 10.0 pcbkit needs Update KiCad.
pyproject.toml not found Add one that lists pcbkit and pytest as dependencies, or start the project again with pcbkit new.
.venv exists and is not a virtual environment Move that folder or file away and run setup again.
uv venv failed or uv sync failed Read uv's message above it. A dependency that cannot be found or built is the usual cause: check the network, and the dependencies and [tool.uv.sources] in pyproject.toml.
pcbnew does not import in .venv uv sync rebuilt .venv on another Python. Delete .venv, make sure no .python-version file or UV_PYTHON variable names a different Python, and run setup again.
FREEROUTING_JAR=... does not exist Unset it, so that setup downloads Freerouting, or point it at the jar.
... is not a valid jar The file is damaged (a download cut short): delete it, or point FREEROUTING_JAR at a good one.
the Freerouting download was N bytes and should be 5044336 The download was cut short or is not the right file; nothing was kept. Run setup again, or download the file by hand to the path the message gives.
could not download Freerouting The network, or GitHub, failed; the message has the URL and where to save the file by hand.

The Freerouting jar

setup uses a jar it finds, in this order: the file FREEROUTING_JAR names (when that is set it is the only place looked at, and a name that does not exist is an error, never a reason to download somewhere else), then ~/.local/share/pcbkit/freerouting-1.9.0.jar, then ~/.local/share/freerouting/freerouting-1.9.0.jar. A jar it finds must be a whole zip file.

If there is none, setup downloads Freerouting 1.9.0 from its GitHub release (https://github.com/freerouting/freerouting/releases/download/v1.9.0/freerouting-1.9.0.jar) to ~/.local/share/pcbkit/freerouting-1.9.0.jar. The bytes are written to a .part file first, and the jar is put in place only if it is exactly 5044336 bytes and a zip file; a short or damaged download is thrown away, so the folder never holds a half jar. A damaged jar in that folder is fetched again; a damaged one anywhere else is left for you to deal with.

The blinky example and the template

examples/blinky in the pcbkit repository is a real, tiny board: a 2-pin power connector (VIN, GND), a 330 ohm resistor and a green LED on a 30 x 20 mm two-layer board. It has every file a project needs, a golden/ route made by pcbkit route and pcbkit promote, and two checks of its own (the LED's current across the supply range, and the resistor's power) with three planted mistakes in mutants.py that they must catch. pcbkit check runs 11 checks on it and all pass: ERC and DRC (kicad), the seven checks of outputs, and the two of its own. The fab group is not switched on, but specs.py holds what it reads; with "fab" added to [checks] groups there are 19 checks, 17 pass and two are skipped, the ones that read the tables blinky leaves empty (decoupling capacitors, I2C buses), because the board has no IC and no bus.

The folder pcbkit/templates/board, which ships inside the package, is a copy of it: the example's files without what commands generate (kicad/, out/, fab/, .venv/) and without the pyproject.toml and README.md that pcbkit new writes for each board. A unit test compares the two folders and fails when they differ. After changing the example, bring the template up to date from a pcbkit checkout:

uv run python examples/sync_template.py

and --check says whether they differ without changing anything. Change the example's route (design.py or layout.py) and the golden route has to be made again in the example (route, then promote), or finalize rebuilds the old board.