Compare commits

..
Author SHA1 Message Date
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
24 changed files with 2219 additions and 181 deletions
+4
View File
@@ -20,3 +20,7 @@ htmlcov/
# Editor / OS
.DS_Store
*.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
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
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
`freerouting.jar`, which drags a JVM into every install and container image.
`freeroute` reimplements the routing engine in pure Python — no dependencies at
all, standard library only — so the JAR step becomes a `pip install`.
`freeroute` reimplements the routing engine in pure Python, with no dependencies
beyond the standard library, so the JAR step becomes a `pip install`.
## The contract
@@ -21,7 +21,7 @@ board.dsn ──▶ freeroute ──▶ board.ses
```
`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.
## Install
@@ -33,27 +33,31 @@ uv tool install freeroute # or: pipx install freeroute
## Usage
```bash
freeroute board.dsn -o board.ses # grid engine, the default
freeroute -de board.dsn -do board.ses # the FreeRouting JAR spelling
# a KiCad board: the exact engine, with the board's copper-to-edge rule
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 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
Three routing tracks, selected with `--engine`:
- **`grid`** (default) — multi-layer maze router over a uniform occupancy grid,
with rip-up-and-retry and via search. Highest raw connectivity of the three;
its geometry is grid-quantised, so it is the least precise about clearance.
- **`exact`** — orthogonal router whose output is verified against exact integer
geometry (tile/octagon clearance, not bounding boxes). Cleaner, DRC-clean
output where it succeeds; it will drop a net rather than emit a violation.
- **`room`** — continuous expansion-room router: an exact decomposition of free
space, so it routes off-grid channels that the grid quantisation cannot see.
- **`exact`** (recommended for real boards): orthogonal grid search whose every
accepted connection is verified against exact integer geometry. It drops a
connection rather than emit a violation. Defaults to `--grid-resolution 2`.
- **`grid`** (the default, for JAR-compatible invocation): the same multi-layer
maze router with rip-up and via search, without the exact check, so its
geometry is only as precise as the grid.
- **`room`**: continuous expansion-room router, an exact decomposition of free
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
@@ -62,6 +66,9 @@ Three routing tracks, selected with `--engine`:
| `--engine {grid,exact,room}` | routing track (default: `grid`) |
| `-de`, `--design` | input `.dsn` (FreeRouting-compatible alias) |
| `-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) |
| `--no-rip-up` | single greedy pass, no rip-up |
| `--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` |
|---|---|---|---|---|---|
| **grid** | — | — | — | yes | yes |
| **exact** | — | yes | yes | yes | yes |
| **room** | yes | yes | — | — | — |
| **grid** | no | no | no | yes | yes |
| **exact** | no | yes | yes | 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:
- **`--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
channel physically wide enough for all of them stops dropping nets to greedy
per-net gate collisions.
- **`--shove`** (exact, room) — a net that would be dropped nudges the blocking
- **`--pack`** (room): when several 2-pin nets have to cross the same obstacle
gap, they are assigned consistent parallel lanes and routed together.
- **`--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
retry fails. On the room engine it also turns on occupancy-aware gate
placement. It recovers *ordering* drops; it cannot manufacture space that is
not there.
- **`--diagonal`** (exact) — a still-dropped 2-pin net is retried as a 45° trace
whose diagonal copper is covered by an exact integer octagon, so it fits
corridors the orthogonal L cannot, at the ideal hypotenuse length.
retry fails. It recovers *ordering* drops; it cannot make space that is not
there.
- **`--diagonal`** (exact): a still-dropped 2-pin net is retried as a 45° trace
covered by an exact integer octagon.
## 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
```python
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
Alpha, version 0.1.0. It parses real `kicad-cli` DSN output, routes, and writes
SES that KiCad imports. What is actually implemented:
Alpha. Measured on two real KiCad 10 boards (the TerraHawk charge node's power
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)
and SES writer.
- Multi-layer routing with via search and rip-up-and-retry.
- Exact integer geometry (tiles, octagons) with a spatial index, so the `exact`
and `room` engines verify their own output is clearance-clean.
- Continuous expansion-room free-space decomposition.
- Channel packing, shove, and 45° routing as described above.
| board | freeroute exact | FreeRouting JAR | DRC errors from freeroute |
|---|---|---|---|
| power stage (40 x 30 mm) | 7 unconnected, ~5 s | 12 | 0 |
| sense board (55 x 25 mm, 0.5 mm pitch MCU) | 41 unconnected, ~2.5 min | 32 | 0 |
In the board owner's production flow (hand-routed power lanes kept, pours added
after routing), freeroute completed the power stage with no unconnected items.
What it is **not**, stated plainly:
- **Not at FreeRouting/JAR density parity on dense commercial boards.** The JAR
remains the better router when the board is hard. `freeroute` is honest about
drops: it leaves a net unrouted rather than emit a DRC violation, so on a
crowded board expect fewer completed nets than the JAR would manage.
- **`--diagonal` is a shortening/recovery pass, not diagonal-native search.**
The maze search is orthogonal; 45° geometry is applied to nets the orthogonal
pass dropped or to shorten a staircase. A true 45°-native expansion-room maze
is not implemented.
- No fanout pass, no post-route optimiser, no interactive/incremental routing,
no net classes with per-class widths beyond the default rules, no plane/pour
handling.
- **Not yet at JAR completion on a dense board.** The legality of what it routes
holds up; how much it routes does not match the JAR yet. The remaining gap is
congestion: routes committed one at a time block later ones. The JAR's
negotiated rip-up loop handles that better. Negotiated congestion routing is
the planned next step.
- **Slower than the JAR** on a dense board (minutes against seconds), being
pure Python.
- **`--diagonal` is a recovery pass, not diagonal-native search.**
- The `room` engine does not use zones, the outline edge rule is only enforced
approximately there, and it is slow on real boards.
- 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
routing without a JVM — CI, containers, an MCP server, a library call — this is
that.
For maximum completion on a dense board today, the JAR is still the stronger
router. For routing without a JVM (CI, containers, an MCP server, a library
call), freeroute routes legally and says what it could not do.
## Development
@@ -144,7 +176,7 @@ uv run ruff check src/ tests/
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
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/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
import json
import math
import os
import re
import shlex
import subprocess
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
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())
+13 -3
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from freeroute.geometry import IntBox
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 .net import Nets
@@ -22,7 +22,7 @@ __all__ = ["BasicBoard"]
class BasicBoard:
"""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__(
self,
@@ -35,6 +35,8 @@ class BasicBoard:
self.nets = nets
self.clearance_matrix = clearance_matrix
self.bounding_box = bounding_box
#: the real board outline, when the DSN has a polygon boundary
self.outline = None
self._items: list[Item] = []
# --- layers -------------------------------------------------------------
@@ -55,7 +57,15 @@ class BasicBoard:
return [it for it in self._items if isinstance(it, Pin)]
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]:
return [it for it in self._items if isinstance(it, Trace)]
+228 -12
View File
@@ -27,10 +27,11 @@ import math
from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex
from freeroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard
from .clearance import ClearanceMatrix
from .items import ObstacleArea, Pin
from .items import ConductionArea, ObstacleArea, Pin
from .layer import Layer, LayerStructure
from .net import Nets
from .transform import CoordinateTransform
@@ -49,9 +50,12 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
bounding_box = _build_bounding_box(dsn, transform)
board = BasicBoard(layer_structure, nets, clearance, bounding_box)
board.outline = _build_outline(dsn, transform)
_insert_pins(dsn, board, transform, terminal_to_net)
_insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets)
return board
@@ -97,6 +101,18 @@ def _build_bounding_box(dsn: DsnBoard, transform: CoordinateTransform) -> IntBox
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]:
if isinstance(shape, Rectangle):
return list(shape.coords)
@@ -132,7 +148,7 @@ def _insert_pins(
location = _pin_location(place, dsn_pin, transform)
padstack = dsn.padstack(dsn_pin.padstack_name)
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))
pin = Pin(
@@ -140,6 +156,7 @@ def _insert_pins(
component_no=id(place) & 0xFFFFFF,
fixed=True,
name=dsn_pin.name,
component_ref=place.name,
padstack_name=dsn_pin.padstack_name,
location=location,
layers=layers,
@@ -148,22 +165,44 @@ def _insert_pins(
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:
"""Absolute board location of a pin: component origin + rotated pin offset."""
rel_x = -dsn_pin.x if not place.is_front else dsn_pin.x # back side mirrors in x
rel = FloatPoint(rel_x, dsn_pin.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)
"""Absolute board location of a pin: component origin + placed pin offset."""
rel = _to_board_offset(place, dsn_pin.x, dsn_pin.y)
return transform.dsn_to_board_point(place.x + rel.x, place.y + rel.y)
def _pad_shape_and_layers(
padstack,
place,
dsn_pin,
location: IntPoint,
transform: CoordinateTransform,
layer_no: dict[str, int],
signal_layers: 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:
# degenerate: a zero-size box at the location (still contains it)
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])
if not 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))
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):
"""A board-relative pad tile from a DSN padstack shape (centred near origin)."""
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]
for keepout in dsn.keepouts:
# A place_keepout only restricts where parts go, not copper.
if keepout.kind == "place_keepout":
continue
area = keepout.area
if area is None or area.border is None:
continue
tile = _area_border_tile(area.border, transform)
if tile is None:
tiles = _area_border_tiles(area.border, transform)
if not tiles:
continue
border_layer = area.border.layer
if border_layer in ("signal", "pcb"):
@@ -237,11 +330,38 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
ObstacleArea(
name=keepout.area.name,
layer=layer_index,
tiles=[tile],
tiles=tiles,
blocks_wires=keepout.kind != "via_keepout",
blocks_vias=keepout.kind != "wire_keepout",
)
)
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):
"""Convert an absolute-coordinate keepout border shape to one board tile."""
if isinstance(shape, Rectangle):
@@ -269,3 +389,99 @@ def _area_border_tile(shape: Shape, transform: CoordinateTransform):
ys = [p.y for p in pts] or [0]
return IntBox(min(xs), min(ys), max(xs), max(ys))
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,
)
)
+16 -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."""
name: str = ""
#: reference designator of the owning component, for diagnostics
component_ref: str = ""
padstack_name: str = ""
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
#: layer indices the pad occupies (all signal layers when spanning)
@@ -70,6 +72,10 @@ class ObstacleArea(Item):
name: str | None = None
layer: int = 0
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
def is_obstacle(self) -> bool:
return True
@@ -85,7 +91,16 @@ class ObstacleArea(Item):
@dataclass
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
+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
+8 -1
View File
@@ -47,6 +47,9 @@ class TreeShape:
routed: bool = False
exact: Simplex | None = None
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"
def on_layer(self, layer: int) -> bool:
return layer in self.layers
@@ -118,7 +121,9 @@ class ShapeSearchTree:
result.append(shape)
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
than* ``clearance`` to a different net's tile on that layer (exact).
@@ -129,6 +134,8 @@ class ShapeSearchTree:
for shape in self.overlapping(expanded):
if shape.net_no == net_no or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue # a wire-only keepout does not stop a via, and so on
if shape.exact is not None:
if overlaps_2d(shape.exact, expanded):
return True
+118 -12
View File
@@ -19,6 +19,7 @@ import argparse
import sys
from freeroute.dsn.reader import parse_dsn
from freeroute.route.orders import best_of_orders, promote_failures
from freeroute.route.pipeline import (
build_exact_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
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}),
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote"}),
"room": frozenset({"shove", "pack"}),
}
@@ -41,6 +42,9 @@ _FLAG_NAMES = {
"diagonal": "--diagonal",
"rip_up": "--no-rip-up",
"max_passes": "-mp/--max-passes",
"seed": "--seed",
"orders": "--orders",
"promote": "--promote-failures",
}
@@ -110,6 +114,56 @@ def build_parser() -> argparse.ArgumentParser:
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(
"--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(
"--jobs",
type=int,
metavar="J",
help="processes for --orders (default: one per order, up to the CPU count)",
)
return parser
@@ -129,6 +183,9 @@ def main(argv: list[str] | None = None) -> int:
("diagonal", args.diagonal),
("rip_up", args.no_rip_up),
("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),
)
if asked
]
@@ -150,21 +207,70 @@ def main(argv: list[str] | None = None) -> int:
rip_up = not args.no_rip_up
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":
result = build_exact_routing_result(
dsn,
layers=layers,
shove=args.shove,
diagonal=args.diagonal,
rip_up=rip_up,
max_passes=max_passes,
options = {
"layers": layers,
"shove": args.shove,
"diagonal": args.diagonal,
"rip_up": rip_up,
"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,
}
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,
}
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":
result = build_rooms_routing_result(dsn, layers=layers, shove=args.shove, pack=args.pack)
elif args.promote_failures and args.promote_failures > 1:
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:
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)
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:
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)
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)
@@ -169,12 +170,20 @@ class NetClass:
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
class Keepout:
"""A structure-level keepout area with its kind."""
area: Area
kind: str = "keepout" # keepout | via_keepout | place_keepout
kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout
@dataclass
@@ -186,6 +195,8 @@ class DsnBoard:
resolution: Resolution = field(default_factory=Resolution)
unit: str = "mil"
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
boundary: Shape | None = None
#: outline shapes (layer ``signal``)
+30
View File
@@ -178,6 +178,16 @@ def _read_structure(node: SExp, board: DsnBoard) -> None:
area = read_area(it)
if area is not None:
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":
area = read_area(it)
if area is not None:
@@ -285,6 +295,10 @@ def _read_image(node: SExp) -> Image | None:
area = read_area(it)
if area is not None:
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":
area = read_area(it)
if area is not None:
@@ -504,3 +518,19 @@ def parse_dsn(text: str) -> DsnBoard:
# `wiring` and unknown scopes are intentionally ignored for now.
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
+60 -14
View File
@@ -90,6 +90,12 @@ def route_board_exact(
diagonal: bool = False,
max_shove_depth: int = 5,
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,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry.
@@ -102,6 +108,7 @@ def route_board_exact(
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
]
half_width = max(trace_width // 2, 1)
via_half = via_half or half_width
step = trace_width + clearance
result = route_board(
@@ -113,7 +120,15 @@ def route_board_exact(
max_passes=max_passes,
rip_up=rip_up,
orthogonal=True,
via_half=via_half,
edge_clearance=edge_clearance,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
)
edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline
tree = ShapeSearchTree()
owner = _counter()
@@ -132,29 +147,49 @@ def route_board_exact(
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
blocks = (
"all" if obstacle.blocks_wires and obstacle.blocks_vias
else "wires" if obstacle.blocks_wires else "vias"
)
for tile in obstacle.tiles:
box = tile.bounding_box()
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)
)
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
for net_no in sorted(set(result.wires) | set(result.vias)):
boxes = _net_boxes(result, net_no, half_width, via_layers)
# Verify connection by connection, so one conflicting segment of a
# 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(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
tree.clearance_conflict(
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
):
) 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)
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:
oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
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)
# but was not accepted — whether the grid failed to route it or its exact
# copper conflicted.
@@ -189,6 +224,12 @@ def route_board_exact(
)
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():
clean.wires[net_no] = acc.wires
clean.vias[net_no] = acc.vias
@@ -543,15 +584,20 @@ def _rollback(shoved, accepted, tree, owner) -> int: # noqa: ARG001
# --- 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 _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width
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():
boxes.append((box, frozenset({layer})))
for loc in result.vias.get(net_no, []):
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
)
for loc in vias:
vbox = IntBox(loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half)
boxes.append((vbox, via_layers))
return boxes
+638 -55
View File
@@ -28,10 +28,11 @@ from __future__ import annotations
import heapq
import math
import random
from freeroute.board.board import BasicBoard
from freeroute.board.items import ObstacleArea, Pin
from freeroute.geometry import IntPoint
from freeroute.board.items import ConductionArea, ObstacleArea, Pin
from freeroute.geometry import IntBox, IntPoint
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -59,7 +60,7 @@ class RouteResult:
``routed_net_numbers`` are the nets that got any wire or via.
"""
__slots__ = ("wires", "vias", "half_width", "unrouted")
__slots__ = ("wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed")
def __init__(self, half_width: int) -> None:
self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {}
@@ -67,6 +68,13 @@ class RouteResult:
self.half_width = half_width
#: number of ratsnest connections left unrouted
self.unrouted = 0
#: the same copper per routed connection: (net_no, wires, vias)
self.connections: list[tuple[int, list[tuple[int, list[IntPoint]]], list[IntPoint]]] = []
#: what each routed connection joins, and the zone both ends sit in
#: (or ""), parallel to ``connections``
self.connection_ends: list[tuple[str, str, str]] = []
#: connections left unrouted: (net_no, one end, the other end)
self.failed: list[tuple[int, str, str]] = []
def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None:
self.wires.setdefault(net_no, []).append((layer, path))
@@ -98,6 +106,12 @@ class _Connection:
"vias",
"trace_cells",
"via_cells",
"via_halo",
"halo_nodes",
"a_group",
"b_group",
"a_end",
"b_end",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -111,6 +125,14 @@ class _Connection:
self.vias: list[IntPoint] = []
self.trace_cells: list[tuple[Cell, int]] = []
self.via_cells: list[Cell] = []
self.via_halo: list[Cell] = []
self.halo_nodes: list[Node] = []
# the pins and zones each end may use, and which pin the route ended
# on (None when it ended on a zone)
self.a_group: tuple[list[Pin], list] = ([a], [])
self.b_group: tuple[list[Pin], list] = ([b], [])
self.a_end: Pin | None = a
self.b_end: Pin | None = b
class GridRouter:
@@ -129,8 +151,24 @@ class GridRouter:
rip_cap: int = 4,
rip_penalty: float = 30.0,
orthogonal: bool = False,
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,
) -> None:
self.board = board
# None keeps the deterministic order; a seed shuffles which connection
# routes first, to measure how much a result owes to the order
self.seed = seed
# connections, as (net_no, one end, other end), to route before the rest
self.route_first = frozenset(route_first or ())
# how far (in cells) an escape stub may run to leave a walled-in pin
self._escape_cells = 12
self._crowded_cache: dict = {}
self._trapped_cache: dict = {}
self.heuristic_weight = max(float(heuristic_weight), 1.0)
self.trace_width = max(trace_width, 1)
self.clearance = max(clearance, 0)
self.half_width = self.trace_width // 2
@@ -142,8 +180,30 @@ class GridRouter:
# orthogonal routing (axis-aligned segments only) yields exact IntBox
# trace copper; 8-connected also produces 45-degree segments.
self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS
# cell size must fit a trace plus its clearance to a neighbouring trace
self.step = max(self.trace_width + self.clearance, 1)
self._orthogonal = orthogonal
# At resolution 1 a cell is exactly one trace plus its clearance, so a
# trace owning its cell is the whole clearance model. At resolution k
# cells are k times finer, fine enough to give every pin of a 0.5 mm
# pitch row its own column, and a trace also claims a halo: the cells
# whose centre is nearer than trace + clearance to one of its own.
self.resolution = max(int(resolution), 1)
self.step = max((self.trace_width + self.clearance) // self.resolution, 1)
need = self.trace_width + self.clearance
reach = math.ceil(need / self.step)
self._halo_offsets = [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step < need
]
# A via's copper is wider than a trace. Another net's trace needs its
# centre via_half + clearance + half_width from the via's centre, so a
# via also claims every cell within via_reach of its own.
self.via_half = via_half or self.half_width
# copper to board edge; KiCad's rule is usually wider than clearance
self.edge_clearance = 0 if edge_clearance is None else edge_clearance
need = self.via_half + self.clearance + self.half_width
self.via_reach = max(0, math.ceil(need / self.step) - 1)
if layers is None:
layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal]
@@ -157,10 +217,19 @@ class GridRouter:
# static obstacles
self._pad_block: dict[Node, set[int]] = {}
self._pad_own: dict[Node, set[int]] = {}
self._keepout: set[Node] = set()
self._via_keepout: set[Node] = set()
# inner power (plane) layers: not routed on, reached by vias
self._power_layers = {
i for i, layer in enumerate(board.layer_structure.arr) if not layer.is_signal
}
self._zone_cells: dict[int, list[Node]] = {}
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
self._via_owner: dict[Cell, int] = {}
# trace halos: node -> indices of the connections claiming it
self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles()
# --- grid helpers -------------------------------------------------------
@@ -176,16 +245,57 @@ class GridRouter:
return IntPoint(self.origin_x + cell[0] * self.step, self.origin_y + cell[1] * self.step)
def _cells_in_box(self, box, margin: int):
lo_x = round((box.ll.x - margin - self.origin_x) / self.step)
hi_x = round((box.ur.x + margin - self.origin_x) / self.step)
lo_y = round((box.ll.y - margin - self.origin_y) / self.step)
hi_y = round((box.ur.y + margin - self.origin_y) / self.step)
"""Cells whose centre lies strictly inside ``box`` grown by ``margin``.
A trace centred there would come closer than ``margin`` to the box.
Rounding the bounds outward instead blocked cells up to half a step
clear of it, which walled in every pin of a 0.5 mm pitch package.
"""
lo_x = math.floor((box.ll.x - margin - self.origin_x) / self.step) + 1
hi_x = math.ceil((box.ur.x + margin - self.origin_x) / self.step) - 1
lo_y = math.floor((box.ll.y - margin - self.origin_y) / self.step) + 1
hi_y = math.ceil((box.ur.y + margin - self.origin_y) / self.step) - 1
for gx in range(max(lo_x, 0), min(hi_x, self.cols - 1) + 1):
for gy in range(max(lo_y, 0), min(hi_y, self.rows - 1) + 1):
yield gx, gy
def _outline_blocked(self, reach: int) -> set[Cell]:
"""Cells whose centre is outside the outline or within ``reach`` of its edge."""
outline = self.board.outline
blocked: set[Cell] = set()
for gy in range(self.rows): # outside: one scanline per row
y = self.origin_y + gy * self.step
spans = outline.row_inside_spans(y)
for gx in range(self.cols):
x = self.origin_x + gx * self.step
if not any(lo < x < hi for lo, hi in spans):
blocked.add((gx, gy))
for a, b in outline.edges(): # too close: only cells near each edge
edge_box = IntBox(min(a.x, b.x), min(a.y, b.y), max(a.x, b.x), max(a.y, b.y))
for cell in self._cells_in_box(edge_box, reach):
if cell not in blocked and outline.edge_distance(self._cell_center(cell)) < reach:
blocked.add(cell)
return blocked
def _inside_own_pad(self, cell: Cell, shape) -> bool:
"""A trace centred on ``cell`` lies entirely on the pad's own copper."""
c = self._cell_center(cell)
h = self.half_width
return all(
shape.contains(IntPoint(c.x + dx, c.y + dy)) for dx in (-h, h) for dy in (-h, h)
)
def _rasterize_obstacles(self) -> None:
margin = self.clearance + self.half_width
outline = self.board.outline
self._via_edge_block: set[Cell] = set()
if outline is not None:
# cells too close to the real outline (rounded corners included):
# for a trace on every layer, and separately for a via's wider copper
for cell in self._outline_blocked(self.half_width + self.edge_clearance):
for layer in self.layers:
self._keepout.add((*cell, layer))
self._via_edge_block = self._outline_blocked(self.via_half + self.edge_clearance)
for item in self.board.get_items():
if isinstance(item, Pin):
box = item.shape.bounding_box() if item.shape is not None else None
@@ -196,19 +306,41 @@ class GridRouter:
for cell in self._cells_in_box(box, margin):
for layer in pin_layers:
self._pad_block.setdefault((*cell, layer), set()).add(net)
# Copper of the pin's own net: a trace inside it adds nothing,
# so it is open to that net however close the neighbours are.
# That is how a route leaves a fine-pitch pin.
for cell in self._cells_in_box(box, 0):
if self._inside_own_pad(cell, item.shape):
for layer in pin_layers:
self._pad_own.setdefault((*cell, layer), set()).add(net)
elif isinstance(item, ConductionArea):
continue # a zone is copper of its net, not an obstacle
elif isinstance(item, ObstacleArea):
if item.layer not in self.layers:
continue
box = item.bounding_box()
if box.is_empty():
continue
for cell in self._cells_in_box(box, self.clearance):
self._keepout.add((*cell, item.layer))
# Tile by tile: the area's own bounding box of a ring (a
# no-track border along the board edge) is the whole board.
for tile in item.tiles:
box = tile.bounding_box()
if box.is_empty():
continue
# the same distance the exact engine verifies against
for cell in self._cells_in_box(box, margin):
if item.blocks_wires:
self._keepout.add((*cell, item.layer))
if item.blocks_vias:
self._via_keepout.add((*cell, item.layer))
# --- occupancy cost -----------------------------------------------------
def _enter_cost(
self, cell: Cell, layer: int, net_no: int, allow_ripup: bool, conns: list[_Connection]
self,
cell: Cell,
layer: int,
net_no: int,
allow_ripup: bool,
conns: list[_Connection],
wire_keepout: bool = True,
) -> float | None:
"""Cost to enter ``(cell, layer)`` for ``net_no``; ``None`` if blocked.
@@ -218,10 +350,10 @@ class GridRouter:
already hit ``rip_cap`` (then it hardens into a wall).
"""
node = (*cell, layer)
if node in self._keepout:
if wire_keepout and node in self._keepout:
return None
pads = self._pad_block.get(node)
if pads and any(n != net_no for n in pads):
if pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()):
return None
blocking = None
owner = self._cell_owner.get(node)
@@ -231,6 +363,11 @@ class GridRouter:
via_owner = self._via_owner.get(cell)
if via_owner is not None and conns[via_owner].net_no != net_no:
blocking = via_owner
if blocking is None:
for i in self._halo.get(node, ()):
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None:
return 0.0
if not allow_ripup:
@@ -240,40 +377,90 @@ class GridRouter:
return None
return self.rip_penalty * (1 + rip_count)
def _via_cells(self, cell: Cell) -> list[Cell]:
"""The cell a via sits in plus the neighbours its copper reaches."""
r = self.via_reach
cx, cy = cell
return [
(x, y)
for x in range(max(cx - r, 0), min(cx + r, self.cols - 1) + 1)
for y in range(max(cy - r, 0), min(cy + r, self.rows - 1) + 1)
]
def _via_placeable(self, cell: Cell, net_no: int, conns: list[_Connection]) -> bool:
"""A through via needs every signal layer strictly free of other nets."""
return all(
self._enter_cost(cell, layer, net_no, False, conns) == 0.0 for layer in self.layers
)
"""A through via needs its whole footprint free of other nets on every layer."""
if cell in self._via_edge_block:
return False
for c in self._via_cells(cell):
for layer in self.layers:
if (*c, layer) in self._via_keepout:
return False
if self._enter_cost(c, layer, net_no, False, conns, wire_keepout=False) != 0.0:
return False
return True
# --- A* search ----------------------------------------------------------
def _search(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection]
) -> list[Node] | None:
start_cell = self._cell_of(conn.a.location)
goal_cell = self._cell_of(conn.b.location)
a_layers = [layer for layer in conn.a.layers if layer in self.layers]
b_layers = [layer for layer in conn.b.layers if layer in self.layers]
if not a_layers or not b_layers:
goal_cell = self._group_target(conn.b_group)
if not conn.b_group[0] and any(z.layer in self._power_layers for z in conn.b_group[1]):
# the plane is everywhere: aim at where the route starts instead
goal_cell = self._group_target(conn.a_group)
starts = self._group_ends(conn.a_group, conn.net_no, conns)
goals = self._group_ends(conn.b_group, conn.net_no, conns)
if not starts or not goals:
return None
starts = {(*start_cell, layer) for layer in a_layers}
goals = {(*goal_cell, layer) for layer in b_layers}
if starts & goals:
return [min(starts & goals)]
common = sorted(set(starts) & set(goals))
if common:
conn.a_end, conn.b_end = starts[common[0]], goals[common[0]]
return [common[0]]
net_no = conn.net_no
open_heap: list[tuple[float, Node]] = []
g_score: dict[Node, float] = {}
came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked
goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if p is not None}
for s in sorted(starts):
g_score[s] = 0.0
heapq.heappush(open_heap, (self._h(s, goal_cell), s))
pin = starts[s]
g_score[s] = self._stub_cost(pin, s) if pin is not None else 0.0
heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s))
# Via footprints are checked lazily: a layer change goes on the heap as
# a candidate and is checked only if it is popped. Most never are (a
# via costs a lot), and the check is the single most expensive thing
# the search does. A check can only rule a via out, never make it
# cheaper, so deferring it cannot change which route is found.
via_ok: dict[Cell, bool] = {}
while open_heap:
_, current = heapq.heappop(open_heap)
entry = heapq.heappop(open_heap)
if len(entry) == 4: # a deferred via: (f, target, g, source)
_, target, g_via, source = entry
if g_via >= g_score.get(target, math.inf):
continue
cell = (target[0], target[1])
ok = via_ok.get(cell)
if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
if ok:
self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap)
continue
current = entry[1]
if current in goals:
return _reconstruct(came_from, current)
end = goals[current]
cell = (current[0], current[1])
if isinstance(end, ConductionArea): # a plane end needs its via to fit
ok = via_ok.get(cell)
if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
else:
ok = True
if ok:
path = _reconstruct(came_from, current)
conn.a_end, conn.b_end = starts.get(path[0]), end
return path
cx, cy, cl = current
base = g_score[current]
for dx, dy in self._neighbours:
@@ -281,8 +468,8 @@ class GridRouter:
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
nxt = (*cell, cl)
if nxt in goals:
enter = 0.0
if nxt in goal_stub: # a pin end; a zone end is entered normally
enter = goal_stub[nxt]
else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
if enter is None:
@@ -291,16 +478,241 @@ class GridRouter:
self._relax(
current, nxt, base + step + enter, goal_cell, g_score, came_from, open_heap
)
if len(self.layers) > 1 and self._via_placeable((cx, cy), net_no, conns):
if len(self.layers) > 1:
g_via = base + self.via_cost
for layer in self.layers:
if layer == cl:
continue
nxt = (cx, cy, layer)
self._relax(
current, nxt, base + self.via_cost, goal_cell, g_score, came_from, open_heap
)
if g_via < g_score.get(nxt, math.inf):
heapq.heappush(
open_heap, (g_via + self._h(nxt, goal_cell), nxt, g_via, current)
)
return None
# --- pin stubs ----------------------------------------------------------
# A pin rarely sits on a cell centre, so every route starts and ends with a
# short stub from the pin to its first cell. The grid has to see that
# copper too, or a stub ends up closer than clearance to a trace in the
# cell beside it.
def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> list[Cell]:
"""Cells where another net's trace would come too close to the stub."""
from freeroute.board.outline import _point_segment
end = self._cell_center(cell)
reach = 2 * self.half_width + self.clearance
box = IntBox(
min(pin_location.x, end.x), min(pin_location.y, end.y),
max(pin_location.x, end.x), max(pin_location.y, end.y),
)
return [
c
for c in self._cells_in_box(box, reach)
if _point_segment(self._cell_center(c), pin_location, end) < reach
]
def _stub_clear(self, pin: Pin, cell: Cell, layer: int, net_no: int, conns) -> bool:
for c in self._stub_cells(pin.location, cell):
owner = self._cell_owner.get((*c, layer))
if owner is not None and conns[owner].net_no != net_no:
return False
via_owner = self._via_owner.get(c)
if via_owner is not None and conns[via_owner].net_no != net_no:
return False
return True
def _zone_nodes(self, zone) -> list[Node]:
"""Cells of a zone where a via of this net fits inside its copper; for a
plane on a power layer, on every routing layer (a via reaches it)."""
key = id(zone)
if key not in self._zone_cells:
h = self.via_half
layers = [zone.layer] if zone.layer in self.layers else list(self.layers)
nodes = []
for tile in zone.tiles:
for cell in self._cells_in_box(tile.bounding_box(), 0):
c = self._cell_center(cell)
if all(
zone.polygon.contains(IntPoint(c.x + dx, c.y + dy))
for dx in (-h, h) for dy in (-h, h)
):
nodes.extend((*cell, layer) for layer in layers)
self._zone_cells[key] = sorted(set(nodes))
return self._zone_cells[key]
def _zone_is_solid(self, zone, coverage: float = 0.95) -> bool:
"""Whether a zone's fill can be trusted to be one piece of copper.
A zone outline is where copper may go, not where it will be: other
nets' tracks through a board-wide pour break its fill into islands,
and a pad inside the outline need not reach the rest of the net. It
is safe to count on only when no track can cross it, which is what a
no-tracks rule area over the zone guarantees (the rc4 power pours).
Otherwise the zone is left out and its pads are routed with tracks.
"""
cells = [cell for tile in zone.tiles for cell in self._cells_in_box(tile.bounding_box(), 0)
if zone.polygon.contains(self._cell_center(cell))]
if not cells:
return False
covered = sum((*cell, zone.layer) in self._keepout for cell in cells)
return covered >= coverage * len(cells)
def _left_to_zone(self, conn) -> str:
"""The zone both ends of an unrouted connection sit in, if any: its
fill may well join them, even when it is not trusted for routing."""
for zone in self._every_zone_by_net.get(conn.net_no, []):
if any(_pin_touches_zone(p, zone) for p in conn.a_group[0]) and any(
_pin_touches_zone(p, zone) for p in conn.b_group[0]
):
return zone.name or "zone"
return ""
def _group_ends(self, group, net_no: int, conns) -> dict[Node, Pin | None]:
"""End nodes of a group, each mapped to its pin (None for a zone cell)."""
ends: dict[Node, Pin | None] = {}
for pin in group[0]:
for node in sorted(self._end_nodes(pin, net_no, conns)):
ends.setdefault(node, pin)
for zone in group[1]:
plane = zone.layer in self._power_layers
for node in self._zone_nodes(zone):
# a plane end is marked with the plane: the route ends in a via
ends.setdefault(node, zone if plane else None)
return ends
def _group_target(self, group) -> Cell:
if group[0]:
return self._cell_of(group[0][0].location)
box = group[1][0].bounding_box()
return self._cell_of(IntPoint((box.ll.x + box.ur.x) // 2, (box.ll.y + box.ur.y) // 2))
def _stub_cost(self, pin: Pin, node: Node) -> float:
c = self._cell_center(node[:2])
dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y)
if self._orthogonal: # measured the way the route itself is allowed to move
return (dx + dy) / self.step
return math.hypot(dx, dy) / self.step
def _end_nodes(self, pin: Pin, net_no: int, conns) -> set[Node]:
"""Nodes a route may start or end on for ``pin``: the nearest cell and
its neighbours, where the cell is enterable and the stub to it is clear.
A crowded fine-pitch pin gets more than one way out."""
layers = [layer for layer in pin.layers if layer in self.layers]
cx, cy = self._cell_of(pin.location)
nodes: set[Node] = set()
for dx, dy in ((0, 0), *self._neighbours):
cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
for layer in layers:
if (dx or dy) and self._enter_cost(cell, layer, net_no, False, conns) is None:
continue
if self._stub_clear(pin, cell, layer, net_no, conns):
nodes.add((*cell, layer))
# Also offer exact escape stubs: a fine-pitch pin's nearby cells can be
# a pocket inside its own pad that leads nowhere (the grid columns miss
# the narrow legal band past the pad tip). The stub's length is its
# cost, so it is only taken where a short step would not do.
if self._orthogonal and self._crowded(pin, net_no) and self._trapped(pin, net_no, nodes):
nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy))
if not nodes: # nothing clear: fall back to the nearest cell, as before
nodes = {(cx, cy, layer) for layer in layers}
return nodes
def _crowded(self, pin: Pin, net_no: int) -> bool:
"""A pin whose neighbour pad of another net is too close for a trace
to pass between them: a fine-pitch pin, the case escapes are for."""
key = (id(pin), net_no)
cached = self._crowded_cache.get(key)
if cached is not None:
return cached
box = pin.shape.bounding_box() if pin.shape is not None else None
gap = self.trace_width + 2 * self.clearance
crowded = False
if box is not None:
for other in self.board.get_pins():
if other is pin or net_no in other.net_nos or other.shape is None:
continue
if not set(other.layers) & set(pin.layers):
continue
ob = other.shape.bounding_box()
dx = max(ob.ll.x - box.ur.x, box.ll.x - ob.ur.x, 0)
dy = max(ob.ll.y - box.ur.y, box.ll.y - ob.ur.y, 0)
if max(dx, dy) < gap and min(dx, dy) == 0:
crowded = True
break
self._crowded_cache[key] = crowded
return crowded
def _static_open(self, node: Node, net_no: int) -> bool:
"""Open to ``net_no`` as far as pads and keepouts go (routes ignored)."""
if node in self._keepout:
return False
pads = self._pad_block.get(node)
return not (pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()))
def _trapped(self, pin: Pin, net_no: int, nodes: set[Node], limit: int = 60) -> bool:
"""Whether a pin's nearby cells are a dead-end pocket, judged against
pads and keepouts only (not other routes), so it is fixed per pin."""
key = (id(pin), net_no)
cached = self._trapped_cache.get(key)
if cached is not None:
return cached
seen = set(nodes)
frontier = list(nodes)
while frontier and len(seen) < limit:
x, y, layer = frontier.pop()
for dx, dy in self._neighbours:
n = (x + dx, y + dy, layer)
if n in seen or not (0 <= n[0] < self.cols and 0 <= n[1] < self.rows):
continue
if self._static_open(n, net_no):
seen.add(n)
frontier.append(n)
trapped = len(seen) < limit
self._trapped_cache[key] = trapped
return trapped
def _escape_nodes(self, pin: Pin, net_no: int, conns, layers, origin: Cell) -> set[Node]:
"""For a pin walled in on the grid (a fine-pitch pin whose legal band
the grid columns miss): look along each axis for the first open cell
whose straight stub from the pin clears every other net's pad by the
full margin, measured exactly rather than on the grid."""
from freeroute.board.outline import _segment_box
margin = self.half_width + self.clearance
reach = self._escape_cells
near = [
(p.shape.bounding_box(), set(p.layers))
for p in self.board.get_pins()
if p.shape is not None
and net_no not in p.net_nos
and abs(p.location.x - pin.location.x) < (reach + 2) * self.step * 2
and abs(p.location.y - pin.location.y) < (reach + 2) * self.step * 2
]
cx, cy = origin
nodes: set[Node] = set()
for dx, dy in _NEIGHBOURS[:4]:
for k in range(2, reach + 1):
cell = (cx + dx * k, cy + dy * k)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
break
end = self._cell_center(cell)
for layer in layers:
if self._enter_cost(cell, layer, net_no, False, conns) is None:
continue
if any(
layer in box_layers and _segment_box(pin.location, end, box) < margin
for box, box_layers in near
):
continue
if self._stub_clear(pin, cell, layer, net_no, conns):
nodes.add((*cell, layer))
if any(n[:2] == cell for n in nodes):
break # the first open cell along this axis is enough
return nodes
def _relax(self, current, nxt, tentative, goal_cell, g_score, came_from, open_heap):
if tentative < g_score.get(nxt, math.inf):
came_from[nxt] = current
@@ -308,25 +720,58 @@ class GridRouter:
heapq.heappush(open_heap, (tentative + self._h(nxt, goal_cell), nxt))
def _h(self, node: Node, goal_cell: Cell) -> float:
return math.hypot(node[0] - goal_cell[0], node[1] - goal_cell[1])
dx, dy = abs(node[0] - goal_cell[0]), abs(node[1] - goal_cell[1])
# the tightest estimate the allowed moves admit (Manhattan when only
# orthogonal steps exist), scaled by heuristic_weight: above 1 the
# search heads for the goal instead of flooding open board, for a
# route at most that factor longer than the shortest
base = dx + dy if self._orthogonal else math.hypot(dx, dy)
return self.heuristic_weight * base
# --- commit / rip -------------------------------------------------------
def _commit(self, conn: _Connection, path: list[Node]) -> None:
wire_segs, via_cells = _split_path(path)
via_cells = list(via_cells)
if isinstance(conn.a_end, ConductionArea):
via_cells.append((path[0][0], path[0][1]))
if isinstance(conn.b_end, ConductionArea):
via_cells.append((path[-1][0], path[-1][1]))
conn.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells]
conn.via_cells = list(via_cells)
for cell, layer in conn.trace_cells:
self._cell_owner[(*cell, layer)] = conn.index
conn.halo_nodes = []
for (cx, cy), layer in conn.trace_cells:
for dx, dy in self._halo_offsets:
x, y = cx + dx, cy + dy
if 0 <= x < self.cols and 0 <= y < self.rows:
node = (x, y, layer)
self._halo.setdefault(node, []).append(conn.index)
conn.halo_nodes.append(node)
conn.via_halo = []
for cell in conn.via_cells:
self._via_owner[cell] = conn.index
for c in self._via_cells(cell):
if c not in self._via_owner:
self._via_owner[c] = conn.index
conn.via_halo.append(c)
ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else ()
for pin, (layer, cells), at in ends:
if not isinstance(pin, Pin):
continue # ended on a zone or a plane: no stub
cell = cells[at]
for c in self._stub_cells(pin.location, cell):
node = (*c, layer)
if node not in self._cell_owner:
self._cell_owner[node] = conn.index
conn.trace_cells.append((c, layer))
conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs):
points = [self._cell_center(c) for c in cells]
if idx == 0:
points.insert(0, conn.a.location)
if idx == len(wire_segs) - 1:
points.append(conn.b.location)
if idx == 0 and isinstance(conn.a_end, Pin):
points.insert(0, conn.a_end.location)
if idx == len(wire_segs) - 1 and isinstance(conn.b_end, Pin):
points.append(conn.b_end.location)
points = _simplify(points)
if len(points) >= 2:
conn.wires.append((layer, points))
@@ -337,9 +782,17 @@ class GridRouter:
for cell, layer in conn.trace_cells:
if self._cell_owner.get((*cell, layer)) == conn.index:
del self._cell_owner[(*cell, layer)]
for cell in conn.via_cells:
for cell in conn.via_halo:
if self._via_owner.get(cell) == conn.index:
del self._via_owner[cell]
conn.via_halo = []
for node in conn.halo_nodes:
claims = self._halo.get(node)
if claims is not None:
claims.remove(conn.index)
if not claims:
del self._halo[node]
conn.halo_nodes = []
conn.trace_cells = []
conn.via_cells = []
conn.wires = []
@@ -356,6 +809,9 @@ class GridRouter:
via_owner = self._via_owner.get((cx, cy))
if via_owner is not None and conns[via_owner].net_no != net_no:
owners.add(via_owner)
for i in self._halo.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
return owners
# --- passes -------------------------------------------------------------
@@ -363,6 +819,7 @@ class GridRouter:
def route(self) -> RouteResult:
"""Route all ratsnest connections, using rip-up-and-retry across passes."""
conns = self._build_connections()
self._conns = conns
if not conns:
return RouteResult(self.half_width)
@@ -370,9 +827,16 @@ class GridRouter:
best_count = -1
best_snapshot: list[tuple[int, list, list]] | None = None
best_unrouted = len(conns)
# conns[i].index == i is what rip-up relies on, so shuffle a view
order = list(conns)
if self.seed is not None:
random.Random(self.seed).shuffle(order)
if self.route_first:
# stable: the promoted keep their order among themselves, as do the rest
order.sort(key=lambda c: (c.net_no, *_ends_of(c)) not in self.route_first)
for _ in range(passes):
changed = False
for conn in conns: # deterministic: connection creation order
for conn in order:
if conn.routed:
continue
if self._try_route(conn, conns):
@@ -403,12 +867,37 @@ class GridRouter:
def _build_connections(self) -> list[_Connection]:
pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {}
self._every_zone_by_net: dict[int, list] = {}
for zone in self.board.get_conduction_areas():
if zone.layer in self.layers and zone.net_nos:
self._every_zone_by_net.setdefault(zone.net_nos[0], []).append(zone)
for zone in self.board.get_conduction_areas():
if zone.layer in self.layers and zone.net_nos and self._zone_is_solid(zone):
zones_by_net.setdefault(zone.net_nos[0], []).append(zone)
# planes: zones of a net on an inner power layer, solid by construction
planes_by_net: dict[int, list] = {}
for zone in self.board.get_conduction_areas():
if zone.net_nos and zone.layer in self._power_layers:
planes_by_net.setdefault(zone.net_nos[0], []).append(zone)
plane_ids = frozenset(id(z) for zs in planes_by_net.values() for z in zs)
conns: list[_Connection] = []
nets = set(pins_by_net) | set(zones_by_net) | set(planes_by_net)
# deterministic: fewer-pin nets first, then by net number
for net_no in sorted(pins_by_net, key=lambda n: (len(pins_by_net[n]), n)):
pins = pins_by_net[net_no]
for a, b in zip(pins, pins[1:], strict=False):
conns.append(_Connection(len(conns), net_no, a, b))
for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)):
zones = zones_by_net.get(net_no, []) + planes_by_net.get(net_no, [])
groups = _connected_groups(pins_by_net.get(net_no, []), zones, plane_ids)
hubs = [g for g in groups if any(id(z) in plane_ids for z in g[1])]
if hubs:
# a plane net: every other group goes straight to the plane
# (usually a stub and a via), not pad to pad across the board
pairs = [(g, hubs[0]) for g in groups if g is not hubs[0]]
else:
pairs = list(zip(groups, groups[1:], strict=False))
for ga, gb in pairs:
conn = _Connection(len(conns), net_no, _first(ga[0]), _first(gb[0]))
conn.a_group, conn.b_group = ga, gb
conns.append(conn)
return conns
def _pins_by_net(self) -> dict[int, list[Pin]]:
@@ -420,9 +909,10 @@ class GridRouter:
by_net.setdefault(net_no, []).append(pin)
return by_net
def _snapshot(self, conns: list[_Connection]) -> list[tuple[int, list, list]]:
def _snapshot(self, conns: list[_Connection]) -> list[tuple]:
return [
(c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias))
(c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias),
(*_ends_of(c), self._left_to_zone(c)))
for c in conns
if c.routed
]
@@ -430,14 +920,95 @@ class GridRouter:
def _result_from(self, snapshot, unrouted: int) -> RouteResult:
result = RouteResult(self.half_width)
result.unrouted = unrouted
for net_no, wires, vias in snapshot:
for net_no, wires, vias, ends in snapshot:
for layer, points in wires:
result.add_wire(net_no, layer, points)
for location in vias:
result.add_via(net_no, location)
result.connections.append((net_no, wires, vias))
result.connection_ends.append(ends)
routed = {ends[:2] + (net_no,) for net_no, _w, _v, ends in snapshot}
result.failed = [
(c.net_no, *_ends_of(c), self._left_to_zone(c))
for c in self._conns
if _ends_of(c) + (c.net_no,) not in routed
]
return result
def _first(items):
return items[0] if items else None
def _group_label(group) -> str:
pins, zones = group
parts = [f"{p.component_ref}-{p.name}" for p in pins[:3]]
if len(pins) > 3:
parts.append(f"+{len(pins) - 3} pins")
parts += [f"zone {z.name}" for z in zones[:1]]
return "/".join(parts) or "?"
def _ends_of(conn) -> tuple[str, str]:
"""A readable description of what a connection joins."""
return _group_label(conn.a_group), _group_label(conn.b_group)
def _pin_touches_zone(pin: Pin, zone, plane: bool = False) -> bool:
"""A pad on the zone's layer whose centre or a corner lies in the zone.
For a plane on an inner power layer: a through-hole pad (on more than one
copper layer) inside it passes through the plane and is joined to it; an
SMD pad is not, and needs a via.
"""
if plane:
return len(pin.layers) > 1 and zone.polygon is not None and zone.polygon.contains(pin.location)
if zone.layer not in pin.layers or zone.polygon is None:
return False
if zone.polygon.contains(pin.location):
return True
box = pin.shape.bounding_box() if pin.shape is not None else None
if box is None:
return False
corners = (box.ll, IntPoint(box.ur.x, box.ll.y), box.ur, IntPoint(box.ll.x, box.ur.y))
return any(zone.polygon.contains(c) for c in corners)
def _connected_groups(
pins: list[Pin], zones: list, planes: frozenset = frozenset()
) -> list[tuple[list[Pin], list]]:
"""Pins and zones of one net that copper already joins: a pad in a zone
is connected to it, and two zones sharing a pad are one. Only the groups
need routing between them. Pin groups come first, in pin order."""
parent = list(range(len(pins) + len(zones)))
def find(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
for j, zone in enumerate(zones):
for i, pin in enumerate(pins):
if _pin_touches_zone(pin, zone, id(zone) in planes):
parent[find(i)] = find(len(pins) + j)
groups: dict[int, tuple[list[Pin], list]] = {}
order: list[int] = []
for i, pin in enumerate(pins):
root = find(i)
if root not in groups:
groups[root] = ([], [])
order.append(root)
groups[root][0].append(pin)
for j, zone in enumerate(zones):
root = find(len(pins) + j)
if root not in groups:
groups[root] = ([], [])
order.append(root)
groups[root][1].append(zone)
return [groups[r] for r in order]
def _split_path(path: list[Node]) -> tuple[list[tuple[int, list[Cell]]], list[Cell]]:
"""Split a node path into per-layer wire segments and via cells."""
wires: list[tuple[int, list[Cell]]] = []
@@ -490,6 +1061,12 @@ def route_board(
rip_up: bool = True,
max_passes: int = 10,
orthogonal: bool = False,
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,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
@@ -501,5 +1078,11 @@ def route_board(
rip_up=rip_up,
max_passes=max_passes,
orthogonal=orthogonal,
via_half=via_half,
edge_clearance=edge_clearance,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
)
return router.route()
+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
+99 -7
View File
@@ -45,12 +45,43 @@ def _via_padstack(dsn: DsnBoard) -> str:
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(
dsn: DsnBoard,
*,
resolution: int = 1,
edge_clearance_mm: float | None = None,
layers: list[int] | None = None,
rip_up: bool = True,
max_passes: int = 10,
seed: int | None = None,
route_first: frozenset | None = None,
) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer
@@ -58,15 +89,21 @@ def route_dsn_board(
toggles rip-up-and-retry (``False`` = greedy single pass)."""
board = build_board(dsn)
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)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
result = route_board(
board,
resolution=resolution,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board,
clearance=clearance_board,
layers=layers,
rip_up=rip_up,
max_passes=max_passes,
seed=seed,
route_first=route_first,
)
return result, scale, [layer.name for layer in dsn.layers]
@@ -93,19 +130,28 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
name = numbers_to_names.get(net_no, str(net_no))
for loc in locations:
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
def build_routing_result(
dsn: DsnBoard,
*,
resolution: int = 1,
layers: list[int] | None = None,
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
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,
)
return _to_routing_result(route, dsn, scale, layer_names)
@@ -113,11 +159,16 @@ def build_routing_result(
def route_dsn_board_exact(
dsn: DsnBoard,
*,
resolution: int = 1,
heuristic_weight: float = 1.0,
edge_clearance_mm: float | None = None,
layers: list[int] | None = None,
max_passes: int = 10,
rip_up: bool = True,
shove: bool = False,
diagonal: bool = False,
seed: int | None = None,
route_first: frozenset | None = None,
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names.
@@ -126,10 +177,15 @@ def route_dsn_board_exact(
the 45-degree recovery pass for still-dropped 2-pin nets."""
board = build_board(dsn)
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)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
exact = route_board_exact(
board,
resolution=resolution,
heuristic_weight=heuristic_weight,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board,
clearance=clearance_board,
layers=layers,
@@ -137,6 +193,8 @@ def route_dsn_board_exact(
rip_up=rip_up,
shove=shove,
diagonal=diagonal,
seed=seed,
route_first=route_first,
)
return exact, scale, [layer.name for layer in dsn.layers]
@@ -144,11 +202,16 @@ def route_dsn_board_exact(
def build_exact_routing_result(
dsn: DsnBoard,
*,
resolution: int = 1,
heuristic_weight: float = 1.0,
layers: list[int] | None = None,
shove: bool = False,
diagonal: bool = False,
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(
@@ -158,12 +221,22 @@ def build_exact_routing_result(
diagonal=diagonal,
rip_up=rip_up,
max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
)
return _to_routing_result(exact.result, dsn, scale, layer_names)
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]]:
"""Route a parsed :class:`DsnBoard` with the continuous expansion-room track;
return the result, the scale, and layer names. ``shove`` enables occupancy-
@@ -171,10 +244,13 @@ def route_dsn_board_rooms(
``pack`` enables the coordinated multi-trace channel packer."""
board = build_board(dsn)
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)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
rooms = route_board_rooms(
board,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board,
clearance=clearance_board,
layers=layers,
@@ -185,10 +261,17 @@ def route_dsn_board_rooms(
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:
"""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)
@@ -200,6 +283,8 @@ def route(
shove: bool = False,
pack: bool = False,
diagonal: bool = False,
edge_clearance_mm: float | None = None,
resolution: int = 1,
) -> str:
"""Route a Specctra DSN string and return the routed SES string.
@@ -213,9 +298,16 @@ def route(
"""
dsn = parse_dsn(dsn_text)
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":
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:
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)
+33 -13
View File
@@ -471,6 +471,8 @@ def route_board_rooms(
pack: bool = False,
max_shove_depth: int = 5,
shove_cap: int = 3,
via_half: int | None = None,
edge_clearance: int | None = None,
) -> ExactRouteResult:
"""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
]
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
outline = board.bounding_box
@@ -537,7 +543,7 @@ def route_board_rooms(
via_layers,
accepted,
shove,
)
via_half=via_half, edge=edge)
for net_no in sorted(_routable_nets(board, signal_layers)):
if net_no in packed:
continue
@@ -562,7 +568,7 @@ def route_board_rooms(
accepted,
max_shove_depth,
shove_cap,
)
via_half=via_half, edge=edge)
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean, shoves=shoves)
@@ -587,6 +593,8 @@ def _route_connection(
accepted=None,
max_shove_depth=5,
shove_cap=3,
via_half=None,
edge=None,
):
rooms_by_layer = {
layer: _free_rooms(tree, outline, layer, net_no, margin, half_width)
@@ -607,7 +615,7 @@ def _route_connection(
if realized is not None:
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(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
@@ -615,7 +623,7 @@ def _route_connection(
):
_commit(
net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted
)
, via_half=via_half)
return 0
# the net would be dropped: try a transactional shove-in-rooms recovery
@@ -634,11 +642,12 @@ def _route_connection(
clearance,
max_shove_depth,
shove_cap,
)
via_half=via_half, edge=edge)
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
tile leaves the board outline (the same early-out as the old inline check)."""
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():
if not outline.contains_box(box):
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})))
for loc in vias:
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))
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.
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))
for loc in vias:
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))
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)
for loc in vias:
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)
tree.insert(TreeShape(oid, net_no, via_layers, vbox, routed=True))
@@ -854,6 +868,8 @@ def _pack_channels(
via_layers,
accepted,
shove,
via_half=None,
edge=None,
) -> set[int]:
"""Coordinated multi-trace channel packing (opt-in pre-pass).
@@ -922,7 +938,7 @@ def _pack_channels(
spacing_min,
via_layers,
outline,
)
via_half=via_half, edge=edge)
if placed is None:
continue # could not pack every member cleanly -> leave to greedy
for net_no, wires in placed:
@@ -937,7 +953,7 @@ def _pack_channels(
via_layers,
shove,
accepted,
)
via_half=via_half)
routed.add(net_no)
return routed
@@ -958,6 +974,8 @@ def _pack_group(
spacing_min,
via_layers,
outline,
via_half=None,
edge=None,
):
"""Assign parallel lanes + staggered fan-in trunks to every net crossing one
gap, and return ``[(net_no, wires), ...]`` if ALL of them realize exactly
@@ -1014,7 +1032,7 @@ def _pack_group(
if len(corners) < 2:
return None
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:
return None
if any(
@@ -1049,6 +1067,8 @@ def _shove_recover_room(
clearance,
max_shove_depth,
shove_cap,
via_half=None,
edge=None,
) -> int:
"""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."""
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:
"""Return the :class:`RoutedNet` with ``name`` (creating it if asked)."""
+129 -4
View File
@@ -17,6 +17,7 @@ import pytest
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
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):
# each keepout expands to one obstacle per applicable layer; with no keepouts
# in this fixture the count is zero
assert len(board.get_obstacle_areas()) >= len(dsn.keepouts)
# Board-level keepouts plus every placed instance of a footprint-level one.
# This fixture has none of the first and six plated mounting holes, whose
# 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 board.get_obstacle_areas() == []
assert placed == 12
assert len(board.get_obstacle_areas()) == placed
# --- per-item invariants ----------------------------------------------------
@@ -129,3 +139,118 @@ def test_board_net_set_covers_oracle_routed_nets(board):
assert routed, "oracle produced no routed nets"
# every net the reference router routed exists on our constructed board
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
+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:
out_path = tmp.name
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(
[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,
text=True,
timeout=timeout,
check=False,
env=env,
)
text = Path(out_path).read_text()
finally:
+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
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 ----------------------------------------------------------
+3
View File
@@ -154,6 +154,9 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"diagonal": ["--diagonal"],
"rip_up": ["--no-rip-up"],
"max_passes": ["-mp", "4"],
"seed": ["--seed", "3"],
"orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"],
}
for engine, options in ENGINE_OPTIONS.items():
for option in options:
Generated
+1 -1
View File
@@ -128,7 +128,7 @@ wheels = [
[[package]]
name = "freeroute"
version = "0.1.0"
version = "2026.7.14"
source = { editable = "." }
[package.dev-dependencies]