Compare commits

...
Author SHA1 Message Date
rsp2k 8b1b00cf6c --reserve-escapes: keep a way out open for every fine-pitch pin
Before routing, each crowded pin gets the shortest way from its pad to
open board, found against pads, keepouts and earlier escapes. While the
pin still has connections to route, no other net may put a trace or a
via there, nor within clearance of it; the reservation lifts once the
pin is routed.
2026-09-28 14:21:24 -06:00
rsp2k 35891c02ff Merge feat/rule-area-keepouts: keepout exits, via-only walled pins, rule-area support 2026-09-28 10:32:44 -06:00
rsp2k 3e4f53a469 Keepout exits and rule areas
A route may not leave a cell inside a wire keepout as a wire, nor reach
a pin there by one: it changes layer instead, and the via goes in the
pad. A pin whose every stub enters a keepout is reached by a via only.

A structure keepout named rule_area... (mckicad names KiCad rule areas
so) is checked for overlap only, as KiCad's DRC does, with the free ends
of each wire tested for their round caps.
2026-09-28 10:08:02 -06:00
rsp2k bb75dc5067 DSN reader: only the declared string_quote character opens a string
KiCad declares " and writes a keycap part number as a bare ', which the
tokenizer took for an unterminated string (PCBench f.60_keyboard).
2026-09-28 09:41:27 -06:00
rsp2k 9de8329027 Merge feat/pin-access: vias in pads inside keepouts, exact stub checks, --access-cost 2026-09-28 09:38:18 -06:00
rsp2k f1d0f8df70 Pin access: --access-cost, vias in pads inside wire keepouts, exact stub checks
--access-cost C makes a route pay C to pass where it would close another
pin's way out (its end cells, the via it may need there, and the
clearance around both) while that pin still has connections to route.

A pin inside a wire keepout can only leave by a via; its stub to the
cell centre was track inside the keepout, which the exact check refused.
The via now goes on the pin itself. Pin stubs are also checked exactly
against wire keepouts, as the exact engine will check them.

bench/diagnose.py reports what each refused connection collided with.
2026-09-28 09:15:11 -06:00
rsp2k c44ff272ae bench/diagnose.py: sort an exact-engine run's failures by cause
rejected by the exact check, unroutable even alone, walled in (a pin's
free region is closed off by other copper, with the nets that close it),
or congestion. The board state is the best pass's, rebuilt by replaying
its paths.
2026-09-28 00:30:05 -06:00
rsp2k 05f31fc828 Merge feat/promote-failures: reroute with failed connections first; --orders 2026-09-27 22:53:47 -06:00
rsp2k 01ca108831 --promote-failures: reroute with the connections that failed moved first
Each round routes every connection that has failed so far ahead of the
rest, which keep their order. The promoted set only grows, so two groups
cannot take turns failing; the best round is kept.
2026-09-27 22:34:06 -06:00
rsp2k 0f5dacbd88 --orders: route several connection orders in parallel, keep the best
The default order is always one of the candidates, so the result is
never worse than a plain run; ties go to the earlier candidate.
2026-09-27 22:14:59 -06:00
rsp2k e67ccc04a0 Keep benchmark board lists and results out of git 2026-09-27 15:07:13 -06:00
rsp2k ed2e4c3fac bench: compare routing variants over several connection orders
Runs every board with every variant under the same seeds and compares
them seed by seed, so a change is judged against the routing-order
noise rather than one lucky or unlucky run.
2026-09-27 15:06:30 -06:00
rsp2k 76c3af94b8 --seed: shuffle the order connections are routed in
The router is deterministic, but its result depends heavily on which
connection routes first. A seed shuffles that order reproducibly, so a
change can be judged over several orders instead of the one it happened
to get.
2026-09-27 15:04:35 -06:00
rsp2k cf8b7ece60 uv.lock: record the CalVer version from pyproject 2026-09-27 15:04:35 -06:00
rsp2k f8804512ac Merge feat/plane-layers: route to inner plane layers with a via 2026-09-27 15:01:38 -06:00
rsp2k 56d576e613 Route to inner plane layers with a via, not pad to pad
On a four-layer board with a GND plane on an inner power layer, freeroute
ignored the plane (it only used zones on routing layers) and chained every
GND pad to the next with tracks across the board. Now a through-hole pad
inside a plane is joined to it already, and for a net with a plane every
other group connects straight to the plane: a route may end on any cell
inside it where a via fits (checked lazily, like other vias), and ending
there places that via. Signal nets are unaffected.

Tested on a four-layer DSN: three SMD GND pads each get a short stub and
their own via, the THT GND pad needs nothing, and no track crosses the
board. Ahead of the TerraHawk ESC, a four-layer board.
2026-09-27 14:19:13 -06:00
rsp2k 6f3db3e0e3 README: engines, flags and zones as they now are, and measured results
Recommend the exact engine for KiCad boards; document --edge-clearance,
--grid-resolution, --heuristic-weight and the unrouted report; list what is
read from a KiCad DSN (pads, vias, keepout kinds, outline, zones, fine
pitch), each checked against KiCad; replace the scope section with the
TerraHawk measurements and what still trails the JAR.
2026-09-27 13:04:23 -06:00
rsp2k 3253e6fa43 Escape stubs for pins walled in on the grid
On the rc5 sense board at its real 0.2 mm clearance, ten pins of the
0.5 mm pitch STM32 could only reach a pocket of seven or eight cells
inside their own pads: the grid columns miss the narrow legal band past
the pad tip, so twelve connections could not route even on an empty
board. For a crowded pin (a neighbouring pad of another net closer than a
trace can pass) whose nearby cells are such a pocket against pads and
keepouts, offer straight stubs along each axis to the first open cell,
accepted only if the stub clears every other net's pad by the full
margin, measured exactly. All twelve then route alone; on the full board
unrouted connections drop from 61 to 59, the rest being congestion.
2026-09-27 13:01:27 -06:00
rsp2k cdb3c7b568 Say which connections were not routed
freeroute exited 0 and said nothing when it dropped connections, which
cost real diagnosis time: on rc4 two simple two-pin nets came back with no
copper and no explanation. The grid now records each connection it could
not route, the exact check adds each it rejects, and the CLI lists them on
stderr (the SES is still written; the exit code stays 0, as the JAR's).

A connection whose ends both sit in one zone of its net is listed apart,
as left to the zone fill: freeroute does not route through a zone it
cannot trust to be solid, but KiCad's fill often joins those pads anyway.
2026-09-27 12:34:20 -06:00
rsp2k c49c79604d Exact engine defaults to --grid-resolution 2
Resolution 2 reaches 0.5 mm pitch pins and cut unconnected items on both
rc4 boards; since lazy via checks it costs about 2x in time, not 4x.
The grid engine keeps 1, the old grid exactly.
2026-09-27 12:00:19 -06:00
rsp2k 19b17378fa Count on a zone's connectivity only when no track can cross it
A zone outline is where copper may go, not where it ends up: on the rc4
sense board the board-wide GND pours break into islands around other
nets' tracks, and treating every pad inside them as connected left 28
GND islands unjoined (63 unconnected against 42 without zones). Trust a
zone only when a no-tracks rule area covers it on its layer, as the
power stage's pours are covered; route the rest with tracks as before.

rc4, exact engine, resolution 2: power stage 5 unconnected (JAR 12),
sense board 41 (JAR 32), no DRC errors on either.
2026-09-27 11:59:59 -06:00
rsp2k 354c89a8d5 Route with copper zones, and let vias into no-tracks areas
freeroute ignored the DSN's planes (KiCad's zones), so it never knew that
pads in a pour are joined by it. On the rc4 power stage those pads sit
inside "keep router off" areas as well, and every connection to them was
dropped without a word.

- Read (plane <net> <shape>) and put each zone on the board as a
  ConductionArea of its net. Zones are not obstacles: KiCad refills them
  around other nets' copper.
- The grid groups each net's pads and zone pieces that copper already
  joins (a pad in a zone, two zones sharing a pad) and routes only between
  groups. A connection may end on a zone by a via inside its copper, or by
  a track where tracks are allowed.
- wire_keepout now blocks traces only, as KiCad's "no tracks" rule area
  does; via_keepout only vias; keepout both, in the grid and in the exact
  verifier.

rc4 power stage, exact engine, resolution 2: 2 unconnected, down from 8
(FreeRouting JAR: 12), no DRC errors.
2026-09-27 11:54:04 -06:00
rsp2k 83b2248a97 Check via footprints lazily in the grid search
Profiling the rc4 sense board at resolution 2 put half the time in
_via_placeable: every expanded cell checked a 5x5 via footprint on both
layers, for vias the search almost never takes. Queue a layer change as a
candidate and check its footprint (memoised per search) only when it is
popped. A check can only rule a via out, never make it cheaper, so this
cannot change which route is found.
2026-09-27 11:41:42 -06:00
rsp2k 67c6c205f1 Tighter A* estimate on the orthogonal track, and --heuristic-weight
The grid search estimated the remaining distance as a straight line even
when only orthogonal steps exist; Manhattan is the tighter estimate those
moves admit and still never overestimates. --heuristic-weight W (default
1) makes it weighted A*: routes at most W times the shortest (tested).
2026-09-27 10:55:15 -06:00
rsp2k 8929fd367e Finer routing grid: --grid-resolution K with trace halos
A cell was exactly one trace plus its clearance, so a trace owning its cell
was the whole clearance model, and the 0.4 mm grid could not reach most
pins of a 0.5 mm pitch package: between two 0.3 mm pads a trace's centre
has a legal band only 0.15 mm wide. Resolution K makes cells K times
finer and has every trace claim a halo, the cells whose centre is nearer
than trace + clearance to one of its own, released again on rip-up.

K = 1 has an empty halo and is the old grid exactly (tested). On a 0.5 mm
row, K = 4 gets every pin out where K = 1 gets two. On the rc4 boards, K = 2
cuts the sense board's opens from 66 to 41 (JAR: 32) and the power board's
to 7 (JAR: 12), still with no DRC errors from freeroute's own copper.
2026-09-27 07:59:05 -06:00
rsp2k 9fa5432953 Give pin stubs clearance, and let a route leave a pin by any open side
Every route starts and ends with a stub from the pin to its first grid
cell, and the grid never saw that copper: on the rc4 sense board 24 of 39
connections the exact verifier rejected had a later route pass beside an
earlier stub. A route's stubs now claim the cells they come near, a stub
must be clear of other nets to be used, and a route may start from any
enterable neighbour of the pin cell, costed by stub length (Manhattan on
the orthogonal track, so a pin on a cell centre still leaves straight).

Tests: a route near a rounded corner keeps its edge clearance; and a
strict xfail that pins down the remaining gap, a 0.5 mm pin row the grid
cannot give a column to every pin of.
2026-09-27 07:37:50 -06:00
rsp2k c1df284147 Route to the real board outline, per connection, and out of fine-pitch pins
- The exact engine verified copper per net and dropped a whole net on any
  conflict: a 25-pin +3V3 net lost every connection to one bad segment.
  Verify and accept per ratsnest connection instead.
- Pad and keepout cells were rounded outward, blocking cells up to half a
  step clear of the copper; 62 of the 64 pins of a 0.5 mm pitch LQFP could
  not start a route. Block only cells whose centre is inside the clearance
  zone, and let a net walk along inside its own pad, which adds no copper.
- Routing was bounded by the outline's bounding box, so tracks cut across
  rounded corners. Keep the DSN boundary as a polygon; the grid blocks cells
  too close to it (scanline plus per-edge, precomputed for vias) and the
  exact and room engines reject copper that is.
- --edge-clearance MM sets copper-to-edge clearance, e.g. KiCad's
  min_copper_edge_clearance, which the DSN does not carry. Default 0.

On the TerraHawk rc4 power stage the exact engine now matches the
FreeRouting JAR: same open count, no DRC errors, with a 0.5 mm edge rule.
2026-09-27 07:26:32 -06:00
rsp2k 96c212466a Tests for pads placed as KiCad places them, and the via size 2026-09-27 06:41:13 -06:00
rsp2k f1df60fc31 Model pads and vias as KiCad places them
Measured against KiCad's own pads on the TerraHawk rc4 power stage:

- Back-side parts kept their pads on F.Cu (32 pads). Mirror the copper
  layer for a back-side part.
- Pad outlines were copied unrotated (29 pads on rotated parts). Place the
  outline through the same chain as the pin: pin rotation, back-side
  mirror, part rotation. Oval pads, a path with a width, came out as a
  zero-height sliver; pad them out by the aperture.
- Multi-shape padstacks used the first shape; use the largest.
- Footprint-level keepouts were parsed but never placed. KiCad exports a
  mounting hole's plated ring that way, so the router could not see it.
- Vias were sized like a trace (0.25 mm square for a 0.6 mm via), and every
  remaining short and clearance error was a via. Read the size from the
  DSN's via padstack and use it in all three engines; the grid engine now
  reserves the cells a via's copper reaches.

With these the exact and room engines route that board with no shorts,
clearance or other DRC errors. Pins now carry their component reference.
2026-09-27 06:40:34 -06:00
rsp2k 3ad5c983f5 Honour KiCad's keepouts: keyhole rings, wire_keepout, per-tile rasterizing
On the TerraHawk rc4 power stage every engine misread the board's rule areas:

- A no-track ring along the board edge arrives as one keyhole polygon, and
  keepout polygons were wrapped in a single convex tile, so the ring became
  the whole board. Split keepout polygons into convex pieces instead.
- The grid engine rasterized an area by its overall bounding box, which for
  that ring is again the whole board; it routed nothing. Rasterize per tile.
- "Keep router off" rule areas arrive as wire_keepout, which the reader did
  not know, so tracks ran through them (172 DRC items_not_allowed). Read it
  at board and image level. For now it blocks vias too, which is stricter
  than KiCad and cannot create a violation.
- place_keepout only restricts part placement; stop treating it as copper.

