soldermaskdocs

Two routers

The pipeline has always held that the router is the only judge: a placement is good if a router closes it, and no opinion of ours overrides that. Running a second router is what that principle costs when you take it seriously — because if the verdict is the router's, then part of every verdict is the tool.

The two

freeroutingThe A* router
What it isThe vendored jar, vendor/freerouting-2.4.1.jarKiCadRoutingTools (drandyhaas, MIT): grid-based A* with a Rust core
What it readsSpecctra .dsn, and answers with a .sesThe .kicad_pcb directly
How it is pinnedThe jar, by version — 2.4.1 and 2.3.0 are different judges, and the corpus records which one labelled each exampleBy commit (KRT_SHA) and by what the built core must call itself (KRT_CORE_VERSION)
What it needsA real JVM. macOS ships a /usr/bin/java stub that exists and only prints an advert, which is why cli.find_java() existsA C toolchain and a Rust toolchain. soldermask setup --astar puts it there; astar.ready() says what is missing

A router fetched rather than vendored is still the same router or it is not used, and that matters more for the checkout than for the jar: an unpinned HEAD would relabel the corpus with a program nobody recorded.

In the build: a second opinion, not a second pass

When freerouting finishes and this pipeline's own walk of the copper still finds a net short of a pin, astar.second_opinion() routes the same placement again with the A* router, reads its copper back out of the .kicad_pcb, and walks it the same way. The A* copper is kept only when it reaches pins freerouting's did not — never on a tie.

It is wired into workspace.realise(), so the command line and the app both get it, and into the poured path, where the pour is laid over whichever copper is kept. A board that routes first time never reaches the second router and costs exactly what it did.

Worked example from the held-out set: on daschiller-nfc_sensor, freerouting reports nothing unrouted and no violation while leaving GND short of two pins. The second opinion takes the board from 61 wires with a dead ground to 110 wires and 8 vias with every pin reached.

One thing it refuses to do quietly: a board left short of a pin with no router installed says so in its gaps, with the command that installs one, instead of shipping a dead net in silence. No .ses is left beside a rescued board: the session is freerouting's and the copper is not. (Until 22 Sep 2026 a placement with a net designed wider than the convention was refused too, as "wider than this router lays"; see Per-net widths below.)

Choosing which goes first

Since 13 Sep 2026 the A router goes first by default*, at its own rule — 0.127 mm track and clearance, the fab's stated floor, which the measurement below found to be its best setting — and freerouting is the second opinion at its own, 0.15/0.15. Under one judge the two close the same boards and A* leaves no rule error on 55 % of them against freerouting's 25 %, in a third of the time. A person's floor (constraints.rules.min_track_mm) and a net's declared current still widen either; a part with pads closer than the clearance still pulls it down to the fab's floor. The board can ask for the other order: constraints.rules.router is krt (the default) or freerouting, set by --router krt on the command line, by a prompt that names one (route it with krt, use the A* router), through PUT /constraints, or from the Route box on the workbench's chain tab, beside Route again. It is a constraint because it is a decision about how the board is built, and it is saved with the project so every rebuild after honours it.

