name: plan-pcb-routing description: Analyzes a KiCad PCB file and creates a comprehensive routing plan. Examines components for fanout needs (BGA/QFN/QFP/PGA), identifies differential pairs, categorizes power/ground nets, and presents a step-by-step routing workflow with explanations.
Plan PCB Routing
When this skill is invoked with a KiCad PCB file, perform a comprehensive analysis and present a routing plan to the user.
Step 1: Load and Analyze PCB Structure
from kicad_parser import parse_kicad_pcb
pcb = parse_kicad_pcb('path/to/file.kicad_pcb')
# Basic stats
print(f'Total nets: {len(pcb.nets)}')
print(f'Total footprints: {len(pcb.footprints)}')
print(f'Existing segments: {len(pcb.segments)}')
print(f'Existing vias: {len(pcb.vias)}')
Report to user:
- Number of nets, components, existing routing
- Whether this is a fresh board or partially routed
Step 2: Identify Copper Layers
Check the KiCad file directly for layer definitions:
grep -E "^\s+\([0-9]+ \".*\.Cu\"" path/to/file.kicad_pcb
Report to user:
- Available copper layers (F.Cu, B.Cu, In1.Cu, In2.Cu, etc.)
- Whether it's a 2-layer, 4-layer, or multi-layer board
Stackup Check (always run this early)
Inspect the stackup now, before planning, and report the verdict at the top of the plan report so problems surface before any routing work:
from kicad_parser import parse_kicad_pcb
pcb = parse_kicad_pcb('path/to/file.kicad_pcb')
for layer in pcb.board_info.stackup: # List[StackupLayer], ordered top to bottom
print(layer.name, layer.layer_type, layer.thickness, layer.epsilon_r)
- No stackup section, or all dielectrics with identical thickness and ε_r ≈ 4.5, means
KiCad's untouched default. If the board also has impedance-relevant signals (see the
speed detection in Step 4), lead the report with a clear warning: impedance and
time-matching calculations will not match the user's fab, and
/recommend-stackupshould be run before impedance-controlled routing. Take plane-layer assignments from its output when available. - A 2-layer board with multiple differential pairs or planes-worth of power nets is itself worth flagging (no inner layers for reference planes).
- If the stackup looks deliberate, say so in one line and move on.
Report problems prominently but still produce the full plan - the user decides whether to fix the stackup first.
Step 3: Check for Components Needing Fanout
Identify BGA, QFN, QFP, PGA, LGA, and other array packages that benefit from escape routing:
for ref, fp in pcb.footprints.items():
name_upper = fp.footprint_name.upper()
pad_count = len(fp.pads)
# Check for array / fine-pitch land/no-lead packages by name. Note 'QFP'
# already matches LQFP/TQFP/VQFP, 'QFN' matches VQFN/WQFN/HVQFN, and 'BGA'
# matches FBGA/UFBGA/TFBGA, so only distinct families need listing.
needs_fanout = any(k in name_upper for k in (
'BGA', # ball grid array
'PGA', # pin grid array (through-hole)
'LGA', # land grid array (interior lands, e.g. LGA-12) - issue #144
'CSP', 'WLCSP', 'WLP', # wafer-level / chip-scale = micro-BGA, sub-0.5mm
'CGA', # column grid array
'QFN', 'DFN', # quad / dual no-lead (exposed-pad)
'QFP', # quad flat pack
))
# Fine-pitch arrays strand even at low pad count: trigger by PITCH + interior
# pads, not just pad_count > 40 (issue #144: LGA-12 at 0.5mm has only 12 pads
# but its center lands box in). Compute the min pad-to-pad spacing and whether
# any pad is interior (not on the bounding-box edge).
if not needs_fanout and pad_count >= 6:
xs = sorted({round(p.local_x, 3) for p in fp.pads})
ys = sorted({round(p.local_y, 3) for p in fp.pads})
def _min_step(v):
return min((b - a for a, b in zip(v, v[1:])), default=999)
pitch = min(_min_step(xs), _min_step(ys))
minx, maxx, miny, maxy = xs[0], xs[-1], ys[0], ys[-1]
has_interior = any(minx < round(p.local_x, 3) < maxx and
miny < round(p.local_y, 3) < maxy for p in fp.pads)
# Fine pitch (<=0.6mm) with interior pads, OR a large multi-row part.
if (pitch <= 0.6 and has_interior) or pad_count > 40:
needs_fanout = True
if needs_fanout:
# Analyze pad arrangement
xs = sorted(set(round(p.local_x, 2) for p in fp.pads))
ys = sorted(set(round(p.local_y, 2) for p in fp.pads))
grid_cols, grid_rows = len(xs), len(ys)
# Check SMD vs through-hole
smd_count = sum(1 for p in fp.pads if p.drill == 0)
th_count = sum(1 for p in fp.pads if p.drill > 0)
Fanout Tool Selection
| Package Type | Tool | Notes |
|---|---|---|
| BGA (SMD grid) | bga_fanout.py |
Escape routing for ball grid arrays |
| PGA (through-hole grid) | bga_fanout.py |
Same tool works for PGA |
| LGA / WLCSP / CGA (land/chip-scale grid) | bga_fanout.py |
Grid escape; interior lands strand without it (issue #144) |
| QFN/QFP/DFN (perimeter SMD) | qfn_fanout.py |
Stub routing for quad/dual no-lead and flat packages |
| DIP/SOIC (through-hole/SMD rows) | None needed | Standard routing handles these |
When to Use Fanout for BGA/PGA/LGA
Rule: Use fanout for any grid array (BGA/PGA/LGA/WLCSP/CGA) with more than 2 pins depth from outside to center, OR any fine-pitch (<=0.5mm) array with interior pads regardless of pin count — a small LGA-12/WLCSP at 0.5mm pitch boxes its center lands in even though it has well under 40 pads (issue #144).
Important: Calculate ACTUAL depth by counting pads from the edge toward center, not grid size. Many PGA/BGA packages (especially FPGAs/CPLDs) have hollow centers with only perimeter pins populated.
To calculate actual depth:
# Check middle column from top edge toward center
mid_col = xs[len(xs)//2]
depth = 0
for y in ys: # ys sorted from edge
if (mid_col, y) in pad_positions:
depth += 1
else:
break # Stop at first empty position
Examples:
- 13×13 grid, fully populated → depth = 7 → USE FANOUT
- 13×13 grid, hollow center (3 rows populated) → depth = 3 → USE FANOUT
- 10×10 grid, hollow center (2 rows populated) → depth = 2 → fanout optional
- 4×4 grid, fully populated → depth = 2 → fanout optional
Inner pins beyond depth 2 cannot escape without fanout routing through channels between outer pins.
Escape layers (multi-layer boards): bga_fanout.py defaults to --layers F.Cu B.Cu
only. On a 4+ layer board, pass ALL the board's copper layers, e.g.
--layers F.Cu In1.Cu In2.Cu B.Cu — otherwise deep balls have nowhere to escape to
and those nets are dropped from the fanout. qfn_fanout.py is perimeter-only and
doesn't take escape layers.
Crowded fine-pitch QFN edge (surface fan has no room): if a qfn_fanout
stub (especially a diff pair) is boxed in by a neighbour pair and a foreign
track and the surface 45° fan drops it, use qfn_fanout.py --escape-method underpad --via-size 0.45 --via-drill 0.25 (#164). It drops a through-via just
past each pad and escapes on an inner/back layer — straight out past the lateral
congestion instead of fanning into it (adjacent vias are staggered to clear).
Match --via-size/--via-drill to the board's fine-pitch via rule. If the
underpad run still drops a leg ("N dropped") because the via has no clear
room outward (a neighbour pad/track exactly one pitch away), add
--allow-via-in-pad (#161): the escape via may then sit on its own pad and
stagger inward toward the chip, away from the neighbour, instead of being
dropped. It still clears every other-net pad/via/track — it only gains
permission to overlap its own pad — so reach for it specifically when underpad
reports drops on a boxed-in fine-pitch pair.
Size the escape via/track to the pitch BEFORE running fanout (issue #158).
bga_fanout.py escapes one track down the channel between adjacent via columns —
at the half-pitch. So the via, track, and clearance must fit that half-pitch
or every escape grazes the neighbouring column's via by a few µm, and the fanout
still reports failed: 0 (its success metric ignores sub-clearance grazes). The
budget, per array (measure each component's own pitch — they differ):
via_size + track_width + 2·clearance + margin ≤ pitch (one escape track per channel)
via_size ≥ via_drill + 2·min_annular_ring, track_width ≥ fab min track (fab floors)
Don't just shrink the via against a fixed track — solve for via AND track
together, taking each down toward the fab floor as the pitch demands, and leave
a little margin so the result clears DRC instead of merely touching it. Read each
array's own ball pitch P (the min ball spacing — arrays on one board differ) and
the requested clearance C (Default net-class clearance from
list_nets.py --design-rules), plus the board's fab floors (min_track_width,
min_via_diameter/min_via_drill, annular ring), then:
margin = 0.05 # slack: clear DRC, don't graze it
budget = P - 2*C - margin # room for one via + one track
track = max(min(nominal_track, 0.15), min_track_width) # keep a routable track
via = min(nominal_via, budget - track) # largest via that still fits
if via < via_floor: # via fell below the floor -> thin the track to free room
via = via_floor
track = max(min_track_width, budget - via)
infeasible = track < min_track_width or via < via_floor # even fab floors won't fit
via_drill = max(min_via_drill, via - 2*min_annular_ring) # hold the annular ring at floor
# via_floor = max(min_via_diameter, min_via_drill + 2*min_annular_ring)
Pass the computed --via-size via --via-drill via_drill --track-width track --clearance C to the fanout step. If infeasible, the pitch can't take a channel
escape even at the fab floor → switch to --escape-method underpad and/or add
escape layers; don't ship the graze.
Why this heuristic matters for the GUI: the plugin runs /plan-pcb-routing in
plan-only mode — it never executes the fanout and never runs the DRC↔smaller-via
retry loop, so it cannot discover a too-big via after the fact and shrink it. The
plan must therefore carry via/track that are already DRC-safe for the pitch.
Computing them here — both dimensions, with margin, clamped to the fab floor — is
what lets the single fanout the GUI runs come out clean the first time.
Worked example (keks U1, pitch 0.8, clearance 0.1, fab floor track 0.1 / via 0.45):
budget = 0.8 − 0.2 − 0.05 = 0.55; track 0.127 → via = min(working, 0.55−0.127) =
0.42 (≥ floor) → DRC-clean, vs the Ø0.5 the net-class default would have used
(163 grazes). At 0.4 mm pitch the budget forces both to the floor (track 0.10, via
~0.30/0.20 advanced); if even those don't fit, go --escape-method underpad.
bga_fanout.py also warns WARNING: escape via ... busts the half-pitch budget
when handed infeasible params, but choose feasible ones here so it never fires.
Always check the fanout escaped all requested balls. bga_fanout.py ends with
JSON_SUMMARY: {"component", "requested", "escaped", "failed", "unescaped_nets", ...}.
A dropped ball is removed from the output and later fails signal routing as "no
rippable blockers", so it must be caught here. If failed > 0, retry the fanout with
more layers and/or a smaller --clearance (see "Escape clearance" below) before
moving on — do not start signal routing while balls are still dropped.
If balls still drop on a dense, fully-populated array, switch to the under-pad
escape: add --escape-method underpad with a small via/track for the pitch
(e.g. --via-size 0.35 --track-width 0.12 --clearance 0.1 at 0.8 mm pitch). The
default channel engine confines every layer to the gaps between ball rows, so
a few channels over-subscribe and the deepest balls can't escape; underpad
routes each ball under the pad field on inner layers via a via-in-pad and
escapes arrays channel can't (e.g. a 22×22 BGA that drops ~20 balls → 0).
Caveats: it routes diff pairs as single-ended, and it skips power/plane
nets (they tap their plane), so create the planes first (or exclude power with
--nets). Rule of thumb: try channel first (keeps diff pairs); fall back to
underpad when channel can't escape a dense array.
After every BGA/PGA fanout, run the decoupling-cap placement optimizer
(#130). A fanout drops vias near the ball field; where a foreign-net via
lands under a decoupling cap placed at a ball, the via copper overlaps the
cap pad → a real PAD-VIA DRC violation at the clearance floor. The fix is
placement, so run place_fanout_clearance.py on the fanned board to
nudge those caps clear (and pull each pad toward its nearest same-net ball so
a power/GND via dropped there later shares the via). See "Step 1b" below for
the command. It's cheap, only touches caps near a BGA, and is a no-op when
nothing collides — so run it after each fanout step before moving on.
Report to user:
- List of components that may need fanout
- Package type, pad count, and grid depth for each
- Recommended fanout tool
Step 4: Check for Differential Pairs and Power Nets
Use list_nets.py to detect differential pairs and power/ground nets:
python3 list_nets.py path/to/file.kicad_pcb --diff-pairs --power
Read the board's design rules and pass them to the CLI
The router does NOT read the board's design rules — it falls back to a generic
--clearance 0.25 / --track-width default, which is often WIDER than the
board's own rule and can box pads in so nets fail with "no rippable blockers".
Read the board's real rules and pass them explicitly:
python3 list_nets.py path/to/file.kicad_pcb --design-rules
KiCad has TWO tiers of rules, and DRC only enforces one of them — this matters for fine-pitch boards (#111/#115):
- Net-class values (
clearance,track_width,via_diameter,via_drill): these are the size new objects are drawn at. Of these, only clearance is a DRC-enforced minimum.track_widthandvia_diameter/drillare not DRC floors — they are just defaults, so a board can (and the human originals do) use a smaller via/track than the net-class nominal and still pass DRC. - Board Constraints (
min_clearance,min_track_width,min_via_diameter,min_hole_to_hole,min_through_hole_diameter): these are the actual DRC floors.--design-rulesreads them fromdesign_settings.rulesand combines them with the JLCPCB fab minimum (backstop when a Constraint is 0/unset — e.g.min_clearanceis frequently 0) into a single manufacturing floor.
Use the printed flags as-is:
Routing (
route.py,qfn_fanout.py,bga_fanout.py,route_planes.py):--clearancefrom the Default class, but--via-size/--via-drillfrom the working floor, NOT the net-classvia_diameter. Emitting the net-class via everywhere is #115 — it's a max-like default, far too big for fine-pitch escape (e.g. a 0.4 mm QFN/BGA needs the small working via the original used). For--track-width, the net-class value is only a starting point and is not a hard minimum: on dense/congested boards route ordinary signals at the fab physical floor instead (thinner is both more complete and faster — see "Route signals at the FAB floor by default" in Diagnose and Retry). Keep the net-class width only for current-carrying nets (--power-nets). Do NOT keep the net-class gap/width for impedance-controlled (diff-pair) nets — the stock net class is usually wide (diff_pair_gap0.25 / width 0.2 mm), and a fat pair is a wider bundle that gets dropped on congested boards (measured:glasgow_revCroutes all 13 FPGA pairs at--diff-pair-gap 0.1but loses 2 at 0.25). Per/find-high-speed-nets, route those at the fab floor for gap and clearance (~0.1 mm) while keeping--impedancefor the width (the router computes it from the stackup and clamps it to the floor).route_diff.pythen auto-updates the Default net class to those tight values (only-loosen, viafix_kicad_drc_settings.py), so the.kicad_prostops advertising the wide gap.Escape clearance — trigger on dropped balls, not pitch (issue #122): the inter-ball channel is too narrow to fit a track at the net-class clearance on more BGAs than just "fine-pitch" ones. Even an 0.8 mm-pitch BGA drops balls at
--clearance 0.2(the ~0.45 mm gap between 0.35 mm balls can't fit a 0.2 mm track at 0.2 mm clearance) — the same board escapes all balls at the 0.1 mm floor. So don't gate on pitch: gate on whether balls actually dropped.bga_fanout.pyandqfn_fanout.pyboth end with aJSON_SUMMARY: {...}line givingrequested/escaped/failed/unescaped_nets. **After every fanout, parse it; iffailed > 0(escaped < requested), re-run the fanout with--clearanceat the manufacturing floor** (never below it — the floor is the rule the human board passes DRC against, so tightening board-wide is manufacturable and needs no rule-area settings). If still short, also try the smaller **fine-pitch escape via** (below) and/or a narrower--track-widthtoward the floor. Do not proceed to signal routing withfailed > 0unexpected — those balls are dropped from the output and will fail later as "no rippable blockers".Also check
drc_grazes(even whenfailed == 0). The summary'sdrc_grazes(graded at the fanout--clearance) reports sub-clearance grazes the escape left in the output:via_segment/pad_viaare the #130 classes (an escape via too close to a foreign track or pad),segment_segmentis the #179 class (two escape stubs grazing — typically the 45° fans of two adjacent pads of a tight-pitch diff pair, e.g. 0.4 mm-pitch QFN, clipping at the wrist),totalis all DRC violations. A successful fanout (every ball/pad escaped) can still leave many of these — they're not caught byfailed. If anydrc_grazesclass > 0 and there is headroom above the fab floor, re-run the fanout stepping toward — never below — the floor:via_segment/pad_via(#130): smaller--via-size/--via-drill(and/or a thinner--track-width).segment_segment(#179): thinner--width— the escape stubs carry the track width, so narrowing them widens the gap between the two converging diagonals. Step down toward the fab-floor track (e.g. 0.15 → 0.13 → 0.10 mm) untilsegment_segment == 0; all pads still escape (failedstays 0). (Measured on hackrf_one U17: 3 grazes at--width 0.15/0.13, 0 at0.10.)
These grazes are typically a uniform ~1-grid-cell shortfall, so even one size step down usually clears them all; shrinking the via also relieves escape congestion. (For via-over-pad grazes where a decoupling cap/resistor sits on a via,
place_fanout_clearance.py(Step 1b) is the better fix — it moves the part; smaller vias/thinner tracks help the via-over-track and stub-over-stub classes.)Fine-pitch escape VIA (4+ layer): the 0.45 mm standard via can't dog-bone / via-in-pad sub-~0.5 mm-pitch BGA/QFN balls. For those parts only, pass the smaller fine-pitch escape via that
--design-rulesprints (fine-pitch escape via <d>/<drill>, e.g.0.30/0.15— JLC "advanced", small extra cost) as--via-size/--via-drillto that part'sbga_fanout.py/qfn_fanout.py, toroute_diff.pywhen it launches from that part's escaped stubs, and toroute_disconnected_planes.py(its per-pad repair connects the fine-pitch GND/power plane balls under such parts). Keep the standard working via for generalroute.pyrouting and the bulkroute_planes.pypour — the advanced via is escape-only, not a board-wide default (issues #99/#122).Non-Default classes: route those nets separately with that class's
--clearance/--track-width(clearance is the one per-class DRC value, so keep each class's nets at their own clearance rather than forcing one global value).Diff pairs:
--track-width/--diff-pair-gapfrom the Default class forroute_diff.py.
Verification (DRC/connectivity) grades at the manufacturing floor, not the
inflated net-class clearance — that is the same rule the human original passes, so
it's the honest delta. Use the printed check_drc.py flags
(--clearance <floor> --hole-to-hole-clearance <floor>); see Step 6.
Only fall back to tool defaults when neither net classes nor Constraints are found
(--design-rules then prints the JLCPCB fab floor for the board's layer count).
This will output:
- Differential pairs detected (P/N naming conventions)
- Ground nets with pad counts
- Power nets with pad counts
If differential pairs are found:
- List each P/N pair
- Note that
route_diff.pyshould be used for these - Explain that diff pairs maintain consistent spacing and length matching
- If a pair's pads are on a BGA/PGA being fanned out, escape it with
bga_fanout.pytoo — pass--diff-pairs "<patterns>" --diff-pair-gap <gap>so P and N escape the array together on one layer. Don't just exclude the pair from fanout and hand it toroute_diff.py: it can't launch from the deep balls ("no valid position at any setback").route_diff.pythen connects the escaped stubs — but on a 4+ layer board you must pass those inner layers toroute_diff.pyvia--layerstoo (it defaults to F.Cu B.Cu, so an inner-layer escaped stub is otherwise unreachable and the pair is silently dropped — issue #116). Pairs not on an array package don't need fanout.
Tip: Name-based detection misses pairs with unconventional names. For boards with high-speed ICs (PHYs, SerDes, USB, FPGA transceivers), or when detection finds suspiciously few pairs, run
/identify-diff-pairsfor datasheet-based detection by pin function and per-interface gap/impedance recommendations.
Also note: route_diff.py resolves P/N polarity mismatches automatically, which can swap
target pad net assignments. Swaps are reported in the output — when they happen, the
schematic sync step below applies (see "Schematic Synchronization After Swaps").
Far-apart terminal pads → single-ended follow-up (issue #121). A "diff pair"
sometimes has pads that aren't a coupled connection — e.g. a P and an N test point
several mm apart, or a logical pair daisy-chained through spread-out parts. If the
coupled chain can't be routed, route_diff.py peels those far-apart pads off the
chain (routing the genuinely-coupled terminals as a pair) and lists the affected
nets under single_ended_followup_nets in its JSON_SUMMARY (and a "route them
single-ended next" block on stdout). Those pads are not dropped — the Signal
Routing step (route.py "*" "!GND" "!VCC") connects them P→P / N→N along with
every other unrouted net, since they remain unrouted after the diff-pair step. So:
do not exclude the diff-pair nets from the signal-routing step's net selection —
that step is what finishes the peeled pads. If you scope the signal step to specific
nets instead of "*", add any single_ended_followup_nets to it explicitly.
Check for DDR/High-Speed Memory Signals
Look for DDR signal patterns in the net list that may need length matching:
- Data signals: DQ0-DQ63
- Strobes: DQS, DQM, DM
- Clocks: CLK, CK
If DDR signals detected:
- Note that
--length-match-group autoshould be used - DQ0-7 + DQS0 form byte lane 0, DQ8-15 + DQS1 form byte lane 1, etc.
Report to user:
- List of detected differential pairs (or "none found")
- Whether
route_diff.pyis needed - Whether DDR/length-matching is needed
High-Speed Signal Check (delegate to /find-high-speed-nets)
Whether the plan includes GND return vias - and the --gnd-via-distance to use -
is the /find-high-speed-nets skill's job: it classifies nets into speed tiers
(datasheet lookup, rise-time estimates) and maps tiers to recommended distances.
Follow that skill's methodology here (its quick net-name/footprint scan decides
whether the deeper datasheet pass is worth it) and put the recommended distance
into the plan's GND-via step. Remember its physical floor: never set
--gnd-via-distance below 3 x (via_size + clearance), ~2.5 mm for standard vias.
Report to user when presenting the plan:
- If high-speed nets found: "GND Return Vias: This board has [tier] signals ([examples]).
GND return vias are included in Step N with
--gnd-via-distance [X]mm. Let me know if you'd like to skip this step." - If no high-speed nets found: "GND Return Vias: No high-speed signals detected (only low-frequency I2C/UART/GPIO). GND return vias are included in the plan but are optional for this board. Want me to remove the step?"
/find-high-speed-nets ALSO reports controlled-impedance nets (its Step 4.5):
RF/antenna feeds (radio/PA/LNA -> SMA/U.FL/chip-antenna = 50 ohm single-ended,
or 100 ohm if balanced), DDR SSTL, and the impedance-controlled diff interfaces.
Thread these into the plan:
- Differential impedance nets stay in the diff-pair step (Step 2) — just add
route_diff.py --impedance <ohms>. - Single-ended impedance nets (RF 50, DDR SSTL 40) get a dedicated
route.py --impedancepass placed AFTER diff pairs and BEFORE the general signal route (Step 2b below). They must then be excluded from the general signal route ("*" "!GND" "!VCC" "!RF") and counted in the Step 5b ledger as claimed by the impedance step — otherwise a later rip-up re-routes them at the wrong width. - Impedance width is computed from the stackup: if the board has only KiCad's
default stackup, lead the report with that warning and run
/recommend-stackupfirst (an RF feed routed at a wrong width is electrically useless). - For an RF/antenna feed also recommend (in words) a
User.2keepout around the antenna region and--keepout, and route it short/direct on an outer layer.
If no controlled-impedance nets are found, omit Step 2b.
Step 5: Review Power and Ground Net Strategy (delegate to /recommend-plane-mappings)
Which nets deserve planes and on which copper layers is the
/recommend-plane-mappings skill's job: it weighs pad counts and datasheet
current estimates, and assigns layers with SI rationale (GND adjacent to signal
layers for return paths, power planes paired against GND, split layers for
multiple rails). Follow its methodology here, seeded by the list_nets.py --power
output, and put the resulting net -> layer assignments into the plan's
route_planes steps. Nets it leaves to wide traces become --power-nets /
--power-nets-widths on the route step instead.
Report to user:
- Identified GND nets and pad counts
- Identified power nets and pad counts
- Recommended strategy (plane vs wide traces) with layer assignments
Step 5b: Net-Coverage Reconciliation (mandatory — do not skip)
The stages partition every routable net by glob pattern, and the patterns are
not reconciled automatically. The failure mode this step prevents: a net is
excluded from one stage (!X) but never claimed by a later one, so it
silently gets zero copper and the run "completes" with it fully unrouted. This
is exactly how GNDA (an analog ground tied to GND through a single 0Ω/
ferrite) was dropped — excluded from the signal route as a "power net", yet never
added to the plane step's --nets, ending with 0/23 pads connected while the run
reported success.
The invariant: every routable net (≥2 pads, not no-connect) must be claimed by exactly one stage. A net excluded from any stage MUST be claimed by a later one.
Before running any command, write the net-handling ledger and reconcile it mechanically — do not eyeball it:
Assign every routable net to one handler:
fanout + signal route— ordinary signals (the"*"selection minus exclusions)diff-pair route— detected pairsimpedance SE route (Step 2b)— single-ended controlled-impedance nets (RF/antenna 50 ohm, DDR SSTL 40 ohm); excluded from the signal route, NOT pouredplane / pour— every net you exclude from the signal route with!Xthat a plane pourswide trace— power carried inside the route selection via--power-nets(NOT excluded)
Diff the pattern lists. The set of signal-route exclusions (
!A !B …) MUST equal the nets the plane step pours PLUS the single-ended impedance nets routed in Step 2b. A net in the symmetric difference is a plan bug — excluded from routing but handled by no later stage (→ unrouted), or poured/impedance-routed but not excluded (→ also routed as ordinary tracks, defeating it). Print and assert the difference is empty:route_exclusions = {"GND", "+3V3", "RF"} # the !X you will pass route.py plane_nets = {"GND", "+3V3"} # the --nets you pass route_planes.py impedance_se = {"RF"} # nets routed in Step 2b (route.py --impedance) orphans = route_exclusions ^ (plane_nets | impedance_se) # symmetric difference assert not orphans, f"Net-coverage gap: {sorted(orphans)} handled by no stage"Do not proceed until
orphansis empty.Secondary grounds / split rails (
AGND,GNDA,DGND,VREF, or any rail tied to its parent through a single 0Ω resistor or ferrite bead — find the tie withlist_nets.py: the part with one pad on each net). These are real, separate nets. Pour each as its own local region (Voronoi-sharing an inner layer with the main ground is fine) and let the single tie component join it to the parent. Never merge it into the parent plane (that shorts the split and defeats its purpose — a green connectivity check then hides an electrical error) and never leave it out (that leaves it unrouted). Give each its own--netsentry in the plane step, so it appears in BOTH lists in step 2 above.
Step 6: Generate Routing Plan
Based on the analysis, generate a step-by-step plan. The general order is:
Routing Order Rationale
- Fanout (if needed) - Escape routing first, while the board is empty. Exclude
nets that planes will handle (
"*" "!GND" "!VCC"). After each BGA/PGA fanout, runplace_fanout_clearance.py(Step 1b) to clear decoupling-cap / fanout-via collisions (#130) before signal routing. - Differential Pairs - The most constrained routes claim their channels before
anything else can block them (if present). Add
--impedance <ohms>for the controlled ones (USB/Ethernet/LVDS/balanced-RF; from/find-high-speed-nets). May peel far-apart "terminal" pads (e.g. spread-out test points) off the coupled chain and leave them for the signal-routing step (reported assingle_ended_followup_nets, issue #121). 2b. Impedance-controlled single-ended nets (only if/find-high-speed-netsfound any - RF/antenna feeds = 50 ohm, DDR SSTL = 40 ohm). A dedicatedroute.py --impedance <ohms>pass, routed here - after diff pairs, before the bulk signal route - because they need a stackup-derived width and a short, direct path over a clean ground reference, so (like diff pairs) they must claim their channel before the bulk signals fill the area. Route an RF feed on an outer layer (--layers F.Cu); requires a real stackup (see Step 2 stackup check). These nets are then EXCLUDED from step 3. - Signal Routing - All remaining nets, excluding the plane nets AND any
single-ended impedance nets from step 2b (
--nets "*" "!GND" "!VCC" "!RF"). Routing the plane nets as tracks would defeat the planes step; re-routing the impedance nets here would drop their controlled width - both exclusions are mandatory. This step also finishes any diff-pair pads peeled off in step 2, so keep the diff-pair nets in its selection (the"*"covers them). - Power Planes - Create GND and VCC planes together. Stitching vias adapt
around the routed signals; the reverse is not true - a stitching via placed
early can block the only clean channel for a diff pair (issue #56). If signal
tracks boxed in a power pad, add
--rip-blocker-netsso the blockers are ripped and rerouted. - GND Return Vias - Add return current vias near signal vias (when GND planes
present); folds into the planes call with
--add-gnd-vias. - Plane Repair - Reconnect any broken plane regions
- Verification - DRC and connectivity checks
Example Plan Output Format
Present the plan to the user as a numbered list with explanations:
## Routing Plan for board.kicad_pcb
### Board Summary
- 2-layer board (F.Cu, B.Cu)
- 174 nets, 25 components
- Unrouted (0 existing traces)
### Components Requiring Special Handling
- **U9 (PGA120)**: 120-pin grid array - use bga_fanout.py for signals only
### Differential Pairs
- None detected
### Power/Ground Nets
- **GND**: 42 pads - use plane on B.Cu
- **VCC**: 23 pads - use plane on F.Cu (or wide traces if planes not desired)
---
## Step-by-Step Routing Commands
### Step 1: Fanout U9 (PGA120) - All Non-Plane Nets
Generates escape routing for ALL nets on the component EXCEPT those that the
planes step will handle. This ensures every signal net gets fanned out,
avoiding `--no-bga-zone` workarounds during routing.
**Important:** Use `"*" "!GND" "!VCC"` to fan out all nets except the power
plane nets. Do NOT use `"/*"` alone, as it misses nets with non-hierarchical
names like `Net-(U9-Pad1)` which would then require `--no-bga-zone` to route.
On a 4+ layer board also pass every copper layer with `--layers` (default is
F.Cu B.Cu only) so inner balls can escape — drop `--layers` only for true
2-layer boards.
python3 -X utf8 bga_fanout.py board.kicad_pcb \
--component U9 \
--nets "*" "!GND" "!VCC" \
--layers F.Cu In1.Cu In2.Cu B.Cu \
--output board_step1.kicad_pcb \
2>&1 | tee /tmp/step1_fanout.txt
**Then check the `JSON_SUMMARY` line: if `failed > 0`, balls were dropped — retry
before continuing.** First confirm all copper layers are passed; then re-run with
`--clearance` at the manufacturing floor (e.g. `--clearance 0.1`), which fixes the
common case (an 0.8 mm-pitch BGA can't fit a track between balls at 0.2 mm). If still
short, add the fine-pitch escape via and/or a smaller `--track-width`. Only proceed
to Step 2 once `failed == 0` (or the remaining `unescaped_nets` are understood and
accepted).
### Step 1b: Optimize Decoupling-Cap Placement (run after EACH BGA fanout — issue #130)
Nudges decoupling caps near the BGA off the foreign-net fanout vias (the
`PAD-VIA` violations #130) and pulls each pad toward its nearest same-net
ball. Run it on the just-fanned board, **before** signal routing. Use the
**same `--clearance`** you gave the fanout / your DRC floor — that's the only
setting that matters (it reads each via's real size from the board).
python3 place_fanout_clearance.py board_step1.kicad_pcb board_step1b.kicad_pcb \
--clearance 0.1
It prints `Moved N cap(s); resolved R/M ... K unresolved`. Any **unresolved**
caps had no clear spot within the displacement budget — note them for a manual
nudge; they are not auto-fixed. By default (`--cap-prefix C,R`) it moves 2-pad
**caps and resistors** near a BGA (RN-style arrays auto-excluded since only
2-copper-pad parts move); it never overlaps parts, and is a no-op when nothing
collides. Feed `board_step1b.kicad_pcb`
into the next step (if multiple BGAs are fanned in series, run this once after
each, or once after the last fanout — it considers all BGAs' vias on the board).
Verify with `check_drc.py board_step1b.kicad_pcb -c 0.1` (PAD-VIA count drops).
### Step 2b: Impedance-Controlled Single-Ended Nets (only if any were found; runs before the Step 2 signal route)
ONLY when `/find-high-speed-nets` reported single-ended controlled-impedance nets
(RF/antenna feed = 50 ohm, DDR SSTL = 40 ohm). Route them in their own
`--impedance` pass, after diff pairs and BEFORE the general signal route, so they
claim a clean, short, direct channel at the stackup-derived width. Requires a real
stackup (run `/recommend-stackup` first if the board has KiCad's default). Route an
RF feed on an outer layer over the GND plane; recommend a `User.2` keepout +
`--keepout` around any antenna region (user draws it).
python3 -X utf8 route.py board_diff.kicad_pcb board_step2b.kicad_pcb \
--nets RF --impedance 50 --layers F.Cu \
--clearance <floor> --no-bga-zone \
2>&1 | tee /tmp/step2b_impedance.txt
### Step 2: Route All Signal Nets (excluding plane nets + impedance nets)
Routes all remaining unrouted nets EXCEPT the nets that get planes in the
next step - the `"!GND" "!VCC"` exclusions are mandatory here, otherwise the
power nets get routed as ordinary tracks and the planes step has nothing to
do - AND any single-ended impedance nets already routed in Step 2b
(`"!RF"`), so the bulk pass cannot re-route them off their controlled width.
Routing signals before planes means the plane stitching vias (placed
next) adapt around the signals instead of blocking them.
For boards with BGA/PGA components, use `--no-bga-zone` to allow the router
to find alternative paths through the dense pin area (even when fanout was
done, some paths may require this). Use `--max-ripup 10
--max-iterations 1000000` for difficult 2-layer boards.
python3 -X utf8 route.py board_step1.kicad_pcb board_step2.kicad_pcb \
--nets "*" "!GND" "!VCC" \
--no-bga-zone \
--max-ripup 10 \
--max-iterations 1000000 \
2>&1 | tee /tmp/step2_routing.txt
(When Step 2b ran, add its impedance nets to the exclusions, e.g.
`--nets "*" "!GND" "!VCC" "!RF"`, and route from `board_step2b.kicad_pcb`.)
### Step 3: Create Power Planes (GND and VCC) + GND Return Vias
Creates power planes in a single call, after signal routing so the stitching
vias find spots around the finished tracks. Each net is paired with its
corresponding layer (GND→B.Cu, VCC→F.Cu). Through-hole PGA/BGA pads
automatically connect to planes on their layer; SMD pads get vias routed to
the plane. `--add-gnd-vias` also places return-current vias near the signal
vias that now exist. If signal tracks boxed in a power pad, add
`--rip-blocker-nets` to rip the blockers out of the way (they are left unrouted
and reconnected by the Step 5c route.py pass).
> **Note to user:** GND return vias improve signal integrity for high-speed
> signals. Based on the speed analysis, this board has [speed_tier] signals,
> so `--gnd-via-distance` is set to [X] mm. If this is a purely low-frequency
> board (I2C/UART/GPIO only), drop `--add-gnd-vias`. Let me know if you'd
> like that.
python3 -X utf8 route_planes.py board_step2.kicad_pcb board_step4.kicad_pcb \
--nets GND VCC \
--plane-layers B.Cu F.Cu \
--add-gnd-vias --gnd-via-distance 2.0 \
2>&1 | tee /tmp/step3_planes.txt
Adjust `--gnd-via-distance` based on the board's highest signal speed:
- Ultra-high (>1 GHz): 2.0 mm
- High (100 MHz - 1 GHz): 3.0 mm
- Medium (10 - 100 MHz): 5.0 mm
- Minimum physical limit: 3 x (via_size + clearance)
### Step 5: Repair Disconnected Plane Regions
Signal traces and GND return vias may have cut through planes. This step
reconnects any isolated copper islands AND repairs pad-level plane connections.
With `--rip-blocker-nets`, a plane-net pad that can't reach its plane (e.g. a tiny
connector GND pin blocked by a signal trace) is connected by tracing to an
adjacent same-net pad, **ripping the blocking net out of the way**. The ripped
blockers are **left UNROUTED here** — they are reconnected by the route.py pass in
Step 5c, NOT inside this step. (Re-routing them in-step is unsafe: a ripped net
that fails to re-route had its original copper restored on top of whatever had
meanwhile been routed through its freed corridor, shorting them — the restore
bypasses the obstacle map. Issue #141 reverted; `--reroute-ripped-nets` and the
plugin's "Auto-reroute ripped nets" checkbox are now deprecated no-ops.) Carry
over Step 2's clearance/via/track-width/grid and `--no-bga-zone`.
python3 -X utf8 route_disconnected_planes.py board_step4.kicad_pcb board_step5_repair.kicad_pcb \
--clearance <floor> --via-size <V> --via-drill <D> --track-width <signal_track> --grid-step <G> \
--rip-blocker-nets \
--power-nets <PWR...> --power-nets-widths <W...> [--no-bga-zone] \
2>&1 | tee /tmp/step5_plane_repair.txt
**If it reports `Pads still unconnected` on fine-pitch (BGA/QFN ≤0.5 mm-pitch)
pads, retry the repair in this order — cheapest/safest first:**
1. **Smaller via first** — drop `--via-size`/`--via-drill` toward the **fab-floor
/ fine-pitch escape via** (e.g. `0.30/0.15`), but **never below the fab via
floor**. A boxed fine-pitch pad usually fails because the repair *via* can't
fit beside the ball; a smaller via fits in/near it and frees the connection.
2. **Then finer grid** — drop `--grid-step` (e.g. `0.05 → 0.025`), but **not below
the board's minimum feature / your fab grid**. This is for the case where the
pad connects by a *trace* to an adjacent already-connected same-net ball (the
repair does this automatically): the trace is already thin, but at a coarse
grid the A* can't thread the 0.65 mm-pitch BGA escape. **Measured on
ottercast_audio: GND U1.N4 fails to route at `--grid-step 0.05` but connects
to its neighbour ball at `0.025`** — it's a grid-resolution limit, not a width
one (the trace runs at the thin signal track in both the A* and obstacle map).
Re-check `check_connected.py` after each retry; stop as soon as the pads connect
(finer grid is slower, so only escalate to it if the smaller via didn't do it).
### Step 5c: Reconnect the nets plane-repair left unrouted (mandatory if Step 5 ripped any)
route_disconnected_planes lists the blockers it ripped and left unrouted. Reconnect
them with a final route.py pass using the **same parameters as the Step 2 signal
route** — clearance/via/track-width/grid, `--no-bga-zone`, and the **same
`--power-nets`/`--power-nets-widths`** so a wide power net re-routes at its wide
width, not the signal default. route.py routes against the live obstacle map
(planes + repairs included) with safe rip-up/restore, so it reconnects them without
the shorts the old in-step reroute caused. This produces the canonical final board
`board_step5.kicad_pcb`. (If Step 5 reports it ripped nothing, you may skip this and
rename board_step5_repair.kicad_pcb -> board_step5.kicad_pcb.)
python3 -X utf8 route.py board_step5_repair.kicad_pcb board_step5.kicad_pcb \
--nets "*" "!GND" "!<other_plane_nets...>" \
--clearance <floor> --via-size <V> --via-drill <D> --track-width <signal_track> --grid-step <G> \
--max-ripup 10 [--no-bga-zone] \
--power-nets <PWR...> --power-nets-widths <W...> \
2>&1 | tee /tmp/step5c_reconnect.txt
### Step 6: Verify Results
Invoke `/review-routed-board board_step5.kicad_pcb` for the full review (DRC,
connectivity, orphan stubs, length-match tolerances, GND return via coverage,
diff pair checks). If that skill is unavailable, run the raw checks — DRC at the
**manufacturing floor** from Step 4's `--design-rules` output (the
`check_drc.py` flags it printed), NOT a hardcoded 0.25, so legitimately-tight
fine-pitch escapes that are still fabbable don't read as violations (#111):
python3 -X utf8 check_drc.py board_step5.kicad_pcb --clearance <floor> --hole-to-hole-clearance <floor> 2>&1 | tee /tmp/step6_drc.txt
python3 -X utf8 check_connected.py board_step5.kicad_pcb 2>&1 | tee /tmp/step6_connectivity.txt
python3 -X utf8 check_orphan_stubs.py board_step5.kicad_pcb 2>&1 | tee /tmp/step6_orphans.txt
Coverage gate (mandatory — close the loop on Step 5b). check_connected.py
already lists every net with ≥2 pads but no copper and no covering zone as
"Unrouted net with N pads" (it accounts for plane zones and ignores genuine
single-pad / no-connect nets). After planes + repair, this unrouted list must
be empty except for entries you can individually justify in writing (true
single-pad nets, deliberate no-connects). A fully-unrouted multi-pad net is a
coverage defect, NOT a shortfall to report-and-accept: it means a net fell
through the stage partition (Step 5b). For each one, go back and handle it —
route it, or add it to the plane step (a secondary ground gets its own pour
region per Step 5b) — then re-verify. Do not declare the board done while the
list has unjustified entries.
Alternative: VCC as Wide Traces (No Plane)
If you prefer not to use a VCC plane, route VCC with wide traces instead:
### Step 1 (Alternative): Fanout U9 Including VCC
python3 -X utf8 bga_fanout.py board.kicad_pcb \
--component U9 \
--nets "*" "!GND" \
--output board_step1.kicad_pcb
### Step 2 (Alternative): Route Signals + VCC as Wide Traces
python3 -X utf8 route.py board_step1.kicad_pcb board_step2.kicad_pcb \
--nets "*" "!GND" \
--power-nets VCC --power-nets-widths 0.5
Only GND keeps its exclusion (it still gets a plane in Step 3, now with
--nets GND --plane-layers B.Cu only). If VCC wasn't fanned out, add
--no-bga-zone U9 to allow router access.
Step 7: Check for High-Speed Signal Requirements
Length Matching (DDR, high-speed buses)
For DDR memory or other length-matched buses, detect signals that need matching:
# Common DDR signal patterns
ddr_patterns = ['DQ', 'DQS', 'DQM', 'DM', 'CLK', 'CK', 'CAS', 'RAS', 'WE', 'CS', 'ODT', 'CKE']
ddr_nets = [n.name for n in pcb.nets.values()
if n.name and any(p in n.name.upper() for p in ddr_patterns)]
If DDR or length-matched signals detected, add to the plan:
--length-match-group autofor automatic DDR byte lane grouping--length-match-tolerance 0.1for acceptable variance (mm)--time-matchingif routes span different layers (accounts for dielectric)
Impedance-Controlled Routing
For high-speed signals with impedance requirements:
--impedance 50for 50Ω single-ended (calculates width per layer from stackup)--impedance 100withroute_diff.pyfor 100Ω differential
Bus Detection
For parallel data/address buses with clustered endpoints:
--busenables automatic bus detection and parallel routing- Routes are attracted to neighbors, creating clean parallel traces
Step 8: Handle Special Cases
2-Layer Board with Dense Components
On 2-layer boards, BGA/PGA fanout may fail for some inner pins due to insufficient routing channels. Options:
- Accept partial fanout; router will complete remaining connections
- Skip fanout entirely; direct routing often works for through-hole PGA
Important: If you skip fanout for a BGA/PGA component but still need to connect its
internal pads, use --no-bga-zone <component> to disable the automatic exclusion zone
and allow the router to enter the dense pin area:
python3 route.py board.kicad_pcb \
--nets "*" \
--no-bga-zone U9 \
--output board_routed.kicad_pcb
Without this flag, the router auto-detects BGA/PGA zones and avoids them, which would leave internal pads unconnected if they weren't fanned out.
Multi-Layer Boards (4+ layers)
- Use inner layers for planes (In1.Cu for GND, In2.Cu for VCC). Roughly half the copper layers should be planes — on a 4-layer board that's In1+In2 as planes, F.Cu+B.Cu for signals.
- More fanout options available.
Derive --layer-costs from the plane plan — penalize the plane-reserved
layers (issue #185). The 4-layer default is all 1.0, so the router has no
idea which inner layers are about to become planes and freely routes signals
across them. Once you've decided the plane→layer map (via
/recommend-plane-mappings or the route_planes call you're about to make),
pass --layer-costs to the signal route.py step (and the later reconnect
passes) that makes each plane-reserved layer expensive, so signals prefer the
signal layers and leave the inner layers clean for the pour:
# GND plane on In1.Cu, power plane on In2.Cu -> penalize In1/In2 for signals:
route.py ... --layers F.Cu In1.Cu In2.Cu B.Cu --layer-costs 1.0 3.0 3.0 1.0
~3× is the sweet spot. Any value ≥2× keeps signals off the planes and doesn't hurt completion; ≥5× just adds vias/copper for negligible further gain. Order matches
--layers; keep the real signal layers (F.Cu/B.Cu) at 1.0.Why it matters — it's a cascade, not just tidiness. Signals crossing a plane layer fragment the pour into islands;
route_disconnected_planesthen carpets the layer with island-stitching tracks. Keep signals off the plane layers and the planes stay whole, so the repair has almost nothing to stitch.Measured on castor_pollux (4-layer, In1=GND, In2=+3.3V/+3.3VA), full chain, default
1.0 1.0 1.0 1.0vs smart1.0 3.0 3.0 1.0, both fully connected and DRC-clean:default smart 3× total segments 4857 2966 (−39%) signal copper on plane layers 307 mm 44 mm (−86%) vias 309 318 (+9) The 39% segment drop is the carpet disappearing because the planes stayed whole.
This is the 4-layer analogue of the 2-layer rebalance in best-practice #8 / #178: in both cases derive the costs from how the layers will actually be used, rather than taking the blunt default.
Differential Pairs Present
Insert diff pair routing after fanout but before single-ended signals:
python3 route_diff.py board.kicad_pcb \
--nets "*LVDS*" "*USB*" \
--diff-pair-gap 0.15 \
--layers F.Cu In1.Cu In2.Cu B.Cu \
--output board_diff.kicad_pcb
Escape layers (multi-layer boards): like bga_fanout.py, route_diff.py
defaults to --layers F.Cu B.Cu only. On a 4+ layer board you MUST pass every
copper layer — when a pair was escaped by bga_fanout.py onto an INNER layer,
route_diff.py can only launch from those escaped stubs if that inner layer is
in --layers. Omitting it strands the inner-layer stubs and silently drops
those pairs (you'll see a low routed-pair count, e.g. 8/40 instead of 22/40 —
issue #116). Use the same copper-layer list you passed to bga_fanout.py; drop
--layers only for true 2-layer boards.
Key options:
--diff-pair-gap 0.1- Gap between P and N traces (mm)--no-gnd-vias- Disable automatic GND via placement near signal vias--diff-pair-intra-match- Match P/N lengths within each pair--swappable-nets "*rx*"- Allow target swap optimization for memory lanes
QFN/QFP Components (Perimeter Pads)
Use qfn_fanout.py instead of bga_fanout.py:
python3 qfn_fanout.py board.kicad_pcb \
--component U1 \
--output board_qfn.kicad_pcb
Creates two-segment stubs (straight + 45° fan) for each pad. On a crowded
fine-pitch edge where the surface fan has no room, add --escape-method underpad
(drop a through-via past each pad) and, if a boxed-in leg still drops,
--allow-via-in-pad so the via can sit on its own pad and stagger inward — see
"Crowded fine-pitch QFN edge" above.
Like bga_fanout.py, qfn_fanout.py ends with a JSON_SUMMARY carrying
drc_grazes (graded at --clearance). Parse it after the fanout: if
drc_grazes.segment_segment > 0 the 45° escape stubs of two adjacent tight-pitch
pads (often a diff pair) are grazing at the wrist — re-run with a thinner
--width toward the fab floor until it's 0 (issue #179; see the drc_grazes
bullet under Step 1). All pads keep escaping (failed stays 0).
Power Net Width Options
Instead of routing power separately, use --power-nets with signal routing:
python3 route.py board.kicad_pcb \
--nets "*" \
--power-nets "GND" "VCC" "+3.3V" \
--power-nets-widths 0.5 0.4 0.4 \
--output board_routed.kicad_pcb
First matching pattern determines width. Useful when not using planes.
Target Swap Optimization (Memory Routing)
For swappable signals (e.g., memory data lanes where any DQ can connect to any):
python3 route.py board.kicad_pcb \
--nets "*DQ*" \
--swappable-nets "*DQ*" \
--output board_routed.kicad_pcb
Uses Hungarian algorithm to find optimal assignments minimizing crossings.
Schematic Synchronization After Swaps
When routing performs polarity swaps (P↔N) or target swaps, the schematic can get
out of sync with the PCB. Use --schematic-dir to automatically update:
python3 route_diff.py board.kicad_pcb \
--nets "*LVDS*" \
--swappable-nets "*LVDS*" \
--schematic-dir /path/to/kicad/project \
--output board_routed.kicad_pcb
This updates the .kicad_sch files with any pad swaps made during routing.
Important: After routing with swaps, ask the user:
"The router performed X polarity swaps and Y target swaps. Would you like to update the schematic to match? If so, provide the path to your KiCad project directory and I'll re-run with
--schematic-dir."
Schematic sync is disabled by default to avoid unexpected changes. Only enable when the user confirms they want schematic updates.
Guide Corridors (user-drawn preferred routes)
When specific nets keep taking bad paths (or the user wants control over where a bundle
runs), the user can draw a polyline on User.1 in KiCad and re-route those nets with:
python3 route.py board.kicad_pcb --nets "SPI*" --guide-corridor --output board_routed.kicad_pcb
The route follows the line as waypoints, strictly best-effort — a guide never makes a route
fail or adds vias. See docs/configuration.md "Guide Corridor Options" for details.
Scope rule: do NOT draw guide corridor geometry yourself. Suggest in words where a
corridor would help ("a line on User.1 south of J3, between the mounting hole and C14") and
let the user draw it; then incorporate --guide-corridor into the plan.
Keepout Zones (RF / analog exclusions)
Check the board for components that warrant routing exclusions: antennas (footprint/value
keywords ANT, ANTENNA, chip antenna parts), RF modules, and sensitive analog front-ends. If
found, recommend the user draw closed polygon(s) on User.2 around those regions and add
--keepout to every routing step (route.py, route_diff.py) so tracks and vias stay out
on all copper layers. Same scope rule as guide corridors: describe where the keepout should
go; the user draws it.
MPS Layer Swap (crossing conflicts)
When MPS ordering reports crossing conflicts (nets in Round 2+), or failures show pairs of
nets repeatedly ripping each other up, add --mps-layer-swap to attempt layer swaps that
eliminate same-layer crossings before routing begins.
Vertical Track Alignment
On 4+ layer boards where through-hole components need via space, --vertical-attraction-radius
/ --vertical-attraction-cost attract tracks on different layers to stack vertically,
consolidating routing corridors.
Plane Via Placement Options (route_planes.py)
- Multiple nets can share one plane layer (Voronoi partitioning):
--nets GND VCC --plane-layers In2.Cu In2.Cu --same-net-pad-clearance <mm>forces plane vias outside same-net pads with that edge-to-edge clearance (default places at pad center when possible)--rip-blocker-netsrips up interfering routed nets to maximize via placement and leaves them unrouted (reconnect with a route.py pass afterward — Step 5c).--reroute-ripped-netsis a deprecated no-op.
Net Ordering Strategies
| Strategy | Flag | Best For |
|---|---|---|
| MPS (default) | --ordering mps |
General routing, minimizes crossings |
| Inside-Out | --ordering inside_out |
BGA escape routing |
| Original | --ordering original |
Manual control |
Useful Utility Scripts
| Script | Purpose |
|---|---|
list_nets.py U1 |
List all nets connected to a component |
list_nets.py U1 --pads |
Show pad-to-net assignments |
check_orphan_stubs.py |
Find traces ending without connection |
Debug and Visualization Options
When routing fails or behaves unexpectedly:
# Verbose output with diagnostic info
python3 route.py board.kicad_pcb --nets "*" --verbose --output board_debug.kicad_pcb
# Debug geometry on User layers (visible in KiCad)
python3 route.py board.kicad_pcb --nets "*" --debug-lines --output board_debug.kicad_pcb
# Real-time visualization (requires pygame-ce)
python3 route.py board.kicad_pcb --nets "*" --visualize --output board_debug.kicad_pcb
# A* search statistics
python3 route.py board.kicad_pcb --nets "*" --stats --output board_debug.kicad_pcb
Post-Routing Enhancements
# Add teardrop settings to all pads (improves manufacturability)
python3 route.py board.kicad_pcb --nets "*" --add-teardrops --output board_routed.kicad_pcb
Advanced Routing Parameters
For difficult boards, consider tuning these parameters:
| Parameter | Default | Effect |
|---|---|---|
--max-ripup 3 |
3 | Max blocking nets to rip up and retry |
--max-iterations 200000 |
200000 | A* iteration limit per route |
--heuristic-weight 1.9 |
1.9 | >1 = faster but may miss tight routes, 1.0 = optimal |
--via-cost 50 |
50 | Higher = fewer vias, longer paths; lower (10-25) for BGA escape |
--grid-step 0.1 |
0.1 | Smaller = finer routing but slower; 0.05 for fine-pitch |
Manufacturing constraints (set to match your fab's requirements):
| Parameter | Default | Description |
|---|---|---|
--clearance 0.25 |
0.25 | Track-to-track clearance (mm) |
--board-edge-clearance 0.5 |
0 | Min distance from board edge (mm) |
--hole-to-hole-clearance 0.2 |
0.2 | Min drill-to-drill spacing (mm) |
Proximity Penalties
For dense boards, use proximity penalties to spread out routes:
python3 route.py board.kicad_pcb --nets "*" \
--stub-proximity-radius 2.0 --stub-proximity-cost 0.2 \
--bga-proximity-radius 7.0 --bga-proximity-cost 0.2 \
--track-proximity-distance 2.0 --track-proximity-cost 0.1 \
--output board_routed.kicad_pcb
Important Notes
- Net-coverage invariant (Step 5b) - Every routable net must be claimed by exactly one stage; a net excluded from one stage (
!X) MUST appear in a later stage's selection. Reconcile the route-exclusion set against the plane--netsset before routing (symmetric difference empty), and confirmcheck_connected.py's unrouted list is empty at the end. This is the guard against a net (e.g. a secondary ground like GNDA) being silently dropped by every stage. - Always check for GND connections - If a component has GND pads but GND isn't being fanned out, the plane vias will handle it
- Fanout ALL non-plane nets - Use
--nets "*" "!GND" "!VCC"to fan out all nets except those handled by planes. Do NOT use"/*"alone as it misses nets with non-hierarchical names likeNet-(U9-Pad1). Unconnected nets are automatically filtered out. - Order matters - Fanout, then diff pairs, then signals (always excluding plane nets with
"!GND" "!VCC"exclusions), then planes + GND return vias, then repair. Signals route first because stitching vias can relocate around tracks, but a diff pair cannot relocate around a badly placed via - Verify at the end - Always run DRC, connectivity, and orphan stub checks
- Consider the analyze-power-nets skill - For complex boards where power net identification isn't obvious, use that skill first to analyze component datasheets
- Consider the find-high-speed-nets skill - For accurate GND return via distance recommendations based on actual component datasheet speeds and rise times, run
/find-high-speed-netsbefore planning. The lightweight inline analysis (Step 4) uses net name patterns only. - Stub layer switching is on by default - The router automatically moves stubs to eliminate vias when beneficial; disable with
--no-stub-layer-swap - Default layer costs - 2-layer boards default to F.Cu=1.0, B.Cu=3.0 to prefer top layer; 4+ layer boards use 1.0 for all. On dense 2-layer boards this 3× back-side penalty can over-bias routing onto F.Cu (top channel exhausted, B.Cu empty, excess vias, stranded pads); if completion is low or the layer balance is badly skewed, retry with more balanced
--layer-costs(e.g.1.0 1.5, down toward1.0 1.0) — see "Dense 2-layer boards: rebalance layer costs" under Diagnose and Retry (issue #178). On 4+ layer boards the all-1.0 default is plane-blind: derive--layer-costsfrom the plane→layer map and penalize the plane-reserved inner layers (~3×) so signals stay on F.Cu/B.Cu and the planes stay whole — see "Multi-Layer Boards (4+ layers)" (issue #185). - Schematic sync is disabled by default - After routing with swaps, offer to re-run with
--schematic-dirif the user wants to update their schematic - Rip-up and reroute is automatic - When a route fails, the router automatically rips up blocking nets and retries (up to
--max-ripupblockers) - Component shortcut - Use
--component U1to route all signal nets on a component (auto-excludes GND/VCC/unconnected) - Use --no-bga-zone for difficult boards - Even when fanout is complete, use
--no-bga-zoneduring routing to allow the router to find alternative paths through the dense pin area. This is especially important for 2-layer boards where routing channels are limited. - Windows UTF-8 encoding - On Windows, use
python3 -X utf8to avoid Unicode encoding errors when scripts print special characters (like Ω for resistance). Example:python3 -X utf8 route_planes.py ... - BGA/PGA power pins and planes - When using power planes, BGA/PGA power pins (GND, VCC) connect most efficiently via direct vias to the plane rather than fanout routing. Create planes first, then fanout only signal nets. Through-hole PGA pads automatically connect to planes on that layer; SMD BGA pads need vias placed by
route_planes.py. This approach:- Reduces routing congestion (power pins don't consume escape channels)
- Provides lower impedance power connections
- Aggressive parameters for 2-layer BGA/PGA boards - Use
--max-ripup 10 --max-iterations 1000000from the start for boards with dense components. These parameters help resolve routing conflicts that would otherwise fail. - Guide corridors and keepouts are user-drawn - Never draw
User.1guide polylines orUser.2keepout polygons yourself; suggest in words where they should go and let the user draw them, then add--guide-corridor/--keepoutto the plan. - Companion skills - Defer to
/identify-diff-pairs(datasheet-based pair detection),/recommend-stackup(before impedance/time-matching work),/diagnose-routing-failures(after failures), and/review-routed-board(final verification) rather than duplicating their logic inline.
Presenting the Plan
After generating the plan:
- Show the board summary
- Explain any special components found
- List differential pairs if detected
- Highlight any length-matching or impedance requirements
- Present each step with the command AND a brief explanation of why
- Ask the user if they want to proceed or modify the plan
- Offer to run the commands if approved
After Routing Completes
Capture Logs for Analysis
Always capture command output to /tmp files for later analysis:
python3 -X utf8 route.py input.kicad_pcb output.kicad_pcb --nets "*" 2>&1 | tee /tmp/route_output.txt
python3 -X utf8 route_planes.py input.kicad_pcb output.kicad_pcb --nets GND --plane-layers B.Cu 2>&1 | tee /tmp/planes_output.txt
python3 -X utf8 check_connected.py output.kicad_pcb 2>&1 | tee /tmp/connectivity.txt
python3 -X utf8 check_drc.py output.kicad_pcb --clearance <floor> --hole-to-hole-clearance <floor> 2>&1 | tee /tmp/drc.txt
(<floor> = the manufacturing floor from list_nets.py --design-rules, not the
0.2 default — grade DRC at the rule the board's own Constraints + fab capability
define, per #111/#115.)
Parse Logs for Failure Analysis
After routing, parse the log files to understand failures:
# Check routing summary (last 20 lines usually have the summary)
tail -20 /tmp/route_output.txt
# Look for failed nets
grep -i "failed\|FAILED" /tmp/route_output.txt
# Check JSON summary for detailed failure info
grep "JSON_SUMMARY" /tmp/route_output.txt | sed 's/JSON_SUMMARY: //' | python -m json.tool
# Find specific failure reasons
grep -A5 "FAILED NET HISTORIES" /tmp/route_output.txt
The JSON_SUMMARY line contains structured data including:
failed_single: List of failed single-ended net namesfailed_multipoint: List of nets with unconnected pads (includes pad coordinates)multipoint_pads_connectedvsmultipoint_pads_total: Connection success rate
Diagnose and Retry
After running routing commands:
- Report how many nets were routed successfully
- If routes failed, invoke
/diagnose-routing-failures <board> <log files>— it parses the JSON summary, failed-net histories, and blocking reports, correlates failures spatially, and outputs a targeted retry command. Apply its recommendation. If that skill is unavailable, fall back to this table:
| Failure Pattern | Likely Cause | Solution |
|---|---|---|
| "no rippable blockers found" | Route blocked by non-rippable obstacle | Use --no-bga-zone; if pads are "boxed in by static obstacles", shrink geometry / finer grid (see "Congestion escalation" below) |
| "Re-route FAILED: no path found" | Ripped net couldn't find new path | Increase --max-iterations |
| Many multipoint pads failed on same component | Congested area | Use --max-ripup 10 or higher; shrink geometry toward the fab floor (see below) |
| Many failures cluster in one channel/region | Tracks too fat for the channel | Congestion escalation: re-route the failed nets at smaller track/via/clearance down to the fab floor (see below) |
| 2-layer board: low completion, via count far above a hand layout, or copper badly skewed to F.Cu while B.Cu sits empty | Default B.Cu cost (3.0×) over-penalizes the back layer | Retry with balanced --layer-costs 1.0 1.5 (down toward 1.0 1.0) — see "Dense 2-layer boards: rebalance layer costs" below |
| Routes near BGA boundary failing | BGA exclusion zone too aggressive | Use --no-bga-zone |
python3 -X utf8 route.py board_prev.kicad_pcb board_routed.kicad_pcb \
--nets "*" \
--no-bga-zone \
--max-ripup 10 \
--max-iterations 1000000 \
2>&1 | tee /tmp/route_retry.txt
Key parameters for difficult boards (especially 2-layer with BGA/PGA):
--no-bga-zone- Critical: Allows router to enter BGA area for alternative paths--max-ripup 10(default 3) - More rip-up attempts to resolve conflicts--max-iterations 1000000(default 200000) - 5x more search iterations--stub-proximity-radius 10 --stub-proximity-cost 3.0- Spread out fanout stubs (optional, for aesthetics)
Dense 2-layer boards: rebalance layer costs (issue #178)
On 2-layer boards the router defaults to per-layer costs F.Cu=1.0, B.Cu=3.0 (best practice #8) to keep most signal copper on top. But with a GND/power plane already filling B.Cu, that 3× back-side penalty can over-bias routing onto F.Cu: the top channel fills up while B.Cu sits nearly empty, the router takes long F.Cu detours that then need a via to reach a B.Cu pad, and on congested boards the exhausted F.Cu channel strands pads that B.Cu could have carried. This is the dominant route-quality gap on tight 2-layer keyboard/peripheral boards.
When to suspect it (check the route JSON_SUMMARY / comparison block, or
measure per-layer copper length and via count against a reference):
- Strong F.Cu skew — e.g. >80% of signal copper on F.Cu while B.Cu is sparse.
- Via count far above a hand layout (the F.Cu-detour-then-via pattern).
- Low completion with failed pads clustered where F.Cu is full but B.Cu is free.
Retry with more balanced layer costs so the router crosses to B.Cu for short
diagonal runs instead of detouring on F.Cu (order matches --layers: F.Cu first,
B.Cu second):
python3 -X utf8 route.py board_fanout.kicad_pcb board_signal.kicad_pcb \
--nets "*" "!GND" "!VCC" \
--track-width 0.127 --clearance 0.1 \
--layer-costs 1.0 1.5 \
--no-bga-zone --max-ripup 10 --max-iterations 1000000 \
2>&1 | tee /tmp/route_balanced.txt
Start around 1.0 1.5 (down from the 1.0 3.0 default); if F.Cu is still
saturated, step to 1.0 1.2 or fully balanced 1.0 1.0 (fine when a
plane fills B.Cu — signals carve the pour and it reflows around them). This is
complementary to, not a replacement for, routing at the fab floor (below): a
balanced layer that's still too fat won't fit the channel either, so keep
--track-width thin. Re-route the whole signal step, not just the failures (a
victim is blocked by the successful F.Cu tracks already in its channel). Then
compare completion, via count, and F.Cu:B.Cu balance, and keep whichever connects
more pads with fewer vias.
Measured at --track-width 0.127 (B/F = B.Cu:F.Cu copper-length ratio; both
boards stay 100% connected at every setting — the win is via count and balance):
| board | default 1.0 3.0 |
1.0 1.5 |
|---|---|---|
| urchin | B/F 0.17, 177 vias | B/F 1.01, 98 vias |
| piantor | B/F 0.19, 102 vias | B/F 1.85, 59 vias |
1.0 1.5 roughly halves the via count and pulls the layer balance from a
~6:1 F.Cu skew to near parity (the human urchin layout sits around B/F 0.89).
1.0 1.0 lands in the same neighbourhood — pick the one with fewer vias.
Route signals at the FAB floor by default (thin is faster AND more complete)
track_width and via_diameter are NOT DRC floors (Step 4), and — this is
the subtlety — the fab floor is NOT the board's min_track_width constraint
either. Three different numbers get confused here; keep them straight:
- Board
min_track_width(from.kicad_pro, e.g. ottercast = 0.2 mm) — the author's self-imposed DRC rule. Often conservative. Notelist_nets --design-rulesreports its "manufacturing floor" track asmax(this, JLC min), so it currently clamps the track floor to this constraint (0.2) and does NOT surface the finer fab capability — do not treat that printed track number as the real floor (it's right for clearance/via, just not for track). - Fab physical track minimum (JLC ≈ 0.0889 mm / 3.5 mil standard; 0.127
mm / 5 mil is the safe no-extra-cost width) — the actual floor. This is the
target. It can be below the board's
min_track_width: the human ottercast board routes most signals at 0.127 mm, under its own 0.2 mm constraint, which is exactly why it fits channels our 0.2 mm net-class tracks can't.
For ordinary signals there is no benefit to routing fat and a real cost. Measured on ottercast_audio (signal pass, same clearance/grid, width only):
| Signal track width | Multipoint nets routed | Pads connected | Time |
|---|---|---|---|
| 0.127 (5 mil) | 122 | 360/376 | 2.69 s |
| 0.15 | 118 | 354/376 | 2.93 s |
| 0.20 (net-class) | 103 | 323/376 | 6.52 s |
Thinner is monotonically better on both axes — more nets complete and it
finishes faster (fat tracks cause ripup churn). So don't route fat and escalate;
route the signal step at the fab floor from the start, and if still congested
go DOWN toward the fab physical minimum (0.2 → 0.127 → 0.0889), not toward the
board's conservative min_track_width. There is no "knee" above the fab floor to
hunt for.
Take the fab floor, not the board constraint: the fab's physical track minimum (JLC 0.0889 mm / 3.5 mil; use 0.127 mm / 5 mil for a zero-cost, high-yield default). Going below the board's
min_track_widthis intended here — it's what the human did. (Keep DRC honest separately: grade at the clearance floor from--design-rules; a thinner track only increases clearance to neighbours, so it never creates a clearance violation.)Route the whole signal step at that width (re-route everything, not just the failed nets — a victim is blocked by the successful wide tracks already in its channel, so thinning only the failures leaves the channel full):
python3 -X utf8 route.py board_fanout.kicad_pcb board_signal.kicad_pcb \ --nets "*" "!GND" "!VCC" \ --track-width <fab floor, e.g. 0.127 or 0.0889> --clearance <floor, e.g. 0.1> \ --via-size <floor via, e.g. 0.30> --via-drill <floor drill, e.g. 0.15> \ --no-bga-zone --max-ripup 10 --max-iterations 1000000 \ 2>&1 | tee /tmp/route_signal.txtA finer
--grid-step(0.05, or 0.025 for sub-0.4 mm pitch) is the complementary lever — a corridor that exists geometrically still needs a grid line on it to be found; pair it with the thin width at fine-pitch escapes ("boxed in by static obstacles"). If still congested, step the width down further toward the fab physical minimum and re-route.Keep only the nets that NEED width wide — by rule, not by sweep. Power/high-current nets stay wide via
--power-nets/--power-nets-widths, and impedance-controlled nets keep their calculated width (--impedance, orroute_diff.pyfor pairs). Everything else routes at the fab floor. You do not need to find which signals are "genuinely congested": there's no reason to widen an ordinary signal at all, so the question never arises (and a net that passes wide can itself be the blocker of another, so a per-net width guess is unsound regardless).If swaps occurred (polarity or target swaps):
- Tell the user how many swaps were made
- Ask if they want to sync the schematic
- If yes, ask for the KiCad project directory path
- Re-run the routing command with
--schematic-diradded
Run verification: invoke
/review-routed-board(falls back to the raw DRC and connectivity checks) 4b. Apply the coverage gate (Step 6): ifcheck_connected.pylists any fully-unrouted multi-pad net, the board is NOT done — handle each (route or pour it) and re-verify before summarizing. Do not present an unrouted net as an accepted shortfall.Summarize the final state of the board
Offer to clean up intermediate files:
- List the intermediate
.kicad_pcbfiles created (e.g.,board_step1.kicad_pcb,board_step2.kicad_pcb, etc.) - Ask if the user wants to delete them, keeping only the final output
- If yes, delete the intermediate files
- List the intermediate
Example cleanup prompt:
"Routing complete. The following intermediate files were created:
- board_step1.kicad_pcb (after GND/VCC planes)
- board_step2.kicad_pcb (after fanout)
- board_step3.kicad_pcb (after signal routing)
- board_step4.kicad_pcb (after GND return vias)
The final routed board is: board_step5.kicad_pcb
Would you like me to delete the intermediate files?"