The oracle now runs the JAR headless; it was opening FreeRouting's GUI on
the desktop for every oracle test.
2026-09-27 06:30:43 -06:00
28 changed files with 2968 additions and 182 deletions
+4
View File
@@ -20,3 +20,7 @@ htmlcov/
# Editor / OS # Editor / OS
.DS_Store .DS_Store
*.swp *.swp
# Benchmark inputs and results: board lists name local, often private, designs
/bench/boards*.txt
/bench/results/
+88 -56
View File
@@ -1,6 +1,6 @@
# freeroute # freeroute
A native Python PCB autorouter — a Java-free reimplementation of the A native Python PCB autorouter: a Java-free reimplementation of the
[FreeRouting](https://github.com/freerouting/freerouting) engine. Specctra DSN [FreeRouting](https://github.com/freerouting/freerouting) engine. Specctra DSN
in, Specctra SES out, no JVM anywhere in the pipeline. in, Specctra SES out, no JVM anywhere in the pipeline.
@@ -8,8 +8,8 @@ in, Specctra SES out, no JVM anywhere in the pipeline.
Toolchains that autoroute (`kicad-mcp` among them) do it by shelling out to Toolchains that autoroute (`kicad-mcp` among them) do it by shelling out to
`freerouting.jar`, which drags a JVM into every install and container image. `freerouting.jar`, which drags a JVM into every install and container image.
`freeroute` reimplements the routing engine in pure Python — no dependencies at `freeroute` reimplements the routing engine in pure Python, with no dependencies
all, standard library only — so the JAR step becomes a `pip install`. beyond the standard library, so the JAR step becomes a `pip install`.
## The contract ## The contract
@@ -21,7 +21,7 @@ board.dsn ──▶ freeroute ──▶ board.ses
``` ```
`kicad-cli` already exports `.dsn` and imports `.ses`, so `freeroute` drops in `kicad-cli` already exports `.dsn` and imports `.ses`, so `freeroute` drops in
wherever `java -jar freerouting.jar` was invoked — nothing else in the pipeline wherever `java -jar freerouting.jar` was invoked, and nothing else in the pipeline
changes. The `-de` / `-do` / `-mp` flags are the JAR's, on purpose. changes. The `-de` / `-do` / `-mp` flags are the JAR's, on purpose.
## Install ## Install
@@ -33,27 +33,31 @@ uv tool install freeroute # or: pipx install freeroute
## Usage ## Usage
```bash ```bash
freeroute board.dsn -o board.ses # grid engine, the default # a KiCad board: the exact engine, with the board's copper-to-edge rule
freeroute -de board.dsn -do board.ses # the FreeRouting JAR spelling freeroute board.dsn --engine exact --shove --edge-clearance 0.5 -do board.ses
freeroute -de board.dsn -do board.ses # the FreeRouting JAR spelling (grid engine)
freeroute board.dsn --engine room --pack --shove -do board.ses freeroute board.dsn --engine room --pack --shove -do board.ses
freeroute board.dsn --engine exact --diagonal --shove -do board.ses
``` ```
With no `-do` / `-o`, the SES goes to stdout. With no `-do` / `-o`, the SES goes to stdout. Connections freeroute could not
make are listed on stderr; the SES is still written and the exit code stays 0,
as with the JAR. Connections whose two ends sit in one zone of their net are
listed separately, as left to that zone's fill.
### Engines ### Engines
Three routing tracks, selected with `--engine`: Three routing tracks, selected with `--engine`:
- **`grid`** (default) — multi-layer maze router over a uniform occupancy grid, - **`exact`** (recommended for real boards): orthogonal grid search whose every
with rip-up-and-retry and via search. Highest raw connectivity of the three; accepted connection is verified against exact integer geometry. It drops a
its geometry is grid-quantised, so it is the least precise about clearance. connection rather than emit a violation. Defaults to `--grid-resolution 2`.
- **`exact`** — orthogonal router whose output is verified against exact integer - **`grid`** (the default, for JAR-compatible invocation): the same multi-layer
geometry (tile/octagon clearance, not bounding boxes). Cleaner, DRC-clean maze router with rip-up and via search, without the exact check, so its
output where it succeeds; it will drop a net rather than emit a violation. geometry is only as precise as the grid.
- **`room`** — continuous expansion-room router: an exact decomposition of free - **`room`**: continuous expansion-room router, an exact decomposition of free
space, so it routes off-grid channels that the grid quantisation cannot see. space. It routes off-grid channels the grid cannot see, but it is much slower
on real boards and does not yet use zones.
### Flags ### Flags
@@ -62,6 +66,9 @@ Three routing tracks, selected with `--engine`:
| `--engine {grid,exact,room}` | routing track (default: `grid`) | | `--engine {grid,exact,room}` | routing track (default: `grid`) |
| `-de`, `--design` | input `.dsn` (FreeRouting-compatible alias) | | `-de`, `--design` | input `.dsn` (FreeRouting-compatible alias) |
| `-do`, `--output` | output `.ses` (default: stdout) | | `-do`, `--output` | output `.ses` (default: stdout) |
| `--edge-clearance MM` | copper to board-edge clearance, e.g. KiCad's `min_copper_edge_clearance`, which the DSN does not carry (default 0: copper only has to stay inside the outline) |
| `--grid-resolution K` | grid cells K times finer than trace + clearance (default 2 for exact, 1 for grid); finer reaches fine-pitch pins at a cost in time |
| `--heuristic-weight W` | exact: weighted A*, routes at most W times the shortest (default 1) |
| `-mp`, `--max-passes` | rip-up-and-retry pass bound (default 10) | | `-mp`, `--max-passes` | rip-up-and-retry pass bound (default 10) |
| `--no-rip-up` | single greedy pass, no rip-up | | `--no-rip-up` | single greedy pass, no rip-up |
| `--layers` | comma-separated signal layer indices to route on | | `--layers` | comma-separated signal layer indices to route on |
@@ -74,65 +81,90 @@ unsupported combination is a hard error (exit 2) rather than a silent no-op:
| | `--pack` | `--shove` | `--diagonal` | `--no-rip-up` | `-mp` | | | `--pack` | `--shove` | `--diagonal` | `--no-rip-up` | `-mp` |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **grid** | — | — | — | yes | yes | | **grid** | no | no | no | yes | yes |
| **exact** | — | yes | yes | yes | yes | | **exact** | no | yes | yes | yes | yes |
| **room** | yes | yes | — | — | — | | **room** | yes | yes | no | no | no |
`--layers` works on all three. `--layers` and `--edge-clearance` work on all three; `--grid-resolution` on grid
and exact.
What the opt-in passes buy you: What the opt-in passes buy you:
- **`--pack`** (room) — when several 2-pin nets have to cross the same obstacle - **`--pack`** (room): when several 2-pin nets have to cross the same obstacle
gap, they are assigned consistent parallel lanes and routed together, so a gap, they are assigned consistent parallel lanes and routed together.
channel physically wide enough for all of them stops dropping nets to greedy - **`--shove`** (exact, room): a net that would be dropped nudges the blocking
per-net gate collisions.
- **`--shove`** (exact, room) — a net that would be dropped nudges the blocking
committed traces aside and re-routes, rolling every moved trace back if the committed traces aside and re-routes, rolling every moved trace back if the
retry fails. On the room engine it also turns on occupancy-aware gate retry fails. It recovers *ordering* drops; it cannot make space that is not
placement. It recovers *ordering* drops; it cannot manufacture space that is there.
not there. - **`--diagonal`** (exact): a still-dropped 2-pin net is retried as a 45° trace
- **`--diagonal`** (exact) — a still-dropped 2-pin net is retried as a 45° trace covered by an exact integer octagon.
whose diagonal copper is covered by an exact integer octagon, so it fits
corridors the orthogonal L cannot, at the ideal hypotenuse length. ## What freeroute reads from a KiCad DSN
These were each checked against KiCad itself, pad by pad or with its DRC:
- **Pads** placed as KiCad places them: back-side parts on the mirrored copper
layer, outlines rotated with the part, oval pads at their full width, the
largest shape of a multi-layer padstack.
- **Vias** at the size of the DSN's via padstack.
- **Keepouts**: `keepout` blocks tracks and vias, `wire_keepout` (KiCad's "no
tracks" rule area) tracks only, `via_keepout` vias only; a rule area with a
hole (a no-track ring along the edge) is split into convex pieces rather than
filled in; footprint-level keepouts (a mounting hole's plated ring) are placed
with their part; `place_keepout` does not affect routing.
- **The real board outline**, rounded corners included, with `--edge-clearance`.
- **Copper zones** (`plane`): a pad inside a zone connects through it, and a
route can end on it with a via. A zone is only counted on to connect when a
no-tracks rule area covers it; a board-wide pour can be split into islands by
other nets' tracks, so its pads are routed with tracks instead.
- **Fine-pitch pins**: a finer grid, and exact escape stubs for pins the grid
cannot reach.
SES coordinates are written in resolution units, exactly as the JAR writes them.
## Library use ## Library use
```python ```python
from freeroute.route import route from freeroute.route import route
ses_text = route(dsn_text, engine="room", shove=True, pack=True) ses_text = route(dsn_text, engine="exact", shove=True, edge_clearance_mm=0.5)
``` ```
## Status and honest scope ## Status and honest scope
Alpha, version 0.1.0. It parses real `kicad-cli` DSN output, routes, and writes Alpha. Measured on two real KiCad 10 boards (the TerraHawk charge node's power
SES that KiCad imports. What is actually implemented: stage and sense board, 2026-09-27), with every track stripped, then routed, then
checked with `kicad-cli pcb drc --refill-zones`:
- Specctra DSN parser (structure, library, placement, network, rules, keepouts) | board | freeroute exact | FreeRouting JAR | DRC errors from freeroute |
and SES writer. |---|---|---|---|
- Multi-layer routing with via search and rip-up-and-retry. | power stage (40 x 30 mm) | 7 unconnected, ~5 s | 12 | 0 |
- Exact integer geometry (tiles, octagons) with a spatial index, so the `exact` | sense board (55 x 25 mm, 0.5 mm pitch MCU) | 41 unconnected, ~2.5 min | 32 | 0 |
and `room` engines verify their own output is clearance-clean.
- Continuous expansion-room free-space decomposition. In the board owner's production flow (hand-routed power lanes kept, pours added
- Channel packing, shove, and 45° routing as described above. after routing), freeroute completed the power stage with no unconnected items.
What it is **not**, stated plainly: What it is **not**, stated plainly:
- **Not at FreeRouting/JAR density parity on dense commercial boards.** The JAR - **Not yet at JAR completion on a dense board.** The legality of what it routes
remains the better router when the board is hard. `freeroute` is honest about holds up; how much it routes does not match the JAR yet. The remaining gap is
drops: it leaves a net unrouted rather than emit a DRC violation, so on a congestion: routes committed one at a time block later ones. The JAR's
crowded board expect fewer completed nets than the JAR would manage. negotiated rip-up loop handles that better. Negotiated congestion routing is
- **`--diagonal` is a shortening/recovery pass, not diagonal-native search.** the planned next step.
The maze search is orthogonal; 45° geometry is applied to nets the orthogonal - **Slower than the JAR** on a dense board (minutes against seconds), being
pass dropped or to shorten a staircase. A true 45°-native expansion-room maze pure Python.
is not implemented. - **`--diagonal` is a recovery pass, not diagonal-native search.**
- No fanout pass, no post-route optimiser, no interactive/incremental routing, - The `room` engine does not use zones, the outline edge rule is only enforced
no net classes with per-class widths beyond the default rules, no plane/pour approximately there, and it is slow on real boards.
handling. - No fanout pass, no post-route optimiser, no incremental routing, no per-class
track widths beyond the default rules.
- Copper that KiCad draws as graphics (a net tie's polygon, a logo) is not in
the DSN at all, so no router sees it; mckicad adds footprint copper to the DSN
as keepouts.
If you need maximum completion on a dense board today, use the JAR. If you need For maximum completion on a dense board today, the JAR is still the stronger
routing without a JVM — CI, containers, an MCP server, a library call — this is router. For routing without a JVM (CI, containers, an MCP server, a library
that. call), freeroute routes legally and says what it could not do.
## Development ## Development
@@ -144,7 +176,7 @@ uv run ruff check src/ tests/
The `oracle`-marked tests run the real `freerouting.jar` as a differential The `oracle`-marked tests run the real `freerouting.jar` as a differential
reference and skip cleanly when Java or the JAR is absent. The JAR is never reference and skip cleanly when Java or the JAR is absent. The JAR is never
vendored, and neither is the upstream Java source — see vendored, and neither is the upstream Java source; see
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the port map and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the port map and
[`docs/PORTING_PLAN.md`](docs/PORTING_PLAN.md) for the roadmap. [`docs/PORTING_PLAN.md`](docs/PORTING_PLAN.md) for the roadmap.
+206
View File
@@ -0,0 +1,206 @@
"""Route boards under several connection orders and compare variants fairly.
One routing run says little: which connection routes first moves a result by
ten connections either way on a crowded board. This runs every board with
every variant under the same seeds (``--seed``), so variant A and variant B
face the same order on seed 3, and compares them seed by seed.
uv run python bench/bench.py BOARDS -v base= -v hist1="--history-cost 1" \\
--seeds 6 --jobs 4 --out bench/results/hist.jsonl
BOARDS is a text file, one board per line: a name, a DSN path and any flags
that board always needs, e.g.
sense /path/to/charge-sense.dsn --edge-clearance 0.5
# comments and blank lines are ignored
A variant is ``NAME=FLAGS``, or ``NAME@SRC=FLAGS`` to run the code under SRC
(a worktree's ``src`` directory) rather than the installed package. The first
variant is the baseline. Results are appended to --out as JSON lines, and runs
already in it are skipped, so an interrupted benchmark picks up where it left
off. Exits non-zero if any run crashed or timed out.
"""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import math
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
import threading
import time
NOT_ROUTED = re.compile(r"freeroute: (\d+) connection\(s\) not routed")
TO_ZONE = re.compile(r"freeroute: (\d+) more connection\(s\) left to a zone fill")
def parse_boards(path: Path) -> list[tuple[str, Path, list[str]]]:
boards = []
for line in path.read_text().splitlines():
words = shlex.split(line, comments=True)
if not words:
continue
if len(words) < 2:
raise SystemExit(f"{path}: want 'name path [flags]', got {line!r}")
dsn = Path(words[1]).expanduser()
if not dsn.is_file():
raise SystemExit(f"{path}: no such DSN {dsn}")
boards.append((words[0], dsn, words[2:]))
return boards
def parse_variant(spec: str) -> tuple[str, str | None, list[str]]:
head, sep, flags = spec.partition("=")
if not sep or not head:
raise SystemExit(f"variant {spec!r}: want NAME=FLAGS or NAME@SRC=FLAGS")
name, _, src = head.partition("@")
if src and not (Path(src) / "freeroute").is_dir():
raise SystemExit(f"variant {name}: {src} has no freeroute package")
return name, src or None, shlex.split(flags)
def run_one(board, variant, seed: int, timeout: float) -> dict:
name, dsn, board_flags = board
vname, src, flags = variant
env = dict(os.environ)
if src:
env["PYTHONPATH"] = src + os.pathsep + env.get("PYTHONPATH", "")
with tempfile.TemporaryDirectory() as tmp:
cmd = [
sys.executable, "-m", "freeroute.cli", str(dsn), "-do", f"{tmp}/out.ses",
*board_flags, *flags, "--seed", str(seed),
]
start = time.monotonic()
proc = subprocess.Popen(
cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
)
expired = threading.Event()
def expire() -> None:
expired.set()
proc.kill()
timer = threading.Timer(timeout, expire)
timer.start()
err = proc.stderr.read()
# reap it here rather than through Popen, to keep its peak memory
_, wstatus, usage = os.wait4(proc.pid, 0)
timer.cancel()
proc.returncode = os.waitstatus_to_exitcode(wstatus)
proc.stderr.close()
status = "timeout" if expired.is_set() else (
"ok" if proc.returncode == 0 else f"exit {proc.returncode}"
)
rss_mb = round(usage.ru_maxrss / 1024)
secs = round(time.monotonic() - start, 1)
missing = NOT_ROUTED.search(err)
zone = TO_ZONE.search(err)
return {
"board": name, "variant": vname, "seed": seed, "status": status,
"unrouted": int(missing.group(1)) if missing else 0,
"to_zone": int(zone.group(1)) if zone else 0,
"secs": secs, "rss_mb": rss_mb,
"tail": err.strip().splitlines()[-1] if status != "ok" and err.strip() else "",
}
def mean_sd(xs: list[float]) -> tuple[float, float]:
m = sum(xs) / len(xs)
sd = math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) if len(xs) > 1 else 0.0
return m, sd
def report(rows: list[dict], boards, variants) -> None:
ok = {(r["board"], r["variant"], r["seed"]): r for r in rows if r["status"] == "ok"}
base = variants[0][0]
print(f"\nunrouted per board (lower is better), baseline {base!r}\n")
print(f"{'board':14} {'variant':14} {'n':>3} {'mean':>6} {'sd':>5} {'min':>4} {'max':>4}"
f" {'secs':>6} vs baseline, seed by seed")
for bname, _, _ in boards:
for vname, _, _ in variants:
runs = sorted((k[2], r) for k, r in ok.items() if k[:2] == (bname, vname))
if not runs:
print(f"{bname:14} {vname:14} no finished runs")
continue
xs = [r["unrouted"] for _, r in runs]
m, sd = mean_sd(xs)
t, _ = mean_sd([r["secs"] for _, r in runs])
line = (f"{bname:14} {vname:14} {len(xs):>3} {m:>6.1f} {sd:>5.1f} {min(xs):>4}"
f" {max(xs):>4} {t:>6.0f}")
if vname != base:
diffs = [r["unrouted"] - ok[(bname, base, s)]["unrouted"]
for s, r in runs if (bname, base, s) in ok]
if diffs:
dm, dsd = mean_sd(diffs)
se = dsd / math.sqrt(len(diffs)) if len(diffs) > 1 else float("inf")
better = sum(d < 0 for d in diffs)
worse = sum(d > 0 for d in diffs)
# a difference inside two standard errors is not a finding
verdict = ("better" if dm < 0 else "worse") if abs(dm) > 2 * se else "no clear change"
line += (f" {dm:+.1f} ± {se:.1f} ({better} better, {worse} worse,"
f" {len(diffs) - better - worse} same) {verdict}")
print(line)
failed = [r for r in rows if r["status"] != "ok"]
for r in failed:
print(f"FAILED {r['board']} {r['variant']} seed {r['seed']}: {r['status']} {r['tail']}")
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("boards", type=Path, help="board list: name, DSN path, flags")
ap.add_argument("-v", "--variant", action="append", required=True,
help="NAME=FLAGS or NAME@SRC=FLAGS; the first is the baseline")
ap.add_argument("--seeds", type=int, default=6, help="orders per board and variant")
ap.add_argument("--first-seed", type=int, default=0)
ap.add_argument("--jobs", type=int, default=4, help="runs at once")
ap.add_argument("--timeout", type=float, default=1800, help="seconds per run")
ap.add_argument("--out", type=Path, required=True, help="JSON lines, appended")
ap.add_argument("--report-only", action="store_true", help="summarise --out, run nothing")
args = ap.parse_args(argv)
boards = parse_boards(args.boards)
variants = [parse_variant(v) for v in args.variant]
if len({v[0] for v in variants}) != len(variants):
raise SystemExit("variant names must differ")
seeds = range(args.first_seed, args.first_seed + args.seeds)
rows: list[dict] = []
if args.out.exists():
rows = [json.loads(line) for line in args.out.read_text().splitlines() if line.strip()]
done = {(r["board"], r["variant"], r["seed"]) for r in rows if r["status"] == "ok"}
todo = [(b, v, s) for b in boards for v in variants for s in seeds
if (b[0], v[0], s) not in done]
if not args.report_only and todo:
args.out.parent.mkdir(parents=True, exist_ok=True)
print(f"{len(todo)} runs, {args.jobs} at a time", file=sys.stderr)
# retried runs replace their earlier failure
rows = [r for r in rows if (r["board"], r["variant"], r["seed"]) in done]
with ThreadPoolExecutor(args.jobs) as pool, args.out.open("w") as out:
for r in rows:
out.write(json.dumps(r) + "\n")
futures = [pool.submit(run_one, b, v, s, args.timeout) for b, v, s in todo]
for n, fut in enumerate(as_completed(futures), 1):
r = fut.result()
rows.append(r)
out.write(json.dumps(r) + "\n")
out.flush()
print(f"[{n}/{len(todo)}] {r['board']} {r['variant']} seed {r['seed']}:"
f" {r['status']} unrouted {r['unrouted']} ({r['secs']}s,"
f" {r['rss_mb']} MB)", file=sys.stderr)
wanted = {(b[0], v[0], s) for b in boards for v in variants for s in seeds}
rows = [r for r in rows if (r["board"], r["variant"], r["seed"]) in wanted]
report(rows, boards, variants)
return 1 if any(r["status"] != "ok" for r in rows) or len(rows) < len(wanted) else 0
if __name__ == "__main__":
raise SystemExit(main())
+266
View File
@@ -0,0 +1,266 @@
"""Why did each connection fail? Sort the failures of one exact-engine run.
uv run python bench/diagnose.py board.dsn --edge-clearance 0.5 [--json out.json]
Every connection the exact engine leaves unrouted lands in one bucket:
rejected the grid routed it, the exact geometry check refused its copper
alone it cannot be routed even on a board with no other routes: the
pads, keepouts or the grid itself are the limit, not congestion
walled-in in the routed board one of its pins has no way out: a flood from
the pin's end cells through free cells stops short (pin access)
congestion both pins can get out, but no free path joins them
The board state is the one the result came from (the best pass), rebuilt by
replaying that pass's paths. Diagnostic only: it reaches into the router.
"""
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import sys
from freeroute.board import build_board
from freeroute.board.search_tree import ShapeSearchTree
from freeroute.dsn import parse_dsn
from freeroute.geometry.octagon import overlaps_2d
from freeroute.route import grid_router
from freeroute.route.exact_router import route_board_exact
from freeroute.route.grid_router import GridRouter, _ends_of
from freeroute.route.pipeline import (
_mm_to_board,
_rule_clearance_dsn,
_rule_width_dsn,
_via_half_board,
)
# a pin whose free neighbourhood holds fewer nodes than this is walled in
FLOOD_LIMIT = 200
def _capture_router():
"""Patch GridRouter so the instance route_board_exact builds, and the paths
of its best pass, can be read back afterwards."""
seen: dict = {}
original_commit = GridRouter._commit
original_snapshot = GridRouter._snapshot
def commit(self, conn, path):
original_commit(self, conn, path)
self._diag_paths[conn.index] = (list(path), conn.a_end, conn.b_end)
def snapshot(self, conns):
self._diag_best = {c.index: self._diag_paths[c.index] for c in conns if c.routed}
seen["router"] = self
return original_snapshot(self, conns)
def init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
self._diag_paths = {}
self._diag_best = {}
seen["kwargs"] = kwargs
seen["args"] = args
original_init = GridRouter.__init__
GridRouter.__init__ = init
GridRouter._commit = commit
GridRouter._snapshot = snapshot
return seen
def _capture_conflicts() -> list:
"""Record each exact-check refusal: the copper box, its kind, and the
shape it came too close to. The check stops at a connection's first
conflict, so refusals and rejected connections line up in order."""
log: list = []
original = ShapeSearchTree.clearance_conflict
def conflict(self, box, net_no, layer, clearance, kind="wire"):
hit = original(self, box, net_no, layer, clearance, kind)
if hit:
# the shape that tripped the check, found the way the check does
other = None
expanded = box.offset(clearance)
for s in self.overlapping(expanded):
if s.net_no == net_no or not s.on_layer(layer):
continue
if s.blocks != "all" and s.blocks != kind + "s":
continue
if not s.clearance:
if s.tile.overlaps(box):
other = s
break
continue
if (s.exact is not None and overlaps_2d(s.exact, expanded)) or (
s.exact is None and s.tile.overlaps(expanded)
):
other = s
break
log.append((net_no, kind, layer, box, clearance, other))
return hit
ShapeSearchTree.clearance_conflict = conflict
return log
def _gap(a, b) -> int:
dx = max(b.ll.x - a.ur.x, a.ll.x - b.ur.x, 0)
dy = max(b.ll.y - a.ur.y, a.ll.y - b.ur.y, 0)
return max(dx, dy)
def _fresh(seen) -> GridRouter:
router = grid_router.GridRouter(*seen["args"], **seen["kwargs"])
router._conns = router._build_connections()
return router
def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
"""How many nodes a pin can reach through free cells (up to FLOOD_LIMIT),
and which other nets' copper bounds the region."""
walls: Counter = Counter()
# the search starts on every end node unconditionally (it may leave one
# inside a wire keepout by a via), so the flood does too
start = list(router._end_nodes(pin, net_no, conns))
seen = set(start)
frontier = list(start)
while frontier and len(seen) < FLOOD_LIMIT:
x, y, layer = frontier.pop()
steps = [(x + dx, y + dy, layer) for dx, dy in router._neighbours]
if router._via_placeable((x, y), net_no, conns):
steps += [(x, y, other) for other in router.layers if other != layer]
elif len(seen) == 1:
# the pin's own exit is a via that other copper leaves no room for
via_nodes = [(*c, lay) for c in router._via_cells((x, y)) for lay in router.layers]
for owner in router._crossed_owners(via_nodes, net_no, conns):
walls[conns[owner].net_no] += 1
for n in steps:
if n in seen or not (0 <= n[0] < router.cols and 0 <= n[1] < router.rows):
continue
if router._enter_cost(n[:2], n[2], net_no, False, conns) is None:
# trace, via footprint or clearance halo, as rip-up sees them
owners = router._crossed_owners([n], net_no, conns)
for owner in owners:
walls[conns[owner].net_no] += 1
if not owners:
walls[0] += 1 # a pad or keepout
continue
seen.add(n)
frontier.append(n)
return len(seen), walls
def diagnose(
dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False,
access_cost: float = 0.0,
) -> dict:
dsn = parse_dsn(dsn_text)
names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
scale = max(dsn.resolution.value, 1)
seen = _capture_router()
conflicts = _capture_conflicts()
exact = route_board_exact(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * scale),
clearance=round(_rule_clearance_dsn(dsn) * scale),
via_half=_via_half_board(dsn, scale),
edge_clearance=_mm_to_board(dsn, scale, edge_clearance_mm),
resolution=resolution,
shove=shove,
access_cost=access_cost,
)
routed = seen["router"]
best = routed._diag_best
grid_failed = {(c.net_no, *_ends_of(c)) for c in routed._conns if c.index not in best}
# the board as the best pass left it
state = _fresh(seen)
conns = state._conns
for idx, (path, a_end, b_end) in best.items():
conns[idx].a_end, conns[idx].b_end = a_end, b_end
state._commit(conns[idx], path)
empty = _fresh(seen)
# refusals in the order the check made them, one per rejected connection
per_net: dict[int, list] = {}
for net_no, kind, layer, box, clearance, other in conflicts:
if other is None:
what = "?"
elif other.net_no == -1:
what = "keepout" if other.clearance else "rule area"
else:
what = ("trace/via of " if other.routed else "pad of ") + names.get(other.net_no, "?")
need = clearance if other is None or other.clearance else 0
short = (need - _gap(box, other.tile)) / scale / 1000 if other is not None else 0
per_net.setdefault(net_no, []).append(
{"copper": kind, "layer": layer, "against": what, "short_mm": round(short, 4)}
)
by_key = {(c.net_no, *_ends_of(c)): c for c in conns}
rows = []
for failure in exact.result.failed:
net_no, a, b = failure[0], failure[1], failure[2]
zone = failure[3] if len(failure) > 3 else ""
if zone:
continue # left to a zone fill, not a failure
key = (net_no, a, b)
row = {"net": names.get(net_no, str(net_no)), "a": a, "b": b}
conn = by_key.get(key)
if key not in grid_failed:
row["kind"] = "rejected"
if per_net.get(net_no):
row["conflict"] = per_net[net_no].pop(0)
elif conn is None:
row["kind"] = "unknown"
elif empty._search(empty._conns[conn.index], allow_ripup=False, conns=empty._conns) is None:
row["kind"] = "alone"
else:
walled = []
for end, group in ((a, conn.a_group), (b, conn.b_group)):
for pin in group[0][:1]: # a group's first pin is its route end
reach, walls = _flood(state, pin, net_no, conns)
if reach < FLOOD_LIMIT:
walled.append({
"pin": end, "reach": reach,
"walls": [names.get(n, "pads/keepouts") for n, _ in walls.most_common(4)],
})
row["kind"] = "walled-in" if walled else "congestion"
if walled:
row["walled"] = walled
rows.append(row)
return {"failures": rows, "counts": dict(Counter(r["kind"] for r in rows))}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("dsn", type=Path)
ap.add_argument("--edge-clearance", type=float, metavar="MM")
ap.add_argument("--grid-resolution", type=int, default=2, metavar="K")
ap.add_argument("--shove", action="store_true", help="as mckicad routes")
ap.add_argument("--access-cost", type=float, default=0.0)
ap.add_argument("--json", type=Path, help="also write the full table here")
args = ap.parse_args(argv)
report = diagnose(args.dsn.read_text(), args.edge_clearance, args.grid_resolution, args.shove,
args.access_cost)
for row in report["failures"]:
extra = ""
for w in row.get("walled", []):
extra += f" [{w['pin']} reaches {w['reach']} cells, walled by {', '.join(w['walls'])}]"
if "conflict" in row:
c = row["conflict"]
extra += (f" [{c['copper']} on layer {c['layer']} {c['short_mm']} mm too close"
f" to {c['against']}]")
print(f"{row['kind']:11} {row['net']}: {row['a']} to {row['b']}{extra}")
total = sum(report["counts"].values())
print(f"\n{total} failed: " + ", ".join(f"{k} {v}" for k, v in sorted(report["counts"].items())),
file=sys.stderr)
if args.json:
args.json.write_text(json.dumps(report, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+13 -3
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from freeroute.geometry import IntBox from freeroute.geometry import IntBox
from .clearance import ClearanceMatrix from .clearance import ClearanceMatrix
from .items import Item, ObstacleArea, Pin, Trace, Via from .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via
from .layer import LayerStructure from .layer import LayerStructure
from .net import Nets from .net import Nets
@@ -22,7 +22,7 @@ __all__ = ["BasicBoard"]
class BasicBoard: class BasicBoard:
"""A board: layers, nets, clearances, a bounding box, and items.""" """A board: layers, nets, clearances, a bounding box, and items."""
__slots__ = ("layer_structure", "nets", "clearance_matrix", "bounding_box", "_items") __slots__ = ("layer_structure", "nets", "clearance_matrix", "bounding_box", "outline", "_items")
def __init__( def __init__(
self, self,
@@ -35,6 +35,8 @@ class BasicBoard:
self.nets = nets self.nets = nets
self.clearance_matrix = clearance_matrix self.clearance_matrix = clearance_matrix
self.bounding_box = bounding_box self.bounding_box = bounding_box
#: the real board outline, when the DSN has a polygon boundary
self.outline = None
self._items: list[Item] = [] self._items: list[Item] = []
# --- layers ------------------------------------------------------------- # --- layers -------------------------------------------------------------
@@ -55,7 +57,15 @@ class BasicBoard:
return [it for it in self._items if isinstance(it, Pin)] return [it for it in self._items if isinstance(it, Pin)]
def get_obstacle_areas(self) -> list[ObstacleArea]: def get_obstacle_areas(self) -> list[ObstacleArea]:
return [it for it in self._items if isinstance(it, ObstacleArea)] """Keepouts. Copper zones are not obstacles; see get_conduction_areas."""
return [
it
for it in self._items
if isinstance(it, ObstacleArea) and not isinstance(it, ConductionArea)
]
def get_conduction_areas(self) -> list[ConductionArea]:
return [it for it in self._items if isinstance(it, ConductionArea)]
def get_traces(self) -> list[Trace]: def get_traces(self) -> list[Trace]:
return [it for it in self._items if isinstance(it, Trace)] return [it for it in self._items if isinstance(it, Trace)]
+238 -12
View File
@@ -27,10 +27,11 @@ import math
from freeroute.dsn.model import DsnBoard from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex
from freeroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard from .board import BasicBoard
from .clearance import ClearanceMatrix from .clearance import ClearanceMatrix
from .items import ObstacleArea, Pin from .items import ConductionArea, ObstacleArea, Pin
from .layer import Layer, LayerStructure from .layer import Layer, LayerStructure
from .net import Nets from .net import Nets
from .transform import CoordinateTransform from .transform import CoordinateTransform
@@ -49,9 +50,12 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
bounding_box = _build_bounding_box(dsn, transform) bounding_box = _build_bounding_box(dsn, transform)
board = BasicBoard(layer_structure, nets, clearance, bounding_box) board = BasicBoard(layer_structure, nets, clearance, bounding_box)
board.outline = _build_outline(dsn, transform)
_insert_pins(dsn, board, transform, terminal_to_net) _insert_pins(dsn, board, transform, terminal_to_net)
_insert_keepouts(dsn, board, transform) _insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets)
return board return board
@@ -97,6 +101,18 @@ def _build_bounding_box(dsn: DsnBoard, transform: CoordinateTransform) -> IntBox
return IntBox(llx, lly, urx, ury) return IntBox(llx, lly, urx, ury)
def _build_outline(dsn: DsnBoard, transform: CoordinateTransform):
"""The DSN boundary as a polygon, or None when it is not a polygon."""
from .outline import BoardOutline
shape = dsn.boundary
if not isinstance(shape, (Polygon, Path)):
return None
c = shape.coords
pts = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
return BoardOutline(pts) if len(pts) >= 3 else None
def _shape_coords(shape: Shape) -> list[float]: def _shape_coords(shape: Shape) -> list[float]:
if isinstance(shape, Rectangle): if isinstance(shape, Rectangle):
return list(shape.coords) return list(shape.coords)
@@ -132,7 +148,7 @@ def _insert_pins(
location = _pin_location(place, dsn_pin, transform) location = _pin_location(place, dsn_pin, transform)
padstack = dsn.padstack(dsn_pin.padstack_name) padstack = dsn.padstack(dsn_pin.padstack_name)
shape, layers = _pad_shape_and_layers( shape, layers = _pad_shape_and_layers(
padstack, location, transform, layer_no, signal_layers padstack, place, dsn_pin, location, transform, layer_no, signal_layers
) )
net_no = terminal_to_net.get((place.name, dsn_pin.name)) net_no = terminal_to_net.get((place.name, dsn_pin.name))
pin = Pin( pin = Pin(
@@ -140,6 +156,7 @@ def _insert_pins(
component_no=id(place) & 0xFFFFFF, component_no=id(place) & 0xFFFFFF,
fixed=True, fixed=True,
name=dsn_pin.name, name=dsn_pin.name,
component_ref=place.name,
padstack_name=dsn_pin.padstack_name, padstack_name=dsn_pin.padstack_name,
location=location, location=location,
layers=layers, layers=layers,
@@ -148,22 +165,44 @@ def _insert_pins(
board.insert(pin) board.insert(pin)
def _to_board_offset(place, x: float, y: float, pin_rotation: float = 0.0) -> FloatPoint:
"""Image-relative DSN offset -> board-oriented DSN offset for a placed part.
The chain is: the pin's own rotation (pad shapes only), then the mirror of
a back-side part (x -> -x), then the part's rotation. Pin positions use the
same chain without the pin rotation, so pads and positions always agree.
"""
p = FloatPoint(x, y)
if pin_rotation:
p = p.rotate(math.radians(pin_rotation), FloatPoint(0.0, 0.0))
if not place.is_front:
p = FloatPoint(-p.x, p.y)
if place.rotation:
p = p.rotate(math.radians(place.rotation), FloatPoint(0.0, 0.0))
return p
def _pin_location(place, dsn_pin, transform: CoordinateTransform) -> IntPoint: def _pin_location(place, dsn_pin, transform: CoordinateTransform) -> IntPoint:
"""Absolute board location of a pin: component origin + rotated pin offset.""" """Absolute board location of a pin: component origin + placed pin offset."""
rel_x = -dsn_pin.x if not place.is_front else dsn_pin.x # back side mirrors in x rel = _to_board_offset(place, dsn_pin.x, dsn_pin.y)
rel = FloatPoint(rel_x, dsn_pin.y) return transform.dsn_to_board_point(place.x + rel.x, place.y + rel.y)
rotated = rel.rotate(math.radians(place.rotation), FloatPoint(0.0, 0.0))
return transform.dsn_to_board_point(place.x + rotated.x, place.y + rotated.y)
def _pad_shape_and_layers( def _pad_shape_and_layers(
padstack, padstack,
place,
dsn_pin,
location: IntPoint, location: IntPoint,
transform: CoordinateTransform, transform: CoordinateTransform,
layer_no: dict[str, int], layer_no: dict[str, int],
signal_layers: list[int], signal_layers: list[int],
) -> tuple[object, list[int]]: ) -> tuple[object, list[int]]:
"""A pad tile centred on ``location`` plus the layer indices it occupies.""" """A pad tile around ``location`` plus the layer indices it occupies.
A back-side part's pads move to the mirrored copper layer (F.Cu -> B.Cu on
a two-layer board), and the pad outline is placed through the same
rotation and mirror as the pin itself.
"""
if padstack is None or not padstack.shapes: if padstack is None or not padstack.shapes:
# degenerate: a zero-size box at the location (still contains it) # degenerate: a zero-size box at the location (still contains it)
return IntBox(location.x, location.y, location.x, location.y), list(signal_layers) return IntBox(location.x, location.y, location.x, location.y), list(signal_layers)
@@ -176,12 +215,63 @@ def _pad_shape_and_layers(
layers.add(layer_no[shape.layer]) layers.add(layer_no[shape.layer])
if not layers: if not layers:
layers = set(signal_layers) layers = set(signal_layers)
if not place.is_front and signal_layers:
top, bottom = min(signal_layers), max(signal_layers)
layers = {top + bottom - layer if layer in signal_layers else layer for layer in layers}
tile = _pad_tile(padstack.shapes[0], transform) # The largest of the padstack's shapes: per-layer shapes of a through-hole
# pad usually match, and when they do not the bigger one is the safe side.
shape = max(padstack.shapes, key=_shape_extent)
tile = _placed_pad_tile(shape, place, dsn_pin, transform)
tile = tile.translate_by(IntVector(location.x, location.y)) tile = tile.translate_by(IntVector(location.x, location.y))
return tile, sorted(layers) return tile, sorted(layers)
def _shape_extent(shape: Shape) -> float:
coords = _shape_coords(shape)
if isinstance(shape, Circle):
return shape.diameter ** 2
if len(coords) < 4:
return 0.0
xs, ys = coords[0::2], coords[1::2]
return (max(xs) - min(xs)) * (max(ys) - min(ys))
def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransform):
"""The pad outline, rotated and mirrored with its part, relative to the pin."""
if isinstance(shape, Circle):
centre = _to_board_offset(place, shape.center_x, shape.center_y, dsn_pin.rotation)
r = round(transform.dsn_to_board(shape.diameter / 2))
cx = round(transform.dsn_to_board(centre.x))
cy = round(transform.dsn_to_board(centre.y))
return IntBox(cx - r, cy - r, cx + r, cy + r)
if isinstance(shape, Rectangle):
x0, y0, x1, y1 = shape.coords[:4]
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
elif isinstance(shape, (Polygon, Path)):
c = shape.coords
corners = [(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
else:
return _pad_tile(shape, transform)
# An oval pad is a path with a width (the aperture). Pad the centreline
# out by half the width in every direction; the square ends cover more
# than the round ones do, which is the safe side.
half = getattr(shape, "width", 0.0) / 2 if isinstance(shape, Path) else 0.0
if half:
corners = [(x + dx, y + dy) for x, y in corners for dx in (-half, half) for dy in (-half, half)]
pts = []
for x, y in corners:
p = _to_board_offset(place, x, y, dsn_pin.rotation)
pts.append(IntPoint(round(transform.dsn_to_board(p.x)), round(transform.dsn_to_board(p.y))))
if len(pts) >= 3:
simplex = Simplex.from_corners(pts)
if not simplex.is_empty():
return simplex
xs = [p.x for p in pts] or [0]
ys = [p.y for p in pts] or [0]
return IntBox(min(xs), min(ys), max(xs), max(ys))
def _pad_tile(shape: Shape, transform: CoordinateTransform): def _pad_tile(shape: Shape, transform: CoordinateTransform):
"""A board-relative pad tile from a DSN padstack shape (centred near origin).""" """A board-relative pad tile from a DSN padstack shape (centred near origin)."""
if isinstance(shape, Rectangle): if isinstance(shape, Rectangle):
@@ -219,11 +309,14 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal] signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
for keepout in dsn.keepouts: for keepout in dsn.keepouts:
# A place_keepout only restricts where parts go, not copper.
if keepout.kind == "place_keepout":
continue
area = keepout.area area = keepout.area
if area is None or area.border is None: if area is None or area.border is None:
continue continue
tile = _area_border_tile(area.border, transform) tiles = _area_border_tiles(area.border, transform)
if tile is None: if not tiles:
continue continue
border_layer = area.border.layer border_layer = area.border.layer
if border_layer in ("signal", "pcb"): if border_layer in ("signal", "pcb"):
@@ -237,11 +330,48 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
ObstacleArea( ObstacleArea(
name=keepout.area.name, name=keepout.area.name,
layer=layer_index, layer=layer_index,
tiles=[tile], tiles=tiles,
blocks_wires=keepout.kind != "via_keepout",
blocks_vias=keepout.kind != "wire_keepout",
clearance=not is_rule_area(keepout.area.name),
) )
) )
#: a structure keepout named with this prefix is a KiCad rule area (mckicad
#: names them on export); it is checked for overlap only
RULE_AREA_PREFIX = "rule_area"
def is_rule_area(name: str | None) -> bool:
return bool(name) and name.startswith(RULE_AREA_PREFIX)
def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list:
"""Convex board tiles covering a keepout border shape.
Polygons are split into convex pieces rather than wrapped in one convex
tile: KiCad exports a rule area with a hole (a no-track ring along the
board edge, say) as a single keyhole polygon, and its convex hull is the
whole board.
"""
if isinstance(shape, (Polygon, Path)):
coords = shape.coords
pts = [
IntPoint(
round(transform.dsn_to_board(coords[i])),
round(transform.dsn_to_board(coords[i + 1])),
)
for i in range(0, len(coords) - 1, 2)
]
if len(pts) >= 3:
pieces = [t for t in PolygonShape(pts).split_to_convex() if not t.is_empty()]
if pieces:
return pieces
tile = _area_border_tile(shape, transform)
return [tile] if tile is not None else []
def _area_border_tile(shape: Shape, transform: CoordinateTransform): def _area_border_tile(shape: Shape, transform: CoordinateTransform):
"""Convert an absolute-coordinate keepout border shape to one board tile.""" """Convert an absolute-coordinate keepout border shape to one board tile."""
if isinstance(shape, Rectangle): if isinstance(shape, Rectangle):
@@ -269,3 +399,99 @@ def _area_border_tile(shape: Shape, transform: CoordinateTransform):
ys = [p.y for p in pts] or [0] ys = [p.y for p in pts] or [0]
return IntBox(min(xs), min(ys), max(xs), max(ys)) return IntBox(min(xs), min(ys), max(xs), max(ys))
return None return None
def _insert_image_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform) -> None:
"""Keepouts declared inside a footprint, placed with each instance.
KiCad exports copper that has no pin number this way, for instance the
plated ring of a mounting hole, so skipping them leaves copper the router
cannot see.
"""
images = {img.name: img for img in dsn.images}
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
for placement in dsn.placements:
image = images.get(placement.lib_name)
if image is None:
continue
areas = [
*((a, True, True) for a in image.keepouts),
*((a, False, True) for a in image.via_keepouts),
*((a, True, False) for a in image.wire_keepouts),
]
if not areas:
continue
for place in placement.places:
if place.x is None or place.y is None:
continue
for area, blocks_wires, blocks_vias in areas:
border = area.border
if border is None:
continue
tiles = _placed_area_tiles(border, place, transform)
if not tiles:
continue
if border.layer in ("signal", "pcb") or border.layer not in layer_no:
layers = list(signal_layers)
else:
layers = [layer_no[border.layer]]
if not place.is_front and signal_layers:
top, bottom = min(signal_layers), max(signal_layers)
layers = [top + bottom - layer for layer in layers]
for layer_index in layers:
board.insert(
ObstacleArea(
name=area.name, layer=layer_index, tiles=tiles,
blocks_wires=blocks_wires, blocks_vias=blocks_vias,
)
)
def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> list:
"""An image-relative area border placed with its part, as convex board tiles."""
def to_board(x: float, y: float) -> IntPoint:
p = _to_board_offset(place, x, y)
return transform.dsn_to_board_point(place.x + p.x, place.y + p.y)
if isinstance(shape, Circle):
c = to_board(shape.center_x, shape.center_y)
r = round(transform.dsn_to_board(shape.diameter / 2))
return [IntBox(c.x - r, c.y - r, c.x + r, c.y + r)]
if isinstance(shape, Rectangle):
x0, y0, x1, y1 = shape.coords[:4]
pts = [to_board(x, y) for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))]
elif isinstance(shape, (Polygon, Path)):
c = shape.coords
pts = [to_board(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
else:
return []
if len(pts) < 3:
return []
return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()]
def _insert_zones(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None:
"""Each DSN plane as a ConductionArea of its net on its layer."""
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
numbers = {net.name: net.net_number for net in nets}
for plane in dsn.planes:
shape = plane.shape
if plane.net not in numbers or shape.layer not in layer_no:
continue
if not isinstance(shape, (Polygon, Path)):
continue
c = shape.coords
pts = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
if len(pts) < 3:
continue
polygon = PolygonShape(pts)
tiles = [t for t in polygon.split_to_convex() if not t.is_empty()]
if not tiles:
continue
board.insert(
ConductionArea(
net_nos=[numbers[plane.net]], name=plane.net, layer=layer_no[shape.layer],
tiles=tiles, polygon=polygon, blocks_wires=False, blocks_vias=False,
)
)
+21 -1
View File
@@ -48,6 +48,8 @@ class Pin(Item):
"""A component pin: a pad shape on one or more layers at a fixed location.""" """A component pin: a pad shape on one or more layers at a fixed location."""
name: str = "" name: str = ""
#: reference designator of the owning component, for diagnostics
component_ref: str = ""
padstack_name: str = "" padstack_name: str = ""
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0)) location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
#: layer indices the pad occupies (all signal layers when spanning) #: layer indices the pad occupies (all signal layers when spanning)
@@ -70,6 +72,15 @@ class ObstacleArea(Item):
name: str | None = None name: str | None = None
layer: int = 0 layer: int = 0
tiles: list = field(default_factory=list) tiles: list = field(default_factory=list)
#: a DSN keepout blocks both; wire_keepout only traces (KiCad's "no
#: tracks" rule area, where vias are allowed), via_keepout only vias
blocks_wires: bool = True
blocks_vias: bool = True
#: False for a KiCad rule area: copper may not overlap it but may touch
#: it, since KiCad's DRC applies no clearance to a rule area. Every other
#: keepout may stand for copper (a net tie, a track kept as it is), so it
#: keeps the clearance unless named as a rule area.
clearance: bool = True
def is_obstacle(self) -> bool: def is_obstacle(self) -> bool:
return True return True
@@ -85,7 +96,16 @@ class ObstacleArea(Item):
@dataclass @dataclass
class ConductionArea(ObstacleArea): class ConductionArea(ObstacleArea):
"""A power-plane conduction area (an obstacle carrying a net).""" """A copper zone of one net (a DSN plane): not an obstacle to routing,
since KiCad refills it around other nets' copper, but copper of its net:
pads it touches are connected through it, and a route of its net ends on
it by placing a via inside it."""
#: the zone outline, for point containment
polygon: object = None
def is_obstacle(self) -> bool:
return False
@dataclass @dataclass
+122
View File
@@ -0,0 +1,122 @@
"""The board outline as a polygon, and clearance tests against its edge.
The DSN boundary is the real Edge.Cuts outline, rounded corners and all
(arcs come as short segments). Routing inside its bounding box instead lets
copper cut across a rounded corner closer to the edge than the board allows.
"""
from __future__ import annotations
import math
from freeroute.geometry import IntBox, IntPoint
class BoardOutline:
"""A simple polygon in board units, with point and box clearance tests."""
def __init__(self, points: list[IntPoint]) -> None:
pts = list(points)
if len(pts) > 1 and pts[0] == pts[-1]:
pts.pop()
self.points = pts
self._edges = [(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))]
def contains(self, p: IntPoint) -> bool:
"""Even-odd point in polygon; points on the edge count as outside."""
inside = False
x, y = p.x, p.y
for a, b in self._edges:
if (a.y > y) != (b.y > y):
t = (y - a.y) / (b.y - a.y)
if x < a.x + t * (b.x - a.x):
inside = not inside
return inside
def row_inside_spans(self, y: float) -> list[tuple[float, float]]:
"""The x intervals inside the polygon along the horizontal line at ``y``."""
xs = sorted(
a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)
for a, b in self._edges
if (a.y > y) != (b.y > y)
)
# an odd count (a line grazing a vertex) drops the unpaired crossing
return list(zip(xs[0::2], xs[1::2], strict=False))
def edges(self):
return self._edges
def edge_distance(self, p: IntPoint) -> float:
return min(_point_segment(p, a, b) for a, b in self._edges)
def point_clear(self, p: IntPoint, reach: float) -> bool:
"""Copper reaching ``reach`` around ``p`` stays inside, clear of the edge."""
return self.contains(p) and self.edge_distance(p) >= reach
def box_clear(self, box: IntBox, clearance: float) -> bool:
"""``box`` lies inside the outline at least ``clearance`` from its edge."""
corners = [
IntPoint(box.ll.x, box.ll.y),
IntPoint(box.ur.x, box.ll.y),
IntPoint(box.ur.x, box.ur.y),
IntPoint(box.ll.x, box.ur.y),
]
if not all(self.contains(c) for c in corners):
return False
return all(_segment_box(a, b, box) >= clearance for a, b in self._edges)
def _point_segment(p: IntPoint, a: IntPoint, b: IntPoint) -> float:
dx, dy = b.x - a.x, b.y - a.y
length2 = dx * dx + dy * dy
if length2 == 0:
return math.hypot(p.x - a.x, p.y - a.y)
t = max(0.0, min(1.0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2))
return math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
def _point_box(p: IntPoint, box: IntBox) -> float:
dx = max(box.ll.x - p.x, 0, p.x - box.ur.x)
dy = max(box.ll.y - p.y, 0, p.y - box.ur.y)
return math.hypot(dx, dy)
def _segment_box(a: IntPoint, b: IntPoint, box: IntBox) -> float:
"""Distance between segment ``ab`` and an axis-aligned box (0 if they touch)."""
if _segments_cross_box(a, b, box):
return 0.0
corners = [
IntPoint(box.ll.x, box.ll.y),
IntPoint(box.ur.x, box.ll.y),
IntPoint(box.ur.x, box.ur.y),
IntPoint(box.ll.x, box.ur.y),
]
return min(
_point_box(a, box),
_point_box(b, box),
*(_point_segment(c, a, b) for c in corners),
)
def _segments_cross_box(a: IntPoint, b: IntPoint, box: IntBox) -> bool:
"""Liang-Barsky clip: does segment ``ab`` enter the box?"""
t0, t1 = 0.0, 1.0
dx, dy = b.x - a.x, b.y - a.y
for p, q in (
(-dx, a.x - box.ll.x),
(dx, box.ur.x - a.x),
(-dy, a.y - box.ll.y),
(dy, box.ur.y - a.y),
):
if p == 0:
if q < 0:
return False
else:
r = q / p
if p < 0:
t0 = max(t0, r)
else:
t1 = min(t1, r)
if t0 > t1:
return False
return True
+34 -1
View File
@@ -47,6 +47,11 @@ class TreeShape:
routed: bool = False routed: bool = False
exact: Simplex | None = None exact: Simplex | None = None
seg: tuple | None = None # (a: IntPoint, b: IntPoint, half_width: int) seg: tuple | None = None # (a: IntPoint, b: IntPoint, half_width: int)
#: which copper a keepout tile stops: "all", "wires" (a wire_keepout,
#: vias allowed) or "vias" (a via_keepout)
blocks: str = "all"
#: False for a rule area: copper may touch it, only overlap counts
clearance: bool = True
def on_layer(self, layer: int) -> bool: def on_layer(self, layer: int) -> bool:
return layer in self.layers return layer in self.layers
@@ -118,17 +123,27 @@ class ShapeSearchTree:
result.append(shape) result.append(shape)
return result return result
def clearance_conflict(self, box: IntBox, net_no: int, layer: int, clearance: int) -> bool: def clearance_conflict(
self, box: IntBox, net_no: int, layer: int, clearance: int, kind: str = "wire"
) -> bool:
"""True if placing ``box`` (of ``net_no`` on ``layer``) would come *closer """True if placing ``box`` (of ``net_no`` on ``layer``) would come *closer
than* ``clearance`` to a different net's tile on that layer (exact). than* ``clearance`` to a different net's tile on that layer (exact).
Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles
exactly ``clearance`` apart (touching after expansion) are allowed. exactly ``clearance`` apart (touching after expansion) are allowed.
A rule area (``clearance=False``) only has to be clear of the copper
itself.
""" """
expanded = box.offset(clearance) expanded = box.offset(clearance)
for shape in self.overlapping(expanded): for shape in self.overlapping(expanded):
if shape.net_no == net_no or not shape.on_layer(layer): if shape.net_no == net_no or not shape.on_layer(layer):
continue continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue # a wire-only keepout does not stop a via, and so on
if not shape.clearance:
if shape.tile.overlaps(box):
return True
continue
if shape.exact is not None: if shape.exact is not None:
if overlaps_2d(shape.exact, expanded): if overlaps_2d(shape.exact, expanded):
return True return True
@@ -136,6 +151,17 @@ class ShapeSearchTree:
return True return True
return False return False
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
for shape in self.overlapping(box):
if shape.clearance or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue
if shape.tile.overlaps(box):
return True
return False
def clearance_conflict_shape( def clearance_conflict_shape(
self, region, net_no: int, layer: int self, region, net_no: int, layer: int
) -> bool: ) -> bool:
@@ -171,6 +197,13 @@ class ShapeSearchTree:
continue # static input pair (e.g. pad vs pad) — not a routing DRC continue # static input pair (e.g. pad vs pad) — not a routing DRC
if not (shape.layers & other.layers): if not (shape.layers & other.layers):
continue continue
if not (shape.clearance and other.clearance):
# a rule area: only an overlap is a violation
mine = shape.exact if shape.exact is not None else shape.tile
theirs = other.exact if other.exact is not None else other.tile
if overlaps_2d(mine, theirs):
return shape
continue
if shape.exact is None and other.exact is None: if shape.exact is None and other.exact is None:
if other.tile.overlaps(expanded): # byte-identical box path if other.tile.overlaps(expanded): # byte-identical box path
return shape return shape
+139 -12
View File
@@ -19,6 +19,7 @@ import argparse
import sys import sys
from freeroute.dsn.reader import parse_dsn from freeroute.dsn.reader import parse_dsn
from freeroute.route.orders import best_of_orders, promote_failures
from freeroute.route.pipeline import ( from freeroute.route.pipeline import (
build_exact_routing_result, build_exact_routing_result,
build_rooms_routing_result, build_rooms_routing_result,
@@ -29,8 +30,8 @@ from freeroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for #: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored. #: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = { ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes"}), "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "escapes"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}), "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "escapes"}),
"room": frozenset({"shove", "pack"}), "room": frozenset({"shove", "pack"}),
} }
@@ -41,6 +42,11 @@ _FLAG_NAMES = {
"diagonal": "--diagonal", "diagonal": "--diagonal",
"rip_up": "--no-rip-up", "rip_up": "--no-rip-up",
"max_passes": "-mp/--max-passes", "max_passes": "-mp/--max-passes",
"seed": "--seed",
"orders": "--orders",
"promote": "--promote-failures",
"access": "--access-cost",
"escapes": "--reserve-escapes",
} }
@@ -110,6 +116,69 @@ def build_parser() -> argparse.ArgumentParser:
help="disable rip-up-and-retry (grid and exact engines)", help="disable rip-up-and-retry (grid and exact engines)",
) )
parser.add_argument("--layers", help="comma-separated signal layer indices to route on") parser.add_argument("--layers", help="comma-separated signal layer indices to route on")
parser.add_argument(
"--grid-resolution",
type=int,
default=None,
metavar="K",
help="grid and exact engines: cells K times finer than trace + clearance; "
"default 2 for exact (reaches 0.5 mm pitch pins), 1 for grid",
)
parser.add_argument(
"--heuristic-weight",
type=float,
default=1.0,
metavar="W",
help="exact engine: weighted A* (W >= 1); larger is faster, routes at most "
"W times the shortest length (default 1)",
)
parser.add_argument(
"--edge-clearance",
type=float,
metavar="MM",
help="copper to board-edge clearance in mm, e.g. KiCad's min_copper_edge_clearance "
"(the DSN does not carry it); default 0: copper only has to stay inside the outline",
)
parser.add_argument(
"--seed",
type=int,
metavar="N",
help="grid and exact engines: shuffle the order connections are routed in; "
"the same seed gives the same result (default: fewer-pin nets first)",
)
parser.add_argument(
"--orders",
type=int,
metavar="N",
help="grid and exact engines: route N connection orders in parallel and keep the "
"one leaving the fewest connections unrouted (the default order is one of them)",
)
parser.add_argument(
"--promote-failures",
type=int,
metavar="R",
help="grid and exact engines: up to R rounds, each routing the connections that "
"failed so far first; keeps the best round",
)
parser.add_argument(
"--access-cost",
type=float,
metavar="C",
help="grid and exact engines: extra cost for a route to pass where it would close "
"another pin's way out while that pin still has connections to route (default 0: off)",
)
parser.add_argument(
"--reserve-escapes",
action="store_true",
help="grid and exact engines: before routing, reserve a way out for every fine-pitch "
"pin that no other net may use until that pin's connections are routed",
)
parser.add_argument(
"--jobs",
type=int,
metavar="J",
help="processes for --orders (default: one per order, up to the CPU count)",
)
return parser return parser
@@ -129,6 +198,11 @@ def main(argv: list[str] | None = None) -> int:
("diagonal", args.diagonal), ("diagonal", args.diagonal),
("rip_up", args.no_rip_up), ("rip_up", args.no_rip_up),
("max_passes", args.max_passes is not None), ("max_passes", args.max_passes is not None),
("seed", args.seed is not None),
("orders", args.orders is not None),
("promote", args.promote_failures is not None),
("access", args.access_cost is not None),
("escapes", args.reserve_escapes),
) )
if asked if asked
] ]
@@ -150,21 +224,74 @@ def main(argv: list[str] | None = None) -> int:
rip_up = not args.no_rip_up rip_up = not args.no_rip_up
max_passes = args.max_passes if args.max_passes is not None else 10 max_passes = args.max_passes if args.max_passes is not None else 10
if args.orders is not None and args.orders < 1:
parser.error("--orders must be at least 1")
if args.engine == "exact": if args.engine == "exact":
result = build_exact_routing_result( options = {
dsn, "layers": layers,
layers=layers, "shove": args.shove,
shove=args.shove, "diagonal": args.diagonal,
diagonal=args.diagonal, "rip_up": rip_up,
rip_up=rip_up, "max_passes": max_passes,
max_passes=max_passes, "edge_clearance_mm": args.edge_clearance,
"resolution": 2 if args.grid_resolution is None else args.grid_resolution,
"heuristic_weight": args.heuristic_weight,
"access_cost": args.access_cost or 0.0,
"reserve_escapes": args.reserve_escapes,
}
elif args.engine == "grid":
options = {
"layers": layers,
"rip_up": rip_up,
"max_passes": max_passes,
"edge_clearance_mm": args.edge_clearance,
"resolution": 1 if args.grid_resolution is None else args.grid_resolution,
"access_cost": args.access_cost or 0.0,
"reserve_escapes": args.reserve_escapes,
}
if args.engine == "room":
result = build_rooms_routing_result(
dsn, layers=layers, shove=args.shove, pack=args.pack,
edge_clearance_mm=args.edge_clearance,
) )
elif args.engine == "room": elif args.promote_failures and args.promote_failures > 1:
result = build_rooms_routing_result(dsn, layers=layers, shove=args.shove, pack=args.pack) if args.orders:
parser.error("--orders and --promote-failures cannot be combined")
result, rounds = promote_failures(
dsn_text, args.engine, options, rounds=args.promote_failures, seed=args.seed
)
counts = ", ".join(str(m) for m, _z in rounds)
print(f"freeroute: {len(rounds)} rounds, unrouted {counts}; kept the best", file=sys.stderr)
elif args.orders and args.orders > 1:
result, kept, tried = best_of_orders(
dsn_text, args.engine, options, orders=args.orders, seed=args.seed, jobs=args.jobs
)
counts = ", ".join(f"{'default' if s is None else s}: {m}" for s, (m, _z) in tried)
print(
f"freeroute: tried {len(tried)} orders, kept "
f"{'the default order' if kept is None else f'--seed {kept}'} (unrouted {counts})",
file=sys.stderr,
)
elif args.engine == "exact":
result = build_exact_routing_result(dsn, seed=args.seed, **options)
else: else:
result = build_routing_result(dsn, layers=layers, rip_up=rip_up, max_passes=max_passes) result = build_routing_result(dsn, seed=args.seed, **options)
ses_text = write_ses(dsn, result) ses_text = write_ses(dsn, result)
missing = [u for u in result.unrouted if not u[3]]
in_zone = [u for u in result.unrouted if u[3]]
if missing:
# the SES is still written; say plainly what it is missing
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
for net, a, b, _zone in missing:
print(f" {net}: {a} to {b}", file=sys.stderr)
if in_zone:
print(
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
file=sys.stderr,
)
for net, a, b, zone in in_zone:
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
if args.output: if args.output:
with open(args.output, "w", encoding="utf-8") as f: with open(args.output, "w", encoding="utf-8") as f:
+12 -1
View File
@@ -112,6 +112,7 @@ class Image:
outlines: list[Shape] = field(default_factory=list) outlines: list[Shape] = field(default_factory=list)
keepouts: list[Area] = field(default_factory=list) keepouts: list[Area] = field(default_factory=list)
via_keepouts: list[Area] = field(default_factory=list) via_keepouts: list[Area] = field(default_factory=list)
wire_keepouts: list[Area] = field(default_factory=list)
place_keepouts: list[Area] = field(default_factory=list) place_keepouts: list[Area] = field(default_factory=list)
@@ -169,12 +170,20 @@ class NetClass:
via_rule: str | None = None via_rule: str | None = None
@dataclass
class Plane:
"""A ``(plane <net> <shape>)``: a copper zone of that net (KiCad's zones)."""
net: str
shape: Shape
@dataclass @dataclass
class Keepout: class Keepout:
"""A structure-level keepout area with its kind.""" """A structure-level keepout area with its kind."""
area: Area area: Area
kind: str = "keepout" # keepout | via_keepout | place_keepout kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout
@dataclass @dataclass
@@ -186,6 +195,8 @@ class DsnBoard:
resolution: Resolution = field(default_factory=Resolution) resolution: Resolution = field(default_factory=Resolution)
unit: str = "mil" unit: str = "mil"
layers: list[Layer] = field(default_factory=list) layers: list[Layer] = field(default_factory=list)
#: copper zones, as planes of a net
planes: list[Plane] = field(default_factory=list)
#: bounding-box boundary shape (layer ``pcb``), if present #: bounding-box boundary shape (layer ``pcb``), if present
boundary: Shape | None = None boundary: Shape | None = None
#: outline shapes (layer ``signal``) #: outline shapes (layer ``signal``)
+47 -1
View File
@@ -9,6 +9,8 @@ tolerant ``skip_scope`` behaviour.
from __future__ import annotations from __future__ import annotations
import re
from .model import ( from .model import (
ClearanceRule, ClearanceRule,
ComponentPlace, ComponentPlace,
@@ -178,6 +180,16 @@ def _read_structure(node: SExp, board: DsnBoard) -> None:
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
board.keepouts.append(Keepout(area=area, kind="via_keepout")) board.keepouts.append(Keepout(area=area, kind="via_keepout"))
elif head == "wire_keepout":
# KiCad writes a "no tracks" rule area this way (vias allowed).
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="wire_keepout"))
elif head == "plane":
# (plane <net> <shape>): KiCad exports each zone this way
plane = _read_plane(it)
if plane is not None:
board.planes.append(plane)
elif head == "place_keepout": elif head == "place_keepout":
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
@@ -285,6 +297,10 @@ def _read_image(node: SExp) -> Image | None:
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
image.via_keepouts.append(area) image.via_keepouts.append(area)
elif head == "wire_keepout":
area = read_area(it)
if area is not None:
image.wire_keepouts.append(area)
elif head == "place_keepout": elif head == "place_keepout":
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
@@ -467,12 +483,26 @@ def _read_network(node: SExp, board: DsnBoard) -> None:
# --- top level --------------------------------------------------------------- # --- top level ---------------------------------------------------------------
_STRING_QUOTE = re.compile(r"\(\s*string_quote\s+(\S)\s*\)")
def _declared_quote(text: str) -> str:
"""The quote character the file declares in ``(string_quote X)``.
Only that character opens a string. Without a declaration both kinds are
accepted, as before: KiCad declares ``"`` and then writes a bare ``'``
where a value is a single quote mark, e.g. a keycap part number.
"""
found = _STRING_QUOTE.search(text[:4096])
return found.group(1) if found else "\"'"
def parse_dsn(text: str) -> DsnBoard: def parse_dsn(text: str) -> DsnBoard:
"""Parse DSN ``text`` into a :class:`DsnBoard`. """Parse DSN ``text`` into a :class:`DsnBoard`.
Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``. Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``.
""" """
top = parse(text) top = parse(text, _declared_quote(text))
if top.head != "pcb": if top.head != "pcb":
raise DsnParseError( raise DsnParseError(
f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'" f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'"
@@ -504,3 +534,19 @@ def parse_dsn(text: str) -> DsnBoard:
# `wiring` and unknown scopes are intentionally ignored for now. # `wiring` and unknown scopes are intentionally ignored for now.
return board return board
def _read_plane(node: SExp):
"""``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`."""
from .model import Plane
items = node.items
if not items or not isinstance(items[0], Token):
return None
net = items[0].text
for sub in items[1:]:
if isinstance(sub, SExp):
shape = read_shape(sub)
if shape is not None:
return Plane(net=net, shape=shape)
return None
+80 -13
View File
@@ -42,6 +42,7 @@ from freeroute.geometry import (
segment_octagon, segment_octagon,
shove_segment, shove_segment,
) )
from freeroute.geometry.polyline import corner_box
from .grid_router import RouteResult, route_board from .grid_router import RouteResult, route_board
@@ -90,6 +91,14 @@ def route_board_exact(
diagonal: bool = False, diagonal: bool = False,
max_shove_depth: int = 5, max_shove_depth: int = 5,
shove_cap: int = 3, shove_cap: int = 3,
via_half: int | None = None,
edge_clearance: int | None = None,
resolution: int = 1,
heuristic_weight: float = 1.0,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
reserve_escapes: bool = False,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -102,6 +111,7 @@ def route_board_exact(
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
] ]
half_width = max(trace_width // 2, 1) half_width = max(trace_width // 2, 1)
via_half = via_half or half_width
step = trace_width + clearance step = trace_width + clearance
result = route_board( result = route_board(
@@ -113,7 +123,17 @@ def route_board_exact(
max_passes=max_passes, max_passes=max_passes,
rip_up=rip_up, rip_up=rip_up,
orthogonal=True, orthogonal=True,
via_half=via_half,
edge_clearance=edge_clearance,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
reserve_escapes=reserve_escapes,
) )
edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline
tree = ShapeSearchTree() tree = ShapeSearchTree()
owner = _counter() owner = _counter()
@@ -132,29 +152,54 @@ def route_board_exact(
for obstacle in board.get_obstacle_areas(): for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers: if obstacle.layer not in signal_layers:
continue continue
blocks = (
"all" if obstacle.blocks_wires and obstacle.blocks_vias
else "wires" if obstacle.blocks_wires else "vias"
)
for tile in obstacle.tiles: for tile in obstacle.tiles:
box = tile.bounding_box() box = tile.bounding_box()
if not box.is_empty(): if not box.is_empty():
tree.insert(TreeShape(next(owner), -1, frozenset({obstacle.layer}), box)) tree.insert(
TreeShape(
next(owner), -1, frozenset({obstacle.layer}), box,
blocks=blocks, clearance=obstacle.clearance,
)
)
via_layers = frozenset(signal_layers) via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {} accepted: dict[int, _Accepted] = {}
for net_no in sorted(set(result.wires) | set(result.vias)): # Verify connection by connection, so one conflicting segment of a
boxes = _net_boxes(result, net_no, half_width, via_layers) # 25-pin supply net costs that one connection, not the whole net.
rejected: list[tuple[int, str, str]] = []
ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections)
for (net_no, wires, vias), ends in sorted(
zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0]
):
boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half))
if any( if any(
tree.clearance_conflict(box, net_no, layer, clearance) tree.clearance_conflict(
for box, box_layers in boxes box, net_no, layer, clearance, "wire" if i < wire_count else "via"
)
for i, (box, box_layers) in enumerate(boxes)
for layer in box_layers for layer in box_layers
) or _end_in_rule_area(tree, wires, half_width) or (
outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)
): ):
rejected.append((net_no, *ends))
continue # exact-clearance conflict -> drop (may be recovered by shove) continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = _Accepted(wires=result.wires.get(net_no, []), vias=result.vias.get(net_no, [])) acc = accepted.get(net_no)
if acc is None:
acc = accepted[net_no] = _Accepted(wires=[], vias=[])
acc.wires.extend(wires)
acc.vias.extend(vias)
for box, box_layers in boxes: for box, box_layers in boxes:
oid = next(owner) oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True)) tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
acc.owners.append(oid) acc.owners.append(oid)
accepted[net_no] = acc
accepted_before_recovery = set(accepted)
# a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer) # a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer)
# but was not accepted — whether the grid failed to route it or its exact # but was not accepted — whether the grid failed to route it or its exact
# copper conflicted. # copper conflicted.
@@ -189,6 +234,12 @@ def route_board_exact(
) )
clean = RouteResult(half_width) clean = RouteResult(half_width)
# what is still missing: what the grid could not route, plus what the
# exact check rejected, less any net shove or the diagonal pass recovered
clean.failed = [
*result.failed,
*(r for r in rejected if not (r[0] in accepted and r[0] not in accepted_before_recovery)),
]
for net_no, acc in accepted.items(): for net_no, acc in accepted.items():
clean.wires[net_no] = acc.wires clean.wires[net_no] = acc.wires
clean.vias[net_no] = acc.vias clean.vias[net_no] = acc.vias
@@ -543,15 +594,31 @@ def _rollback(shoved, accepted, tree, owner) -> int: # noqa: ARG001
# --- helpers ----------------------------------------------------------------- # --- helpers -----------------------------------------------------------------
def _net_boxes(result, net_no, half_width, via_layers): def _net_boxes(result, net_no, half_width, via_layers, via_half=None):
return _copper_boxes(
result.wires.get(net_no, []), result.vias.get(net_no, []), half_width, via_layers, via_half
)
def _end_in_rule_area(tree, wires, half_width) -> bool:
"""A wire's tiles stop at its end points, but a track ends in a round cap
half its width beyond them. Against other copper the pad or via there
covers it; a rule area has no clearance to absorb it, so check it."""
for layer, points in wires:
for end in (points[0], points[-1]):
if tree.rule_area_overlap(corner_box(end, half_width), layer):
return True
return False
def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = [] boxes: list[tuple[IntBox, frozenset[int]]] = []
for layer, points in result.wires.get(net_no, []): for layer, points in wires:
for box in PolylineShape(Polyline(points), half_width).tiles(): for box in PolylineShape(Polyline(points), half_width).tiles():
boxes.append((box, frozenset({layer}))) boxes.append((box, frozenset({layer})))
for loc in result.vias.get(net_no, []): for loc in vias:
vbox = IntBox( vbox = IntBox(loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half)
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
)
boxes.append((vbox, via_layers)) boxes.append((vbox, via_layers))
return boxes return boxes
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
"""Route one board in several connection orders at once and keep the best.
Which connection routes first moves the result on a crowded board by several
connections either way, and no single order wins everywhere. Routing a few
orders in parallel processes and keeping the one that leaves the fewest
connections unrouted costs cores, not wall time.
Without a seed the candidates are the default order and seeds 0 to N-2, so the
result is never worse than a plain run. With seed S they are seeds S to S+N-1.
Ties go to the earlier candidate, so the choice is deterministic.
"""
from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor
import os
from freeroute.dsn import parse_dsn
from freeroute.ses import RoutingResult
from .pipeline import build_exact_routing_result, build_routing_result
def candidate_seeds(orders: int, seed: int | None = None) -> list[int | None]:
orders = max(int(orders), 1)
if seed is None:
return [None, *range(orders - 1)]
return list(range(seed, seed + orders))
def score(result: RoutingResult) -> tuple[int, int]:
"""Connections left open, then those left to a zone fill: lower is better."""
missing = sum(1 for u in result.unrouted if not u[3])
return missing, len(result.unrouted) - missing
def route_once(dsn_text: str, engine: str, options: dict, seed: int | None) -> RoutingResult:
dsn = parse_dsn(dsn_text)
if engine == "exact":
return build_exact_routing_result(dsn, seed=seed, **options)
if engine == "grid":
return build_routing_result(dsn, seed=seed, **options)
raise ValueError(f"routing orders are not supported by the {engine} engine")
def best_of_orders(
dsn_text: str,
engine: str,
options: dict,
*,
orders: int,
seed: int | None = None,
jobs: int | None = None,
) -> tuple[RoutingResult, int | None, list[tuple[int | None, tuple[int, int]]]]:
"""Route ``orders`` orders and return the best result, the seed that made
it (None for the default order), and every candidate's seed and score."""
seeds = candidate_seeds(orders, seed)
if len(seeds) == 1:
result = route_once(dsn_text, engine, options, seeds[0])
return result, seeds[0], [(seeds[0], score(result))]
jobs = max(1, min(jobs or os.cpu_count() or 1, len(seeds)))
with ProcessPoolExecutor(jobs) as pool:
futures = [pool.submit(route_once, dsn_text, engine, options, s) for s in seeds]
results = [f.result() for f in futures]
scores = [score(r) for r in results]
best = min(range(len(seeds)), key=lambda i: (scores[i], i))
return results[best], seeds[best], list(zip(seeds, scores, strict=True))
def promote_failures(
dsn_text: str,
engine: str,
options: dict,
*,
rounds: int,
seed: int | None = None,
) -> tuple[RoutingResult, list[tuple[int, int]]]:
"""Route, then route again with every connection that has failed so far
moved to the front, keeping the rest in their order. Returns the best
round's result and every round's score.
The promoted set only grows, so a connection that failed once keeps its
early place and two groups cannot take turns failing. Stops early when a
round routes everything or fails nothing new."""
numbers = {n.name: i + 1 for i, n in enumerate(parse_dsn(dsn_text).nets)}
first: frozenset = frozenset()
best: RoutingResult | None = None
scores: list[tuple[int, int]] = []
for _ in range(max(int(rounds), 1)):
result = route_once(dsn_text, engine, {**options, "route_first": first}, seed)
scores.append(score(result))
if best is None or scores[-1] < score(best):
best = result
failed = {(numbers.get(name), a, b) for name, a, b, zone in result.unrouted if not zone}
if not failed or failed <= first:
break
first = first | failed
return best, scores
+115 -7
View File
@@ -45,12 +45,45 @@ def _via_padstack(dsn: DsnBoard) -> str:
return dsn.via_padstack_names[0] if dsn.via_padstack_names else _DEFAULT_VIA return dsn.via_padstack_names[0] if dsn.via_padstack_names else _DEFAULT_VIA
def _mm_to_board(dsn: DsnBoard, scale: int, value_mm: float | None) -> int | None:
"""A length in millimetres as board units, or None."""
if value_mm is None:
return None
unit_mm = {"inch": 25.4, "mil": 0.0254, "mm": 1.0, "cm": 10.0, "um": 0.001}[dsn.unit]
return round(value_mm / unit_mm * scale)
def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
"""Half the via pad's size in board units, from the DSN's via padstack.
None when the DSN names no via padstack with a shape, so the engines fall
back to their old estimate.
"""
padstack = dsn.padstack(_via_padstack(dsn))
if padstack is None or not padstack.shapes:
return None
half = 0.0
for shape in padstack.shapes:
if hasattr(shape, "diameter"):
half = max(half, shape.diameter / 2)
elif len(getattr(shape, "coords", [])) >= 4:
xs, ys = shape.coords[0::2], shape.coords[1::2]
half = max(half, (max(xs) - min(xs)) / 2, (max(ys) - min(ys)) / 2)
return round(half * scale) or None
def route_dsn_board( def route_dsn_board(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
edge_clearance_mm: float | None = None,
layers: list[int] | None = None, layers: list[int] | None = None,
rip_up: bool = True, rip_up: bool = True,
max_passes: int = 10, max_passes: int = 10,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
reserve_escapes: bool = False,
) -> tuple[RouteResult, int, list[str]]: ) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer and the layer names. ``layers`` restricts routing to those signal-layer
@@ -58,15 +91,23 @@ def route_dsn_board(
toggles rip-up-and-retry (``False`` = greedy single pass).""" toggles rip-up-and-retry (``False`` = greedy single pass)."""
board = build_board(dsn) board = build_board(dsn)
scale = max(dsn.resolution.value, 1) scale = max(dsn.resolution.value, 1)
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale) width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale)
result = route_board( result = route_board(
board, board,
resolution=resolution,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board, trace_width=width_board,
clearance=clearance_board, clearance=clearance_board,
layers=layers, layers=layers,
rip_up=rip_up, rip_up=rip_up,
max_passes=max_passes, max_passes=max_passes,
seed=seed,
route_first=route_first,
access_cost=access_cost,
reserve_escapes=reserve_escapes,
) )
return result, scale, [layer.name for layer in dsn.layers] return result, scale, [layer.name for layer in dsn.layers]
@@ -93,19 +134,32 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
name = numbers_to_names.get(net_no, str(net_no)) name = numbers_to_names.get(net_no, str(net_no))
for loc in locations: for loc in locations:
result.add_via(name, RoutedVia(padstack=via_name, x=loc.x / scale, y=loc.y / scale)) result.add_via(name, RoutedVia(padstack=via_name, x=loc.x / scale, y=loc.y / scale))
result.unrouted = [
(numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", [])
]
return result return result
def build_routing_result( def build_routing_result(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
layers: list[int] | None = None, layers: list[int] | None = None,
rip_up: bool = True, rip_up: bool = True,
max_passes: int = 10, max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
reserve_escapes: bool = False,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board( route, scale, layer_names = route_dsn_board(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first,
access_cost=access_cost,
reserve_escapes=reserve_escapes,
) )
return _to_routing_result(route, dsn, scale, layer_names) return _to_routing_result(route, dsn, scale, layer_names)
@@ -113,11 +167,18 @@ def build_routing_result(
def route_dsn_board_exact( def route_dsn_board_exact(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
heuristic_weight: float = 1.0,
edge_clearance_mm: float | None = None,
layers: list[int] | None = None, layers: list[int] | None = None,
max_passes: int = 10, max_passes: int = 10,
rip_up: bool = True, rip_up: bool = True,
shove: bool = False, shove: bool = False,
diagonal: bool = False, diagonal: bool = False,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
reserve_escapes: bool = False,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names. DRC-verified) track; return the exact result, the scale, and layer names.
@@ -126,10 +187,15 @@ def route_dsn_board_exact(
the 45-degree recovery pass for still-dropped 2-pin nets.""" the 45-degree recovery pass for still-dropped 2-pin nets."""
board = build_board(dsn) board = build_board(dsn)
scale = max(dsn.resolution.value, 1) scale = max(dsn.resolution.value, 1)
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale) width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale)
exact = route_board_exact( exact = route_board_exact(
board, board,
resolution=resolution,
heuristic_weight=heuristic_weight,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board, trace_width=width_board,
clearance=clearance_board, clearance=clearance_board,
layers=layers, layers=layers,
@@ -137,6 +203,10 @@ def route_dsn_board_exact(
rip_up=rip_up, rip_up=rip_up,
shove=shove, shove=shove,
diagonal=diagonal, diagonal=diagonal,
seed=seed,
route_first=route_first,
access_cost=access_cost,
reserve_escapes=reserve_escapes,
) )
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
@@ -144,11 +214,18 @@ def route_dsn_board_exact(
def build_exact_routing_result( def build_exact_routing_result(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
heuristic_weight: float = 1.0,
layers: list[int] | None = None, layers: list[int] | None = None,
shove: bool = False, shove: bool = False,
diagonal: bool = False, diagonal: bool = False,
rip_up: bool = True, rip_up: bool = True,
max_passes: int = 10, max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
reserve_escapes: bool = False,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact( exact, scale, layer_names = route_dsn_board_exact(
@@ -158,12 +235,24 @@ def build_exact_routing_result(
diagonal=diagonal, diagonal=diagonal,
rip_up=rip_up, rip_up=rip_up,
max_passes=max_passes, max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
reserve_escapes=reserve_escapes,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
def route_dsn_board_rooms( def route_dsn_board_rooms(
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False dsn: DsnBoard,
*,
layers: list[int] | None = None,
shove: bool = False,
pack: bool = False,
edge_clearance_mm: float | None = None,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the continuous expansion-room track; """Route a parsed :class:`DsnBoard` with the continuous expansion-room track;
return the result, the scale, and layer names. ``shove`` enables occupancy- return the result, the scale, and layer names. ``shove`` enables occupancy-
@@ -171,10 +260,13 @@ def route_dsn_board_rooms(
``pack`` enables the coordinated multi-trace channel packer.""" ``pack`` enables the coordinated multi-trace channel packer."""
board = build_board(dsn) board = build_board(dsn)
scale = max(dsn.resolution.value, 1) scale = max(dsn.resolution.value, 1)
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale) width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale)
rooms = route_board_rooms( rooms = route_board_rooms(
board, board,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board, trace_width=width_board,
clearance=clearance_board, clearance=clearance_board,
layers=layers, layers=layers,
@@ -185,10 +277,17 @@ def route_dsn_board_rooms(
def build_rooms_routing_result( def build_rooms_routing_result(
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False dsn: DsnBoard,
*,
layers: list[int] | None = None,
shove: bool = False,
pack: bool = False,
edge_clearance_mm: float | None = None,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (room track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (room track) and convert to a DSN-unit RoutingResult."""
rooms, scale, layer_names = route_dsn_board_rooms(dsn, layers=layers, shove=shove, pack=pack) rooms, scale, layer_names = route_dsn_board_rooms(
dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm
)
return _to_routing_result(rooms.result, dsn, scale, layer_names) return _to_routing_result(rooms.result, dsn, scale, layer_names)
@@ -200,6 +299,8 @@ def route(
shove: bool = False, shove: bool = False,
pack: bool = False, pack: bool = False,
diagonal: bool = False, diagonal: bool = False,
edge_clearance_mm: float | None = None,
resolution: int = 1,
) -> str: ) -> str:
"""Route a Specctra DSN string and return the routed SES string. """Route a Specctra DSN string and return the routed SES string.
@@ -213,9 +314,16 @@ def route(
""" """
dsn = parse_dsn(dsn_text) dsn = parse_dsn(dsn_text)
if engine == "exact": if engine == "exact":
result = build_exact_routing_result(dsn, layers=layers, shove=shove, diagonal=diagonal) result = build_exact_routing_result(
dsn, layers=layers, shove=shove, diagonal=diagonal,
edge_clearance_mm=edge_clearance_mm, resolution=resolution,
)
elif engine == "room": elif engine == "room":
result = build_rooms_routing_result(dsn, layers=layers, shove=shove, pack=pack) result = build_rooms_routing_result(
dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm
)
else: else:
result = build_routing_result(dsn, layers=layers) result = build_routing_result(
dsn, layers=layers, edge_clearance_mm=edge_clearance_mm, resolution=resolution
)
return write_ses(dsn, result) return write_ses(dsn, result)
+33 -13
View File
@@ -471,6 +471,8 @@ def route_board_rooms(
pack: bool = False, pack: bool = False,
max_shove_depth: int = 5, max_shove_depth: int = 5,
shove_cap: int = 3, shove_cap: int = 3,
via_half: int | None = None,
edge_clearance: int | None = None,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` with the continuous expansion-room track. """Route ``board`` with the continuous expansion-room track.
@@ -494,6 +496,10 @@ def route_board_rooms(
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
] ]
half_width = max(trace_width // 2, 1) half_width = max(trace_width // 2, 1)
# half the via pad's size: a via is not a trace, and sizing it like one
# let routes pass within a via's real copper
via_half = via_half or half_width
edge = (board.outline, 0 if edge_clearance is None else edge_clearance)
margin = clearance + half_width margin = clearance + half_width
outline = board.bounding_box outline = board.bounding_box
@@ -537,7 +543,7 @@ def route_board_rooms(
via_layers, via_layers,
accepted, accepted,
shove, shove,
) via_half=via_half, edge=edge)
for net_no in sorted(_routable_nets(board, signal_layers)): for net_no in sorted(_routable_nets(board, signal_layers)):
if net_no in packed: if net_no in packed:
continue continue
@@ -562,7 +568,7 @@ def route_board_rooms(
accepted, accepted,
max_shove_depth, max_shove_depth,
shove_cap, shove_cap,
) via_half=via_half, edge=edge)
drc_clean = tree.has_violation(clearance) is None drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean, shoves=shoves) return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean, shoves=shoves)
@@ -587,6 +593,8 @@ def _route_connection(
accepted=None, accepted=None,
max_shove_depth=5, max_shove_depth=5,
shove_cap=3, shove_cap=3,
via_half=None,
edge=None,
): ):
rooms_by_layer = { rooms_by_layer = {
layer: _free_rooms(tree, outline, layer, net_no, margin, half_width) layer: _free_rooms(tree, outline, layer, net_no, margin, half_width)
@@ -607,7 +615,7 @@ def _route_connection(
if realized is not None: if realized is not None:
wires, vias = realized wires, vias = realized
boxes = _collect_boxes(wires, vias, half_width, via_layers, outline) boxes = _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=via_half, edge=edge)
if boxes is not None and not any( if boxes is not None and not any(
tree.clearance_conflict(box, net_no, layer, clearance) tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes for box, box_layers in boxes
@@ -615,7 +623,7 @@ def _route_connection(
): ):
_commit( _commit(
net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted
) , via_half=via_half)
return 0 return 0
# the net would be dropped: try a transactional shove-in-rooms recovery # the net would be dropped: try a transactional shove-in-rooms recovery
@@ -634,11 +642,12 @@ def _route_connection(
clearance, clearance,
max_shove_depth, max_shove_depth,
shove_cap, shove_cap,
) via_half=via_half, edge=edge)
return 0 return 0
def _collect_boxes(wires, vias, half_width, via_layers, outline): def _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=None, edge=None):
via_half = via_half or half_width
"""The ``(box, layers)`` copper tiles of a realized route, or ``None`` if any """The ``(box, layers)`` copper tiles of a realized route, or ``None`` if any
tile leaves the board outline (the same early-out as the old inline check).""" tile leaves the board outline (the same early-out as the old inline check)."""
boxes: list[tuple[IntBox, frozenset[int]]] = [] boxes: list[tuple[IntBox, frozenset[int]]] = []
@@ -646,16 +655,21 @@ def _collect_boxes(wires, vias, half_width, via_layers, outline):
for box in PolylineShape(Polyline(corners), half_width).tiles(): for box in PolylineShape(Polyline(corners), half_width).tiles():
if not outline.contains_box(box): if not outline.contains_box(box):
return None return None
if edge is not None and edge[0] is not None and not edge[0].box_clear(box, edge[1]):
return None
boxes.append((box, frozenset({layer}))) boxes.append((box, frozenset({layer})))
for loc in vias: for loc in vias:
vbox = IntBox( vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
) )
boxes.append((vbox, via_layers)) boxes.append((vbox, via_layers))
return boxes return boxes
def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted): def _commit(
net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted, via_half=None
):
via_half = via_half or half_width
"""Insert a verified route's copper and record it in the routed output. """Insert a verified route's copper and record it in the routed output.
With ``shove=False`` this is the foundation router's exact commit sequence. With ``shove=False`` this is the foundation router's exact commit sequence.
@@ -668,7 +682,7 @@ def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, sh
tree.insert(TreeShape(next(owner), net_no, frozenset({layer}), box, routed=True)) tree.insert(TreeShape(next(owner), net_no, frozenset({layer}), box, routed=True))
for loc in vias: for loc in vias:
vbox = IntBox( vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
) )
tree.insert(TreeShape(next(owner), net_no, via_layers, vbox, routed=True)) tree.insert(TreeShape(next(owner), net_no, via_layers, vbox, routed=True))
for layer, corners in wires: for layer, corners in wires:
@@ -693,7 +707,7 @@ def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, sh
acc.wire_owners.append(w_owners) acc.wire_owners.append(w_owners)
for loc in vias: for loc in vias:
vbox = IntBox( vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
) )
oid = next(owner) oid = next(owner)
tree.insert(TreeShape(oid, net_no, via_layers, vbox, routed=True)) tree.insert(TreeShape(oid, net_no, via_layers, vbox, routed=True))
@@ -854,6 +868,8 @@ def _pack_channels(
via_layers, via_layers,
accepted, accepted,
shove, shove,
via_half=None,
edge=None,
) -> set[int]: ) -> set[int]:
"""Coordinated multi-trace channel packing (opt-in pre-pass). """Coordinated multi-trace channel packing (opt-in pre-pass).
@@ -922,7 +938,7 @@ def _pack_channels(
spacing_min, spacing_min,
via_layers, via_layers,
outline, outline,
) via_half=via_half, edge=edge)
if placed is None: if placed is None:
continue # could not pack every member cleanly -> leave to greedy continue # could not pack every member cleanly -> leave to greedy
for net_no, wires in placed: for net_no, wires in placed:
@@ -937,7 +953,7 @@ def _pack_channels(
via_layers, via_layers,
shove, shove,
accepted, accepted,
) via_half=via_half)
routed.add(net_no) routed.add(net_no)
return routed return routed
@@ -958,6 +974,8 @@ def _pack_group(
spacing_min, spacing_min,
via_layers, via_layers,
outline, outline,
via_half=None,
edge=None,
): ):
"""Assign parallel lanes + staggered fan-in trunks to every net crossing one """Assign parallel lanes + staggered fan-in trunks to every net crossing one
gap, and return ``[(net_no, wires), ...]`` if ALL of them realize exactly gap, and return ``[(net_no, wires), ...]`` if ALL of them realize exactly
@@ -1014,7 +1032,7 @@ def _pack_group(
if len(corners) < 2: if len(corners) < 2:
return None return None
wires = [(layer, corners)] wires = [(layer, corners)]
boxes = _collect_boxes(wires, [], half_width, via_layers, outline) boxes = _collect_boxes(wires, [], half_width, via_layers, outline, via_half=via_half, edge=edge)
if boxes is None: if boxes is None:
return None return None
if any( if any(
@@ -1049,6 +1067,8 @@ def _shove_recover_room(
clearance, clearance,
max_shove_depth, max_shove_depth,
shove_cap, shove_cap,
via_half=None,
edge=None,
) -> int: ) -> int:
"""Recover a dropped net by nudging the committed trace(s) that block it. """Recover a dropped net by nudging the committed trace(s) that block it.
+3
View File
@@ -57,6 +57,9 @@ class RoutingResult:
"""The complete routing output for a board, grouped by net.""" """The complete routing output for a board, grouped by net."""
nets: list[RoutedNet] = field(default_factory=list) nets: list[RoutedNet] = field(default_factory=list)
#: connections the router could not make: (net name, one end, other end,
#: the zone both ends sit in or "") -- a zone's fill may join the latter
unrouted: list[tuple[str, str, str, str]] = field(default_factory=list)
def net(self, name: str, *, create: bool = False) -> RoutedNet | None: def net(self, name: str, *, create: bool = False) -> RoutedNet | None:
"""Return the :class:`RoutedNet` with ``name`` (creating it if asked).""" """Return the :class:`RoutedNet` with ``name`` (creating it if asked)."""
+163 -4
View File
@@ -17,6 +17,7 @@ import pytest
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board
from freeroute.dsn import parse_dsn from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -62,11 +63,20 @@ def test_pin_count_matches_placement(dsn, board):
def test_obstacle_count_matches_keepouts(dsn, board): def test_obstacle_count_matches_keepouts(dsn, board):
# each keepout expands to one obstacle per applicable layer; with no keepouts # Board-level keepouts plus every placed instance of a footprint-level one.
# in this fixture the count is zero # This fixture has none of the first and six plated mounting holes, whose
assert len(board.get_obstacle_areas()) >= len(dsn.keepouts) # copper KiCad exports as a keepout on each layer.
images = {img.name: img for img in dsn.images}
placed = 0
for placement in dsn.placements:
image = images.get(placement.lib_name)
if image is None:
continue
per_part = len(image.keepouts) + len(image.via_keepouts) + len(image.wire_keepouts)
placed += per_part * sum(p.x is not None for p in placement.places)
assert len(dsn.keepouts) == 0 assert len(dsn.keepouts) == 0
assert board.get_obstacle_areas() == [] assert placed == 12
assert len(board.get_obstacle_areas()) == placed
# --- per-item invariants ---------------------------------------------------- # --- per-item invariants ----------------------------------------------------
@@ -129,3 +139,152 @@ def test_board_net_set_covers_oracle_routed_nets(board):
assert routed, "oracle produced no routed nets" assert routed, "oracle produced no routed nets"
# every net the reference router routed exists on our constructed board # every net the reference router routed exists on our constructed board
assert routed <= board.nets.names() assert routed <= board.nets.names()
# --- keepouts as KiCad exports them ------------------------------------------
# Found on the TerraHawk charge-node boards (2026-09-27): a no-track ring along
# the board edge arrives as one keyhole polygon, "keep router off" rule areas
# arrive as wire_keepout, and neither may block the board's interior.
_RING_DSN = """(pcb ring (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "" (polygon F.Cu 0 0 0 40000 0 40000 -30000 0 -30000 0 0
500 -500 500 -29500 39500 -29500 39500 -500 500 -500 0 0))
(wire_keepout "pour" (polygon F.Cu 0 10000 -10000 20000 -10000 20000 -20000 10000 -20000))
(place_keepout "courtyard" (polygon F.Cu 0 30000 -10000 35000 -10000 35000 -20000 30000 -20000))
(rule (width 250) (clearance 150)))
(placement) (library) (network))"""
def _areas(dsn_text):
return build_board(parse_dsn(dsn_text)).get_obstacle_areas()
def _blocked(areas, x_um, y_um):
p = IntPoint(x_um * 10, y_um * 10)
return any(t.contains(p) for a in areas for t in a.tiles)
def test_keyhole_ring_keepout_blocks_only_the_border():
areas = _areas(_RING_DSN)
assert _blocked(areas, 200, -15000) # inside the 0.5 mm edge strip
assert not _blocked(areas, 5000, -5000) # open board interior
def test_wire_keepout_is_read_and_blocks_its_area():
dsn = parse_dsn(_RING_DSN)
assert [k.kind for k in dsn.keepouts].count("wire_keepout") == 1
assert _blocked(_areas(_RING_DSN), 15000, -15000)
def test_place_keepout_does_not_block_routing():
assert not _blocked(_areas(_RING_DSN), 32500, -15000)
def test_grid_router_leaves_the_interior_of_a_ring_keepout_open():
from freeroute.route.pipeline import route_dsn_board
ring_only = "\n".join(
line for line in _RING_DSN.splitlines() if "wire_keepout" not in line
).replace(
"(placement) (library) (network)",
"(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))\n"
" (library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))\n"
" (network (net N (pins P1-1 P2-1)))",
)
result, _, _ = route_dsn_board(parse_dsn(ring_only), max_passes=2)
assert result.routed_net_numbers
# --- pads placed as KiCad places them (2026-09-27, TerraHawk rc4) ------------
_PAD_DSN = """(pcb pads (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(via "Via[0-1]_600:300_um")
(rule (width 250) (clearance 150)))
(placement
(component R (place TOP 10000 -10000 front 0) (place BOT 20000 -10000 back 0)
(place ROT 30000 -10000 front 90)))
(library
(image R (pin rect 1 -1000 0))
(padstack rect (shape (rect F.Cu -400 -250 400 250)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network))"""
def _pins_by_ref(text):
board = build_board(parse_dsn(text))
return {p.component_ref: p for p in board.get_pins()}, board
def test_back_side_pad_moves_to_the_bottom_layer():
pins, board = _pins_by_ref(_PAD_DSN)
names = [layer.name for layer in board.layer_structure.arr]
assert [names[i] for i in pins["TOP"].layers] == ["F.Cu"]
assert [names[i] for i in pins["BOT"].layers] == ["B.Cu"]
def test_back_side_pin_offset_is_mirrored():
pins, _ = _pins_by_ref(_PAD_DSN)
assert pins["TOP"].location.x == (10000 - 1000) * 10
assert pins["BOT"].location.x == (20000 + 1000) * 10
def test_rotated_part_rotates_its_pad_outline():
pins, _ = _pins_by_ref(_PAD_DSN)
flat = pins["TOP"].shape.bounding_box()
turned = pins["ROT"].shape.bounding_box()
assert (flat.ur.x - flat.ll.x, flat.ur.y - flat.ll.y) == (8000, 5000)
assert (turned.ur.x - turned.ll.x, turned.ur.y - turned.ll.y) == (5000, 8000)
def test_oval_pad_keeps_its_width():
oval = _PAD_DSN.replace("(shape (rect F.Cu -400 -250 400 250))", "(shape (path F.Cu 500 -300 0 300 0))")
pins, _ = _pins_by_ref(oval)
box = pins["TOP"].shape.bounding_box()
assert box.ur.y - box.ll.y == 5000 # the aperture, not a zero-height line
assert box.ur.x - box.ll.x == 11000 # centreline 600 um plus the aperture
def test_via_size_comes_from_the_via_padstack():
from freeroute.route.pipeline import _via_half_board
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
# --- rule areas: overlap only ------------------------------------------------
_CHANNEL_DSN = """(pcb channel.dsn
(parser (string_quote ")) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "{name}top" (polygon signal 0 18000 0 22000 0 22000 -14750 18000 -14750))
(keepout "{name}bottom" (polygon signal 0 18000 -15250 22000 -15250 22000 -30000 18000 -30000))
(rule (width 250) (clearance 150)))
(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))
(library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))
(network (net N (pins P1-1 P2-1))))"""
def test_a_keepout_named_rule_area_takes_no_clearance():
named = _areas(_CHANNEL_DSN.format(name="rule_area_"))
plain = _areas(_CHANNEL_DSN.format(name=""))
assert named and not any(a.clearance for a in named)
assert plain and all(a.clearance for a in plain)
def _route_channel(name):
from freeroute.route.pipeline import build_exact_routing_result
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
def test_a_trace_may_run_beside_a_rule_area_closer_than_clearance():
# the 0.5 mm channel fits a 0.25 mm trace, but not with 0.15 mm each side
assert _route_channel("rule_area_").unrouted == []
assert _route_channel("").unrouted != []
+34
View File
@@ -62,3 +62,37 @@ def test_has_violation_ignores_static_pad_pairs():
# a routed trace of net 3 within clearance of pad net 1 -> violation # a routed trace of net 3 within clearance of pad net 1 -> violation
tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True)) tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True))
assert tree.has_violation(clearance=20) is not None assert tree.has_violation(clearance=20) is not None
def test_a_rule_area_conflicts_only_on_overlap():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
near = IntBox(0, 0, 95, 100) # 5 short of the area, well inside clearance 50
over = IntBox(0, 0, 105, 100)
assert not tree.clearance_conflict(near, 7, 0, 50)
assert tree.clearance_conflict(over, 7, 0, 50)
def test_an_ordinary_keepout_still_needs_clearance():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100)))
assert tree.clearance_conflict(IntBox(0, 0, 95, 100), 7, 0, 50)
def test_the_routed_board_check_treats_a_rule_area_the_same_way():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, 7, frozenset({0}), IntBox(0, 0, 95, 100), routed=True))
assert tree.has_violation(50) is None
tree.insert(TreeShape(3, 7, frozenset({0}), IntBox(150, 0, 160, 100), routed=True))
assert tree.has_violation(50) is not None
def test_rule_area_overlap_sees_only_rule_areas():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, -1, frozenset({0}), IntBox(300, 0, 400, 100)))
assert tree.rule_area_overlap(IntBox(90, 40, 110, 60), 0)
assert not tree.rule_area_overlap(IntBox(290, 40, 310, 60), 0)
assert not tree.rule_area_overlap(IntBox(90, 40, 110, 60), 1)
+23
View File
@@ -156,3 +156,26 @@ def test_unplaced_component_has_no_coords():
place = board.placements[0].places[0] place = board.placements[0].places[0]
assert place.name == "U9" assert place.name == "U9"
assert place.x is None and place.y is None assert place.x is None and place.y is None
# --- string_quote ----------------------------------------------------------
_QUOTED = """(pcb q.dsn
(parser (string_quote {q}) (space_in_quoted_tokens on))
(resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (boundary (path pcb 0 0 0 1000 0 1000 1000 0 1000 0 0)))
(placement (component "SW1" (place SW1 100 100 front 0 (PN {pn}))))
(library)
(network)
)"""
def test_a_bare_apostrophe_is_an_atom_when_the_file_quotes_with_double_quotes():
# KiCad declares " and writes a keycap's part number as a bare '
board = parse_dsn(_QUOTED.format(q='"', pn="'"))
assert board.parser.string_quote == '"'
def test_the_declared_quote_character_is_the_one_that_opens_strings():
board = parse_dsn(_QUOTED.format(q="'", pn="'10k 1%'").replace('"SW1"', "'SW1'"))
assert board.parser.string_quote == "'"
+6 -1
View File
@@ -111,12 +111,17 @@ def route_dsn(dsn_path: str | Path, *, max_passes: int = 6, timeout: int = 180)
with tempfile.NamedTemporaryFile(suffix=".ses", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".ses", delete=False) as tmp:
out_path = tmp.name out_path = tmp.name
try: try:
# Headless: without these the JAR opens its GUI on the developer's
# desktop for every oracle run.
env = {k: v for k, v in os.environ.items() if k not in ("DISPLAY", "WAYLAND_DISPLAY")}
subprocess.run( subprocess.run(
[JAVA, "-jar", JAR, "-de", str(dsn_path), "-do", out_path, "-mp", str(max_passes)], [JAVA, "-Djava.awt.headless=true", "-jar", JAR, "-de", str(dsn_path),
"-do", out_path, "-mp", str(max_passes), "--gui.enabled=false"],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
check=False, check=False,
env=env,
) )
text = Path(out_path).read_text() text = Path(out_path).read_text()
finally: finally:
+65
View File
@@ -0,0 +1,65 @@
"""--reserve-escapes: a crowded pin keeps a way out until it is routed."""
from __future__ import annotations
from pathlib import Path
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import (
_rule_clearance_dsn,
_rule_width_dsn,
build_exact_routing_result,
)
SMD = (Path(__file__).resolve().parent.parent / "dsn" / "fixtures" / "smd_demo.dsn").read_text()
def _router():
dsn = parse_dsn(SMD)
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
orthogonal=True,
resolution=2,
reserve_escapes=True,
)
conns = router._build_connections()
router._conns = conns
router._count_pending(conns)
router._reserve_escapes(conns)
return router, conns
def test_a_reserved_escape_is_closed_to_other_nets_and_open_to_its_own():
router, conns = _router()
assert router._reserved
node, claims = next(iter(router._reserved.items()))
pin_id, owner = claims[0]
other = next(c.net_no for c in conns if c.net_no != owner)
cell, layer = node[:2], node[2]
assert router._enter_cost(cell, layer, other, True, conns) is None # even for rip-up
assert router._enter_cost(cell, layer, owner, False, conns) is not None or node in router._keepout
def test_the_escape_is_released_once_its_pin_is_routed():
router, conns = _router()
node, claims = next(iter(router._reserved.items()))
other = next(c.net_no for c in conns if c.net_no != claims[0][1])
cell, layer = node[:2], node[2]
assert router._enter_cost(cell, layer, other, False, conns) is None
for c in conns: # every connection routed: no pin is pending any more
router._settle(c, -1)
released = router._enter_cost(cell, layer, other, False, conns)
reserved, router._reserved = router._reserved, {}
assert released == router._enter_cost(cell, layer, other, False, conns)
router._reserved = reserved
def test_reserving_escapes_loses_nothing_on_the_demo_board():
plain = build_exact_routing_result(parse_dsn(SMD), resolution=2)
reserved = build_exact_routing_result(parse_dsn(SMD), resolution=2, reserve_escapes=True)
assert len(reserved.unrouted) <= len(plain.unrouted)
+78
View File
@@ -0,0 +1,78 @@
"""--orders: route several connection orders and keep the best."""
from __future__ import annotations
from pathlib import Path
from freeroute.cli import main
from freeroute.route.orders import (
best_of_orders,
candidate_seeds,
promote_failures,
route_once,
score,
)
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
# Greedy (no rip-up), the default order strands a net here and seeds 1 to 3 do not.
RIPUP = (FIXTURES / "ripup_needed.dsn").read_text()
GREEDY = {"rip_up": False}
def test_the_default_order_is_always_a_candidate_without_a_seed():
assert candidate_seeds(1) == [None]
assert candidate_seeds(4) == [None, 0, 1, 2]
assert candidate_seeds(3, seed=7) == [7, 8, 9]
def test_keeps_the_order_that_leaves_the_fewest_unrouted():
assert score(route_once(RIPUP, "grid", GREEDY, None)) == (1, 0)
result, kept, tried = best_of_orders(RIPUP, "grid", GREEDY, orders=3, jobs=2)
assert kept == 1
assert score(result) == (0, 0)
assert tried == [(None, (1, 0)), (0, (1, 0)), (1, (0, 0))]
def test_ties_go_to_the_earlier_candidate():
# every order routes this board fully, so the default order is kept
_, kept, tried = best_of_orders(RIPUP, "grid", {}, orders=3, jobs=3)
assert kept is None
assert all(s == (0, 0) for _, s in tried)
def test_cli_orders_writes_the_best_session(tmp_path, capsys):
out = tmp_path / "best.ses"
rc = main([str(FIXTURES / "ripup_needed.dsn"), "--no-rip-up", "--orders", "3", "-do", str(out)])
assert rc == 0
err = capsys.readouterr().err
assert "tried 3 orders, kept --seed 1" in err
assert "not routed" not in err
assert out.read_text().startswith("(session")
def test_promoting_the_failed_connection_routes_it():
best, rounds = promote_failures(RIPUP, "grid", GREEDY, rounds=3)
assert rounds == [(1, 0), (0, 0)] # stops once nothing fails
assert score(best) == (0, 0)
def test_nothing_promoted_is_the_plain_order():
plain = route_once(RIPUP, "grid", {}, None)
empty = route_once(RIPUP, "grid", {"route_first": frozenset()}, None)
assert plain == empty
def test_cli_promote_failures(tmp_path, capsys):
out = tmp_path / "p.ses"
rc = main(
[
str(FIXTURES / "ripup_needed.dsn"),
"--no-rip-up",
"--promote-failures",
"3",
"-do",
str(out),
]
)
assert rc == 0
assert "2 rounds, unrouted 1, 0" in capsys.readouterr().err
+213
View File
@@ -0,0 +1,213 @@
"""Routing behaviours found on the TerraHawk rc4 boards (2026-09-27)."""
from __future__ import annotations
import math
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
# 20 x 20 mm board whose corners are rounded with a 3 mm radius, as polylines
_R = 3000.0
_arc = [(_R - _R * math.cos(math.radians(a)), _R - _R * math.sin(math.radians(a))) for a in range(0, 91, 15)]
def _outline_coords():
pts = []
for cx, cy, sx, sy in ((0, 0, 1, 1), (20000, 0, -1, 1), (20000, -20000, -1, -1), (0, -20000, 1, -1)):
corner = [(cx + sx * x, cy - sy * y) for x, y in _arc]
pts.extend(corner if sx * sy > 0 else corner[::-1])
return " ".join(f"{x:.1f} {y:.1f}" for x, y in pts)
def _dsn(placement, network, extra=""):
return f"""(pcb t (parser) (resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 {_outline_coords()}))
(via "Via[0-1]_600:300_um") (rule (width 250) (clearance 150)) {extra})
(placement {placement})
(library (image P (pin pad 1 0 0))
(padstack pad (shape (rect F.Cu -150 -500 150 500)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network {network}))"""
def test_routes_keep_edge_clearance_at_a_rounded_corner():
# two pads near the top-left corner: the direct path cuts the corner arc
text = _dsn("(component P (place A 1800 -6000 front 0) (place B 6000 -1800 front 90))",
"(net N (pins A-1 B-1))")
dsn = parse_dsn(text)
result, scale, _ = route_dsn_board_exact(dsn, edge_clearance_mm=0.5)
assert result.routed_net_numbers
outline = build_board(dsn).outline
half = 125 * scale
for segs in result.result.wires.values():
for _layer, pts in segs:
for p in pts:
# every trace point, plus half the trace, keeps 0.5 mm to the edge
assert outline.edge_distance(p) >= half + 500 * scale - 1
@pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)])
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape):
# Five 0.3 mm pads at 0.5 mm pitch, each wired to its own pad 8 mm away.
# Between two neighbours a trace's centre has a legal band only 0.15 mm
# wide. At resolution 1 the 0.4 mm grid misses most bands; at 4 its
# 0.1 mm columns cannot, and every pin gets out, as the JAR manages.
places = " ".join(f"(place R{i} {6000 + 500 * i} -6000 front 0)" for i in range(5))
places += " " + " ".join(f"(place F{i} {6000 + 500 * i} -14000 front 0)" for i in range(5))
nets = " ".join(f"(net N{i} (pins R{i}-1 F{i}-1))" for i in range(5))
result, _, _ = route_dsn_board_exact(
parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution
)
assert len(result.routed_net_numbers) == escape
def test_resolution_one_has_no_halo_so_the_grid_is_unchanged():
from freeroute.route.grid_router import GridRouter
board = build_board(parse_dsn(_dsn("", "")))
assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == []
fine = GridRouter(board, trace_width=2500, clearance=1500, resolution=2)
assert fine.step == 2000 and (1, 0) in fine._halo_offsets and (2, 0) not in fine._halo_offsets
def test_heuristic_weight_bounds_route_length():
# weighted A* may take a longer route, but never more than W times the shortest
places = "(place A 3000 -3000 front 0) (place B 17000 -17000 front 0)"
def length(weight):
result, _, _ = route_dsn_board_exact(
parse_dsn(_dsn(f"(component P {places})", "(net N (pins A-1 B-1))")),
heuristic_weight=weight,
)
return sum(
math.hypot(q.x - p.x, q.y - p.y)
for segs in result.result.wires.values()
for _layer, pts in segs
for p, q in zip(pts, pts[1:], strict=False)
)
shortest = length(1.0)
assert shortest > 0
assert length(2.0) <= 2.0 * shortest
# --- copper zones (DSN planes) -----------------------------------------------
# rc4 power stage: pads inside "keep router off" pours connect through the pour,
# and freeroute, not knowing zones, dropped every such connection.
_ZONE = "(plane N (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000 4000 -4000))"
_NO_TRACKS = "(wire_keepout \"pour\" (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000))"
def _zone_board():
places = "(place P1 5500 -7000 front 0) (place P2 8500 -7000 front 0) (place P3 16000 -16000 front 0)"
return parse_dsn(_dsn(f"(component P {places})", "(net N (pins P1-1 P2-1 P3-1))", _ZONE + " " + _NO_TRACKS))
def test_planes_are_read_and_are_not_obstacles():
dsn = _zone_board()
assert [(p.net, p.shape.layer) for p in dsn.planes] == [("N", "F.Cu")]
board = build_board(dsn)
assert len(board.get_conduction_areas()) == 1
assert all(not a.is_obstacle() or a.name == "pour" for a in board.get_obstacle_areas())
def test_pads_in_one_zone_need_no_route_between_them():
from freeroute.route.grid_router import _connected_groups
board = build_board(_zone_board())
pins = sorted(board.get_pins(), key=lambda p: p.component_ref)
groups = _connected_groups(pins, board.get_conduction_areas())
assert [sorted(p.component_ref for p in g[0]) for g in groups] == [["P1", "P2"], ["P3"]]
def test_a_pad_reaches_a_zone_under_a_no_tracks_area_by_a_via():
dsn = _zone_board()
result, scale, names = route_dsn_board_exact(dsn)
net = next(iter(result.routed_net_numbers))
assert result.result.vias.get(net) # it had to drop into the zone
board = build_board(dsn)
pour = next(a for a in board.get_obstacle_areas() if a.name == "pour")
f_cu = names.index("F.Cu")
for layer, pts in result.result.wires[net]:
if layer == f_cu: # no track runs inside the no-tracks area
assert not any(t.contains(p) and not t.bounding_box().is_empty()
for t in pour.tiles for p in pts[1:-1])
def test_a_pour_other_nets_can_cross_is_not_trusted_to_connect():
# the same zone with no no-tracks area over it: its fill could be split by
# other nets' tracks, so its pads are joined by routing, not assumed
places = "(place P1 5500 -7000 front 0) (place P2 8500 -7000 front 0)"
dsn = parse_dsn(_dsn(f"(component P {places})", "(net N (pins P1-1 P2-1))", _ZONE))
result, _, _ = route_dsn_board_exact(dsn)
net = next(iter(result.routed_net_numbers))
assert result.result.wires.get(net) # P1 and P2 got a track between them
def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys):
# a keepout wall across the board on both layers: A and B cannot be joined
from freeroute.cli import main
wall = " ".join(
f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))"
for layer in ("F.Cu", "B.Cu")
)
dsn = tmp_path / "wall.dsn"
dsn.write_text(_dsn("(component P (place A 4000 -10000 front 0) (place B 16000 -10000 front 0))",
"(net N (pins A-1 B-1))", wall))
assert main([str(dsn), "--engine", "exact", "-do", str(tmp_path / "out.ses")]) == 0
err = capsys.readouterr().err
assert "1 connection(s) not routed" in err and "N: A-1 to B-1" in err
# --- inner plane layers (a 4-layer board with a GND plane on In1.Cu) --------
def _four_layer_dsn():
return """(pcb four (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power))
(layer In2.Cu (type power)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 30000 0 30000 -30000 0 -30000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))
(via "Via[0-3]_600:300_um") (rule (width 250) (clearance 150)))
(placement
(component S (place G1 5000 -5000 front 0) (place G2 25000 -5000 front 0)
(place G3 15000 -25000 front 0) (place A 5000 -15000 front 0) (place B 25000 -15000 front 0))
(component T (place GT 15000 -15000 front 0)))
(library
(image S (pin smd 1 0 0))
(image T (pin tht 1 0 0))
(padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack tht (shape (circle F.Cu 1000)) (shape (circle B.Cu 1000)) (attach off))
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net GND (pins G1-1 G2-1 G3-1 GT-1)) (net SIG (pins A-1 B-1))))"""
def test_smd_pads_reach_an_inner_plane_by_their_own_via():
dsn = parse_dsn(_four_layer_dsn())
result, scale, names = route_dsn_board_exact(dsn)
gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND")
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
assert len(result.result.vias.get(gnd, [])) == 3 # one per SMD pad, none for the THT pad
for _layer, pts in result.result.wires.get(gnd, []): # stubs, not tracks across the board
length = sum(abs(q.x - p.x) + abs(q.y - p.y) for p, q in zip(pts, pts[1:], strict=False))
assert length < 3000 * scale # under 3 mm: a stub to its via
assert sig in result.result.routed_net_numbers # signals still route on the outer layers
assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs)
def test_a_through_hole_pad_inside_a_plane_needs_no_route():
from freeroute.route.grid_router import _connected_groups
board = build_board(parse_dsn(_four_layer_dsn()))
plane = [z for z in board.get_conduction_areas() if z.name == "GND"]
gnd_pins = [p for p in board.get_pins() if p.component_ref in ("G1", "G2", "G3", "GT")]
groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane))
hub = next(g for g in groups if g[1])
assert [p.component_ref for p in hub[0]] == ["GT"]
+13
View File
@@ -264,6 +264,19 @@ def test_larger_board_still_connects_its_multipin_nets():
assert len(result.routed_net_numbers) == multi_pin == 4 assert len(result.routed_net_numbers) == multi_pin == 4
def test_a_seed_is_repeatable_and_does_change_the_order():
# The benchmark samples seeds to measure how much a result owes to the
# routing order. A seed that changed nothing would make every sample the
# same run and report no spread at all.
text = (FIXTURES / "kicad_routable.dsn").read_text()
default = _wire_key(route_dsn_board(parse_dsn(text))[0])
runs = {s: route_dsn_board(parse_dsn(text), seed=s)[0] for s in range(4)}
again = route_dsn_board(parse_dsn(text), seed=1)[0]
assert _wire_key(again) == _wire_key(runs[1])
assert any(_wire_key(r) != default for r in runs.values())
assert all(r.unrouted == 0 for r in runs.values())
# --- oracle parity ---------------------------------------------------------- # --- oracle parity ----------------------------------------------------------
+5
View File
@@ -154,6 +154,11 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"diagonal": ["--diagonal"], "diagonal": ["--diagonal"],
"rip_up": ["--no-rip-up"], "rip_up": ["--no-rip-up"],
"max_passes": ["-mp", "4"], "max_passes": ["-mp", "4"],
"seed": ["--seed", "3"],
"orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"],
"escapes": ["--reserve-escapes"],
} }
for engine, options in ENGINE_OPTIONS.items(): for engine, options in ENGINE_OPTIONS.items():
for option in options: for option in options:
Generated
+1 -1
View File
@@ -128,7 +128,7 @@ wheels = [
[[package]] [[package]]
name = "freeroute" name = "freeroute"
version = "0.1.0" version = "2026.7.14"
source = { editable = "." } source = { editable = "." }
[package.dev-dependencies] [package.dev-dependencies]