What the A* router is told (since 14 Sep 2026, astar.route()): its track, clearance and via sizes are exact (--escalation off) and no via sits in a pad (--same-net-pad-clearance at the rule's clearance). Left to its defaults it closes a fine-pitch net by shrinking a via into the pad — 0.28 mm with a 0.18 mm drill on an HVQFN-32's 0.5 mm pitch — which is under the fab's drill floor and which this pipeline's reader does not carry: every via is written back at the rule's 0.6/0.3, where a shrunk one lands 0.06 mm from the pads either side. On the ATmega board of the 11 Sep suite that read as 28 clearance violations in copper the router's own check called clean. Under exact sizes a net that cannot close is left open and said so, and freerouting gets the same placement next. The board it is given is written with round pads longer than they are wide as KiCad's oval; written as circle they were 0.27 mm dots to KiCad, the pour, the DRC judge and this router alike, and an STM32's LQFP-48 had tracks laid through its pads (294 violations on one board, 13 Sep 2026).

One rule, three writers (14 Sep 2026). A board is routed by one of two programs and judged by a third, and each is told the rule in its own language: freerouting reads it from the DSN (dsn.write), the A* router from its command line (astar.argv), kicad-cli from the project file (kicad.project). Nothing held the three together, and nine of the sixty evaluation boards had routed every net and failed DRC on the difference — vias inside fine-pitch pads, hole clearance against a default KiCad supplied because nothing named it, freerouting's pin-exit neck at three quarters of a 0.15 mm rule. tests/test_rules.py now reads the number each writer emits for both routers' rules and holds them to one another: track, clearance and via to the same four numbers in all three; every floor DRC refuses a board under (min_track_width, min_clearance, via, annular, hole clearance, hole to hole, edge) at or under what the routers' geometry gives. Two rules the A* router used to take from a project file it is not given are now on its command line — --board-edge-clearance at the distance freerouting's copper lands from the cut line, --hole-to-hole-clearance at the number the project file names — and the project file names min_hole_clearance and min_hole_to_hole rather than falling back to them.

freerouting's pin-exit neck is floored, not shipped. freerouting draws the trace leaving a pin at three quarters of its width when the pin's surroundings are tight (FoundConnectionInserter, max(1, half × 3/4); hard-coded, and not the automatic_neckdown setting, which is for wide traces and leaves these in place). At its 0.15 mm rule that is 0.1125 mm, under the fab's 0.127 — on the BMS board 18 track-width errors, on the industrial IO board 3, the only DRC errors on either, both fully routed, and both reported by ses.width_report and shipped. ses.floor() widens such a wire to the fab's floor wherever the product reads freerouting's session — the poured path (pour._route, which every build takes), the bare path (workspace._route_once) and the second opinion (astar.freerouting_opinion); tests/test_pour.py holds the poured one, because the first two were floored and the BMS board came back with its 18 errors all the same — the routing report says how many and from what width, and kicad-cli judges the widened copper after the pour, so a neck that only fit at 0.1125 comes back as the clearance violation it is rather than a track no fab makes.

freerouting is asked not to optimise (15 Sep 2026). After routing, freerouting runs an "optimization stage" that shortens traces and removes vias, and on a board that is already routed or already not it changes neither verdict. Measured on two boards that do not close, ten cores: a 30-part board's second-opinion run took 36.8 s with the optimizer and 6.8 s without, the same verdict, the optimizer having spent two passes of 7.5 and 15.8 s for a 0.0 % gain; a 38-part board 19.4 s against 18.9. There is no command-line switch for it in 2.4.1 — -oit is accepted and the stage runs all the same — so dsn.write puts (autoroute_settings (postroute off)) in the DSN, which it honours (dsn.POSTROUTE). The verdict freerouting prints is computed before the stage, so the corpus label is the same judge either way; the copper is not (more vias, longer traces, and none of the 0.03 mm track fragments the optimizer was found to leave, which KiCad reads as a broken net), and corpus.Outcome.optimized says which a label's copper was. The second opinion is asked only where a pin was left unreached, whichever router went first: it is kept only when it reaches pins the first did not, so on a board with none unreached it was a run that could not be kept, and freerouting's own violation count used to ask for it on every attempt of every board with a USB-C receptacle (see a ground pour).

Whichever goes first, the other is the second opinion, by the same walk of the copper: astar.route_placement() is the A* router as a first router, astar.freerouting_opinion() is freerouting's opinion on its board, and SecondOpinion names both routers in its note whichever way round. On the poured path the reduced netlist goes to the chosen router and the pour is laid over its copper as before.

What a board that asks for the A* router and cannot have it does: freerouting routes it and the gaps say so — the router is not installed (with the setup --astar line). The choice was the person's, so its loss is a gap and not a note.

Per-net widths (22 Sep 2026). The A* router was told one track width and refused any board with a net designed wider — a declared current, a person's min_track_mm floor — which sent every such board to freerouting: seven of the eleven copper failures of the 18 Sep evaluation were boards with a 2–3 A rail routed that way. The router takes a width per net (--power-nets with --power-nets-widths, each name escaped to the one glob that matches it alone), so astar.net_widths() names every net wider than the track with the width electrical.width_for_net_mm gives it, astar.argv carries them, and astar.refuses() answers nothing for every board. A tap of a wide net that will not fit is left open, not laid at the track (--no-power-tap-neckdown): the router's default retries a failed power tap at the layer's width, and on the first replay that was a 3 A battery net with 44 mm of 0.127 mm copper in it, one run 23 mm long, every pin reached and DRC clean — a board that fails later, passing. ses.width_report still judges the copper against the design widths as it does freerouting's. Measured on the fixture two-resistor board with a 3 A net: routed at 1.367 mm, every pin reached, the signal beside it at 0.127. Which router goes first on such a board has not changed: freerouting, with the A* router as the second opinion it used to be denied — on the seven wide-copper failures of 18 Sep the A* router first, honest, left more nets short than freerouting on three and fewer on two, every board failing either way, so the default the evaluation was measured under stays and the board's notes say so. A person who names the A* router gets it first, at those widths.

Fine-pitch fanout, an environment switch while it is measured (22 Sep 2026). The router ships its own fanout for QFN/QFP parts (qfn_fanout.py): stub draws a short trace out of every pad, fanned at 45° so the ends sit a rule apart; underpad drops a via just past each pad instead, staggered so neighbours a pitch apart still clear, each checked against every other net's copper. SOLDERMASK_FANOUT=stub|underpad runs it (astar.fanout_pass) on every part with 16 pads or more on a pitch of 0.5 mm or finer (astar.fanout_candidates) before the A* router routes, and the board's notes say which parts and how many pads each escape reached. Off by default. What it did on the stored failures and on 70 fine-pitch boards of the held-out set is in the changelog under 22 Sep 2026.

What the label records: the A* router keeps no count of its own, so its corpus.Outcome carries unrouted and violations as None, router as krt-02e4b52, and is never clean by the label's rule — a board nobody counted. In the build it is judged by the walk and, on a poured board, by kicad-cli's DRC of the copper, which is the outside standard the corpus's A* labels were made with; PourOutcome.router_ok reads whichever applies. learn train --router keeps the two judges apart.

Measured on the fixture LDO (8 parts, poured), same placement: freerouting 20 wires, 9 vias, 37.8 mm in 6.8 s; the A* router 23 wires, 9 vias, 29.3 mm in 0.4 s, pour DRC 0 errors, verdict passes. Two boards; not a comparison, and the comparison below is the one to read.

What the second router says about the labels

Every example in the corpus is freerouting's opinion, which makes it worth asking how much of a failed label is the board and how much is the tool.

tools/router_ab.py puts a board's human placement to both routers at one fixed rule and has KiCad's own DRC judge both. Over 300 boards, 276 of which both routers answered for:

freeroutingA* router
Clean159192
Closed by both145145
Closed by this one only1447
Seconds8,3018,466
Median DRC errors left standing (154 boards both fully connected)112
Tidier of the two, by board6104

McNemar's chi-squared is 16.79, so the difference is not noise. The headline is not which router is better. It is that 47 of the 117 boards freerouting calls failures — 40 % of them — route cleanly under a different router: a label that reads as a fact about the placement is, two times in five, partly a fact about the tool. The gap holds at both ends of the size range (132 against 159 under forty footprints, 27 against 33 above it). freerouting also crashed on 3 boards and hit its 300-second timeout on 20 — 8 % of the sample it returned nothing for at all.

Run over the whole board bank — 42,334 annealed placements of 10,895 boards, on six 32-core machines in three Azure regions over nineteen hours (tools/azure_ab.py, about $90 including earlier batches) — the picture does not move: freerouting closes 54.0 %, the A* router 67.7 %, either 74.3 %, and they disagree on 27 %. The same to a point as on 131 boards, so the twenty-point lift is the corpus and not a sample.

And on the corpus's own placements, under one judge (11 September 2026). The A* relabel of 7 September routed every stored placement with the A* router and scored it with kicad-cli; its headline of +19.0 points over the corpus label compared kicad-cli's verdict on A* with freerouting's log on freerouting, which is a comparison of judges as much as of routers, and the control — the same placements, freerouting, kicad-cli — had not been run. It has now (tools/astar_relabel.py --router freerouting, .cache/corpus-fr-drc, 39,391 placements, four rented machines, ~$32). Paired by id over the 38,769 placements whose boards the DSN writer served correctly (622 on boards with a quoted pin id are left out — a writer defect found the same night, see the changelog), a router with no verdict counted as not clean: the corpus label 51.1 %, freerouting under kicad-cli's relative bar 64.9 %, the A* router under the same 69.7 % — so the nineteen points were 13.8 for the judge and 4.8 for the router, and the router's share is not noise (on the 34,739 placements both routers answered for, A* wins 4,359 discordant pairs and freerouting 2,654, McNemar p ≈ 10⁻⁹²; either router 84.9 %). On the strict bar, no rule error left at all, it is 19.8 % against 45.8 %, 10,603 pairs to 575. The router is ahead in every size band but the 29 boards over a hundred nets, on every kind of placement, and most on the placements made to fail (random 20.7 → 45.5 %, swap 42.7 → 67.5 %); where both close a board they lay the same vias (median 4) and the same copper (146 mm), and A* does it in 1.6 s median against 5.3. freerouting's characteristic failure is the timeout: 3,839 of its 4,010 missing verdicts. Corrected the next day: the corpus judges wrote no project file, so kicad-cli judged at KiCad's 0.2/0.2 defaults whatever the board's rule; on the 30,440 placements routed at 0.2 the routers tie on the relative bar (77.6 % against 78.4 %) and A* keeps the strict bar by thirty points (25.1 % against 55.0 %). On the 8,329 placements routed under 0.2 the default judge failed 43 % of freerouting's fully connected boards for spacing it had been told to use, and A* 0.4 %; those are re-judged at their own rule before any claim is made about them. Labels judged at the routed rule say at board rules in Outcome.judge from 12 September; the earlier ones do not, and were not. The judge swap itself, measured on the same router across 35,157 routes rather than the 98 of 6 September: the log and kicad-cli agree on 81.6 % of routes — so the 59 % agreement quoted from the 98-route sample is superseded.

Why the third reading is the one to believe

The first version of that comparison was wrong twice, and both corrections are the reason to trust what replaced them.

  1. It scored freerouting with this repository's own copper walk and the A* router with the A* router's own summary — which flattered the newcomer, 14 boards to 11. A judge that is not the same judge for both sides is not a judge.
  2. Putting both through KiCad's DRC then appeared to show the newcomer leaving rule errors on 9 of its 14. That was an artefact too: DRC over the bare board — the same placement with no copper on it at all — returns a median of 65 errors before either router has done anything, because a courtyard that overlaps or a pad on the outline is the board's own problem.
  3. The third reading was also wrong, and the correction came from a peer session reading the same file. Asking what a router adds over the bare board, clamped at zero, reports a median of zero for both and says nothing about either — routing almost always reduces the count, 274 of 276 boards coming in under their own baseline, because most of a bare board's errors are its nets being unconnected. The clamp was a floor, not a measurement. What separates the routers is the errors left standing.

What it does not change

The corpus's label is freerouting's verdict on freerouting's copper, and since 13 Sep 2026 learn generate --router astar writes the A router's — kicad-cli's verdict on A copper at the A* rule, with base_violations beside it — into .cache/corpus-astar beside it.** The two are kept apart because an example's id names the placement and not the judge; learn train --router fits on one or the other. A heatmap target is still the copper its own verdict describes. See Routability.

It also does not make the verdict arbitrary. Nudging every part 0.05 mm and re-routing (router_ab.py --jitter) over 2,000 placements: under freerouting all five copies route on 41 % of placements, all fail on 38 %, and the unnudged verdict is its copies' majority 92–97 % of the time. The verdict is a property of the placement more than of the search — the routers' disagreement is systematic rather than noisy.

tests/test_astar.py (34) holds the seam: copper written to a .kicad_pcb and read back is the same copper to within KiCad's own three decimals, read without the y-flip it is the mirror and the pin walk catches it, a wide net is refused before anything is written, and a harness that supplies its own router never triggers a second one.

On this page