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/andfab/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 promotewrites it,pcbkit finalizerebuilds from it).- Coordinates in
layout.pyand 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 = 2is fine) but never a boolean, and neverinfornan. Whole-number keys reject2.0and"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)
partmakes one part.refis unique in the design.symisLibrary:Name, a symbol of KiCad's stock libraries or of the project's own (see footprints.py below).pinsmaps pin numbers, as strings, to net names.blocknames the group the part is drawn in.dnpmarks it do-not-populate;bom=Falsekeeps it out of the bill of materials.Rmakes aDevice:Rin theFP["R0603"]footprint, or infp.mpndefaults to0603 <value> 1%anddesctoResistor <value> 0603 1%.Cmakes aDevice:Cwhose footprint is theFPentry named bysize.descdefaults toCeramic capacitor <value> <size without its letter>, so0603for the default.LEDmakes aDevice:LEDinFP["LED0603"], with the anode on pin 2 and the cathode on pin 1, as KiCad numbers it.descisLED <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
WandHinlayout.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 andsolid_pad_refsfind ground by that name. Stitching also needs aGNDpour on each layer, sozonesmust 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, ... (anHand a number, nothing else) is a mounting hole: it goes toHOLES[n - 1]with rotation 0, whateverPsays about it. There must be a position for each. A reference such asHR1orHS1is an ordinary part. - A part of the design that has no entry in
Pis 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 buildprints them asmissing. An entry inPfor a part the design does not have is ignored. - The outline is a rectangle on
Edge.Cuts, with quarter-circle corners of radiusCORNER_R. For any other shape, defineoutline(board, api).apiis thepcbkit.kicad.boardmodule (see "Theapiargument" under routing.py), soapi.add_line(board, x0, y0, x1, y1)andapi.add_arc(board, cx, cy, sx, sy, ex, ey)draw onEdge.Cutsin 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 iscopper_mm, the board isthickness_mmthick, and the FR4 core between the coppers isthickness_mmless 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.
LABELS, at their spots.CONN_LABELS, beside their parts.- Whatever
extra(board, api)draws. - A reference designator for every part, on the silkscreen where there is room round its courtyard.
- 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.pywas 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/andout/docs/are emptied before an export, so a file left by an earlier run cannot end up in the zip. Anything else you keep underout/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.Faboutline, 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¶
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:
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¶
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.pycoordinates 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;--jsonalways 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¶
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¶
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 inpcbkit.tomlare replaced; its comments and every other key stay as they are.revstays"A". golden/, the template's route, with<stem>.sesand<stem>.dsnrenamed 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 finalizelooks for those names, so a board made bynewcan be finalized the moment it is built.pyproject.toml, written for the board (below), and aREADME.mdwith 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:
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¶
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:
- Finds
uvand KiCad's own Python, the interpreter that importspcbnew(theKiCad Pythonline ofpcbkit doctor). KiCad 10 or newer is required. - Makes
.venvwithuv venv --python <KiCad's Python> --system-site-packages. A.venvthat already importspcbnewis kept (uv venvrefuses to replace one, and runningsetupagain must do no harm); one that does not is made again. A.venvthat is not a virtual environment is never touched. - Runs
uv sync --python <KiCad's Python>to install the dependencies ofpyproject.tomlinto it. The interpreter is named again because a bareuv syncrebuilds.venvon the Python that a.python-versionfile in the project folder (or aUV_PYTHONvariable) names, without the system site packages, and pcbnew cannot be imported there. Runpcbkit setup, not a bareuv sync, to change dependencies. - Checks that
.venv/bin/pythonimportspcbnew. That is the point of all of it. - Makes sure the Freerouting 1.9.0 jar is there (below).
- Looks for Java 17 or newer, which the router needs. A missing Java is reported with
how to install it and does not make
setupfail.
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:
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.