KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
master
f564403
25d ago
ESC G431 routing round: Claude Fable 5.1's router
The placement round left esc-g431-fable-placed.kicad_pcb: 149 footprints inside a 64 x 74 mm
outline, four copper layers, 365 pads, 59 nets with two or more pads, no copper. This folder is
the routing round: a real grid maze router written for the board, an offline DRC gate that proves
its plan before anything touches a desktop, and the live replay through the bridge.
Everything here is Python 3 with numpy. Nothing in the Rust workspace changed.
The four scripts
| Script | What it does |
|---|---|
tools/add_planes.py |
Adds two full-outline copper zones to a copy of the board: GND on In1.Cu and +3V3 on In2.Cu. Output esc-g431-fable-placed-planes.kicad_pcb. --drc runs the shared headless KiCad DRC on it. |
demo/routing/esc/ai_router.py |
The router. Reads the planes board, writes the plan JSON (fable-esc-routing-plan.json) and a report (fable-esc-routing-report.json). |
demo/routing/esc/write_copper.py |
Applies a plan offline into a copy of the board (segments, vias, uuids, net references, widths, layers), adds a raster fill to the plane zones so connectivity through the planes counts, runs service-kicad pcb drc and compares the errors with the unrouted board. This is the gate. |
demo/routing/esc/route_live.py |
Replays the plan through the bridge: kicad_routing_state, one kicad_route_net per plan entry with expectedRevision, dryRun support, a timestamped JSON log, stops on drc_rejected or stale_board, ends with kicad_routing_validate. |
escboard.py is the shared board model (pads in board coordinates with KiCad's rotation
convention, custom pad polygons, the outline, the 0.1 mm grid rasterisers). render_plan.py
draws a plan over the pads as a PNG for a quick look.
How to run
# 1. planes (writes demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb, prints the DRC counts)
python3 tools/add_planes.py demo/routing/esc/esc-g431-fable-placed.kicad_pcb --drc
# 2. route (about ten minutes on the container; writes the plan and the report)
python3 demo/routing/esc/ai_router.py demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb \
--out demo/routing/esc/fable-esc-routing-plan.json --report demo/routing/esc/fable-esc-routing-report.json
# 3. offline gate (writes the routed copy and the DRC summary; exit 0 only at zero new errors and zero unconnected)
python3 demo/routing/esc/write_copper.py demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb \
demo/routing/esc/fable-esc-routing-plan.json --out demo/routing/esc/esc-g431-fable-routed-offline.kicad_pcb \
--drc --report demo/routing/esc/offline-drc-report.json
# 4. live replay (John runs this one; the planes board must be the file open in KiCad)
python3 demo/routing/esc/route_live.py --target arav-rog \
--board "C:/Users/arav/Documents/esc/esc-g431-fable-placed-planes.kicad_pcb" \
--plan demo/routing/esc/fable-esc-routing-plan.json --log demo/routing/esc/route-log.json [--dry-run]
# a picture of the plan
python3 demo/routing/esc/render_plan.py demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb \
demo/routing/esc/fable-esc-routing-plan.json demo/routing/esc/plan-preview.png --labels
What the router does
Grid and layers. A 0.1 mm cell grid over the outline (641 x 741 cells), two signal layers (F.Cu and B.Cu) joined by through vias, 0.8 mm with a 0.4 mm drill. The planes on In1.Cu and In2.Cu are never routed on.
Rules. This board has no project file, so KiCad's defaults apply and the router uses them:
0.25 mm track, 0.2 mm clearance, 0.5 mm copper to board edge, plus the local (clearance 0.6)
overrides on the phase contacts MC5, MC9, MC11 and the machine pin MP3. The bridge's
kicad_route_net gets viaSize 0.8 and viaDrill 0.4 on every call.
Search. A* per connection, eight neighbours (octilinear), costs 10 per orthogonal cell and 14 per diagonal, times 1.1 on B.Cu (prefer F.Cu), plus 250 per via (heavily penalised) and 4 per direction change (a little). Collinear cells merge into one segment. A diagonal step is allowed only when one of its ends also clears a larger dilation covering the midpoint of the step, so a 45-degree segment never clips a corner of a pad.
Obstacles. For the net being routed, every pad and every committed segment and via of any other net is dilated by its clearance plus half the track width plus half a cell diagonal (pads sit off-grid). Through pads exist on both layers. The outline is inset by the edge clearance. Own-net copper is free space, so a branch can end on the net's own track.
Fine-pitch pins. A pad narrower than 0.5 mm (the LQFP-48, the DRV8300 and the buck module pins) is entered on its long axis: a 0.2 mm stub from the exact pad centre to an on-grid escape point 0.45 mm beyond either pad end, and the route starts there. The stub and a short corridor beyond it are reserved for the pin until it is routed, an IC body is a keepout on F.Cu for nets with no pin on that IC, a band around the IC carries a small cost so routes do not run along a pin row, and a via pays extra for every unrouted neighbour pin whose straight path it would wall in. Pads that overlap on the same net (the MOSFET source pads) form one cluster and are hit once.
Nets. A net with N clusters is a minimum spanning tree: Prim's order from the biggest pad, each new cluster routed to ANY copper the net already has (pads, tracks, vias, escape points). A branch that lands mid-segment splits that segment at the on-grid junction so KiCad sees connected copper. Nets are routed shortest MST first, then longest.
Widths. +VBAT and the three phase nets /DRV_SHA, /DRV_SHB and /DRV_SHC (these are also the MOSFET drain and source nets) try 1.0 mm, then 0.5, then 0.25; +5V and +12V try 0.5 then 0.25; everything else is 0.25 mm. A pad narrower than the width caps it (a 1.0 mm track never enters a 0.35 mm pin), and the fallback happens per connection when the wider search finds no path.
Planes. GND and +3V3 pads are not routed as traces. Every SMD pad on those nets gets the shortest stub (Dijkstra) to the first cell where a 0.8 mm via fits, and the via reaches the plane; through pads on those nets already reach the plane and get nothing. When no site fits within 6 mm the via goes in the pad (exact geometry check; reported). The plane stubs are routed first because they are the shortest connections on the board and their vias then shape the signal routing rather than hunting for holes in it afterwards.
Rip-up and retry. When a connection fails on every width and window, a second search treats other nets' tracks as expensive rather than solid; the nets it crosses are ripped up (most recently routed first, two at a time), the failing net is routed again, and the ripped nets are re-queued. Bounded to three rip-ups per net and 120 in total.
How the plan maps to kicad_route_net
The plan is {"engine", "board", "rules", "nets": [entry, ...]} and every entry is exactly one
kicad_route_net call: {"net", "width", "viaSize", "viaDrill", "paths": [[...], ...]}.
A waypoint is "REF.PAD" (the bridge resolves it to the pad centre), [x, y], or
{"x": .., "y": .., "layer": "B.Cu"}; a layer marker means a through via at that point and every
following segment on the new layer, which is the grammar in rust/crates/kicad-core/src/pcb.rs
plan and resolve_point. A path that starts on B.Cu at a through pad uses
{"pad": "MC1.1", "layer": "B.Cu"}. A plane stub is ["C7.2", ..., {"x", "y", "layer": "B.Cu"}]:
the stub on F.Cu, then the via. A net appears in several entries when it uses several widths
(the 0.2 mm pin stubs are their own entry), so route_live.py sends the entries in order and
feeds each reply's revision to the next call.
The offline gate
service-kicad pcb drc is the shared headless KiCad 10.0.2. It does not refill zones, so the
gate copy carries a raster fill for each plane: overlapping strips over the outline (0.5 mm from
the edge) minus every other-net through pad, hole and via expanded by its clearance. KiCad's own
fill on the live board replaces it. Two consequences are reported and excluded: the strips are
"isolated copper" islands to KiCad (warnings), and the gate copy connects pads solidly instead of
with thermal reliefs because a raster fill has no spokes to check.
Known and excluded on this board before any routing: 13 inherited errors (six malformed MOSFET
courtyards on Q1 to Q6, seven pad clearances inside U2's own footprint) and 149 library
warnings (lib_footprint_issues) plus 23 silk_over_copper warnings. The gate compares error
fingerprints (type, description, item positions) with the unrouted board the way the bridge's
route_net compares before and after, and passes only at zero NEW errors and zero unconnected.
Gate result (the delivered plan, fable-esc-routing-plan.json)
Command 3 above on the delivered plan, KiCad 10.0.2 through service-kicad:
| Measure | Value |
|---|---|
| DRC errors | 13, all inherited (0 new) |
| DRC warnings | 172 inherited (149 library, 23 silk over copper) plus 34 raster-fill island warnings from the gate copy's own fill |
| Unconnected items | 7 (before routing: 271) |
| Segments | 1795 (0.2 mm: 57, 0.25 mm: 1453, 0.5 mm: 148, 1.0 mm: 137) |
| Vias | 255, all 0.8 mm / 0.4 mm drill, none inside a pad |
| Copper length | 1908.3 mm (F.Cu 1010.4 mm, B.Cu 897.9 mm) |
| Plan entries | 105 kicad_route_net calls for 55 nets, 308 paths, 2267 waypoints |
| Nets routed | 50 of 57 signal nets complete, GND (90 clusters) and +3V3 (21 clusters) complete through the planes |
The gate is therefore zero new errors but NOT zero unconnected: seven connections in six nets
could not be routed under these rules, and the router says so rather than leaving them out
quietly (fable-esc-routing-report.json unrouted, and the router's exit code is 3). The
delivered plan is the best of the runs made while tuning (the router keeps the best
intermediate state of a pass, and the multi-pass mode keeps the best pass); the file is the one
the gate numbers above were measured on.
| Net | Connection left open | Why |
|---|---|---|
| /TIM1_CH2N_INLB | U5.28 to U4.5 | LED1 sits 1.0 mm from the ends of U5 pins 26 to 28, so those three adjacent 0.5 mm pins can only leave inward under the package with vias; two adjacent pins cannot both drop a 0.8 mm via in a 0.5 mm pitch channel, and the one in the middle loses. |
| /HSE_IN | U5.5 to Y1.1 and C35.1 | U5 pins 5 to 12 (crystal, reset, the BEMF comparator inputs) all leave the left column into the same 3 mm strip between U5 and the R26 to R34 network; every F.Cu corridor there is taken by nets routed earlier and no via site is left within reach. |
| /PB6 | U5.43 to R17.2 | Same congestion on the top row: PB6, PB7, MCU_LED, SWCLK, SWDIO and BOOT0 escape upward into the LED2, LED3, R16 group. |
| +5V | U2.9 to the rest of +5V | U2.9 is a 0.35 mm pin of the buck module whose only escape pocket holds the GND stub of C10 and the +3V3 stub next to it. |
| /COMP2_INM_IO1_BEMF_A | R27.2 to the branch | R27's second pad is walled by the +3V3 stub and via of C29 and R27's own GND neighbour. |
| Net-(LED2-A) | R11.1 to LED2.2 | R11 sits at the far left of the board and LED2 next to U5's top row; the 17 mm run has to cross the SWDIO, SWCLK and nRST bundle and the GND vias of J1. |
In every case the router tried: 0.25 mm on F.Cu and B.Cu, a via in the pad when the pad allows it,
a soft search to name the blockers, and up to eight rip-up rounds per net (the plane stubs included
when they were the only blocker). What would fix them is placement, not routing: LED1 and the
resistor network are too close to the QFP pin rows for 0.25 mm tracks with 0.2 mm clearance and
0.8 mm vias. On the live board these seven show up as ratsnest lines after the replay, and
kicad_routing_validate reports them as unconnected; the AI or the user can finish them by hand
or after nudging LED1 and R26 to R34 away from U5.
Per-net widths, segments, vias and copper length are in the table at the end of this file.
Reproducibility: the router is deterministic for a given code state, but which seven connections
lose depends on the rip-up order, and the rip-up rules were still being tuned while the delivered
plan was produced (it came from the run with the fewest open connections; every later rule change
also ended at seven, in a different mix around U5). A fresh ai_router.py run on the planes board
takes about ten minutes and reports its own open connections on stdout and in the report JSON;
gate whatever it produces with write_copper.py --drc before replaying it.
Files
esc-g431-fable-placed.kicad_pcb: the input (placement round output).esc-g431-fable-placed-planes.kicad_pcb: the input plus the two planes (what the desktop opens).fable-esc-routing-plan.json: the plan, one entry perkicad_route_netcall.fable-esc-routing-report.json: per-net clusters, routes, widths, rip-ups, failures.esc-g431-fable-routed-offline.kicad_pcb: the plan applied offline with raster-filled planes (the gate copy).offline-drc-report.json: the gate's DRC summary.plan-preview.png: the plan drawn over the pads (F.Cu red, B.Cu blue, vias black).
Per-net result (delivered plan)
Widths are the track widths the net ended up with (the 0.2 mm entries are the fine-pitch pin stubs). Clusters are pad groups; a failed count is a connection left open.
| Net | widths (mm) | segments | vias | copper mm | clusters | open |
|---|---|---|---|---|---|---|
| +VBAT | 1/0.5/0.25/0.2 | 129 | 16 | 224.0 | 28 | 0 |
| /COMP1_INM_IO2_BEMF_C | 0.25/0.2 | 100 | 14 | 100.1 | 6 | 0 |
| /COMP2_INM_IO1_BEMF_A | 0.25/0.2 | 44 | 5 | 93.2 | 6 | 1 |
| /COMP1_INM_IO1_BEMF_B | 0.25/0.2 | 86 | 14 | 93.0 | 5 | 0 |
| GND_OUT | 0.25 | 22 | 5 | 92.1 | 3 | 0 |
| /SWCLK | 0.25/0.2 | 92 | 8 | 91.0 | 4 | 0 |
| GND | 0.25/0.2 | 110 | 73 | 85.8 | 90 | 0 |
| /PB12 | 0.25/0.2 | 93 | 4 | 78.2 | 4 | 0 |
| /DRV_SHA | 1/0.5/0.25/0.2 | 48 | 9 | 75.4 | 8 | 0 |
| /DRV_SHC | 1/0.5/0.25/0.2 | 60 | 5 | 68.1 | 8 | 0 |
| /DRV_SHB | 1/0.5/0.25/0.2 | 77 | 10 | 67.1 | 8 | 0 |
| /SWDIO | 0.25/0.2 | 68 | 9 | 59.7 | 4 | 0 |
| /nRST | 0.25/0.2 | 94 | 6 | 58.3 | 6 | 0 |
| /TIM15_CH1_DSHOT | 0.25/0.2 | 60 | 5 | 56.7 | 3 | 0 |
| /NEUTRAL | 0.25/0.2 | 75 | 4 | 45.5 | 6 | 0 |
| /VBAT_SENSE | 0.25/0.2 | 40 | 4 | 41.0 | 4 | 0 |
| /IBAT_SENSE | 0.25/0.2 | 10 | 2 | 34.4 | 2 | 0 |
| +12V | 0.5/0.25/0.2 | 38 | 4 | 31.5 | 7 | 0 |
| +5V | 0.5 | 41 | 4 | 31.3 | 9 | 1 |
| /HSE_OUT | 0.25/0.2 | 48 | 3 | 31.0 | 3 | 0 |
| /GLC | 0.25 | 20 | 2 | 30.8 | 3 | 0 |
| /GHC | 0.25 | 20 | 0 | 30.7 | 3 | 0 |
| /5V_EXT | 0.25 | 6 | 1 | 27.5 | 2 | 0 |
| /TIM1_CH3N_INLA | 0.25/0.2 | 38 | 4 | 24.2 | 2 | 0 |
| /USART1_TX | 0.25 | 38 | 1 | 23.8 | 4 | 0 |
| /GHA | 0.25 | 10 | 2 | 22.9 | 3 | 0 |
| +3V3 | 0.25/0.2 | 38 | 19 | 22.8 | 21 | 0 |
| /USART1_RX | 0.25 | 10 | 1 | 21.6 | 4 | 0 |
| /TIM1_CH2_INHB | 0.25/0.2 | 21 | 2 | 19.9 | 2 | 0 |
| /GLA | 0.25 | 7 | 2 | 19.9 | 3 | 0 |
| /TIM1_CH1N_INLC | 0.25/0.2 | 13 | 2 | 19.5 | 2 | 0 |
| /PB7 | 0.25/0.2 | 26 | 2 | 19.1 | 2 | 0 |
| /VDDA | 0.25/0.2 | 32 | 0 | 16.0 | 6 | 0 |
| /TIM1_CH3_INHA | 0.25/0.2 | 14 | 2 | 15.4 | 3 | 0 |
| /5V_EN | 0.25/0.2 | 11 | 1 | 13.9 | 3 | 0 |
| /TIM1_CH1_INHC | 0.25/0.2 | 20 | 2 | 13.4 | 2 | 0 |
| /GLB | 0.25 | 5 | 0 | 12.2 | 3 | 0 |
| /MCU_LED | 0.25/0.2 | 16 | 2 | 11.3 | 2 | 0 |
| Net-(LED1-A) | 0.25 | 18 | 2 | 10.0 | 2 | 0 |
| /DRV_GHB | 0.25/0.2 | 10 | 2 | 8.5 | 2 | 0 |
| /DRV_BSTB | 0.25/0.2 | 7 | 2 | 7.7 | 2 | 0 |
| /DRV_BSTC | 0.25/0.2 | 10 | 0 | 7.4 | 2 | 0 |
| /DRV_GHA | 0.25/0.2 | 6 | 0 | 7.0 | 3 | 0 |
| /GHB | 0.25 | 5 | 0 | 6.6 | 3 | 0 |
| /BOOT0 | 0.25/0.2 | 14 | 0 | 5.8 | 3 | 0 |
| /DRV_GLB | 0.25/0.2 | 7 | 0 | 5.3 | 2 | 0 |
| /5V_VCC | 0.25/0.2 | 8 | 0 | 4.8 | 2 | 0 |
| /DRV_BSTA | 0.25/0.2 | 4 | 0 | 4.3 | 2 | 0 |
| /DRV_GLA | 0.25/0.2 | 3 | 0 | 3.5 | 2 | 0 |
| Net-(LED3-A) | 0.25 | 8 | 0 | 3.1 | 2 | 0 |
| /SW | 0.25/0.2 | 3 | 0 | 3.1 | 2 | 0 |
| /RPM | 0.25/0.2 | 3 | 0 | 2.6 | 2 | 0 |
| /DRV_GHC | 0.25/0.2 | 4 | 0 | 2.2 | 2 | 0 |
| /DRV_GLC | 0.25/0.2 | 3 | 0 | 2.1 | 2 | 0 |
| /E_STOP | 0.25 | 2 | 0 | 2.1 | 2 | 0 |
| total | 1795 | 255 | 1908.3 | 7 |
# ESC G431 routing round: Claude Fable 5.1's router
The placement round left `esc-g431-fable-placed.kicad_pcb`: 149 footprints inside a 64 x 74 mm
outline, four copper layers, 365 pads, 59 nets with two or more pads, no copper. This folder is
the routing round: a real grid maze router written for the board, an offline DRC gate that proves
its plan before anything touches a desktop, and the live replay through the bridge.
Everything here is Python 3 with numpy. Nothing in the Rust workspace changed.
## The four scripts
| Script | What it does |
|---|---|
| `tools/add_planes.py` | Adds two full-outline copper zones to a copy of the board: GND on In1.Cu and +3V3 on In2.Cu. Output `esc-g431-fable-placed-planes.kicad_pcb`. `--drc` runs the shared headless KiCad DRC on it. |
| `demo/routing/esc/ai_router.py` | The router. Reads the planes board, writes the plan JSON (`fable-esc-routing-plan.json`) and a report (`fable-esc-routing-report.json`). |
| `demo/routing/esc/write_copper.py` | Applies a plan offline into a copy of the board (segments, vias, uuids, net references, widths, layers), adds a raster fill to the plane zones so connectivity through the planes counts, runs `service-kicad pcb drc` and compares the errors with the unrouted board. This is the gate. |
| `demo/routing/esc/route_live.py` | Replays the plan through the bridge: `kicad_routing_state`, one `kicad_route_net` per plan entry with `expectedRevision`, `dryRun` support, a timestamped JSON log, stops on `drc_rejected` or `stale_board`, ends with `kicad_routing_validate`. |
`escboard.py` is the shared board model (pads in board coordinates with KiCad's rotation
convention, custom pad polygons, the outline, the 0.1 mm grid rasterisers). `render_plan.py`
draws a plan over the pads as a PNG for a quick look.
## How to run
```sh
# 1. planes (writes demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb, prints the DRC counts)
python3 tools/add_planes.py demo/routing/esc/esc-g431-fable-placed.kicad_pcb --drc
# 2. route (about ten minutes on the container; writes the plan and the report)
python3 demo/routing/esc/ai_router.py demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb \
--out demo/routing/esc/fable-esc-routing-plan.json --report demo/routing/esc/fable-esc-routing-report.json
# 3. offline gate (writes the routed copy and the DRC summary; exit 0 only at zero new errors and zero unconnected)
python3 demo/routing/esc/write_copper.py demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb \
demo/routing/esc/fable-esc-routing-plan.json --out demo/routing/esc/esc-g431-fable-routed-offline.kicad_pcb \
--drc --report demo/routing/esc/offline-drc-report.json
# 4. live replay (John runs this one; the planes board must be the file open in KiCad)
python3 demo/routing/esc/route_live.py --target arav-rog \
--board "C:/Users/arav/Documents/esc/esc-g431-fable-placed-planes.kicad_pcb" \
--plan demo/routing/esc/fable-esc-routing-plan.json --log demo/routing/esc/route-log.json [--dry-run]
# a picture of the plan
python3 demo/routing/esc/render_plan.py demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb \
demo/routing/esc/fable-esc-routing-plan.json demo/routing/esc/plan-preview.png --labels
```
## What the router does
**Grid and layers.** A 0.1 mm cell grid over the outline (641 x 741 cells), two signal layers
(F.Cu and B.Cu) joined by through vias, 0.8 mm with a 0.4 mm drill. The planes on In1.Cu and
In2.Cu are never routed on.
**Rules.** This board has no project file, so KiCad's defaults apply and the router uses them:
0.25 mm track, 0.2 mm clearance, 0.5 mm copper to board edge, plus the local `(clearance 0.6)`
overrides on the phase contacts MC5, MC9, MC11 and the machine pin MP3. The bridge's
`kicad_route_net` gets `viaSize` 0.8 and `viaDrill` 0.4 on every call.
**Search.** A* per connection, eight neighbours (octilinear), costs 10 per orthogonal cell and 14
per diagonal, times 1.1 on B.Cu (prefer F.Cu), plus 250 per via (heavily penalised) and 4 per
direction change (a little). Collinear cells merge into one segment. A diagonal step is allowed
only when one of its ends also clears a larger dilation covering the midpoint of the step, so a
45-degree segment never clips a corner of a pad.
**Obstacles.** For the net being routed, every pad and every committed segment and via of any
other net is dilated by its clearance plus half the track width plus half a cell diagonal (pads
sit off-grid). Through pads exist on both layers. The outline is inset by the edge clearance.
Own-net copper is free space, so a branch can end on the net's own track.
**Fine-pitch pins.** A pad narrower than 0.5 mm (the LQFP-48, the DRV8300 and the buck module
pins) is entered on its long axis: a 0.2 mm stub from the exact pad centre to an on-grid escape
point 0.45 mm beyond either pad end, and the route starts there. The stub and a short corridor
beyond it are reserved for the pin until it is routed, an IC body is a keepout on F.Cu for nets
with no pin on that IC, a band around the IC carries a small cost so routes do not run along a
pin row, and a via pays extra for every unrouted neighbour pin whose straight path it would wall
in. Pads that overlap on the same net (the MOSFET source pads) form one cluster and are hit once.
**Nets.** A net with N clusters is a minimum spanning tree: Prim's order from the biggest pad,
each new cluster routed to ANY copper the net already has (pads, tracks, vias, escape points).
A branch that lands mid-segment splits that segment at the on-grid junction so KiCad sees
connected copper. Nets are routed shortest MST first, then longest.
**Widths.** +VBAT and the three phase nets /DRV_SHA, /DRV_SHB and /DRV_SHC (these are also the
MOSFET drain and source nets) try 1.0 mm, then 0.5, then 0.25; +5V and +12V try 0.5 then 0.25;
everything else is 0.25 mm. A pad narrower than the width caps it (a 1.0 mm track never enters
a 0.35 mm pin), and the fallback happens per connection when the wider search finds no path.
**Planes.** GND and +3V3 pads are not routed as traces. Every SMD pad on those nets gets the
shortest stub (Dijkstra) to the first cell where a 0.8 mm via fits, and the via reaches the
plane; through pads on those nets already reach the plane and get nothing. When no site fits
within 6 mm the via goes in the pad (exact geometry check; reported). The plane stubs are routed
first because they are the shortest connections on the board and their vias then shape the
signal routing rather than hunting for holes in it afterwards.
**Rip-up and retry.** When a connection fails on every width and window, a second search treats
other nets' tracks as expensive rather than solid; the nets it crosses are ripped up (most
recently routed first, two at a time), the failing net is routed again, and the ripped nets are
re-queued. Bounded to three rip-ups per net and 120 in total.
## How the plan maps to `kicad_route_net`
The plan is `{"engine", "board", "rules", "nets": [entry, ...]}` and every entry is exactly one
`kicad_route_net` call: `{"net", "width", "viaSize", "viaDrill", "paths": [[...], ...]}`.
A waypoint is `"REF.PAD"` (the bridge resolves it to the pad centre), `[x, y]`, or
`{"x": .., "y": .., "layer": "B.Cu"}`; a layer marker means a through via at that point and every
following segment on the new layer, which is the grammar in `rust/crates/kicad-core/src/pcb.rs`
`plan` and `resolve_point`. A path that starts on B.Cu at a through pad uses
`{"pad": "MC1.1", "layer": "B.Cu"}`. A plane stub is `["C7.2", ..., {"x", "y", "layer": "B.Cu"}]`:
the stub on F.Cu, then the via. A net appears in several entries when it uses several widths
(the 0.2 mm pin stubs are their own entry), so `route_live.py` sends the entries in order and
feeds each reply's revision to the next call.
## The offline gate
`service-kicad pcb drc` is the shared headless KiCad 10.0.2. It does not refill zones, so the
gate copy carries a raster fill for each plane: overlapping strips over the outline (0.5 mm from
the edge) minus every other-net through pad, hole and via expanded by its clearance. KiCad's own
fill on the live board replaces it. Two consequences are reported and excluded: the strips are
"isolated copper" islands to KiCad (warnings), and the gate copy connects pads solidly instead of
with thermal reliefs because a raster fill has no spokes to check.
Known and excluded on this board before any routing: 13 inherited errors (six malformed MOSFET
courtyards on Q1 to Q6, seven pad clearances inside U2's own footprint) and 149 library
warnings (`lib_footprint_issues`) plus 23 `silk_over_copper` warnings. The gate compares error
fingerprints (type, description, item positions) with the unrouted board the way the bridge's
`route_net` compares before and after, and passes only at zero NEW errors and zero unconnected.
### Gate result (the delivered plan, `fable-esc-routing-plan.json`)
Command 3 above on the delivered plan, KiCad 10.0.2 through `service-kicad`:
| Measure | Value |
|---|---|
| DRC errors | 13, all inherited (0 new) |
| DRC warnings | 172 inherited (149 library, 23 silk over copper) plus 34 raster-fill island warnings from the gate copy's own fill |
| Unconnected items | 7 (before routing: 271) |
| Segments | 1795 (0.2 mm: 57, 0.25 mm: 1453, 0.5 mm: 148, 1.0 mm: 137) |
| Vias | 255, all 0.8 mm / 0.4 mm drill, none inside a pad |
| Copper length | 1908.3 mm (F.Cu 1010.4 mm, B.Cu 897.9 mm) |
| Plan entries | 105 `kicad_route_net` calls for 55 nets, 308 paths, 2267 waypoints |
| Nets routed | 50 of 57 signal nets complete, GND (90 clusters) and +3V3 (21 clusters) complete through the planes |
The gate is therefore zero new errors but NOT zero unconnected: seven connections in six nets
could not be routed under these rules, and the router says so rather than leaving them out
quietly (`fable-esc-routing-report.json` `unrouted`, and the router's exit code is 3). The
delivered plan is the best of the runs made while tuning (the router keeps the best
intermediate state of a pass, and the multi-pass mode keeps the best pass); the file is the one
the gate numbers above were measured on.
| Net | Connection left open | Why |
|---|---|---|
| /TIM1_CH2N_INLB | U5.28 to U4.5 | LED1 sits 1.0 mm from the ends of U5 pins 26 to 28, so those three adjacent 0.5 mm pins can only leave inward under the package with vias; two adjacent pins cannot both drop a 0.8 mm via in a 0.5 mm pitch channel, and the one in the middle loses. |
| /HSE_IN | U5.5 to Y1.1 and C35.1 | U5 pins 5 to 12 (crystal, reset, the BEMF comparator inputs) all leave the left column into the same 3 mm strip between U5 and the R26 to R34 network; every F.Cu corridor there is taken by nets routed earlier and no via site is left within reach. |
| /PB6 | U5.43 to R17.2 | Same congestion on the top row: PB6, PB7, MCU_LED, SWCLK, SWDIO and BOOT0 escape upward into the LED2, LED3, R16 group. |
| +5V | U2.9 to the rest of +5V | U2.9 is a 0.35 mm pin of the buck module whose only escape pocket holds the GND stub of C10 and the +3V3 stub next to it. |
| /COMP2_INM_IO1_BEMF_A | R27.2 to the branch | R27's second pad is walled by the +3V3 stub and via of C29 and R27's own GND neighbour. |
| Net-(LED2-A) | R11.1 to LED2.2 | R11 sits at the far left of the board and LED2 next to U5's top row; the 17 mm run has to cross the SWDIO, SWCLK and nRST bundle and the GND vias of J1. |
In every case the router tried: 0.25 mm on F.Cu and B.Cu, a via in the pad when the pad allows it,
a soft search to name the blockers, and up to eight rip-up rounds per net (the plane stubs included
when they were the only blocker). What would fix them is placement, not routing: LED1 and the
resistor network are too close to the QFP pin rows for 0.25 mm tracks with 0.2 mm clearance and
0.8 mm vias. On the live board these seven show up as ratsnest lines after the replay, and
`kicad_routing_validate` reports them as unconnected; the AI or the user can finish them by hand
or after nudging LED1 and R26 to R34 away from U5.
Per-net widths, segments, vias and copper length are in the table at the end of this file.
Reproducibility: the router is deterministic for a given code state, but which seven connections
lose depends on the rip-up order, and the rip-up rules were still being tuned while the delivered
plan was produced (it came from the run with the fewest open connections; every later rule change
also ended at seven, in a different mix around U5). A fresh `ai_router.py` run on the planes board
takes about ten minutes and reports its own open connections on stdout and in the report JSON;
gate whatever it produces with `write_copper.py --drc` before replaying it.
## Files
- `esc-g431-fable-placed.kicad_pcb`: the input (placement round output).
- `esc-g431-fable-placed-planes.kicad_pcb`: the input plus the two planes (what the desktop opens).
- `fable-esc-routing-plan.json`: the plan, one entry per `kicad_route_net` call.
- `fable-esc-routing-report.json`: per-net clusters, routes, widths, rip-ups, failures.
- `esc-g431-fable-routed-offline.kicad_pcb`: the plan applied offline with raster-filled planes (the gate copy).
- `offline-drc-report.json`: the gate's DRC summary.
- `plan-preview.png`: the plan drawn over the pads (F.Cu red, B.Cu blue, vias black).
## Per-net result (delivered plan)
Widths are the track widths the net ended up with (the 0.2 mm entries are the fine-pitch pin stubs). Clusters are pad groups; a failed count is a connection left open.
| Net | widths (mm) | segments | vias | copper mm | clusters | open |
|---|---|---|---|---|---|---|
| +VBAT | 1/0.5/0.25/0.2 | 129 | 16 | 224.0 | 28 | 0 |
| /COMP1_INM_IO2_BEMF_C | 0.25/0.2 | 100 | 14 | 100.1 | 6 | 0 |
| /COMP2_INM_IO1_BEMF_A | 0.25/0.2 | 44 | 5 | 93.2 | 6 | 1 |
| /COMP1_INM_IO1_BEMF_B | 0.25/0.2 | 86 | 14 | 93.0 | 5 | 0 |
| GND_OUT | 0.25 | 22 | 5 | 92.1 | 3 | 0 |
| /SWCLK | 0.25/0.2 | 92 | 8 | 91.0 | 4 | 0 |
| GND | 0.25/0.2 | 110 | 73 | 85.8 | 90 | 0 |
| /PB12 | 0.25/0.2 | 93 | 4 | 78.2 | 4 | 0 |
| /DRV_SHA | 1/0.5/0.25/0.2 | 48 | 9 | 75.4 | 8 | 0 |
| /DRV_SHC | 1/0.5/0.25/0.2 | 60 | 5 | 68.1 | 8 | 0 |
| /DRV_SHB | 1/0.5/0.25/0.2 | 77 | 10 | 67.1 | 8 | 0 |
| /SWDIO | 0.25/0.2 | 68 | 9 | 59.7 | 4 | 0 |
| /nRST | 0.25/0.2 | 94 | 6 | 58.3 | 6 | 0 |
| /TIM15_CH1_DSHOT | 0.25/0.2 | 60 | 5 | 56.7 | 3 | 0 |
| /NEUTRAL | 0.25/0.2 | 75 | 4 | 45.5 | 6 | 0 |
| /VBAT_SENSE | 0.25/0.2 | 40 | 4 | 41.0 | 4 | 0 |
| /IBAT_SENSE | 0.25/0.2 | 10 | 2 | 34.4 | 2 | 0 |
| +12V | 0.5/0.25/0.2 | 38 | 4 | 31.5 | 7 | 0 |
| +5V | 0.5 | 41 | 4 | 31.3 | 9 | 1 |
| /HSE_OUT | 0.25/0.2 | 48 | 3 | 31.0 | 3 | 0 |
| /GLC | 0.25 | 20 | 2 | 30.8 | 3 | 0 |
| /GHC | 0.25 | 20 | 0 | 30.7 | 3 | 0 |
| /5V_EXT | 0.25 | 6 | 1 | 27.5 | 2 | 0 |
| /TIM1_CH3N_INLA | 0.25/0.2 | 38 | 4 | 24.2 | 2 | 0 |
| /USART1_TX | 0.25 | 38 | 1 | 23.8 | 4 | 0 |
| /GHA | 0.25 | 10 | 2 | 22.9 | 3 | 0 |
| +3V3 | 0.25/0.2 | 38 | 19 | 22.8 | 21 | 0 |
| /USART1_RX | 0.25 | 10 | 1 | 21.6 | 4 | 0 |
| /TIM1_CH2_INHB | 0.25/0.2 | 21 | 2 | 19.9 | 2 | 0 |
| /GLA | 0.25 | 7 | 2 | 19.9 | 3 | 0 |
| /TIM1_CH1N_INLC | 0.25/0.2 | 13 | 2 | 19.5 | 2 | 0 |
| /PB7 | 0.25/0.2 | 26 | 2 | 19.1 | 2 | 0 |
| /VDDA | 0.25/0.2 | 32 | 0 | 16.0 | 6 | 0 |
| /TIM1_CH3_INHA | 0.25/0.2 | 14 | 2 | 15.4 | 3 | 0 |
| /5V_EN | 0.25/0.2 | 11 | 1 | 13.9 | 3 | 0 |
| /TIM1_CH1_INHC | 0.25/0.2 | 20 | 2 | 13.4 | 2 | 0 |
| /GLB | 0.25 | 5 | 0 | 12.2 | 3 | 0 |
| /MCU_LED | 0.25/0.2 | 16 | 2 | 11.3 | 2 | 0 |
| Net-(LED1-A) | 0.25 | 18 | 2 | 10.0 | 2 | 0 |
| /DRV_GHB | 0.25/0.2 | 10 | 2 | 8.5 | 2 | 0 |
| /DRV_BSTB | 0.25/0.2 | 7 | 2 | 7.7 | 2 | 0 |
| /DRV_BSTC | 0.25/0.2 | 10 | 0 | 7.4 | 2 | 0 |
| /DRV_GHA | 0.25/0.2 | 6 | 0 | 7.0 | 3 | 0 |
| /GHB | 0.25 | 5 | 0 | 6.6 | 3 | 0 |
| /BOOT0 | 0.25/0.2 | 14 | 0 | 5.8 | 3 | 0 |
| /DRV_GLB | 0.25/0.2 | 7 | 0 | 5.3 | 2 | 0 |
| /5V_VCC | 0.25/0.2 | 8 | 0 | 4.8 | 2 | 0 |
| /DRV_BSTA | 0.25/0.2 | 4 | 0 | 4.3 | 2 | 0 |
| /DRV_GLA | 0.25/0.2 | 3 | 0 | 3.5 | 2 | 0 |
| Net-(LED3-A) | 0.25 | 8 | 0 | 3.1 | 2 | 0 |
| /SW | 0.25/0.2 | 3 | 0 | 3.1 | 2 | 0 |
| /RPM | 0.25/0.2 | 3 | 0 | 2.6 | 2 | 0 |
| /DRV_GHC | 0.25/0.2 | 4 | 0 | 2.2 | 2 | 0 |
| /DRV_GLC | 0.25/0.2 | 3 | 0 | 2.1 | 2 | 0 |
| /E_STOP | 0.25 | 2 | 0 | 2.1 | 2 | 0 |
| **total** | | **1795** | **255** | **1908.3** | | **7** |