Compare commits

...
Author SHA1 Message Date
rsp2k 08a7ac7e49 Merge feat/smooth: opt-in --smooth turns grid staircases into octilinear runs after routing 2026-10-17 11:35:22 -06:00
rsp2k 34200f27ff --smooth: no 45-degree leg shorter than two track widths
A short diagonal at a wire's end is a kink beside its pad, and the pour
refilled round it left a copper sliver (KiCad copper_sliver) on the
TerraHawk lights node. Small offsets now stay square jogs; chamfers get
the same floor.
2026-10-17 11:15:47 -06:00
rsp2k c91ca1994c --smooth: turn grid staircases into octilinear runs after routing
A last stage on the finished result, wire by wire: a run of legs between
two points becomes the octilinear shape joining them when its copper is
clear, the furthest point first; right-angle corners left are chamfered;
straight runs are merged. Ends, vias, points where another wire of the net
meets the wire, and legs across own-net pads stay. Every new leg and corner
is checked on the exact shapes against other nets, pads, keepouts and the
board edge, slanted legs both ways round as the verifier counts a pair.
A net whose pads end up joined differently is put back, and the whole
smoothing is dropped if the finished check finds more violations than
before. --smooth-report adds track length per layer.
2026-10-17 09:35:45 -06:00
rsp2k f3c4618fbb Merge feat/reduce-vias: opt-in --reduce-vias pass and --via-cost 2026-10-10 09:39:07 -06:00
rsp2k fa20dc5623 Merge main into feat/reduce-vias 2026-10-07 20:54:02 -06:00
rsp2k b9fed0fc4d Merge feat/verify-output: check grid and room output on exact shapes, --strict 2026-10-07 20:12:51 -06:00
rsp2k 8cb7a627f0 Add --reduce-vias and --via-cost to the exact engine
--reduce-vias is a last stage: each net that has vias is routed again on
its own with vias made eight times dearer, everything else held as fixed
copper (the window stage's board), and its new copper is kept only if the
net fails nothing new and has fewer vias; the nets with the most vias go
first, within a share of the routing's own time. --via-cost exposes the
search's layer-change price (default 10 grid steps). bench.py records
each run's via count and prints via totals per variant.
2026-10-07 20:06:00 -06:00
rsp2k 74d97b601c Check grid and room output on exact shapes and report shorts
The grid and room engines write copper they never check. After a grid or
room run the result is now put through the exact engine's geometry (its
pads, fixed copper and keepouts; wires as boxes or slanted slabs; vias as
their pads) and the report gives the shorts and clearance violations, with
examples, instead of a blanket warning. --strict exits 3 when there are any;
by default the exit status stays 0 so the JAR drop-in contract holds.

The exact engine's static-copper setup moves into insert_static_copper so
both use it; exact runs are byte-identical before and after.
2026-10-07 20:04:04 -06:00
rsp2k 1852df3d53 Merge fix/default-exact: exact engine by default, warn on unchecked engines 2026-10-07 18:52:10 -06:00
rsp2k 33de21ba0e Route with the exact engine by default; warn when an engine does not check
The grid engine was the default and its copper is not checked against exact
shapes: a full reroute of the Labrador lid with no flags came out with 15
tracks_crossing and 87 clearance violations under KiCad's DRC while the
report listed only unrouted connections. The exact engine (what mckicad and
every benchmark already use) is now the default, and grid or room runs end
with a warning that their output needs a DRC.
2026-10-07 18:37:33 -06:00
rsp2k 3b29edc624 Merge feat/rename-rpmroute: the router is now rpmroute (freeroute kept as an alias) 2026-10-07 17:35:56 -06:00
rsp2k a6aa1991a1 Rename to rpmroute
The router began as a port of FreeRouting and has grown its own engine
(negotiated congestion, tree nets, exact checks, axis escapes), and
'freeroute' was one letter from the project it no longer follows. The
package, module and command are now rpmroute; the freeroute command stays
as an alias, and the RPMROUTE / RPMROUTE_ORACLE_JAVA variables fall back
to the old FREEROUTE names. Version 2026.10.07. Routing is unchanged: a
PCBench board routes to a byte-identical session either way.
2026-10-07 14:10:36 -06:00
rsp2k 45aee463a8 Merge feat/window-reroute: opt-in --window-reroute, budgeted finer-grid windows around stuck connections 2026-10-07 14:00:39 -06:00
rsp2k d1def2c4b9 Window boards: never search a connection ending outside the window
With items away from the window left off its board, a stub from the window
edge out to a rerouted net's far pin crossed copper the exact check never
saw: 38 shorts on th-eda's flight controller. A connection with an end
outside the bounds is now left unrouted, and a window whose new copper
leaves the window is refused.
2026-10-06 23:07:09 -06:00
rsp2k 28523bcbac Window re-route: time budget, window cap, smallest first, window boards
A window's board now holds every pin and piece of copper of the nets it
reroutes and, of the rest, only items near the window, instead of the whole
board rebuilt per window. The smallest windows go first, at most
--window-cap per round (40), and no window starts once --window-budget of
the routing's own time is spent (0.25). On th-eda's flight controller the
stage had taken 12x the routing time for no gain.
2026-10-06 22:51:51 -06:00
rsp2k 27f7202d2f --window-reroute N: route a finer grid around each connection still stuck
After the exact engine, every net routed wholly inside a window around a
stuck connection's pins is ripped, the rest of the board is laid down as
fixed copper, and the window alone is routed again at --window-resolution
(default 4) with the stuck net and the ripped nets only. The result is kept
only if fewer connections are left unrouted; each round doubles the margin.
The grid and exact routers gain only_nets and bounds for it. Off by default.
2026-10-06 15:04:02 -06:00
rsp2k 95c89032e3 Merge feat/tree-nets: multi-pin nets route as trees, legs tee into routed copper 2026-10-05 17:12:32 -06:00
rsp2k 61f51129e1 A T depends on every connection between its copper and its group
Anchoring a T only on the connection whose copper it touched missed the
connections that tie that copper to the T's own group. When one of those
was ripped, the T stayed routed but cut off: on charge-sense freeroute
counted 15 unrouted while KiCad found 27 opens. Routed connections of a net
form a forest, so the path back is unique; the T now depends on all of it.
KiCad's opens and freeroute's count agree again (18 against 17, the extra
one a GND pour island left to the fill).
2026-10-05 12:58:58 -06:00
rsp2k b91e44e360 Route multi-pin nets as trees: spanning-tree pairs, T into routed copper
A net's pin groups were joined as a chain in pin order, and each
connection had to end on one chosen pin. Groups are now paired by a
minimum spanning tree (Manhattan distance between their nearest pins), and
a connection may start or end on any copper its net has already routed on
that side (path cells, via cells on every layer; not walled cells, whose
via moves into the pad). Plane nets keep their drops to the plane.

A connection that tees onto another records it as an anchor. Ripping a
connection rips what hangs on it, transitively, inside the same rip-up
transaction, so rollback restores all of them. The exact engine gets the
connections in commit order with their anchors and drops a T whose anchor
it rejects. --no-tree restores the chain.
2026-10-05 12:21:00 -06:00
rsp2k 89d6d8d9dd Merge feat/diagonal-escapes: pins of turned parts leave along their pad axis 2026-10-03 22:08:57 -06:00
rsp2k c6297656a6 Let pins on turned fine-pitch pads leave along the pad's axis
A QFN placed at 45 degrees had no exit on the grid: every horizontal or
vertical step from a pin passes its neighbours inside clearance. A pin whose
pad is turned more than ten degrees off the grid axes now leaves along the
pad's own axis, away from the part, until an L from there to a grid cell
clears every other net's pad, then searches as usual. The slanted leg is
checked and stored as an exact slab (segment_slab), not its bounding box,
in the grid stub checks and in the exact engine.

Where pins leave side by side, a cell names only its first owner, so stubs
are also indexed per cell for every connection near them; the exact
stub-vs-stub check, trace entry and rip-up all consult that index. A pin
with an axis exit and no clear cell reports no way out instead of falling
back to a cell on another net's route. diagnose.py replays with the exits.
2026-10-03 18:49:36 -06:00
rsp2k 4933b6f992 Merge feat/negotiated-congestion: --negotiate N, priced sharing and history before routing 2026-10-03 16:14:36 -06:00
rsp2k 6a560b9506 diagnose.py: --negotiate, as the router 2026-10-03 15:34:37 -06:00
rsp2k 09c58ea630 --negotiate: PathFinder rounds on the empty board before routing
Every connection is routed N times on the empty board, where routes may
share cells at a price that doubles each round for every other net on a
cell, and a cell that stays shared gains history. The real routing then
pays that history on free cells, so the first routes stop taking the space
later routes (a fine-pitch part's escapes) have no way round. Off by
default.

The old history term never steered: it only accrued at rip-up fights,
after the first routes had claimed the contested space, and routed
connections are never revisited in later passes.
2026-10-03 14:45:59 -06:00
rsp2k 7c076d9995 Merge fix/minisumo: shared-via owners for search and rip-up, round vias against the outline 2026-10-03 11:31:01 -06:00
rsp2k 8f006fec25 Rip every user of a shared via, and judge vias round at the outline
Two connections of a net can share a via. The grid kept one owner per via
cell next to the claim list, and the search and rip-up read only that owner:
a route of another net through the via ripped the first user and left the
second's via under the new trace. The exact check then threw one of them
out. The search and rip-up now read the claim list, and the single owner is
gone.

The exact check also tested a via against the outline as its square box,
whose corner reaches past a 45-degree chamfer where the round via does not.
The grid keeps the via's centre its radius plus the edge clearance from the
outline, as KiCad measures it; the exact check now does the same.

kitspace_minisumo_v3 seed 4: 4 rejected connections to 0, 11 failed to 7.
2026-10-03 11:03:38 -06:00
rsp2k ad10113b8b Merge fix/round-pads: route past round pads by their outline, not their bounding square 2026-10-02 04:51:51 -06:00
rsp2k dc11b4aa14 Route past round pads by their outline, not their bounding square
A circle pad was its bounding box in both the grid and the exact check, so a
4 mm mounting hole's square corners refused legal routes 0.8 mm clear of the
real copper. It is now a 16-sided polygon whose sides touch the circle, so
the router is never closer than KiCad measures. diagnose.py takes --seed.
2026-10-02 04:14:39 -06:00
rsp2k de00937084 Merge fix/roomba: grid and exact check agree on rotated pads, shared vias and wide nets 2026-09-30 14:02:54 -06:00
rsp2k f79134fee6 Make the grid and the exact check agree on rotated pads, shared vias and wide nets
Rotated oval pads were built from padded corners that are not in outline
order, so they fell back to their bounding box; neighbouring boxes of a
rotated socket overlap, and the grid's own-pad exemption opened the
overlap to the pin's net, which the exact check then refused. Pads are now
the convex hull of their corners, the exact tree checks a rotated pad
against that outline, and the grid blocks cells against the same outline.

A via reused by a second connection of its net left that connection with
no claim on the via's footprint, so ripping the first owner freed cells
around a via still on the board. Footprint cells now keep every claimant.

A net class wider than the default was searched as a default trace: its
spread was empty below half a cell, halos were circular while the exact
check compares boxes, and a rip-up route only counted what its centre
crossed. The spread now covers the extra width in whole cells, halos are
square, and rip-up victims include what the spread crosses.

bench/diagnose.py now routes with net-class widths and hole spacing, as
the CLI does.
2026-09-30 12:08:25 -06:00
rsp2k ef8398c9b2 Merge fix/ttn-9: straight pin exits, rip-up can reach a pin's way out, exact checks between stubs 2026-09-30 11:11:39 -06:00
rsp2k 8a33dc4576 bench/drc_check.sh: route a KiCad board and judge it with DRC at every severity
Copies the board (stripping its tracks unless --keep), exports the DSN
through mckicad, routes, applies the session and runs DRC with zones
refilled, counting hole problems whatever their severity. FREEROUTE and
MCK choose the builds under test.
2026-09-30 10:54:12 -06:00
rsp2k 8b3ebc53cf Leave fine-pitch pins straight and let rip-up reach a pin's way out
A 1.0 mm trace from a 1.27 mm pitch pin (the RN2483 row on the TTN
Enschede mote) has to stay within 0.06 mm of the pin's centreline until
it is past the neighbouring pads. No grid column is that close, and the
router could not get there:

- A stub ran as one slanted segment to its cell centre. The exact check
  sees that as its bounding box, which lands on the next pad. Orthogonal
  stubs now leave straight along the pin's axis and jog to the cell
  only at the end.
- End nodes were chosen as in free space even in the rip-up search, so
  once another net crossed a pin's only exit, the pin had no end left
  and rip-up could never take the exit back. In rip-up mode those cells
  and stubs are now enterable at the rip penalty, and routes on a stub
  are ripped along with the ones the path crosses.
- Two neighbouring pins' stubs each claim the cells a centred trace
  would crowd, so their claims overlapped well before their copper
  did. Stub against stub is now checked on the copper.
- Escapes also look one column either side, so two pins leaving the
  same way can jog apart.

TTN Enschede mote, exact engine with --shove: 12 unrouted before, 0
after; KiCad DRC on the result has no clearance violations and no
unconnected items. The 0.5 mm pitch row test at resolution 1 now
routes all 5 pins (it pinned 2).
2026-09-30 09:49:05 -06:00
rsp2k 7a42230f36 Merge fix/fixed-via-holes: exact hole spacing to fixed vias, vias on one route kept apart 2026-09-29 09:13:47 -06:00
rsp2k 83ad0f8ef7 Keep a route's own vias apart while it is searched
Committed vias were checked for hole spacing, but a route's own vias are
not committed until it is done, so a route hopping layers (more of them
on 6 layers) could put two of its vias 0.41 mm apart, as th-eda's
6-layer flight controller showed. A via is now also checked against
layer changes earlier on its own route.
2026-09-29 03:10:52 -06:00
rsp2k 6e852a929b Hole spacing to a fixed via measured from where it is, with its own drill
A fixed via sits anywhere, not on a cell centre, and may have a bigger
drill than the router's via; checking hole spacing between cell centres
with one drill let the flight controller's routed 0.2 mm via come 0.19
mm from a hand-placed 0.25 mm one. The board now carries each fixed
via's drill (from KiCad's padstack name), and cells are checked against
fixed vias exactly.
2026-09-29 02:34:17 -06:00
rsp2k f56bb10996 Test: the session never repeats wiring already on the board 2026-09-29 01:17:59 -06:00
rsp2k 8f656006a6 Merge fix/hole-spacing-pads: hole spacing from through-hole pads and fixed vias 2026-09-29 01:16:59 -06:00
101 changed files with 4017 additions and 466 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code working in the `freeroute` repository. Guidance for Claude Code working in the `rpmroute` repository.
## What this is ## What this is
A native Python PCB autorouter: a Java-free reimplementation of the A native Python PCB autorouter: a Java-free reimplementation of the
[FreeRouting](https://github.com/freerouting/freerouting) engine. Specctra DSN [FreeRouting](https://github.com/freerouting/freerouting) engine. Specctra DSN
in, Specctra SES out, no JVM anywhere. Published on PyPI as `freeroute` in, Specctra SES out, no JVM anywhere. Published on PyPI as `rpmroute`
(GPL-3.0-or-later, CalVer). (GPL-3.0-or-later, CalVer).
## Commands ## Commands
@@ -16,7 +16,7 @@ uv sync
uv run pytest -m "not oracle" # the fast suite (this is what you run) uv run pytest -m "not oracle" # the fast suite (this is what you run)
uv run pytest -m oracle # differential tests against the real JAR (needs Java) uv run pytest -m oracle # differential tests against the real JAR (needs Java)
uv run ruff check src/ tests/ uv run ruff check src/ tests/
uv run freeroute board.dsn --engine room --pack -do board.ses uv run rpmroute board.dsn --engine room --pack -do board.ses
``` ```
## The three non-negotiable invariants ## The three non-negotiable invariants
@@ -67,7 +67,7 @@ route/ grid_router | exact_router | room_router, shove, pipeline, cli
decomposed into convex rooms joined by doors, so it routes off-grid channels decomposed into convex rooms joined by doors, so it routes off-grid channels
the grid literally cannot see. DRC-clean. Supports `--pack` and `--shove`. the grid literally cannot see. DRC-clean. Supports `--pack` and `--shove`.
The engine/option matrix lives in `freeroute.cli.ENGINE_OPTIONS` and is The engine/option matrix lives in `rpmroute.cli.ENGINE_OPTIONS` and is
importable, so consumers can assert against it. **An unsupported combination is importable, so consumers can assert against it. **An unsupported combination is
a hard error (exit 2), never a silent no-op.** Downstream code (`mckicad`) reads a hard error (exit 2), never a silent no-op.** Downstream code (`mckicad`) reads
this matrix to avoid sending an invalid pair. this matrix to avoid sending an invalid pair.
+26 -17
View File
@@ -1,14 +1,23 @@
# freeroute # rpmroute
A native Python PCB autorouter: a Java-free reimplementation of the A native Python PCB autorouter. Specctra DSN in, Specctra SES out, no JVM
[FreeRouting](https://github.com/freerouting/freerouting) engine. Specctra DSN anywhere in the pipeline.
in, Specctra SES out, no JVM anywhere in the pipeline.
It began as a Java-free port of the
[FreeRouting](https://github.com/freerouting/freerouting) engine and has grown
its own router since: negotiated congestion before routing, multi-pin nets
routed as trees, every route checked against exact pad and copper shapes, and
pin escapes along the pad's own axis for parts placed at an angle. On dense
boards it now finishes more connections than the FreeRouting JAR, with no
rule violations.
It was called `freeroute` until 2026-10-07; that command still works.
## Why ## Why
Toolchains that autoroute (`kicad-mcp` among them) do it by shelling out to Toolchains that autoroute (`kicad-mcp` among them) do it by shelling out to
`freerouting.jar`, which drags a JVM into every install and container image. `freerouting.jar`, which drags a JVM into every install and container image.
`freeroute` reimplements the routing engine in pure Python, with no dependencies `rpmroute` reimplements the routing engine in pure Python, with no dependencies
beyond the standard library, so the JAR step becomes a `pip install`. beyond the standard library, so the JAR step becomes a `pip install`.
## The contract ## The contract
@@ -16,31 +25,31 @@ beyond the standard library, so the JAR step becomes a `pip install`.
The entire integration surface is two Specctra files: The entire integration surface is two Specctra files:
``` ```
board.dsn ──▶ freeroute ──▶ board.ses board.dsn ──▶ rpmroute ──▶ board.ses
(unrouted) (routed session: traces + vias) (unrouted) (routed session: traces + vias)
``` ```
`kicad-cli` already exports `.dsn` and imports `.ses`, so `freeroute` drops in `kicad-cli` already exports `.dsn` and imports `.ses`, so `rpmroute` drops in
wherever `java -jar freerouting.jar` was invoked, and nothing else in the pipeline wherever `java -jar freerouting.jar` was invoked, and nothing else in the pipeline
changes. The `-de` / `-do` / `-mp` flags are the JAR's, on purpose. changes. The `-de` / `-do` / `-mp` flags are the JAR's, on purpose.
## Install ## Install
```bash ```bash
uv tool install freeroute # or: pipx install freeroute uv tool install rpmroute # or: pipx install rpmroute
``` ```
## Usage ## Usage
```bash ```bash
# a KiCad board: the exact engine, with the board's copper-to-edge rule # 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 rpmroute 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) rpmroute -de board.dsn -do board.ses # the FreeRouting JAR spelling (grid engine)
freeroute board.dsn --engine room --pack --shove -do board.ses rpmroute board.dsn --engine room --pack --shove -do board.ses
``` ```
With no `-do` / `-o`, the SES goes to stdout. Connections freeroute could not With no `-do` / `-o`, the SES goes to stdout. Connections rpmroute could not
make are listed on stderr; the SES is still written and the exit code stays 0, 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 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. listed separately, as left to that zone's fill.
@@ -99,7 +108,7 @@ What the opt-in passes buy you:
- **`--diagonal`** (exact): a still-dropped 2-pin net is retried as a 45° trace - **`--diagonal`** (exact): a still-dropped 2-pin net is retried as a 45° trace
covered by an exact integer octagon. covered by an exact integer octagon.
## What freeroute reads from a KiCad DSN ## What rpmroute reads from a KiCad DSN
These were each checked against KiCad itself, pad by pad or with its DRC: These were each checked against KiCad itself, pad by pad or with its DRC:
@@ -125,7 +134,7 @@ SES coordinates are written in resolution units, exactly as the JAR writes them.
## Library use ## Library use
```python ```python
from freeroute.route import route from rpmroute.route import route
ses_text = route(dsn_text, engine="exact", shove=True, edge_clearance_mm=0.5) ses_text = route(dsn_text, engine="exact", shove=True, edge_clearance_mm=0.5)
``` ```
@@ -136,13 +145,13 @@ 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 stage and sense board, 2026-09-27), with every track stripped, then routed, then
checked with `kicad-cli pcb drc --refill-zones`: checked with `kicad-cli pcb drc --refill-zones`:
| board | freeroute exact | FreeRouting JAR | DRC errors from freeroute | | board | rpmroute exact | FreeRouting JAR | DRC errors from rpmroute |
|---|---|---|---| |---|---|---|---|
| power stage (40 x 30 mm) | 7 unconnected, ~5 s | 12 | 0 | | 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 | | 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 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. after routing), rpmroute completed the power stage with no unconnected items.
What it is **not**, stated plainly: What it is **not**, stated plainly:
@@ -164,7 +173,7 @@ What it is **not**, stated plainly:
For maximum completion on a dense board today, the JAR is still the stronger 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 router. For routing without a JVM (CI, containers, an MCP server, a library
call), freeroute routes legally and says what it could not do. call), rpmroute routes legally and says what it could not do.
## Development ## Development
+21 -6
View File
@@ -37,8 +37,8 @@ import tempfile
import threading import threading
import time import time
NOT_ROUTED = re.compile(r"freeroute: (\d+) connection\(s\) not routed") NOT_ROUTED = re.compile(r"rpmroute: (\d+) connection\(s\) not routed")
TO_ZONE = re.compile(r"freeroute: (\d+) more connection\(s\) left to a zone fill") TO_ZONE = re.compile(r"rpmroute: (\d+) more connection\(s\) left to a zone fill")
def parse_boards(path: Path) -> list[tuple[str, Path, list[str]]]: def parse_boards(path: Path) -> list[tuple[str, Path, list[str]]]:
@@ -61,8 +61,8 @@ def parse_variant(spec: str) -> tuple[str, str | None, list[str]]:
if not sep or not head: if not sep or not head:
raise SystemExit(f"variant {spec!r}: want NAME=FLAGS or NAME@SRC=FLAGS") raise SystemExit(f"variant {spec!r}: want NAME=FLAGS or NAME@SRC=FLAGS")
name, _, src = head.partition("@") name, _, src = head.partition("@")
if src and not (Path(src) / "freeroute").is_dir(): if src and not (Path(src) / "rpmroute").is_dir():
raise SystemExit(f"variant {name}: {src} has no freeroute package") raise SystemExit(f"variant {name}: {src} has no rpmroute package")
return name, src or None, shlex.split(flags) return name, src or None, shlex.split(flags)
@@ -74,7 +74,7 @@ def run_one(board, variant, seed: int, timeout: float) -> dict:
env["PYTHONPATH"] = src + os.pathsep + env.get("PYTHONPATH", "") env["PYTHONPATH"] = src + os.pathsep + env.get("PYTHONPATH", "")
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
cmd = [ cmd = [
sys.executable, "-m", "freeroute.cli", str(dsn), "-do", f"{tmp}/out.ses", sys.executable, "-m", "rpmroute.cli", str(dsn), "-do", f"{tmp}/out.ses",
*board_flags, *flags, "--seed", str(seed), *board_flags, *flags, "--seed", str(seed),
] ]
start = time.monotonic() start = time.monotonic()
@@ -100,13 +100,18 @@ def run_one(board, variant, seed: int, timeout: float) -> dict:
) )
rss_mb = round(usage.ru_maxrss / 1024) rss_mb = round(usage.ru_maxrss / 1024)
secs = round(time.monotonic() - start, 1) secs = round(time.monotonic() - start, 1)
try:
with open(f"{tmp}/out.ses", encoding="utf-8") as f:
vias = f.read().count("(via ")
except OSError:
vias = None
missing = NOT_ROUTED.search(err) missing = NOT_ROUTED.search(err)
zone = TO_ZONE.search(err) zone = TO_ZONE.search(err)
return { return {
"board": name, "variant": vname, "seed": seed, "status": status, "board": name, "variant": vname, "seed": seed, "status": status,
"unrouted": int(missing.group(1)) if missing else 0, "unrouted": int(missing.group(1)) if missing else 0,
"to_zone": int(zone.group(1)) if zone else 0, "to_zone": int(zone.group(1)) if zone else 0,
"secs": secs, "rss_mb": rss_mb, "secs": secs, "rss_mb": rss_mb, "vias": vias,
"tail": err.strip().splitlines()[-1] if status != "ok" and err.strip() else "", "tail": err.strip().splitlines()[-1] if status != "ok" and err.strip() else "",
} }
@@ -147,6 +152,16 @@ def report(rows: list[dict], boards, variants) -> None:
line += (f" {dm:+.1f} ± {se:.1f} ({better} better, {worse} worse," line += (f" {dm:+.1f} ± {se:.1f} ({better} better, {worse} worse,"
f" {len(diffs) - better - worse} same) {verdict}") f" {len(diffs) - better - worse} same) {verdict}")
print(line) print(line)
# vias, where the runs recorded them (older result files did not)
print("\nvias per variant, summed over boards (mean over seeds per board)\n")
for vname, _, _ in variants:
total, boards_counted = 0.0, 0
for bname, _, _ in boards:
vs = [r["vias"] for k, r in ok.items() if k[:2] == (bname, vname) and r.get("vias") is not None]
if vs:
total += sum(vs) / len(vs)
boards_counted += 1
print(f"{vname:14} {total:8.1f} vias over {boards_counted} boards")
failed = [r for r in rows if r["status"] != "ok"] failed = [r for r in rows if r["status"] != "ok"]
for r in failed: for r in failed:
print(f"FAILED {r['board']} {r['variant']} seed {r['seed']}: {r['status']} {r['tail']}") print(f"FAILED {r['board']} {r['variant']} seed {r['seed']}: {r['status']} {r['tail']}")
+24 -10
View File
@@ -23,17 +23,19 @@ import json
from pathlib import Path from pathlib import Path
import sys import sys
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.board.search_tree import ShapeSearchTree from rpmroute.board.search_tree import ShapeSearchTree
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.geometry.octagon import overlaps_2d from rpmroute.geometry.octagon import overlaps_2d
from freeroute.route import grid_router from rpmroute.route import grid_router
from freeroute.route.exact_router import route_board_exact from rpmroute.route.exact_router import route_board_exact
from freeroute.route.grid_router import GridRouter, _ends_of from rpmroute.route.grid_router import GridRouter, _ends_of
from freeroute.route.pipeline import ( from rpmroute.route.pipeline import (
_mm_to_board, _mm_to_board,
_net_widths_board,
_rule_clearance_dsn, _rule_clearance_dsn,
_rule_width_dsn, _rule_width_dsn,
_via_drill_board,
_via_half_board, _via_half_board,
) )
@@ -183,7 +185,8 @@ def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
def diagnose( def diagnose(
dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False, dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False,
access_cost: float = 0.0, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, seed: int | None = None,
negotiate: int = 0,
) -> dict: ) -> dict:
dsn = parse_dsn(dsn_text) dsn = parse_dsn(dsn_text)
names = {i + 1: n.name for i, n in enumerate(dsn.nets)} names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
@@ -199,6 +202,12 @@ def diagnose(
resolution=resolution, resolution=resolution,
shove=shove, shove=shove,
access_cost=access_cost, access_cost=access_cost,
seed=seed,
negotiate=negotiate,
# the product's configuration: net-class widths and hole spacing
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
) )
routed = seen["router"] routed = seen["router"]
best = routed._diag_best best = routed._diag_best
@@ -206,6 +215,8 @@ def diagnose(
# the board as the best pass left it # the board as the best pass left it
state = _fresh(seen) state = _fresh(seen)
# pins on turned pads leave along their pad's axis: replay with the same exits
state._axis_exits = dict(routed._axis_exits)
conns = state._conns conns = state._conns
for idx, (path, a_end, b_end) in best.items(): for idx, (path, a_end, b_end) in best.items():
conns[idx].a_end, conns[idx].b_end = a_end, b_end conns[idx].a_end, conns[idx].b_end = a_end, b_end
@@ -270,11 +281,14 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--grid-resolution", type=int, default=2, metavar="K") ap.add_argument("--grid-resolution", type=int, default=2, metavar="K")
ap.add_argument("--shove", action="store_true", help="as mckicad routes") ap.add_argument("--shove", action="store_true", help="as mckicad routes")
ap.add_argument("--access-cost", type=float, default=0.0) ap.add_argument("--access-cost", type=float, default=0.0)
ap.add_argument("--seed", type=int, help="route in this shuffled order, as bench.py does")
ap.add_argument("--negotiate", type=int, default=0, metavar="N", help="as the router's --negotiate")
ap.add_argument("--hole-to-hole", type=float, default=0.25, metavar="MM")
ap.add_argument("--json", type=Path, help="also write the full table here") ap.add_argument("--json", type=Path, help="also write the full table here")
args = ap.parse_args(argv) args = ap.parse_args(argv)
report = diagnose(args.dsn.read_text(), args.edge_clearance, args.grid_resolution, args.shove, report = diagnose(args.dsn.read_text(), args.edge_clearance, args.grid_resolution, args.shove,
args.access_cost) args.access_cost, args.hole_to_hole, args.seed, args.negotiate)
for row in report["failures"]: for row in report["failures"]:
extra = "" extra = ""
for w in row.get("walled", []): for w in row.get("walled", []):
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Route one KiCad board with rpmroute and judge the result with KiCad's DRC.
#
# bench/drc_check.sh [--keep] <board.kicad_pcb> <out-dir> [rpmroute args...]
#
# Copies the board (and its .kicad_pro) into <out-dir>, strips its tracks and
# vias unless --keep is given (then they reach the router as fixed wiring),
# exports the DSN through mckicad, routes, applies the session, and runs DRC
# with zones refilled at every severity. Prints one line: time, copper added,
# shorts, opens, hole problems (whatever severity the board gives them) and
# the other errors. The source board is never touched.
#
# RPMROUTE (or the older FREEROUTE; default: this checkout's .venv/bin/rpmroute) and MCK (default
# ~/claude/mckicad) pick the builds to judge.
set -euo pipefail
keep=0
if [[ "${1:-}" == "--keep" ]]; then keep=1; shift; fi
src="$1"; out="$2"; shift 2
here="$(cd "$(dirname "$0")/.." && pwd)"
RPMROUTE="${RPMROUTE:-${FREEROUTE:-$here/.venv/bin/rpmroute}}"
MCK="${MCK:-$HOME/claude/mckicad}"
rm -rf "$out"; mkdir -p "$out"
cp "${src%.kicad_pcb}.kicad_pro" "$out/board.kicad_pro" 2>/dev/null || true
if [[ $keep == 1 ]]; then
cp "$src" "$out/board.kicad_pcb"
else
(cd "$MCK" && uv run --no-sync python - "$src" "$out/board.kicad_pcb" <<'EOF'
import sys
from mckicad.utils.sexp_tree import parse
text = open(sys.argv[1]).read()
spans = sorted((c.span for c in parse(text).children if c.tag in ("segment", "arc", "via")), reverse=True)
for a, b in spans:
text = text[:a] + text[b:]
open(sys.argv[2], "w").write(text)
EOF
)
fi
(cd "$MCK" && uv run --no-sync python -c "
from mckicad.utils.freerouting import FreeRoutingEngine
assert FreeRoutingEngine().export_dsn_from_kicad('$out/board.kicad_pcb', '$out/board.dsn', {})") \
>/dev/null 2>&1 || { echo "$out: DSN export failed"; exit 1; }
start=$(date +%s.%N)
"$RPMROUTE" "$out/board.dsn" -do "$out/board.ses" "$@" >"$out/route.log" 2>&1 || true
secs=$(python3 -c "print(round($(date +%s.%N)-$start,1))")
[[ -s "$out/board.ses" ]] || { echo "$out: no session ($(tail -1 "$out/route.log"))"; exit 1; }
(cd "$MCK" && uv run --no-sync python -c "
from mckicad.utils.ses_apply import apply_ses_to_board as a
r = a('$out/board.kicad_pcb', '$out/board.ses', '$out/board.kicad_pcb')
print(r['segments_added'], r['vias_added'])" 2>/dev/null) >"$out/apply.txt"
kicad-cli pcb drc --refill-zones --format json --severity-all \
-o "$out/drc.json" "$out/board.kicad_pcb" >/dev/null 2>&1 || true
python3 - "$out" "$secs" <<'EOF'
import collections, json, sys
out, secs = sys.argv[1:]
seg, via = open(f"{out}/apply.txt").read().split()
d = json.load(open(f"{out}/drc.json"))
errors = collections.Counter(v["type"] for v in d["violations"] if v.get("severity") == "error")
other = collections.Counter(v["type"] for v in d["violations"] if v.get("severity") != "error")
holes = {k: errors.pop(k, 0) + other.get(k, 0) for k in ("holes_co_located", "hole_to_hole")}
print(f"{out}: {secs}s {seg} seg {via} via shorts {errors.pop('shorting_items', 0)} "
f"opens {len(d.get('unconnected_items', []))} holes {holes} errors {dict(errors)}")
EOF
+3 -3
View File
@@ -90,7 +90,7 @@ this is the structure the Python `reader.py` mirrors):
**Python port status:** items 1 (tokenizer), the shape grammar, and every **Python port status:** items 1 (tokenizer), the shape grammar, and every
structure/library/network/placement/parser/resolution scope above are structure/library/network/placement/parser/resolution scope above are
implemented in `src/freeroute/dsn/` this session (see below). implemented in `src/rpmroute/dsn/` this session (see below).
## 2. Board data model — `board/` ## 2. Board data model — `board/`
@@ -175,7 +175,7 @@ converge toward FreeRouting's free-space rooms. Not started this session.
into the read result). into the read result).
## Port order (this file drives the roadmap) ## Port order (this file drives the roadmap)
1. **DSN parser** — done this session (`src/freeroute/dsn/`). 1. **DSN parser** — done this session (`src/rpmroute/dsn/`).
2. **SES writer** — next. Start with an empty/no-op session to prove the loop 2. **SES writer** — next. Start with an empty/no-op session to prove the loop
end-to-end through `kicad-cli pcb import specctra-ses`. See `SesWriter.java` end-to-end through `kicad-cli pcb import specctra-ses`. See `SesWriter.java`
and `SpecctraSesFileWriter.java`: `(session <name> (base_design <name>) and `SpecctraSesFileWriter.java`: `(session <name> (base_design <name>)
@@ -184,6 +184,6 @@ converge toward FreeRouting's free-space rooms. Not started this session.
3. **Geometry primitives** — `geometry/planar` subset needed by the board model. 3. **Geometry primitives** — `geometry/planar` subset needed by the board model.
4. **Board model** — `board` items + spatial index. 4. **Board model** — `board` items + spatial index.
5. **Autorouter** — maze/rip-up core, then optimize pass. 5. **Autorouter** — maze/rip-up core, then optimize pass.
6. **CLI + kicad-mcp integration** — `freeroute board.dsn -o board.ses`. 6. **CLI + kicad-mcp integration** — `rpmroute board.dsn -o board.ses`.
</content> </content>
</invoke> </invoke>
+1 -1
View File
@@ -43,7 +43,7 @@ the output. Match that I/O and the rest of `kicad-mcp` is unchanged.
4. **Autorouter core** — maze routing with rip-up-and-retry. Start grid-based 4. **Autorouter core** — maze routing with rip-up-and-retry. Start grid-based
Manhattan/45° for an MVP; converge toward FreeRouting's approach. _(TBD.)_ Manhattan/45° for an MVP; converge toward FreeRouting's approach. _(TBD.)_
5. **Optimize pass** — via minimization, trace smoothing. _(TBD.)_ 5. **Optimize pass** — via minimization, trace smoothing. _(TBD.)_
6. **CLI + kicad-mcp integration** — `freeroute board.dsn -o board.ses`, then 6. **CLI + kicad-mcp integration** — `rpmroute board.dsn -o board.ses`, then
swap it in behind `kicad_mcp/utils/freerouting_engine.py`'s JAR step. swap it in behind `kicad_mcp/utils/freerouting_engine.py`'s JAR step.
## Validation strategy ## Validation strategy
+14 -11
View File
@@ -3,9 +3,9 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "freeroute" name = "rpmroute"
version = "2026.07.14" version = "2026.10.07"
description = "Native Python PCB autorouter — a Java-free port of the FreeRouting engine (Specctra DSN in, SES out)" description = "Native Python PCB autorouter with negotiated congestion and tree routing (Specctra DSN in, SES out)"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -40,14 +40,17 @@ requires-python = ">=3.10"
dependencies = [] dependencies = []
[project.urls] [project.urls]
"Homepage" = "https://git.supported.systems/warehack.ing/freeroute" "Homepage" = "https://git.supported.systems/warehack.ing/rpmroute"
"Repository" = "https://git.supported.systems/warehack.ing/freeroute" "Repository" = "https://git.supported.systems/warehack.ing/rpmroute"
"Issues" = "https://git.supported.systems/warehack.ing/freeroute/issues" "Issues" = "https://git.supported.systems/warehack.ing/rpmroute/issues"
# The Java engine this is a port of; freeroute ships no upstream code. # Where it started: a port of the Java FreeRouting engine. It has since grown
"Upstream" = "https://github.com/freerouting/freerouting" # its own router (negotiation, tree nets, exact checks) and ships no upstream code.
"Origin" = "https://github.com/freerouting/freerouting"
[project.scripts] [project.scripts]
freeroute = "freeroute.cli:main" rpmroute = "rpmroute.cli:main"
# the name it had until 2026-10-07, kept so existing scripts keep working
freeroute = "rpmroute.cli:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -57,7 +60,7 @@ dev = [
] ]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/freeroute"] packages = ["src/rpmroute"]
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
# Ship the package, the licence, and the docs — nothing else. `reference/` is a # Ship the package, the licence, and the docs — nothing else. `reference/` is a
@@ -95,7 +98,7 @@ select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]
ignore = ["E501"] ignore = ["E501"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["freeroute"] known-first-party = ["rpmroute"]
force-sort-within-sections = true force-sort-within-sections = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
-3
View File
@@ -1,3 +0,0 @@
"""freeroute — native Python PCB autorouter (Java-free FreeRouting port)."""
__version__ = "0.1.0"
+3
View File
@@ -0,0 +1,3 @@
"""rpmroute — native Python PCB autorouter (Java-free FreeRouting port)."""
__version__ = "0.1.0"
@@ -1,9 +1,9 @@
"""Board data model for freeroute. """Board data model for rpmroute.
The data model a parsed DSN produces and the (future) router operates on: The data model a parsed DSN produces and the (future) router operates on:
layers, units, coordinate transform, nets, clearance matrix, the item hierarchy, layers, units, coordinate transform, nets, clearance matrix, the item hierarchy,
and the :class:`BasicBoard` container. :func:`build_board` constructs a board and the :class:`BasicBoard` container. :func:`build_board` constructs a board
from a :class:`~freeroute.dsn.model.DsnBoard`. from a :class:`~rpmroute.dsn.model.DsnBoard`.
The routing operations (``RoutingBoard``) and the spatial ``ShapeSearchTree`` The routing operations (``RoutingBoard``) and the spatial ``ShapeSearchTree``
belong to the router phase. belong to the router phase.
@@ -9,7 +9,7 @@ board-model invariants check.
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import IntBox from rpmroute.geometry import IntBox
from .clearance import ClearanceMatrix from .clearance import ClearanceMatrix
from .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via from .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via
@@ -74,7 +74,7 @@ class BasicBoard:
"""Copper already routed on the board (the DSN's ``wiring``), as """Copper already routed on the board (the DSN's ``wiring``), as
``(net, layers, box)``. A track is its segments, its corners and the ``(net, layers, box)``. A track is its segments, its corners and the
round caps at its ends (as squares); a via is its largest pad.""" round caps at its ends (as squares); a via is its largest pad."""
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box from rpmroute.geometry.polyline import Polyline, PolylineShape, corner_box
out: list[tuple[int, frozenset[int], IntBox]] = [] out: list[tuple[int, frozenset[int], IntBox]] = []
for item in self._items: for item in self._items:
@@ -1,6 +1,6 @@
"""Build a :class:`BasicBoard` from a parsed DSN :class:`DsnBoard`. """Build a :class:`BasicBoard` from a parsed DSN :class:`DsnBoard`.
This is the load-bearing integration between :mod:`freeroute.dsn` and the board This is the load-bearing integration between :mod:`rpmroute.dsn` and the board
model. It mirrors what FreeRouting's ``Structure.create_board`` / ``Library`` / model. It mirrors what FreeRouting's ``Structure.create_board`` / ``Library`` /
``Component`` / ``Network`` readers do collectively, but on the already-parsed ``Component`` / ``Network`` readers do collectively, but on the already-parsed
``DsnBoard``: ``DsnBoard``:
@@ -12,9 +12,10 @@ model. It mirrors what FreeRouting's ``Structure.create_board`` / ``Library`` /
* padstacks x placement -> :class:`Pin` items * padstacks x placement -> :class:`Pin` items
* keepouts -> :class:`ObstacleArea` items * keepouts -> :class:`ObstacleArea` items
Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad its Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad a
bounding box (documented approximation pending an exact circle tile); a convex 16-sided :class:`Simplex` drawn just outside it (its corners stand out by 2% of
polygon pad an exact :class:`Simplex`. Every pad is placed centred on its pin the radius, where the bounding box's stood out by 41%); a convex polygon pad an
exact :class:`Simplex`. Every pad is placed centred on its pin
location, so it contains that location by construction. Component rotation is location, so it contains that location by construction. Component rotation is
applied to the pin position; the pad tile itself is not rotated (irrelevant to applied to the pin position; the pad tile itself is not rotated (irrelevant to
the containment invariant, and pads are centre-symmetric in practice). the containment invariant, and pads are centre-symmetric in practice).
@@ -23,11 +24,12 @@ the containment invariant, and pads are centre-symmetric in practice).
from __future__ import annotations from __future__ import annotations
import math import math
import re
from freeroute.dsn.model import DsnBoard from rpmroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape from rpmroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex from rpmroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex
from freeroute.geometry.polygon_shape import PolygonShape from rpmroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard from .board import BasicBoard
from .clearance import ClearanceMatrix from .clearance import ClearanceMatrix
@@ -38,6 +40,9 @@ from .transform import CoordinateTransform
__all__ = ["build_board"] __all__ = ["build_board"]
# DSN units per micrometre, for drills KiCad gives in um in via padstack names
_DSN_PER_UM = {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}
def build_board(dsn: DsnBoard) -> BasicBoard: def build_board(dsn: DsnBoard) -> BasicBoard:
"""Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`.""" """Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`."""
@@ -245,7 +250,7 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor
r = round(transform.dsn_to_board(shape.diameter / 2)) r = round(transform.dsn_to_board(shape.diameter / 2))
cx = round(transform.dsn_to_board(centre.x)) cx = round(transform.dsn_to_board(centre.x))
cy = round(transform.dsn_to_board(centre.y)) cy = round(transform.dsn_to_board(centre.y))
return IntBox(cx - r, cy - r, cx + r, cy + r) return _circle_tile(cx, cy, r)
if isinstance(shape, Rectangle): if isinstance(shape, Rectangle):
x0, y0, x1, y1 = shape.coords[:4] x0, y0, x1, y1 = shape.coords[:4]
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
@@ -264,15 +269,65 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor
for x, y in corners: for x, y in corners:
p = _to_board_offset(place, x, y, dsn_pin.rotation) 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)))) pts.append(IntPoint(round(transform.dsn_to_board(p.x)), round(transform.dsn_to_board(p.y))))
if len(pts) >= 3: # An oval's padded corners are not in outline order, and a mirrored
simplex = Simplex.from_corners(pts) # part reverses the winding: take the convex hull, then either winding.
if not simplex.is_empty(): hull = _convex_hull(pts)
return simplex if len(hull) >= 3:
for corners in (hull, hull[::-1]):
simplex = Simplex.from_corners(corners)
if not simplex.is_empty():
return simplex
xs = [p.x for p in pts] or [0] xs = [p.x for p in pts] or [0]
ys = [p.y for p in pts] or [0] ys = [p.y for p in pts] or [0]
return IntBox(min(xs), min(ys), max(xs), max(ys)) return IntBox(min(xs), min(ys), max(xs), max(ys))
_CIRCLE_SIDES = 16
def _circle_tile(cx: int, cy: int, r: int):
"""A polygon around the circle, every side touching it. A square around a
big round pad (a mounting hole) puts phantom copper in its corners that
blocks legal routes past it; this stays outside the circle, so the router
is still never closer than KiCad measures."""
if r < 8:
return IntBox(cx - r, cy - r, cx + r, cy + r)
reach = r / math.cos(math.pi / _CIRCLE_SIDES)
corners = []
for k in range(_CIRCLE_SIDES):
a = (k + 0.5) * 2 * math.pi / _CIRCLE_SIDES
dx, dy = reach * math.cos(a), reach * math.sin(a)
# round away from the centre so the polygon never cuts into the circle
corners.append(IntPoint(cx + math.copysign(math.ceil(abs(dx)), dx),
cy + math.copysign(math.ceil(abs(dy)), dy)))
corners = [IntPoint(int(p.x), int(p.y)) for p in corners]
for winding in (corners, corners[::-1]):
simplex = Simplex.from_corners(winding)
if not simplex.is_empty():
return simplex
return IntBox(cx - r, cy - r, cx + r, cy + r)
def _convex_hull(pts: list[IntPoint]) -> list[IntPoint]:
"""The convex hull of ``pts`` counter-clockwise, collinear points dropped."""
ps = sorted({(p.x, p.y) for p in pts})
if len(ps) < 3:
return [IntPoint(x, y) for x, y in ps]
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
def half(points):
out: list[tuple[int, int]] = []
for p in points:
while len(out) >= 2 and cross(out[-2], out[-1], p) <= 0:
out.pop()
out.append(p)
return out[:-1]
return [IntPoint(x, y) for x, y in half(ps) + half(ps[::-1])]
def _pad_tile(shape: Shape, transform: CoordinateTransform): def _pad_tile(shape: Shape, transform: CoordinateTransform):
"""A board-relative pad tile from a DSN padstack shape (centred near origin).""" """A board-relative pad tile from a DSN padstack shape (centred near origin)."""
if isinstance(shape, Rectangle): if isinstance(shape, Rectangle):
@@ -282,7 +337,7 @@ def _pad_tile(shape: Shape, transform: CoordinateTransform):
r = round(transform.dsn_to_board(shape.diameter / 2)) r = round(transform.dsn_to_board(shape.diameter / 2))
cx = round(transform.dsn_to_board(shape.center_x)) cx = round(transform.dsn_to_board(shape.center_x))
cy = round(transform.dsn_to_board(shape.center_y)) cy = round(transform.dsn_to_board(shape.center_y))
return IntBox(cx - r, cy - r, cx + r, cy + r) # bounding-box approximation return _circle_tile(cx, cy, r)
if isinstance(shape, (Polygon, Path)): if isinstance(shape, (Polygon, Path)):
coords = shape.coords coords = shape.coords
pts = [ pts = [
@@ -503,10 +558,15 @@ def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransf
location = transform.dsn_to_board_point(via.x, via.y) location = transform.dsn_to_board_point(via.x, via.y)
pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform) pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform)
box = pad.bounding_box() box = pad.bounding_box()
drill = re.search(r"_([\d.]+):([\d.]+)_um", via.padstack)
drill_board = (
round(transform.dsn_to_board(float(drill.group(2)) * _DSN_PER_UM.get(dsn.unit, 1.0)))
if drill else 0
)
board.insert( board.insert(
Via( Via(
net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack, net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack,
location=location, layers=sorted(layers or signal_layers), drill=drill_board, location=location, layers=sorted(layers or signal_layers),
shape=IntBox(box.ll.x + location.x, box.ll.y + location.y, shape=IntBox(box.ll.x + location.x, box.ll.y + location.y,
box.ur.x + location.x, box.ur.y + location.y), box.ur.x + location.x, box.ur.y + location.y),
) )
@@ -3,22 +3,22 @@
Ports the *data model* of ``board/Item.java`` and its subclasses (``Pin``, Ports the *data model* of ``board/Item.java`` and its subclasses (``Pin``,
``ObstacleArea``/``ConductionArea``, ``Via``, ``PolylineTrace``). The routing ``ObstacleArea``/``ConductionArea``, ``Via``, ``PolylineTrace``). The routing
and search-tree machinery in the Java classes is out of scope here — these hold and search-tree machinery in the Java classes is out of scope here — these hold
the geometry and connectivity a :class:`~freeroute.board.board.BasicBoard` the geometry and connectivity a :class:`~rpmroute.board.board.BasicBoard`
needs and that the DSN-import invariants check. needs and that the DSN-import invariants check.
Coordinates are board units (DSN units scaled by the resolution). Shapes are the Coordinates are board units (DSN units scaled by the resolution). Shapes are the
exact convex tiles / boxes from :mod:`freeroute.geometry`. exact convex tiles / boxes from :mod:`rpmroute.geometry`.
``Trace``/``Via`` are only produced by routing (an imported unrouted board has ``Trace``/``Via`` are only produced by routing (an imported unrouted board has
none); they are lightweight here and the router phase fleshes out none); they are lightweight here and the router phase fleshes out
``Trace`` with a proper :class:`~freeroute.geometry.Polyline` shape. ``Trace`` with a proper :class:`~rpmroute.geometry.Polyline` shape.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from freeroute.geometry import IntBox, IntPoint from rpmroute.geometry import IntBox, IntPoint
__all__ = ["Item", "Pin", "ObstacleArea", "ConductionArea", "Via", "Trace"] __all__ = ["Item", "Pin", "ObstacleArea", "ConductionArea", "Via", "Trace"]
@@ -117,6 +117,8 @@ class Via(Item):
#: layer indices the via's pads occupy, and the largest pad as a box #: layer indices the via's pads occupy, and the largest pad as a box
layers: list[int] = field(default_factory=list) layers: list[int] = field(default_factory=list)
shape: object = None shape: object = None
#: drill diameter in board units, when the padstack name gives it
drill: int = 0
@dataclass @dataclass
@@ -124,7 +126,7 @@ class Trace(Item):
"""A routed trace (router-produced). """A routed trace (router-produced).
Held as its corner points plus a half-width; the router phase attaches the Held as its corner points plus a half-width; the router phase attaches the
full :class:`~freeroute.geometry.Polyline` swept shape. full :class:`~rpmroute.geometry.Polyline` swept shape.
""" """
layer: int = 0 layer: int = 0
@@ -9,7 +9,7 @@ from __future__ import annotations
import math import math
from freeroute.geometry import IntBox, IntPoint from rpmroute.geometry import IntBox, IntPoint
class BoardOutline: class BoardOutline:
@@ -2,7 +2,7 @@
Ports the query role of ``board/ShapeSearchTree*.java``. It replaces the exact Ports the query role of ``board/ShapeSearchTree*.java``. It replaces the exact
router's occupancy grid: instead of rasterizing shapes into cells, it indexes router's occupancy grid: instead of rasterizing shapes into cells, it indexes
each item's convex tiles (:class:`~freeroute.geometry.IntBox`) and answers each item's convex tiles (:class:`~rpmroute.geometry.IntBox`) and answers
overlap / region queries with a broad phase (a bounding-box spatial hash) and an overlap / region queries with a broad phase (a bounding-box spatial hash) and an
**exact** narrow phase (exact ``IntBox`` intersection). Insert and remove keep **exact** narrow phase (exact ``IntBox`` intersection). Insert and remove keep
the index in step as traces are added. the index in step as traces are added.
@@ -18,7 +18,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from freeroute.geometry import IntBox, Simplex, overlaps_2d, segment_octagon from rpmroute.geometry import IntBox, IntPoint, Simplex, overlaps_2d, segment_octagon, segment_slab
__all__ = ["TreeShape", "ShapeSearchTree"] __all__ = ["TreeShape", "ShapeSearchTree"]
@@ -34,7 +34,7 @@ class TreeShape:
``exact`` / ``seg`` are set only for a 45-degree (diagonal) trace tile: ``tile`` ``exact`` / ``seg`` are set only for a 45-degree (diagonal) trace tile: ``tile``
is then the bounding box (broad-phase only), ``exact`` is the tight octagon is then the bounding box (broad-phase only), ``exact`` is the tight octagon
copper :class:`~freeroute.geometry.Simplex`, and ``seg`` is ``(a, b, copper :class:`~rpmroute.geometry.Simplex`, and ``seg`` is ``(a, b,
half_width)`` so the clearance region can be rebuilt exactly on demand. For half_width)`` so the clearance region can be rebuilt exactly on demand. For
every orthogonal tile both are ``None`` and all code paths are byte-identical every orthogonal tile both are ``None`` and all code paths are byte-identical
to the box-only tree. to the box-only tree.
@@ -63,7 +63,9 @@ class TreeShape:
segment swept by ``half_width + clearance`` (exact integer superset).""" segment swept by ``half_width + clearance`` (exact integer superset)."""
if self.seg is None: if self.seg is None:
return self.tile.offset(clearance) return self.tile.offset(clearance)
a, b, half_width = self.seg a, b, half_width = self.seg[-3:]
if self.seg[0] == "slab": # a slanted pin exit, at any angle
return segment_slab(a, b, half_width + clearance)
return segment_octagon(a, b, half_width + clearance) return segment_octagon(a, b, half_width + clearance)
@@ -151,6 +153,25 @@ class ShapeSearchTree:
return True return True
return False return False
def segment_conflict(
self, a: IntPoint, b: IntPoint, half_width: int, net_no: int, layer: int,
clearance: int, kind: str = "wire",
) -> bool:
""":meth:`clearance_conflict` for a slanted segment, checked on its
exact swept copper instead of its bounding box (which would reach the
neighbouring pads of a pin leaving along its turned pad's axis)."""
copper = segment_slab(a, b, half_width)
grown = segment_slab(a, b, half_width + clearance)
for shape in self.overlapping(grown.bounding_box()):
if shape.net_no == net_no or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue
other = shape.exact if shape.exact is not None else shape.tile
if overlaps_2d(copper if not shape.clearance else grown, other):
return True
return False
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool: def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``.""" """True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
for shape in self.overlapping(box): for shape in self.overlapping(box):
@@ -188,6 +209,8 @@ class ShapeSearchTree:
overlap). Static pad-vs-pad spacing in the source design is not counted. overlap). Static pad-vs-pad spacing in the source design is not counted.
""" """
for shape in self.all_shapes(): for shape in self.all_shapes():
if not shape.routed:
continue # a pair with routed copper is checked from that side
expanded = shape.tile.offset(clearance) expanded = shape.tile.offset(clearance)
grown = None # exact clearance region, built lazily only for diagonals grown = None # exact clearance region, built lazily only for diagonals
for other in self.overlapping(expanded): for other in self.overlapping(expanded):
@@ -214,3 +237,36 @@ class ShapeSearchTree:
if overlaps_2d(grown, other_tile): if overlaps_2d(grown, other_tile):
return shape return shape
return None return None
def violation_pairs(self, clearance: int) -> list[tuple[TreeShape, TreeShape, bool]]:
"""Every different-net pair sharing a layer, at least one of them
routed, whose coppers come closer than ``clearance``: ``(routed tile,
other tile, touching)``. ``touching`` is True when the coppers
themselves overlap (a short, or copper inside a keepout); False for a
clearance violation. Each pair of tiles is reported once.
:meth:`has_violation` asks whether there is one; this lists them all,
for a report on copper that no engine has checked.
"""
pairs: list[tuple[TreeShape, TreeShape, bool]] = []
seen: set[tuple[int, int]] = set()
for shape in self.all_shapes():
if not shape.routed:
continue
mine = shape.exact if shape.exact is not None else shape.tile
grown = shape.clearance_region(clearance)
for other in self.overlapping(grown.bounding_box()):
if other is shape or other.net_no == shape.net_no:
continue
if not (shape.layers & other.layers):
continue
key = (min(id(shape), id(other)), max(id(shape), id(other)))
if key in seen:
continue
theirs = other.exact if other.exact is not None else other.tile
touching = overlaps_2d(mine, theirs)
if not touching and (not other.clearance or not overlaps_2d(grown, theirs)):
continue # a rule area only counts an overlap
seen.add(key)
pairs.append((shape, other, touching))
return pairs
@@ -8,7 +8,7 @@ resolution`` and zero offset (see ``Structure.create_board``).
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import FloatPoint, IntPoint from rpmroute.geometry import FloatPoint, IntPoint
__all__ = ["CoordinateTransform"] __all__ = ["CoordinateTransform"]
+194 -20
View File
@@ -1,8 +1,8 @@
"""freeroute command-line interface: Specctra DSN in, routed SES out. """rpmroute command-line interface: Specctra DSN in, routed SES out.
Flag-compatible with the FreeRouting JAR (``-de`` / ``-do`` / ``-mp``) so it can Flag-compatible with the FreeRouting JAR (``-de`` / ``-do`` / ``-mp``) so it can
be dropped in wherever ``java -jar freerouting.jar`` was invoked, plus the be dropped in wherever ``java -jar freerouting.jar`` was invoked, plus the
freeroute-specific engine selection (``--engine``) and its opt-in passes. rpmroute-specific engine selection (``--engine``) and its opt-in passes.
Not every engine supports every pass. The matrix below is the single source of Not every engine supports every pass. The matrix below is the single source of
truth; an unsupported combination is an error, never a silent no-op:: truth; an unsupported combination is an error, never a silent no-op::
@@ -18,20 +18,28 @@ from __future__ import annotations
import argparse import argparse
import sys import sys
from freeroute.dsn.reader import parse_dsn from rpmroute.dsn.reader import parse_dsn
from freeroute.route.orders import best_of_orders, promote_failures from rpmroute.route.orders import best_of_orders, promote_failures
from freeroute.route.pipeline import ( from rpmroute.route.pipeline import (
build_exact_routing_result, build_exact_routing_result,
build_rooms_routing_result, build_rooms_routing_result,
build_routing_result, build_routing_result,
) )
from freeroute.ses import write_ses from rpmroute.route.smooth import smooth
from rpmroute.route.verify import verify_result
from rpmroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for #: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored. #: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = { ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}), "grid": frozenset({
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}), "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback",
"tree", "via_cost",
}),
"exact": frozenset({
"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access",
"negotiate", "rollback", "tree", "window", "vias", "via_cost", "smooth",
}),
"room": frozenset({"shove", "pack"}), "room": frozenset({"shove", "pack"}),
} }
@@ -46,7 +54,13 @@ _FLAG_NAMES = {
"orders": "--orders", "orders": "--orders",
"promote": "--promote-failures", "promote": "--promote-failures",
"access": "--access-cost", "access": "--access-cost",
"negotiate": "--negotiate",
"rollback": "--no-rollback", "rollback": "--no-rollback",
"tree": "--no-tree",
"window": "--window-reroute",
"vias": "--reduce-vias",
"via_cost": "--via-cost",
"smooth": "--smooth",
} }
@@ -68,7 +82,7 @@ def _validate(parser: argparse.ArgumentParser, engine: str, requested: list[str]
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="freeroute", prog="rpmroute",
description="Native PCB autorouter — route a Specctra DSN and write an SES session file.", description="Native PCB autorouter — route a Specctra DSN and write an SES session file.",
) )
parser.add_argument("dsn", nargs="?", help="input Specctra .dsn file") parser.add_argument("dsn", nargs="?", help="input Specctra .dsn file")
@@ -88,11 +102,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument( parser.add_argument(
"--engine", "--engine",
choices=sorted(ENGINE_OPTIONS), choices=sorted(ENGINE_OPTIONS),
default="grid", default="exact",
help=( help=(
"routing track: grid (default; multi-layer rip-up maze, highest coverage), " "routing track: exact (default; every route checked against exact pad and "
"exact (orthogonal, exact-geometry DRC-verified output), " "copper shapes, DRC-clean output), grid (quick maze on the grid alone: its "
"room (continuous expansion rooms, routes off-grid channels)" "copper is NOT clearance-checked and can short), room (continuous expansion "
"rooms, routes off-grid channels; not clearance-checked either)"
), ),
) )
parser.add_argument( parser.add_argument(
@@ -167,6 +182,27 @@ def build_parser() -> argparse.ArgumentParser:
help="grid and exact engines: extra cost for a route to pass where it would close " help="grid and exact engines: extra cost for a route to pass where it would close "
"another pin's way out while that pin still has connections to route (default 0: off)", "another pin's way out while that pin still has connections to route (default 0: off)",
) )
parser.add_argument(
"--negotiate",
type=int,
metavar="N",
help="grid and exact engines: before routing, N rounds of negotiation on the empty "
"board, where routes may share space at a price that rises where they compete; "
"the contested cells then cost extra in the real routing (default 0: off)",
)
parser.add_argument(
"--smooth",
action="store_true",
help="exact engine: after routing, turn grid staircases into straight and 45-degree "
"runs and chamfer right-angle corners; same nets, ends, vias and layers, and every "
"new piece of copper checked on exact shapes against the board edge, other nets "
"and keepouts",
)
parser.add_argument(
"--smooth-report",
action="store_true",
help="with --smooth (implies it): also print track length per layer before and after",
)
parser.add_argument( parser.add_argument(
"--no-rollback", "--no-rollback",
dest="rollback", dest="rollback",
@@ -175,6 +211,84 @@ def build_parser() -> argparse.ArgumentParser:
"connections a rip-up displaces are rerouted at once and the rip-up is undone " "connections a rip-up displaces are rerouted at once and the rip-up is undone "
"if fewer connections end up routed", "if fewer connections end up routed",
) )
parser.add_argument(
"--no-tree",
dest="tree_nets",
action="store_false",
help="grid and exact engines: join a multi-pin net's pins as a chain in pin "
"order, each connection ending on one chosen pin, as before. By default they are "
"paired by a minimum spanning tree and a connection may end on copper its net "
"has already routed",
)
parser.add_argument(
"--window-reroute",
type=int,
metavar="N",
help="exact engine: after routing, N rounds of window re-routes: around each "
"connection still unrouted, the nets routed wholly inside a window are ripped and "
"the window routed again on a finer grid, kept only if fewer connections are left "
"unrouted; each round doubles the window's margin (default 0: off)",
)
parser.add_argument(
"--window-resolution",
type=int,
default=4,
metavar="K",
help="the window re-route's grid resolution (default 4)",
)
parser.add_argument(
"--window-budget",
type=float,
default=0.25,
metavar="F",
help="the window re-route stops starting windows once it has spent this share "
"of the routing's own time (default 0.25)",
)
parser.add_argument(
"--window-cap",
type=int,
default=40,
metavar="W",
help="at most this many windows per round, smallest first (default 40)",
)
parser.add_argument(
"--window-margin",
type=float,
default=1.5,
metavar="MM",
help="the first round's window margin around the stuck connection's pins (default 1.5)",
)
parser.add_argument(
"--via-cost",
type=float,
default=None,
metavar="C",
help="grid and exact engines: what a layer change costs the search, in grid "
"steps of track (default 10)",
)
parser.add_argument(
"--reduce-vias",
action="store_true",
default=None,
help="exact engine: after routing, route each net that has vias again on its own "
"with vias made dearer, everything else held fixed; its new copper is kept only "
"if it fails nothing new and has fewer vias",
)
parser.add_argument(
"--reduce-vias-budget",
type=float,
default=0.5,
metavar="F",
help="via reduction stops starting nets once it has spent this share of the "
"routing's own time (default 0.5)",
)
parser.add_argument(
"--reduce-vias-margin",
type=float,
default=2.0,
metavar="MM",
help="how far past its pins and copper a net may go when routed again (default 2.0)",
)
parser.add_argument( parser.add_argument(
"--hole-to-hole", "--hole-to-hole",
type=float, type=float,
@@ -189,6 +303,15 @@ def build_parser() -> argparse.ArgumentParser:
metavar="J", metavar="J",
help="processes for --orders (default: one per order, up to the CPU count)", help="processes for --orders (default: one per order, up to the CPU count)",
) )
parser.add_argument(
"--strict",
action="store_true",
help=(
"exit with status 3 when the routed copper has shorts or clearance "
"violations (grid and room output is checked after routing; the "
"session file is still written)"
),
)
return parser return parser
@@ -212,7 +335,13 @@ def main(argv: list[str] | None = None) -> int:
("orders", args.orders is not None), ("orders", args.orders is not None),
("promote", args.promote_failures is not None), ("promote", args.promote_failures is not None),
("access", args.access_cost is not None), ("access", args.access_cost is not None),
("negotiate", args.negotiate is not None),
("rollback", not args.rollback), ("rollback", not args.rollback),
("tree", not args.tree_nets),
("window", args.window_reroute is not None),
("vias", args.reduce_vias is not None),
("via_cost", args.via_cost is not None),
("smooth", args.smooth or args.smooth_report),
) )
if asked if asked
] ]
@@ -227,7 +356,7 @@ def main(argv: list[str] | None = None) -> int:
with open(dsn_path, encoding="utf-8") as f: with open(dsn_path, encoding="utf-8") as f:
dsn_text = f.read() dsn_text = f.read()
except OSError as e: except OSError as e:
print(f"freeroute: cannot read {dsn_path}: {e}", file=sys.stderr) print(f"rpmroute: cannot read {dsn_path}: {e}", file=sys.stderr)
return 2 return 2
dsn = parse_dsn(dsn_text) dsn = parse_dsn(dsn_text)
@@ -247,9 +376,21 @@ def main(argv: list[str] | None = None) -> int:
"resolution": 2 if args.grid_resolution is None else args.grid_resolution, "resolution": 2 if args.grid_resolution is None else args.grid_resolution,
"heuristic_weight": args.heuristic_weight, "heuristic_weight": args.heuristic_weight,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"negotiate": args.negotiate or 0,
"hole_to_hole_mm": args.hole_to_hole, "hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback, "rollback": args.rollback,
"tree_nets": args.tree_nets,
"window_reroute": args.window_reroute or 0,
"window_resolution": args.window_resolution,
"window_margin_mm": args.window_margin,
"window_budget": args.window_budget,
"window_cap": args.window_cap,
"reduce_vias": bool(args.reduce_vias),
"reduce_vias_budget": args.reduce_vias_budget,
"reduce_vias_margin_mm": args.reduce_vias_margin,
} }
if args.via_cost is not None:
options["via_cost"] = args.via_cost
elif args.engine == "grid": elif args.engine == "grid":
options = { options = {
"layers": layers, "layers": layers,
@@ -258,9 +399,13 @@ def main(argv: list[str] | None = None) -> int:
"edge_clearance_mm": args.edge_clearance, "edge_clearance_mm": args.edge_clearance,
"resolution": 1 if args.grid_resolution is None else args.grid_resolution, "resolution": 1 if args.grid_resolution is None else args.grid_resolution,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"negotiate": args.negotiate or 0,
"hole_to_hole_mm": args.hole_to_hole, "hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback, "rollback": args.rollback,
"tree_nets": args.tree_nets,
} }
if args.via_cost is not None:
options["via_cost"] = args.via_cost
if args.engine == "room": if args.engine == "room":
result = build_rooms_routing_result( result = build_rooms_routing_result(
dsn, layers=layers, shove=args.shove, pack=args.pack, dsn, layers=layers, shove=args.shove, pack=args.pack,
@@ -273,14 +418,14 @@ def main(argv: list[str] | None = None) -> int:
dsn_text, args.engine, options, rounds=args.promote_failures, seed=args.seed dsn_text, args.engine, options, rounds=args.promote_failures, seed=args.seed
) )
counts = ", ".join(str(m) for m, _z in rounds) counts = ", ".join(str(m) for m, _z in rounds)
print(f"freeroute: {len(rounds)} rounds, unrouted {counts}; kept the best", file=sys.stderr) print(f"rpmroute: {len(rounds)} rounds, unrouted {counts}; kept the best", file=sys.stderr)
elif args.orders and args.orders > 1: elif args.orders and args.orders > 1:
result, kept, tried = best_of_orders( result, kept, tried = best_of_orders(
dsn_text, args.engine, options, orders=args.orders, seed=args.seed, jobs=args.jobs 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) counts = ", ".join(f"{'default' if s is None else s}: {m}" for s, (m, _z) in tried)
print( print(
f"freeroute: tried {len(tried)} orders, kept " f"rpmroute: tried {len(tried)} orders, kept "
f"{'the default order' if kept is None else f'--seed {kept}'} (unrouted {counts})", f"{'the default order' if kept is None else f'--seed {kept}'} (unrouted {counts})",
file=sys.stderr, file=sys.stderr,
) )
@@ -289,17 +434,24 @@ def main(argv: list[str] | None = None) -> int:
else: else:
result = build_routing_result(dsn, seed=args.seed, **options) result = build_routing_result(dsn, seed=args.seed, **options)
if args.smooth or args.smooth_report:
report = smooth(dsn, result, edge_clearance_mm=args.edge_clearance)
print(f"rpmroute: {report.line()}", file=sys.stderr)
if args.smooth_report:
for row in report.table():
print(row, file=sys.stderr)
ses_text = write_ses(dsn, result) ses_text = write_ses(dsn, result)
missing = [u for u in result.unrouted if not u[3]] missing = [u for u in result.unrouted if not u[3]]
in_zone = [u for u in result.unrouted if u[3]] in_zone = [u for u in result.unrouted if u[3]]
if missing: if missing:
# the SES is still written; say plainly what it is missing # the SES is still written; say plainly what it is missing
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr) print(f"rpmroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
for net, a, b, _zone in missing: for net, a, b, _zone in missing:
print(f" {net}: {a} to {b}", file=sys.stderr) print(f" {net}: {a} to {b}", file=sys.stderr)
if result.narrowed: if result.narrowed:
print( print(
f"freeroute: {len(result.narrowed)} connection(s) of a wider net class only fitted " f"rpmroute: {len(result.narrowed)} connection(s) of a wider net class only fitted "
"at the default width; their nets are written at the default width:", "at the default width; their nets are written at the default width:",
file=sys.stderr, file=sys.stderr,
) )
@@ -307,18 +459,40 @@ def main(argv: list[str] | None = None) -> int:
print(f" {net}: {a} to {b}", file=sys.stderr) print(f" {net}: {a} to {b}", file=sys.stderr)
if in_zone: if in_zone:
print( print(
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:", f"rpmroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
file=sys.stderr, file=sys.stderr,
) )
for net, a, b, zone in in_zone: for net, a, b, zone in in_zone:
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr) print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
flawed = False
if args.engine != "exact":
# These engines route on approximations and do not check the copper
# they write: check it now against the exact shapes the exact engine
# uses, so shorts are reported rather than shipped.
check = verify_result(dsn, result)
flawed = not check.clean
print(
f"rpmroute: {args.engine} output checked on exact shapes: "
f"{check.shorts} short(s), {check.clearance} clearance violation(s)",
file=sys.stderr,
)
for kind, examples in (("short", check.short_examples), ("clearance", check.clearance_examples)):
for found in examples:
print(f" {kind}: {found}", file=sys.stderr)
if flawed:
print(
f"rpmroute: WARNING: this {args.engine} routing is not DRC-clean; fix it in "
"KiCad or route with --engine exact (the default)",
file=sys.stderr,
)
if args.output: if args.output:
with open(args.output, "w", encoding="utf-8") as f: with open(args.output, "w", encoding="utf-8") as f:
f.write(ses_text) f.write(ses_text)
else: else:
sys.stdout.write(ses_text) sys.stdout.write(ses_text)
return 0 return 3 if (flawed and args.strict) else 0
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,13 +1,13 @@
"""Specctra DSN parsing for freeroute. """Specctra DSN parsing for rpmroute.
Public API:: Public API::
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
board = parse_dsn(dsn_text) board = parse_dsn(dsn_text)
``parse_dsn`` returns a typed :class:`~freeroute.dsn.model.DsnBoard`. The ``parse_dsn`` returns a typed :class:`~rpmroute.dsn.model.DsnBoard`. The
lower-level :func:`~freeroute.dsn.tokenizer.tokenize` and lower-level :func:`~rpmroute.dsn.tokenizer.tokenize` and
:func:`~freeroute.dsn.sexp.parse` are exposed for tooling and tests. :func:`~rpmroute.dsn.sexp.parse` are exposed for tooling and tests.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,7 +22,7 @@ class SExp:
``head`` is the text of the first token when it is an atom (e.g. ``pcb``, ``head`` is the text of the first token when it is an atom (e.g. ``pcb``,
``layer``, ``net``), else ``None``. ``items`` holds the remaining elements, ``layer``, ``net``), else ``None``. ``items`` holds the remaining elements,
each either a :class:`~freeroute.dsn.tokenizer.Token` (a leaf atom/string) each either a :class:`~rpmroute.dsn.tokenizer.Token` (a leaf atom/string)
or a nested :class:`SExp`. or a nested :class:`SExp`.
""" """
@@ -1,4 +1,4 @@
"""Planar geometry primitives for freeroute. """Planar geometry primitives for rpmroute.
A port of the subset of FreeRouting's ``geometry/planar`` package the router A port of the subset of FreeRouting's ``geometry/planar`` package the router
needs, with the exact-arithmetic types preserved. Arithmetic model per type: needs, with the exact-arithmetic types preserved. Arithmetic model per type:
@@ -21,7 +21,7 @@ from .direction import Direction, IntDirection
from .float_point import FloatPoint from .float_point import FloatPoint
from .limits import CRIT_DOUBLE, CRIT_INT from .limits import CRIT_DOUBLE, CRIT_INT
from .line import Line from .line import Line
from .octagon import ceil_sqrt2, overlaps_2d, segment_octagon from .octagon import ceil_sqrt2, overlaps_2d, segment_octagon, segment_slab
from .point import IntPoint, Point, RationalPoint, point, rational_point from .point import IntPoint, Point, RationalPoint, point, rational_point
from .polygon import Polygon from .polygon import Polygon
from .polygon_shape import PolygonShape from .polygon_shape import PolygonShape
@@ -59,5 +59,6 @@ __all__ = [
"shove_segment", "shove_segment",
"ceil_sqrt2", "ceil_sqrt2",
"segment_octagon", "segment_octagon",
"segment_slab",
"overlaps_2d", "overlaps_2d",
] ]
@@ -231,7 +231,7 @@ class IntBox:
return [FloatPoint(c.x, c.y) for c in self.corners()] return [FloatPoint(c.x, c.y) for c in self.corners()]
def to_simplex(self): def to_simplex(self):
"""Return this box as a :class:`~freeroute.geometry.simplex.Simplex`. """Return this box as a :class:`~rpmroute.geometry.simplex.Simplex`.
Four directed border lines whose common right side is the box interior Four directed border lines whose common right side is the box interior
(ports ``IntBox.to_Simplex``). (ports ``IntBox.to_Simplex``).
@@ -6,7 +6,7 @@ Ports ``geometry/planar/FloatPoint.java``.
keeps ``FloatPoint`` *outside* the exact ``Point`` hierarchy because float math keeps ``FloatPoint`` *outside* the exact ``Point`` hierarchy because float math
is inexact; it is used for distances, rounding, and speed-over-accuracy paths. is inexact; it is used for distances, rounding, and speed-over-accuracy paths.
Its :meth:`side_of` has no reliable ``COLLINEAR`` result (numerical noise), so Its :meth:`side_of` has no reliable ``COLLINEAR`` result (numerical noise), so
exact side tests must go through :class:`~freeroute.geometry.point.Point`. exact side tests must go through :class:`~rpmroute.geometry.point.Point`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -137,13 +137,13 @@ class FloatPoint:
# --- rounding to integer points ---------------------------------------- # --- rounding to integer points ----------------------------------------
def round(self): def round(self):
"""Round to the nearest :class:`~freeroute.geometry.point.IntPoint`.""" """Round to the nearest :class:`~rpmroute.geometry.point.IntPoint`."""
from .point import IntPoint from .point import IntPoint
return IntPoint(_round_half_up(self.x), _round_half_up(self.y)) return IntPoint(_round_half_up(self.x), _round_half_up(self.y))
def bounding_box(self): def bounding_box(self):
"""Smallest :class:`~freeroute.geometry.box.IntBox` containing this point.""" """Smallest :class:`~rpmroute.geometry.box.IntBox` containing this point."""
from .box import IntBox from .box import IntBox
return IntBox( return IntBox(
@@ -2,12 +2,12 @@
Ports ``geometry/planar/Line.java`` (the subset needed for the router's Ports ``geometry/planar/Line.java`` (the subset needed for the router's
half-plane work). Like the upstream class, a ``Line`` is defined by two half-plane work). Like the upstream class, a ``Line`` is defined by two
:class:`~freeroute.geometry.point.IntPoint` endpoints. :class:`~rpmroute.geometry.point.IntPoint` endpoints.
**Arithmetic model: exact.** :meth:`side_of` uses an exact integer determinant, **Arithmetic model: exact.** :meth:`side_of` uses an exact integer determinant,
and :meth:`intersection` returns an :class:`~freeroute.geometry.point.IntPoint` and :meth:`intersection` returns an :class:`~rpmroute.geometry.point.IntPoint`
when the crossing is integral and a when the crossing is integral and a
:class:`~freeroute.geometry.point.RationalPoint` otherwise (``z == 0`` when the :class:`~rpmroute.geometry.point.RationalPoint` otherwise (``z == 0`` when the
lines are parallel — the "point at infinity"). :meth:`intersection_approx` is lines are parallel — the "point at infinity"). :meth:`intersection_approx` is
the fast ``float`` path. the fast ``float`` path.
""" """
@@ -29,7 +29,7 @@ from .line import Line
from .point import IntPoint from .point import IntPoint
from .simplex import Simplex from .simplex import Simplex
__all__ = ["ceil_sqrt2", "segment_octagon", "overlaps_2d"] __all__ = ["ceil_sqrt2", "segment_octagon", "segment_slab", "overlaps_2d"]
def ceil_sqrt2(radius: int) -> int: def ceil_sqrt2(radius: int) -> int:
@@ -82,6 +82,46 @@ def segment_octagon(a: IntPoint, b: IntPoint, radius: int) -> Simplex:
return Simplex.get_instance(lines) return Simplex.get_instance(lines)
def segment_slab(a: IntPoint, b: IntPoint, radius: int) -> Simplex:
"""Convex cover of segment ``a``-``b`` swept by ``radius``, at any angle.
The rectangle along the segment, ``radius`` either side, with flush ends
like :func:`polyline.segment_box`: the ends of a trace leg sit on a pad or
on the square corner tile of the next leg, which cover them. Corners are
rounded away from the middle and the radius gets one unit more, so the
side bounds never under-report, and it stays tight for a slanted segment
off the 45-degree lines (a pin leaving along its pad's axis on a part
turned 30 degrees).
"""
dx, dy = b.x - a.x, b.y - a.y
length = math.hypot(dx, dy)
r = max(radius, 0) + 1
if length == 0:
return Simplex.from_corners([
IntPoint(a.x - r, a.y - r), IntPoint(a.x + r, a.y - r),
IntPoint(a.x + r, a.y + r), IntPoint(a.x - r, a.y + r),
])
ux, uy = dx / length, dy / length
nx, ny = -uy, ux
mx, my = (a.x + b.x) / 2, (a.y + b.y) / 2
def out(x: float, y: float) -> IntPoint:
return IntPoint(math.ceil(x) if x >= mx else math.floor(x),
math.ceil(y) if y >= my else math.floor(y))
corners = [
out(a.x + nx * r, a.y + ny * r),
out(b.x + nx * r, b.y + ny * r),
out(b.x - nx * r, b.y - ny * r),
out(a.x - nx * r, a.y - ny * r),
]
for winding in (corners, corners[::-1]):
slab = Simplex.from_corners(winding)
if not slab.is_empty():
return slab
return Simplex(())
def overlaps_2d(shape_a, shape_b) -> bool: def overlaps_2d(shape_a, shape_b) -> bool:
"""True if the two convex tiles share 2-D area (a real clearance overlap). """True if the two convex tiles share 2-D area (a real clearance overlap).
@@ -1,7 +1,7 @@
"""``Polygon`` — a normalized corner list (no duplicate or collinear corners). """``Polygon`` — a normalized corner list (no duplicate or collinear corners).
Ports ``geometry/planar/Polygon.java``. Used as the corner normalizer behind Ports ``geometry/planar/Polygon.java``. Used as the corner normalizer behind
:class:`~freeroute.geometry.polygon_shape.PolygonShape`. :class:`~rpmroute.geometry.polygon_shape.PolygonShape`.
**Arithmetic model:** corner de-duplication and collinearity removal are **Arithmetic model:** corner de-duplication and collinearity removal are
**exact** (exact ``Point.side_of``); the winding-number test is approximate **exact** (exact ``Point.side_of``); the winding-number test is approximate
@@ -2,7 +2,7 @@
Ports ``geometry/planar/PolygonShape.java``, in particular the recursive Ports ``geometry/planar/PolygonShape.java``, in particular the recursive
``split_to_convex`` that decomposes a simple polygon into convex ``split_to_convex`` that decomposes a simple polygon into convex
:class:`~freeroute.geometry.simplex.Simplex` tiles — the decomposition the :class:`~rpmroute.geometry.simplex.Simplex` tiles — the decomposition the
router's search tree relies on. router's search tree relies on.
**Arithmetic model:** corner orientation / convexity tests are **exact** **Arithmetic model:** corner orientation / convexity tests are **exact**
@@ -7,7 +7,7 @@ trace half-width — the copper region — as a set of exact convex tiles.
**Arithmetic model: exact, integer.** The router that uses this routes **Arithmetic model: exact, integer.** The router that uses this routes
*orthogonally* (axis-aligned segments), so each swept segment is an exact *orthogonally* (axis-aligned segments), so each swept segment is an exact
:class:`~freeroute.geometry.box.IntBox` (centreline offset by the half-width, :class:`~rpmroute.geometry.box.IntBox` (centreline offset by the half-width,
squared at the ends). The 45-degree case (which needs ``IntOctagon`` caps to squared at the ends). The 45-degree case (which needs ``IntOctagon`` caps to
stay exact) is deferred; ``segment_box`` returns the axis-aligned bounding box stay exact) is deferred; ``segment_box`` returns the axis-aligned bounding box
for a diagonal segment, which is a *conservative* cover (it never under-reports for a diagonal segment, which is a *conservative* cover (it never under-reports
@@ -1,10 +1,10 @@
"""``Simplex`` — a convex region as the intersection of directed half-planes. """``Simplex`` — a convex region as the intersection of directed half-planes.
Ports ``geometry/planar/Simplex.java``. A simplex is defined by an array of Ports ``geometry/planar/Simplex.java``. A simplex is defined by an array of
directed :class:`~freeroute.geometry.line.Line`s sorted by direction angle; the directed :class:`~rpmroute.geometry.line.Line`s sorted by direction angle; the
region is the intersection of the *right* half-plane of each line. Corners are region is the intersection of the *right* half-plane of each line. Corners are
the exact intersections of consecutive border lines the exact intersections of consecutive border lines
(:meth:`~freeroute.geometry.line.Line.intersection`). (:meth:`~rpmroute.geometry.line.Line.intersection`).
**Arithmetic model: exact where it matters.** Corners and all point-containment **Arithmetic model: exact where it matters.** Corners and all point-containment
tests are exact (exact line intersection + exact ``side_of``). ``offset`` is tests are exact (exact line intersection + exact ``side_of``). ``offset`` is
@@ -3,11 +3,11 @@
Ports the concrete (non-abstract) behaviour of ``geometry/planar/TileShape.java`` Ports the concrete (non-abstract) behaviour of ``geometry/planar/TileShape.java``
that is defined purely in terms of the border lines and corners: point that is defined purely in terms of the border lines and corners: point
containment, area, centre of gravity, and border tests. Concrete subclasses containment, area, centre of gravity, and border tests. Concrete subclasses
(:class:`~freeroute.geometry.simplex.Simplex`) supply ``border_line_count``, (:class:`~rpmroute.geometry.simplex.Simplex`) supply ``border_line_count``,
``border_line``, ``corner`` and ``corner_approx``. ``border_line``, ``corner`` and ``corner_approx``.
**Arithmetic model:** containment and ``contains_on_border`` are **exact** **Arithmetic model:** containment and ``contains_on_border`` are **exact**
(they use the exact :meth:`~freeroute.geometry.line.Line.side_of`); ``area`` and (they use the exact :meth:`~rpmroute.geometry.line.Line.side_of`); ``area`` and
``centre_of_gravity`` are approximate (``float``), as upstream. ``centre_of_gravity`` are approximate (``float``), as upstream.
A convex tile is the intersection of the right half-planes of its directed A convex tile is the intersection of the right half-planes of its directed
@@ -13,7 +13,7 @@ Ports ``geometry/planar/{Vector,IntVector,RationalVector}.java``.
representing the affine vector ``(x/z, y/z)``; ``z`` is kept non-negative. representing the affine vector ``(x/z, y/z)``; ``z`` is kept non-negative.
Mirrors the upstream ``BigInteger`` triple. ``BigIntDirection`` is not needed Mirrors the upstream ``BigInteger`` triple. ``BigIntDirection`` is not needed
because ``int`` is unbounded, so normalized directions are always because ``int`` is unbounded, so normalized directions are always
:class:`~freeroute.geometry.direction.IntDirection`. :class:`~rpmroute.geometry.direction.IntDirection`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -1,9 +1,9 @@
"""Autorouting for freeroute. """Autorouting for rpmroute.
An MVP grid-based maze router and the DSN-in / SES-out pipeline that makes An MVP grid-based maze router and the DSN-in / SES-out pipeline that makes
freeroute a Java-free replacement for the ``freerouting.jar`` step:: rpmroute a Java-free replacement for the ``freerouting.jar`` step::
from freeroute.route import route from rpmroute.route import route
ses_text = route(dsn_text) ses_text = route(dsn_text)
The router (:class:`GridRouter`) is intentionally simple — single-layer A* over The router (:class:`GridRouter`) is intentionally simple — single-layer A* over
@@ -1,18 +1,18 @@
"""Exact-geometry routing track (orthogonal), built on the search tree. """Exact-geometry routing track (orthogonal), built on the search tree.
The grid MVP (:mod:`freeroute.route.grid_router`) approximates clearance with The grid MVP (:mod:`rpmroute.route.grid_router`) approximates clearance with
cell occupancy and emits centrelines with no width-aware DRC check. This track cell occupancy and emits centrelines with no width-aware DRC check. This track
keeps the grid's rip-up/multi-layer/via *topology* search (in orthogonal mode, keeps the grid's rip-up/multi-layer/via *topology* search (in orthogonal mode,
so segments are axis-aligned) but replaces the occupancy check with an **exact** so segments are axis-aligned) but replaces the occupancy check with an **exact**
one: every item's copper is an exact :class:`~freeroute.geometry.IntBox` tile one: every item's copper is an exact :class:`~rpmroute.geometry.IntBox` tile
indexed in a :class:`~freeroute.board.search_tree.ShapeSearchTree`, and the indexed in a :class:`~rpmroute.board.search_tree.ShapeSearchTree`, and the
routed board is verified to have **no clearance violation** by exact tile routed board is verified to have **no clearance violation** by exact tile
intersection. A net whose copper would violate is dropped, so the output is intersection. A net whose copper would violate is dropped, so the output is
DRC-clean by construction. DRC-clean by construction.
**Shove (opt-in, ``shove=True``).** Before a dropped net is abandoned, the **Shove (opt-in, ``shove=True``).** Before a dropped net is abandoned, the
router tries to make room by *moving an existing trace aside* geometrically router tries to make room by *moving an existing trace aside* geometrically
(:func:`~freeroute.geometry.shove_segment`) rather than dropping the incoming (:func:`~rpmroute.geometry.shove_segment`) rather than dropping the incoming
net. A shove is accepted only if the moved trace still connects its pads, clears net. A shove is accepted only if the moved trace still connects its pads, clears
every other item exactly (via the tree), and stays in the board outline — so every other item exactly (via the tree), and stays in the board outline — so
cleanliness is preserved. Shoves are bounded (``max_shove_depth`` and a cleanliness is preserved. Shoves are bounded (``max_shove_depth`` and a
@@ -30,19 +30,21 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
import math import math
from freeroute.board.board import BasicBoard from rpmroute.board.board import BasicBoard
from freeroute.board.items import Pin, Via from rpmroute.board.items import Pin, Via
from freeroute.board.search_tree import ShapeSearchTree, TreeShape from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import ( from rpmroute.geometry import (
IntBox, IntBox,
IntPoint, IntPoint,
Polyline, Polyline,
PolylineShape, PolylineShape,
Simplex,
segment_box, segment_box,
segment_octagon, segment_octagon,
segment_slab,
shove_segment, shove_segment,
) )
from freeroute.geometry.polyline import corner_box from rpmroute.geometry.polyline import corner_box
from .grid_router import RouteResult, route_board from .grid_router import RouteResult, route_board
@@ -98,10 +100,14 @@ def route_board_exact(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
negotiate: int = 0,
rollback: bool = True, rollback: bool = True,
tree_nets: bool = True,
net_widths: dict[int, int] | None = None, net_widths: dict[int, int] | None = None,
via_drill: int | None = None, via_drill: int | None = None,
hole_to_hole: int = 0, hole_to_hole: int = 0,
only_nets: frozenset | None = None,
bounds: IntBox | None = None,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -133,10 +139,14 @@ def route_board_exact(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
negotiate=negotiate,
rollback=rollback, rollback=rollback,
tree_nets=tree_nets,
net_widths=net_widths, net_widths=net_widths,
via_drill=via_drill, via_drill=via_drill,
hole_to_hole=hole_to_hole, hole_to_hole=hole_to_hole,
only_nets=only_nets,
bounds=bounds,
) )
edge = 0 if edge_clearance is None else edge_clearance edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline outline = board.outline
@@ -145,36 +155,7 @@ def route_board_exact(
owner = _counter() owner = _counter()
# static items: pad and keepout copper (routed=False) # static items: pad and keepout copper (routed=False)
for pin in board.get_pins(): insert_static_copper(tree, board, signal_layers, owner)
if pin.shape is None:
continue
box = pin.shape.bounding_box()
if box.is_empty():
continue
net = pin.net_nos[0] if pin.net_nos else -1
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
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,
blocks=blocks, clearance=obstacle.clearance,
)
)
via_layers = frozenset(signal_layers) via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {} accepted: dict[int, _Accepted] = {}
@@ -188,25 +169,43 @@ def route_board_exact(
# 25-pin supply net costs that one connection, not the whole net. # 25-pin supply net costs that one connection, not the whole net.
rejected: list[tuple[int, str, str]] = [] rejected: list[tuple[int, str, str]] = []
ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections) ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections)
anchors_list = getattr(result, "connection_anchors", None) or [()] * len(result.connections)
# each net is checked at the width it will be written at: its class's # each net is checked at the width it will be written at: its class's
# when all of its connections went in at that, else the default # when all of its connections went in at that, else the default
net_half = dict(result.net_half) net_half = dict(result.net_half)
for (net_no, wires, vias), ends in sorted( # a connection that ended on another's copper (a T into its net's tree)
zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0] # dangles if that one is dropped, so it is dropped too; connections come
# in commit order, anchors first
dropped_at: set[int] = set()
for pos, ((net_no, wires, vias), ends, anchors) in sorted(
enumerate(zip(result.connections, ends_list, anchors_list, strict=False)),
key=lambda c: c[1][0][0],
): ):
if any(a in dropped_at for a in anchors):
dropped_at.add(pos)
rejected.append((net_no, *ends))
continue
wire_half = net_half.get(net_no, half_width) wire_half = net_half.get(net_no, half_width)
boxes = _copper_boxes(wires, vias, wire_half, via_layers, via_half) pieces = _copper_pieces(wires, vias, wire_half, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], wire_half, via_layers, via_half)) boxes = [(box, box_layers) for box, box_layers, _ in pieces]
wire_count = len(_copper_pieces(wires, [], wire_half, via_layers, via_half))
if any( if any(
tree.clearance_conflict( (
box, net_no, layer, clearance, "wire" if i < wire_count else "via" tree.segment_conflict(*seg, wire_half, net_no, layer, clearance)
if seg is not None
else tree.clearance_conflict(
box, net_no, layer, clearance, "wire" if i < wire_count else "via"
)
) )
for i, (box, box_layers) in enumerate(boxes) for i, (box, box_layers, seg) in enumerate(pieces)
for layer in box_layers for layer in box_layers
) or _end_in_rule_area(tree, wires, wire_half) or ( ) or _end_in_rule_area(tree, wires, wire_half) or (
outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes) outline is not None and not _inside_outline(
outline, boxes[:wire_count], vias, (via_half or wire_half) + edge, edge
)
): ):
rejected.append((net_no, *ends)) rejected.append((net_no, *ends))
dropped_at.add(pos)
continue # exact-clearance conflict -> drop (may be recovered by shove) continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = accepted.get(net_no) acc = accepted.get(net_no)
if acc is None: if acc is None:
@@ -218,16 +217,25 @@ def route_board_exact(
v for v in dict.fromkeys(vias) v for v in dict.fromkeys(vias)
if v not in acc.vias and not _on_fixed_via(net_no, v, fixed_vias, via_half) if v not in acc.vias and not _on_fixed_via(net_no, v, fixed_vias, via_half)
) )
for box, box_layers in boxes: for box, box_layers, seg in pieces:
oid = next(owner) oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True)) if seg is None:
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
else: # a slanted pin exit: its exact copper, rebuilt for the DRC check
tree.insert(TreeShape(
oid, net_no, box_layers, box, routed=True,
exact=segment_slab(*seg, wire_half), seg=("slab", *seg, wire_half),
))
acc.owners.append(oid) acc.owners.append(oid)
accepted_before_recovery = set(accepted) accepted_before_recovery = set(accepted)
# a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer) # a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer)
# but was not accepted — whether the grid failed to route it or its exact # but was not accepted — whether the grid failed to route it or its exact
# copper conflicted. # copper conflicted.
dropped = sorted(_routable_nets(board, signal_layers) - set(accepted)) routable = _routable_nets(board, signal_layers)
if only_nets is not None: # a window re-route: the other nets are not ours to route
routable &= set(only_nets)
dropped = sorted(routable - set(accepted))
shoves = 0 shoves = 0
if shove and dropped: if shove and dropped:
@@ -252,7 +260,7 @@ def route_board_exact(
if diagonal: if diagonal:
outline = board.bounding_box outline = board.bounding_box
for net_no in sorted(_routable_nets(board, signal_layers)): for net_no in sorted(routable):
_diagonalize( _diagonalize(
net_no, board, tree, accepted, owner, signal_layers, half_width, clearance, outline net_no, board, tree, accepted, owner, signal_layers, half_width, clearance, outline
) )
@@ -286,6 +294,43 @@ def route_board_exact(
# --- 45-degree (diagonal) recovery ------------------------------------------- # --- 45-degree (diagonal) recovery -------------------------------------------
def insert_static_copper(tree: ShapeSearchTree, board: BasicBoard, signal_layers, owner) -> None:
"""Index the board's pads, fixed copper and keepouts (``routed=False``) on
``signal_layers``: what routed copper is checked against."""
for pin in board.get_pins():
if pin.shape is None:
continue
box = pin.shape.bounding_box()
if box.is_empty():
continue
net = pin.net_nos[0] if pin.net_nos else -1
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
# a rotated pad is checked against its outline, not its box
exact = pin.shape if _is_rotated(pin.shape) else None
tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
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,
blocks=blocks, clearance=obstacle.clearance,
)
)
def _octilinear_candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]: def _octilinear_candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]:
"""Octilinear (0/45/90/135-degree) polylines from ``a`` to ``b``. """Octilinear (0/45/90/135-degree) polylines from ``a`` to ``b``.
@@ -594,7 +639,7 @@ def _conflicting_segment(corners, d_boxes, half_width, clearance):
horizontal = a.y == b.y horizontal = a.y == b.y
if not horizontal and a.x != b.x: if not horizontal and a.x != b.x:
continue # skip diagonal grid stubs continue # skip diagonal grid stubs
from freeroute.geometry import segment_box from rpmroute.geometry import segment_box
seg = segment_box(a, b, half_width).offset(clearance) seg = segment_box(a, b, half_width).offset(clearance)
if any(seg.overlaps(box) for box in d_boxes): if any(seg.overlaps(box) for box in d_boxes):
@@ -655,6 +700,27 @@ def _on_fixed_via(net_no, point, fixed_vias, via_half) -> bool:
) )
def _copper_pieces(wires, vias, half_width, via_layers, via_half=None):
"""Like :func:`_copper_boxes`, but a slanted wire segment (a pin leaving
along a turned pad's axis) comes with its end points, so it can be checked
on its exact copper: ``(box, layers, (a, b) or None)``."""
via_half = via_half or half_width
pieces: list[tuple[IntBox, frozenset[int], tuple | None]] = []
for layer, points in wires:
on = frozenset({layer})
for a, b in zip(points, points[1:], strict=False):
if a.x == b.x or a.y == b.y:
pieces.append((segment_box(a, b, half_width), on, None))
else:
pieces.append((segment_slab(a, b, half_width).bounding_box(), on, (a, b)))
for c in points[1:-1]:
pieces.append((corner_box(c, half_width), on, None))
for loc in vias:
vbox = IntBox(loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half)
pieces.append((vbox, via_layers, None))
return pieces
def _copper_boxes(wires, vias, half_width, via_layers, via_half=None): def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = [] boxes: list[tuple[IntBox, frozenset[int]]] = []
@@ -667,6 +733,19 @@ def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
return boxes return boxes
def _inside_outline(outline, wire_boxes, vias, via_reach, edge) -> bool:
"""Copper inside the board, ``edge`` from its outline. A via is round, as
the grid and KiCad take it: its square box would reach past a chamfered
corner where the via itself does not."""
return all(outline.box_clear(b, edge) for b, _ in wire_boxes) and all(
outline.point_clear(v, via_reach) for v in vias
)
def _is_rotated(shape) -> bool:
return isinstance(shape, Simplex) and not shape.is_int_box()
def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]: def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]:
return [ return [
pin pin
File diff suppressed because it is too large Load Diff
@@ -15,8 +15,8 @@ from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
import os import os
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.ses import RoutingResult from rpmroute.ses import RoutingResult
from .pipeline import build_exact_routing_result, build_routing_result from .pipeline import build_exact_routing_result, build_routing_result
@@ -13,14 +13,17 @@ to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and
from __future__ import annotations from __future__ import annotations
import re import re
import time
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import DsnBoard, parse_dsn from rpmroute.dsn import DsnBoard, parse_dsn
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from rpmroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
from .exact_router import ExactRouteResult, route_board_exact from .exact_router import ExactRouteResult, route_board_exact
from .grid_router import RouteResult, route_board from .grid_router import RouteResult, route_board
from .room_router import route_board_rooms from .room_router import route_board_rooms
from .vias import reduce_vias as reduce_vias_stage
from .window import window_reroute as window_reroute_stage
__all__ = ["route", "route_dsn_board", "build_routing_result"] __all__ = ["route", "route_dsn_board", "build_routing_result"]
@@ -118,8 +121,11 @@ def route_dsn_board(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25, hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
tree_nets: bool = True,
via_cost: float = 10.0,
) -> tuple[RouteResult, int, list[str]]: ) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer and the layer names. ``layers`` restricts routing to those signal-layer
@@ -143,10 +149,13 @@ def route_dsn_board(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
negotiate=negotiate,
rollback=rollback, rollback=rollback,
tree_nets=tree_nets,
net_widths=_net_widths_board(dsn, scale), net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale), via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
via_cost=via_cost,
) )
return result, scale, [layer.name for layer in dsn.layers] return result, scale, [layer.name for layer in dsn.layers]
@@ -196,8 +205,11 @@ def build_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25, hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
tree_nets: bool = True,
via_cost: float = 10.0,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board( route, scale, layer_names = route_dsn_board(
@@ -205,8 +217,11 @@ def build_routing_result(
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed, edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
negotiate=negotiate,
hole_to_hole_mm=hole_to_hole_mm, hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback, rollback=rollback,
tree_nets=tree_nets,
via_cost=via_cost,
) )
return _to_routing_result(route, dsn, scale, layer_names) return _to_routing_result(route, dsn, scale, layer_names)
@@ -225,8 +240,19 @@ def route_dsn_board_exact(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25, hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
tree_nets: bool = True,
window_reroute: int = 0,
window_resolution: int = 4,
window_margin_mm: float = 1.5,
window_budget: float = 0.25,
window_cap: int = 40,
reduce_vias: bool = False,
reduce_vias_budget: float = 0.5,
reduce_vias_margin_mm: float = 2.0,
via_cost: float = 10.0,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names. DRC-verified) track; return the exact result, the scale, and layer names.
@@ -238,27 +264,61 @@ def route_dsn_board_exact(
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm) edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale) width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale)
# the settings a window re-route repeats, at its own resolution
common = {
"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,
"max_passes": max_passes,
"rip_up": rip_up,
"shove": shove,
"diagonal": diagonal,
"rollback": rollback,
"tree_nets": tree_nets,
"net_widths": _net_widths_board(dsn, scale),
"via_drill": _via_drill_board(dsn, scale),
"hole_to_hole": _mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
"via_cost": via_cost,
}
started = time.monotonic()
exact = route_board_exact( exact = route_board_exact(
board, board,
resolution=resolution, 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,
max_passes=max_passes,
rip_up=rip_up,
shove=shove,
diagonal=diagonal,
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, negotiate=negotiate,
net_widths=_net_widths_board(dsn, scale), **common,
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
) )
if window_reroute > 0:
via_half = common["via_half"] or max(width_board // 2, 1)
exact.result = window_reroute_stage(
dsn, exact.result, common,
rounds=window_reroute,
resolution=max(int(window_resolution), resolution),
margin=_mm_to_board(dsn, scale, window_margin_mm) or 0,
via_half=via_half,
via_drill=common["via_drill"] or 2 * via_half,
via_name=_via_padstack(dsn),
# a share of what the routing itself took
budget_s=max(float(window_budget), 0.0) * (time.monotonic() - started),
max_windows=window_cap,
)
if reduce_vias:
via_half = common["via_half"] or max(width_board // 2, 1)
exact.result = reduce_vias_stage(
dsn, exact.result, common,
resolution=resolution,
margin=_mm_to_board(dsn, scale, reduce_vias_margin_mm) or 0,
via_half=via_half,
via_drill=common["via_drill"] or 2 * via_half,
via_name=_via_padstack(dsn),
# a share of what the routing itself took
budget_s=max(float(reduce_vias_budget), 0.0) * (time.monotonic() - started),
)
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
@@ -276,8 +336,19 @@ def build_exact_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25, hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
tree_nets: bool = True,
window_reroute: int = 0,
window_resolution: int = 4,
window_margin_mm: float = 1.5,
window_budget: float = 0.25,
window_cap: int = 40,
reduce_vias: bool = False,
reduce_vias_budget: float = 0.5,
reduce_vias_margin_mm: float = 2.0,
via_cost: float = 10.0,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact( exact, scale, layer_names = route_dsn_board_exact(
@@ -293,8 +364,19 @@ def build_exact_routing_result(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
negotiate=negotiate,
hole_to_hole_mm=hole_to_hole_mm, hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback, rollback=rollback,
tree_nets=tree_nets,
window_reroute=window_reroute,
window_resolution=window_resolution,
window_margin_mm=window_margin_mm,
window_budget=window_budget,
window_cap=window_cap,
reduce_vias=reduce_vias,
reduce_vias_budget=reduce_vias_budget,
reduce_vias_margin_mm=reduce_vias_margin_mm,
via_cost=via_cost,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
@@ -30,10 +30,10 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
import heapq import heapq
from freeroute.board.board import BasicBoard from rpmroute.board.board import BasicBoard
from freeroute.board.items import Pin from rpmroute.board.items import Pin
from freeroute.board.search_tree import ShapeSearchTree, TreeShape from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, shove_segment from rpmroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, shove_segment
from .exact_router import ExactRouteResult, _routable_nets from .exact_router import ExactRouteResult, _routable_nets
from .grid_router import RouteResult from .grid_router import RouteResult
@@ -56,7 +56,7 @@ class _Accepted:
``wires`` / ``vias`` are the *same* list objects as ``RouteResult.wires`` / ``wires`` / ``vias`` are the *same* list objects as ``RouteResult.wires`` /
``.vias`` for this net, so rewriting a shoved wire in place also updates the ``.vias`` for this net, so rewriting a shoved wire in place also updates the
routed output. ``wire_owners`` is parallel to ``wires``: each entry is the routed output. ``wire_owners`` is parallel to ``wires``: each entry is the
list of :class:`~freeroute.board.search_tree.TreeShape` owner ids for that list of :class:`~rpmroute.board.search_tree.TreeShape` owner ids for that
wire's copper tiles, so a later shove can locate, remove, and roll back a wire's copper tiles, so a later shove can locate, remove, and roll back a
specific wire without disturbing a multi-wire net's other connections. specific wire without disturbing a multi-wire net's other connections.
""" """
@@ -1,6 +1,6 @@
"""Shared orthogonal shove primitives for the exact and room routers. """Shared orthogonal shove primitives for the exact and room routers.
The exact-geometry router (:mod:`freeroute.route.exact_router`) grew the first The exact-geometry router (:mod:`rpmroute.route.exact_router`) grew the first
shove implementation; the continuous room router reuses its geometry so both shove implementation; the continuous room router reuses its geometry so both
tracks agree on how a blocking trace's conflicting segment is found, how the tracks agree on how a blocking trace's conflicting segment is found, how the
perpendicular displacement ladder is generated, and how a reshaped trace is perpendicular displacement ladder is generated, and how a reshaped trace is
@@ -19,8 +19,8 @@ from __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
from freeroute.board.search_tree import ShapeSearchTree, TreeShape from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape from rpmroute.geometry import IntBox, IntPoint, Polyline, PolylineShape
from .exact_router import _conflicting_segment as conflicting_segment from .exact_router import _conflicting_segment as conflicting_segment
+542
View File
@@ -0,0 +1,542 @@
"""Smoothing: a last stage that turns grid staircases into octilinear runs.
The grid search moves one cell at a time along the axes, so a route that
heads off at an angle comes out as a staircase of short legs. Nothing goes
back over a path once it connects. This stage does, wire by wire, on the
finished result:
1. a run of legs between two of its points is replaced by the octilinear
shape joining them (one straight leg, or a straight leg and a 45-degree
one), the furthest such point first;
2. the right-angle corners left are cut to 45-degree chamfers;
3. legs running on in the same direction are merged.
Same nets, same layers, same vias. A wire keeps its ends, every via of its
net, every point where another wire of its net ends on it, and any leg
across a pad of its own net that it does not end on; only what lies between
those moves. Every new leg and corner is checked against the other nets'
copper, pads and keepouts on the exact shapes the exact engine uses, and
against the board edge; a wire is changed only when all of its new copper
is clear. Fixed wiring from the DSN is not part of the result and is never
touched; it is checked against like any other copper.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import itertools
import math
from rpmroute.board import build_board
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from rpmroute.dsn.model import DsnBoard
from rpmroute.geometry import IntBox, IntPoint, overlaps_2d, segment_box, segment_slab
from rpmroute.geometry.polyline import corner_box
from rpmroute.ses.model import RoutingResult
from .exact_router import insert_static_copper
from .pipeline import _dsn_units_per_um, _mm_to_board, _rule_clearance_dsn, _via_half_board
#: how many points ahead a staircase collapse looks from each point
LOOKAHEAD = 64
#: a 45-degree leg at least this many track widths long: a shorter one is a
#: kink beside its end's pad, where a refilled pour leaves a copper sliver
MIN_DIAGONAL_WIDTHS = 2
@dataclass
class Shape:
"""What a set of wires looks like: legs, right-angle corners, length."""
segments: int = 0
right_angles: int = 0
length_mm: dict[str, float] = field(default_factory=dict)
@property
def total_mm(self) -> float:
return sum(self.length_mm.values())
@dataclass
class SmoothReport:
before: Shape
after: Shape
wires_changed: int = 0
wires: int = 0
#: True when the finished check found more violations than before and
#: the smoothing was thrown away (never expected; a guard)
reverted: bool = False
def line(self) -> str:
b, a = self.before, self.after
if self.reverted:
return "smoothing reverted: it would have added a clearance violation"
return (
f"smoothed {self.wires_changed} of {self.wires} wires: segments {b.segments} -> "
f"{a.segments}, right-angle corners {b.right_angles} -> {a.right_angles}, "
f"length {b.total_mm:.1f} -> {a.total_mm:.1f} mm"
)
def table(self) -> list[str]:
rows = [f" {'layer':10} {'before mm':>10} {'after mm':>10}"]
for layer in sorted(set(self.before.length_mm) | set(self.after.length_mm)):
rows.append(
f" {layer:10} {self.before.length_mm.get(layer, 0.0):>10.1f} "
f"{self.after.length_mm.get(layer, 0.0):>10.1f}"
)
return rows
def _shape_of(wires, mm_per_unit: float) -> Shape:
"""``wires``: (layer name, points) pairs in board units."""
out = Shape()
for layer, pts in wires:
out.segments += max(len(pts) - 1, 0)
out.length_mm[layer] = out.length_mm.get(layer, 0.0) + mm_per_unit * sum(
math.hypot(q.x - p.x, q.y - p.y) for p, q in zip(pts, pts[1:], strict=False)
)
for a, b, c in zip(pts, pts[1:], pts[2:], strict=False):
if (b.x - a.x) * (c.x - b.x) + (b.y - a.y) * (c.y - b.y) == 0:
out.right_angles += 1
return out
def _sign(v: int) -> int:
return (v > 0) - (v < 0)
def _octilinear(a: IntPoint, b: IntPoint) -> bool:
dx, dy = b.x - a.x, b.y - a.y
return dx == 0 or dy == 0 or abs(dx) == abs(dy)
def _candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]:
"""The octilinear ways from ``a`` to ``b``: straight when the two lie on
an axis or a diagonal, else the 45-degree leg first or last."""
if _octilinear(a, b):
return [[a, b]]
dx, dy = b.x - a.x, b.y - a.y
m = min(abs(dx), abs(dy))
sx, sy = _sign(dx), _sign(dy)
return [
[a, IntPoint(a.x + sx * m, a.y + sy * m), b],
[a, IntPoint(b.x - sx * m, b.y - sy * m), b],
]
def _too_short(a: IntPoint, b: IntPoint, half: int) -> bool:
"""A slanted leg shorter than MIN_DIAGONAL_WIDTHS track widths."""
if a.x == b.x or a.y == b.y:
return False
return (b.x - a.x) ** 2 + (b.y - a.y) ** 2 < (MIN_DIAGONAL_WIDTHS * 2 * half) ** 2
def _dedupe(pts: list[IntPoint]) -> list[IntPoint]:
out: list[IntPoint] = []
for p in pts:
if not out or (out[-1].x, out[-1].y) != (p.x, p.y):
out.append(p)
return out
def _merge_collinear(pts: list[IntPoint], keep: set[tuple[int, int]]) -> list[IntPoint]:
"""Drop points where the wire runs straight on, except the ones in ``keep``."""
pts = _dedupe(pts)
out = pts[:1]
for i in range(1, len(pts) - 1):
a, b, c = out[-1], pts[i], pts[i + 1]
straight = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) == 0 and (
(b.x - a.x) * (c.x - b.x) + (b.y - a.y) * (c.y - b.y) > 0
)
if straight and (b.x, b.y) not in keep:
continue
out.append(b)
if len(pts) > 1:
out.append(pts[-1])
return out
def _on_segment(p: IntPoint, a: IntPoint, b: IntPoint, reach: int) -> IntPoint | None:
"""The point of axis leg ``a``-``b`` within ``reach`` of ``p`` (across the
leg), or None. Slanted legs only count ``p`` that lies exactly on them."""
if a.x == b.x:
lo, hi = sorted((a.y, b.y))
if abs(p.x - a.x) <= reach and lo <= p.y <= hi:
return IntPoint(a.x, p.y)
return None
if a.y == b.y:
lo, hi = sorted((a.x, b.x))
if abs(p.y - a.y) <= reach and lo <= p.x <= hi:
return IntPoint(p.x, a.y)
return None
cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
within = min(a.x, b.x) <= p.x <= max(a.x, b.x) and min(a.y, b.y) <= p.y <= max(a.y, b.y)
return IntPoint(p.x, p.y) if cross == 0 and within else None
class _Smoother:
def __init__(self, tree, outline, edge: int, clearance: int) -> None:
self.tree = tree
self.outline = outline
self.edge = edge
self.clearance = clearance
def _inside(self, box: IntBox) -> bool:
return self.outline is None or self.outline.box_clear(box, self.edge)
def leg_clear(self, a: IntPoint, b: IntPoint, half: int, net: int, layer: int) -> bool:
if a.x == b.x or a.y == b.y:
box = segment_box(a, b, half)
return self._inside(box) and not self.tree.clearance_conflict(
box, net, layer, self.clearance
)
copper = segment_slab(a, b, half)
if not self._inside(copper.bounding_box()):
return False
# Both ways round, as the finished check counts a pair: this leg's
# copper grown by the clearance against the other's copper, and the
# other's clearance region (a box grown square, so wider at its
# corners) against this leg's copper.
grown = segment_slab(a, b, half + self.clearance)
for shape in self.tree.overlapping(copper.bounding_box().offset(self.clearance)):
if shape.net_no == net or not shape.on_layer(layer):
continue
if shape.blocks not in ("all", "wires"):
continue
theirs = shape.exact if shape.exact is not None else shape.tile
if not shape.clearance:
if overlaps_2d(copper, theirs):
return False
elif overlaps_2d(grown, theirs) or overlaps_2d(
shape.clearance_region(self.clearance), copper
):
return False
return True
def corner_clear(self, c: IntPoint, half: int, net: int, layer: int) -> bool:
box = corner_box(c, half)
return self._inside(box) and not self.tree.clearance_conflict(
box, net, layer, self.clearance
)
def path_clear(self, path: list[IntPoint], half: int, net: int, layer: int) -> bool:
"""New legs and new corners of ``path`` (its ends are kept points)."""
if any(_too_short(p, q, half) for p, q in zip(path, path[1:], strict=False)):
return False
return all(
self.leg_clear(p, q, half, net, layer) for p, q in zip(path, path[1:], strict=False)
) and all(self.corner_clear(c, half, net, layer) for c in path[1:-1])
def collapse(self, pts: list[IntPoint], half: int, net: int, layer: int) -> list[IntPoint]:
"""Step 1 on one section (both ends kept): from each point, jump to
the furthest later point an octilinear path reaches cleanly."""
out = [pts[0]]
i, n = 0, len(pts)
while i < n - 1:
jumped = False
for j in range(min(n - 1, i + LOOKAHEAD), i + 1, -1):
for path in _candidates(pts[i], pts[j]):
if self.path_clear(path, half, net, layer):
out.extend(path[1:])
i, jumped = j, True
break
if jumped:
break
if not jumped:
out.append(pts[i + 1])
i += 1
return out
def miter(
self, pts: list[IntPoint], half: int, net: int, layer: int, keep: set[tuple[int, int]]
) -> list[IntPoint]:
"""Step 2: cut each right-angle corner between two axis legs to a
45-degree chamfer, as long a one as fits, at least a track width."""
pts = list(pts)
i = 1
while i < len(pts) - 1:
a, c, b = pts[i - 1], pts[i], pts[i + 1]
if (c.x, c.y) in keep or not (
(a.x == c.x or a.y == c.y) and (c.x == b.x or c.y == b.y)
):
i += 1
continue
ux, uy = _sign(c.x - a.x), _sign(c.y - a.y)
vx, vy = _sign(b.x - c.x), _sign(b.y - c.y)
if ux * vx + uy * vy != 0:
i += 1
continue
len_in = abs(c.x - a.x) + abs(c.y - a.y)
len_out = abs(b.x - c.x) + abs(b.y - c.y)
# leave half of a leg for the corner at its other end, unless that
# end stays where it is anyway
cap_in = len_in if (a.x, a.y) in keep or i == 1 else len_in // 2
cap_out = len_out if (b.x, b.y) in keep or i == len(pts) - 2 else len_out // 2
d = min(cap_in, cap_out)
done = False
# the chamfer's leg is d * sqrt(2) long
while d * d * 2 >= (MIN_DIAGONAL_WIDTHS * 2 * half) ** 2:
p = IntPoint(c.x - ux * d, c.y - uy * d)
q = IntPoint(c.x + vx * d, c.y + vy * d)
corners = [x for x in (p, q) if (x.x, x.y) not in ((a.x, a.y), (b.x, b.y))]
if self.leg_clear(p, q, half, net, layer) and all(
self.corner_clear(x, half, net, layer) for x in corners
):
pts[i : i + 1] = [p, q]
pts = _dedupe(pts)
done = True
break
d = d * 3 // 4 if d * 3 // 4 < d else d - 1
i += 2 if done else 1
return pts
def smooth(
dsn: DsnBoard, result: RoutingResult, *, edge_clearance_mm: float | None = None
) -> SmoothReport:
"""Smooth ``result``'s wires in place; return what changed."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
clearance = round(_rule_clearance_dsn(dsn) * scale)
edge = _mm_to_board(dsn, scale, edge_clearance_mm) or 0
mm_per_unit = 1 / (_dsn_units_per_um(dsn) * 1000 * scale)
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
layer_index = {layer.name: i for i, layer in enumerate(dsn.layers)}
via_layers = frozenset(signal_layers)
net_numbers = {n.name: i + 1 for i, n in enumerate(dsn.nets)}
via_half = _via_half_board(dsn, scale)
tree = ShapeSearchTree()
owner = itertools.count(1)
insert_static_copper(tree, board, signal_layers, owner)
def to_points(wire) -> list[IntPoint]:
c = wire.coords
return [IntPoint(round(c[i] * scale), round(c[i + 1] * scale)) for i in range(0, len(c) - 1, 2)]
# own-net pads per (net, layer): legs across one the wire does not end on stay
pads: dict[tuple[int, int], list[IntBox]] = {}
for pin in board.get_pins():
if pin.shape is None or not pin.net_nos:
continue
box = pin.shape.bounding_box()
for layer in pin.layers:
pads.setdefault((pin.net_nos[0], layer), []).append(box)
# every routed wire and via into the tree, each wire under owners of its own
wires: list[tuple[int, object, int, int, list[IntPoint], list[int]]] = []
for net in result.nets:
net_no = net_numbers.get(net.name)
if net_no is None:
continue
for wire in net.wires:
layer = layer_index.get(wire.layer)
if layer is None or layer not in signal_layers or wire.fixed:
continue
half = max(round(wire.width * scale / 2), 1)
pts = to_points(wire)
wires.append((net_no, wire, layer, half, pts, _insert(tree, owner, pts, half, net_no, layer)))
for via in net.vias:
vh = via_half or max(round(max((w.width for w in net.wires), default=0) * scale / 2), 1)
x, y = round(via.x * scale), round(via.y * scale)
tree.insert(TreeShape(
next(owner), net_no, via_layers, IntBox(x - vh, y - vh, x + vh, y + vh), routed=True,
))
before_pairs = len(tree.violation_pairs(clearance))
before = _shape_of(
[(dsn.layers[w[2]].name, w[4]) for w in wires], mm_per_unit
)
# the points of each net another of its wires or a via meets
joints: dict[int, list[IntPoint]] = {}
for net in result.nets:
net_no = net_numbers.get(net.name)
if net_no is None:
continue
for via in net.vias:
joints.setdefault(net_no, []).append(IntPoint(round(via.x * scale), round(via.y * scale)))
for net_no, _wire, _layer, _half, pts, _owners in wires:
joints.setdefault(net_no, []).extend((pts[0], pts[-1]))
pins_of: dict[int, list] = {}
for pin in board.get_pins():
if pin.shape is not None and pin.net_nos:
pins_of.setdefault(pin.net_nos[0], []).append(pin)
vias_of = {
net_numbers[net.name]: [IntPoint(round(v.x * scale), round(v.y * scale)) for v in net.vias]
for net in result.nets
if net.name in net_numbers
}
by_net: dict[int, list[int]] = {}
for k, w in enumerate(wires):
by_net.setdefault(w[0], []).append(k)
smoother = _Smoother(tree, board.outline, edge, clearance)
changed = 0
originals = []
for net_no, ks in by_net.items():
groups_before = _pad_groups(
[wires[k] for k in ks], pins_of.get(net_no, []), vias_of.get(net_no, [])
)
undo = []
for k in ks:
_, wire, layer, half, pts, owners = wires[k]
if len(pts) < 3:
continue
keep_pts, keep = _kept_points(
pts, joints.get(net_no, []), half, pads.get((net_no, layer), [])
)
new = _merge_collinear(keep_pts, keep)
out: list[IntPoint] = [new[0]]
start = 0
for i in range(1, len(new)):
if (new[i].x, new[i].y) in keep or i == len(new) - 1:
section = smoother.collapse(new[start : i + 1], half, net_no, layer)
out.extend(section[1:])
start = i
out = smoother.miter(_merge_collinear(out, keep), half, net_no, layer, keep)
out = _merge_collinear(out, keep)
if [(p.x, p.y) for p in out] == [(p.x, p.y) for p in pts]:
continue
# the wire's own old copper is the same net and never in the way,
# so it can go once the new copper is in
for oid in owners:
tree.remove_owner(oid)
new_owners = _insert(tree, owner, out, half, net_no, layer)
undo.append((k, wires[k], list(wire.coords)))
wire.coords = [v / scale for p in out for v in (p.x, p.y)]
wires[k] = (net_no, wire, layer, half, out, new_owners)
if not undo:
continue
groups_after = _pad_groups(
[wires[k] for k in ks], pins_of.get(net_no, []), vias_of.get(net_no, [])
)
if groups_after != groups_before:
# a pad lost (or gained) its way to the rest of the net: keep the
# net as it was routed
for k, old, coords in reversed(undo):
for oid in wires[k][5]:
tree.remove_owner(oid)
old[1].coords = coords
wires[k] = (*old[:5], _insert(tree, owner, old[4], old[3], net_no, old[2]))
continue
originals.extend((old[1], coords) for _k, old, coords in undo)
changed += len(undo)
report = SmoothReport(
before=before,
after=_shape_of([(dsn.layers[w[2]].name, w[4]) for w in wires], mm_per_unit),
wires_changed=changed,
wires=len(wires),
)
if changed and len(tree.violation_pairs(clearance)) > before_pairs:
for wire, coords in originals:
wire.coords = coords
report.after, report.wires_changed, report.reverted = before, 0, True
return report
def _insert(tree, owner, pts, half, net_no, layer) -> list[int]:
"""A wire's copper into ``tree`` as the exact engine models it: legs (on
their exact slab when slanted) and a square at each inner corner."""
on = frozenset({layer})
owners = []
for a, b in zip(pts, pts[1:], strict=False):
oid = next(owner)
if a.x == b.x or a.y == b.y:
tree.insert(TreeShape(oid, net_no, on, segment_box(a, b, half), routed=True))
else:
slab = segment_slab(a, b, half)
tree.insert(TreeShape(
oid, net_no, on, slab.bounding_box(), routed=True, exact=slab, seg=("slab", a, b, half),
))
owners.append(oid)
for c in pts[1:-1]:
oid = next(owner)
tree.insert(TreeShape(oid, net_no, on, corner_box(c, half), routed=True))
owners.append(oid)
return owners
def _kept_points(
pts: list[IntPoint], joints: list[IntPoint], half: int, own_pads: list[IntBox]
) -> tuple[list[IntPoint], set[tuple[int, int]]]:
"""The wire with a point added wherever another wire of its net or a via
meets it, and the set of points that must stay: those, its ends, and the
ends of any leg across an own-net pad it does not end on."""
keep = {(pts[0].x, pts[0].y), (pts[-1].x, pts[-1].y)}
out = [pts[0]]
for a, b in zip(pts, pts[1:], strict=False):
hits = []
for j in joints:
p = _on_segment(j, a, b, half)
if p is not None:
hits.append(p)
keep.add((p.x, p.y))
hits.sort(key=lambda p: abs(p.x - a.x) + abs(p.y - a.y))
out.extend(h for h in hits if (h.x, h.y) not in ((a.x, a.y), (b.x, b.y)))
out.append(b)
end_pads = [box for box in own_pads if _in(pts[0], box) or _in(pts[-1], box)]
for box in own_pads:
if any(box is e for e in end_pads):
continue
for a, b in zip(out, out[1:], strict=False):
if segment_box(a, b, 0).overlaps(box):
keep.add((a.x, a.y))
keep.add((b.x, b.y))
return _dedupe(out), keep
def _near(p: IntPoint, pts: list[IntPoint], reach: int) -> bool:
"""Whether ``p`` lies on the copper of the wire through ``pts`` (``reach``
its half width), squared at the corners as the copper is modelled."""
for a, b in zip(pts, pts[1:], strict=False):
dx, dy = b.x - a.x, b.y - a.y
n2 = dx * dx + dy * dy
t = 0.0 if n2 == 0 else max(0.0, min(1.0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / n2))
cx, cy = a.x + t * dx, a.y + t * dy
if math.hypot(p.x - cx, p.y - cy) <= reach + 1:
return True
return False
def _pad_groups(wires, pins, vias) -> set[frozenset[int]]:
"""Which of a net's pads its copper joins, as groups of pad indices: a
wire's end on a pad, on another wire, or on a via; a via on a pad."""
parent = list(range(len(pins) + len(wires) + len(vias)))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def join(x: int, y: int) -> None:
parent[find(x)] = find(y)
boxes = [p.shape.bounding_box() for p in pins]
w0, v0 = len(pins), len(pins) + len(wires)
for i, (_n, _w, layer, half, pts, _o) in enumerate(wires):
for end in (pts[0], pts[-1]):
for j, pin in enumerate(pins):
if layer in pin.layers and _in(end, boxes[j]):
join(w0 + i, j)
for k, (_n2, _w2, layer2, half2, pts2, _o2) in enumerate(wires):
if k != i and layer2 == layer and _near(end, pts2, half2):
join(w0 + i, w0 + k)
for v, loc in enumerate(vias):
if (end.x, end.y) == (loc.x, loc.y) or _near(loc, pts, half):
join(w0 + i, v0 + v)
for v, loc in enumerate(vias):
for j in range(len(pins)):
if _in(loc, boxes[j]):
join(v0 + v, j)
groups: dict[int, set[int]] = {}
for j in range(len(pins)):
groups.setdefault(find(j), set()).add(j)
return {frozenset(g) for g in groups.values()}
def _in(p: IntPoint, box: IntBox) -> bool:
return box.ll.x <= p.x <= box.ur.x and box.ll.y <= p.y <= box.ur.y
+151
View File
@@ -0,0 +1,151 @@
"""Check routed copper that no engine has checked.
The exact engine verifies every connection against exact pad and copper
shapes before it accepts it. The grid and room engines do not: they route on
approximations, and their output can short or crowd another net with nothing
in their report to say so. :func:`verify_result` puts a finished
:class:`RoutingResult` through the same exact geometry the exact engine uses
(its pads, fixed copper and keepouts, wires as boxes or slanted slabs, vias as
their pads) and lists what it finds.
A violation is a pair of different-net coppers on a shared layer, at least one
of them routed, closer than the board's clearance rule. It is a short when the
coppers themselves overlap (or routed copper sits inside a keepout), else a
clearance violation. Pairs are counted per routed segment or via, as KiCad's
DRC counts per item pair; pads already too close to each other in the design
are not the router's and are not counted.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import itertools
from rpmroute.board import build_board
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from rpmroute.dsn.model import DsnBoard
from rpmroute.geometry import IntBox, IntPoint, segment_box, segment_slab
from rpmroute.geometry.polyline import corner_box
from rpmroute.ses.model import RoutingResult
from .exact_router import insert_static_copper
from .pipeline import _dsn_units_per_um, _rule_clearance_dsn, _via_half_board
# examples kept per kind; the counts are always complete
MAX_EXAMPLES = 5
@dataclass
class Violation:
"""One offending pair: the routed copper's net, what it came too close
to, the layer, and roughly where (mm, DSN coordinates)."""
net: str
other: str
layer: str
x_mm: float
y_mm: float
def __str__(self) -> str:
return f"{self.net} x {self.other} on {self.layer} at ({self.x_mm:.2f}, {self.y_mm:.2f}) mm"
@dataclass
class VerifyReport:
shorts: int = 0
clearance: int = 0
short_examples: list[Violation] = field(default_factory=list)
clearance_examples: list[Violation] = field(default_factory=list)
@property
def clean(self) -> bool:
return not (self.shorts or self.clearance)
def verify_result(dsn: DsnBoard, result: RoutingResult) -> VerifyReport:
"""Check ``result``'s copper against ``dsn``'s pads, keepouts and each
other, with the board's clearance rule."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
clearance = round(_rule_clearance_dsn(dsn) * scale)
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
layer_index = {layer.name: i for i, layer in enumerate(dsn.layers)}
via_layers = frozenset(signal_layers)
net_numbers = {n.name: i + 1 for i, n in enumerate(dsn.nets)}
names = {number: name for name, number in net_numbers.items()}
tree = ShapeSearchTree()
owner = itertools.count(1)
insert_static_copper(tree, board, signal_layers, owner)
static_names: dict[int, str] = {}
for shape in tree.all_shapes():
static_names[shape.owner] = "keepout" if shape.net_no == -1 else f"pad of {names.get(shape.net_no, '?')}"
via_default = _via_half_board(dsn, scale)
for net in result.nets:
net_no = net_numbers.get(net.name)
if net_no is None:
continue
for wire in net.wires:
layer = layer_index.get(wire.layer)
if layer is None or layer not in signal_layers:
continue
half = max(round(wire.width * scale / 2), 1)
pts = [
IntPoint(round(wire.coords[i] * scale), round(wire.coords[i + 1] * scale))
for i in range(0, len(wire.coords) - 1, 2)
]
on = frozenset({layer})
for a, b in zip(pts, pts[1:], strict=False):
oid = next(owner)
if a.x == b.x or a.y == b.y:
tree.insert(TreeShape(oid, net_no, on, segment_box(a, b, half), routed=True))
else: # a 45-degree or slanted segment, on its exact copper
slab = segment_slab(a, b, half)
tree.insert(TreeShape(
oid, net_no, on, slab.bounding_box(), routed=True,
exact=slab, seg=("slab", a, b, half),
))
# the round end where it meets the next segment, with this one
tree.insert(TreeShape(oid, net_no, on, corner_box(b, half), routed=True))
for via in net.vias:
half = via_default or max(round(_wire_width(net) * scale / 2), 1)
x, y = round(via.x * scale), round(via.y * scale)
tree.insert(TreeShape(
next(owner), net_no, via_layers, IntBox(x - half, y - half, x + half, y + half), routed=True,
))
mm_per_unit = 1 / (_dsn_units_per_um(dsn) * 1000 * scale)
# one entry per pair of items, however many of their tiles meet; a short
# if any of them overlap
by_pair: dict[tuple[int, int], tuple[TreeShape, TreeShape, bool]] = {}
for shape, other, touching in tree.violation_pairs(clearance):
key = (min(shape.owner, other.owner), max(shape.owner, other.owner))
seen = by_pair.get(key)
if seen is None or (touching and not seen[2]):
by_pair[key] = (shape, other, touching)
report = VerifyReport()
for shape, other, touching in by_pair.values():
layer = min(shape.layers & other.layers)
box = shape.tile.intersection(other.tile.offset(clearance))
if box.is_empty():
box = shape.tile
what = names.get(other.net_no, "?") if other.routed else static_names.get(other.owner, "?")
found = Violation(
names.get(shape.net_no, "?"), what, dsn.layers[layer].name,
(box.ll.x + box.ur.x) / 2 * mm_per_unit, (box.ll.y + box.ur.y) / 2 * mm_per_unit,
)
if touching:
report.shorts += 1
if len(report.short_examples) < MAX_EXAMPLES:
report.short_examples.append(found)
else:
report.clearance += 1
if len(report.clearance_examples) < MAX_EXAMPLES:
report.clearance_examples.append(found)
return report
def _wire_width(net) -> float:
return max((w.width for w in net.wires), default=0.0)
+128
View File
@@ -0,0 +1,128 @@
"""Via reduction: a last stage that routes each net again with vias made dear.
The main search prices a layer change at a few millimetres of track, which
is right while the board is crowded and wrong once it is routed: a net that
hopped layers to slip past a route can often stay on one layer around it.
So each net that has vias is ripped and routed again on its own, everything
else held as fixed copper, with the via cost raised; the new copper is kept
only if the net loses no connection and ends with fewer vias.
It reuses the window stage's board: the net's own items and copper, and of
everything else only what lies near the box the net is routed in. The nets
with the most vias go first, and no new net starts once ``budget_s`` is
spent.
"""
from __future__ import annotations
import time
from typing import Any
from rpmroute.board import build_board
from rpmroute.dsn import DsnBoard
from rpmroute.geometry import IntBox
from .exact_router import route_board_exact
from .grid_router import RouteResult
from .window import Copper, _inside, _item_box, _open, _window_board
def _net_box(copper: Copper, pins: list, grow: int) -> IntBox | None:
wires, vias = copper
points = [p for _layer, path in wires for p in path] + list(vias) + pins
if not points:
return None
xs, ys = [p.x for p in points], [p.y for p in points]
return IntBox(min(xs) - grow, min(ys) - grow, max(xs) + grow, max(ys) + grow)
def reduce_vias(
dsn: DsnBoard,
result: RouteResult,
route_args: dict[str, Any],
*,
resolution: int,
margin: int,
via_half: int,
via_drill: int,
via_name: str,
via_cost_factor: float = 8.0,
budget_s: float | None = None,
) -> RouteResult:
"""Route each net with vias again with vias ``via_cost_factor`` times dearer;
keep a net's new copper only when it fails nothing new and has fewer vias.
``route_args`` are the exact engine's settings for the whole board.
``margin`` grows the box around the net's pins and copper that it is
routed in, so it may go round what it used to hop over."""
started = time.monotonic()
copper: dict[int, Copper] = {
net: (list(result.wires.get(net, [])), list(result.vias.get(net, [])))
for net in set(result.wires) | set(result.vias)
}
todo = sorted((n for n, c in copper.items() if c[1]), key=lambda n: -len(set(copper[n][1])))
if not todo:
return result
default_half = result.half_width
half = dict(result.net_half)
failed = list(result.failed)
base = build_board(dsn)
base_items = [(item, _item_box(item)) for item in base.get_items()]
layers = route_args.get("layers") or [
i for i, layer in enumerate(base.layer_structure.arr) if layer.is_signal
]
pins_of: dict[int, list] = {}
for pin in base.get_pins():
for net in pin.net_nos:
pins_of.setdefault(net, []).append(pin.location)
context = 2 * (route_args["trace_width"] + route_args["clearance"]) + 2 * via_half + (
route_args.get("edge_clearance") or 0
)
args = dict(route_args)
args["via_cost"] = float(route_args.get("via_cost", 10.0)) * via_cost_factor
changed = False
for net in todo:
if budget_s is not None and time.monotonic() - started >= budget_s:
break
old_vias = len(set(copper[net][1]))
window = _net_box(copper[net], pins_of.get(net, []), margin)
if window is None:
continue
before = _open(f for f in failed if f[0] == net)
view = IntBox(window.ll.x - context, window.ll.y - context,
window.ur.x + context, window.ur.y + context)
keep = set(copper) - {net}
board = _window_board(base, base_items, view, {net}, copper, keep, half,
default_half, via_half, via_drill, layers, via_name)
sub = route_board_exact(
board, **args, resolution=resolution, only_nets=frozenset({net}), bounds=window,
).result
after = _open(f for f in sub.failed if f[0] == net)
new = (sub.wires.get(net, []), sub.vias.get(net, []))
if len(after) > len(before) or len(set(new[1])) >= old_vias:
continue
# the board left out what lies away from the box, so copper reaching
# out of it was never checked against that
if (new[0] or new[1]) and not _inside(new, window):
continue
copper[net] = (list(new[0]), list(new[1]))
half.pop(net, None)
if net in sub.net_half:
half[net] = sub.net_half[net]
failed = [f for f in failed if f[0] != net] + [f for f in sub.failed if f[0] == net]
changed = True
if not changed:
return result
out = RouteResult(default_half)
for net, (wires, vias) in copper.items():
for layer, path in wires:
out.add_wire(net, layer, path)
for location in dict.fromkeys(vias):
out.add_via(net, location)
out.failed = failed
out.unrouted = len(_open(failed))
out.net_half = half
out.narrowed = list(result.narrowed)
return out
+248
View File
@@ -0,0 +1,248 @@
"""Window re-route: a last stage for the connections still unrouted.
Around each stuck connection a window is cut: the box of its two end pins
plus a margin. Every net whose routed copper lies wholly inside it is
ripped; everything else stays, as fixed copper. The window is then routed
again on its own, on a finer grid than the whole board can afford, the
stuck connection's net and the ripped nets only. The result is kept only
if fewer connections are left unrouted than before; otherwise the board is
as it was. Each round doubles the margin for the connections still stuck.
It is a last stage, so it is kept cheap: a window's board holds only what
the window needs (every pin and piece of copper of the nets it reroutes,
so their connectivity is unchanged, and of the other items only those near
the window), the smallest windows go first, at most ``max_windows`` run per
round, and no new window starts once ``budget_s`` seconds are spent.
What makes it differ from re-running the same search near the failure: the
finer grid finds gaps the board's grid steps over, and the ripped nets go in
again in a new order around the stuck one. A finer grid on the whole board
costs routes elsewhere (its cells are slower and its order changes); inside
a small window it only ever replaces what it can improve.
"""
from __future__ import annotations
import time
from typing import Any
from rpmroute.board import BasicBoard, build_board
from rpmroute.board.items import ObstacleArea, Pin, Trace, Via
from rpmroute.dsn import DsnBoard
from rpmroute.geometry import IntBox, IntPoint
from .exact_router import route_board_exact
from .grid_router import RouteResult
Copper = tuple[list[tuple[int, list[IntPoint]]], list[IntPoint]]
def _pin_labels(board) -> dict[tuple[int, str], IntPoint]:
"""Each pin's location by (net, label), the label a failure names it by."""
found: dict[tuple[int, str], IntPoint] = {}
for pin in board.get_pins():
for net in pin.net_nos:
found.setdefault((net, f"{pin.component_ref}-{pin.name}"), pin.location)
return found
def _end_point(labels, net: int, label: str) -> IntPoint | None:
"""Where a failure's end is: the first pin its label names."""
return labels.get((net, label.split("/")[0]))
def _inside(copper: Copper, window: IntBox) -> bool:
wires, vias = copper
points = [p for _layer, path in wires for p in path] + list(vias)
return bool(points) and all(
window.ll.x <= p.x <= window.ur.x and window.ll.y <= p.y <= window.ur.y for p in points
)
def _item_box(item) -> IntBox | None:
if isinstance(item, (Pin, Via)):
return item.shape.bounding_box() if item.shape is not None else None
if isinstance(item, ObstacleArea):
return item.bounding_box()
if isinstance(item, Trace) and item.corners:
h = item.half_width
xs, ys = [p.x for p in item.corners], [p.y for p in item.corners]
return IntBox(min(xs) - h, min(ys) - h, max(xs) + h, max(ys) + h)
return None
def _meets(box: IntBox | None, view: IntBox) -> bool:
return box is not None and box.ll.x <= view.ur.x and view.ll.x <= box.ur.x and (
box.ll.y <= view.ur.y and view.ll.y <= box.ur.y
)
def _path_box(path: list[IntPoint], grow: int) -> IntBox:
xs, ys = [p.x for p in path], [p.y for p in path]
return IntBox(min(xs) - grow, min(ys) - grow, max(xs) + grow, max(ys) + grow)
def _window_board(
base: BasicBoard, base_items: list, view: IntBox, affected: set[int],
copper: dict[int, Copper], keep: set[int], half: dict[int, int], default_half: int,
via_half: int, via_drill: int, layers: list[int], via_name: str,
) -> BasicBoard:
"""The board a window is routed on: the base board's items of the nets
being rerouted and those near ``view``, plus the routed copper being kept
(all of an affected net's, so its connectivity is the whole board's, and
of other nets only what is near ``view``), as fixed copper."""
board = BasicBoard(base.layer_structure, base.nets, base.clearance_matrix, base.bounding_box)
board.outline = base.outline
for item, box in base_items:
if (item.net_nos and item.net_nos[0] in affected) or _meets(box, view):
board.insert(item)
for net in keep:
wires, vias = copper.get(net, ([], []))
width = half.get(net, default_half)
mine = net in affected
for layer, path in wires:
if len(path) >= 2 and (mine or _meets(_path_box(path, width), view)):
board.insert(Trace(net_nos=[net], fixed=True, layer=layer, half_width=width, corners=list(path)))
for location in dict.fromkeys(vias):
shape = IntBox(location.x - via_half, location.y - via_half,
location.x + via_half, location.y + via_half)
if mine or _meets(shape, view):
board.insert(Via(
net_nos=[net], fixed=True, padstack_name=via_name, drill=via_drill,
location=location, layers=list(layers), shape=shape,
))
return board
def _open(failed) -> list:
"""Failures that are real opens, not connections left to a zone fill."""
return [f for f in failed if not (len(f) > 3 and f[3])]
def window_reroute(
dsn: DsnBoard,
result: RouteResult,
route_args: dict[str, Any],
*,
rounds: int,
resolution: int,
margin: int,
via_half: int,
via_drill: int,
via_name: str,
budget_s: float | None = None,
max_windows: int = 40,
) -> RouteResult:
"""Re-route a window around each still-failing connection; keep only gains.
``route_args`` are the exact engine's settings for the whole board (width,
clearance, layers, shove, ...); the window runs them at ``resolution``.
``margin`` is the first round's window margin in board units. No new
window starts once ``budget_s`` seconds have gone by (None: no limit),
and at most ``max_windows`` run per round."""
started = time.monotonic()
if rounds < 1 or not _open(result.failed):
return result
default_half = result.half_width
copper: dict[int, Copper] = {
net: (list(result.wires.get(net, [])), list(result.vias.get(net, [])))
for net in set(result.wires) | set(result.vias)
}
half = dict(result.net_half)
failed = list(result.failed)
base = build_board(dsn)
base_items = [(item, _item_box(item)) for item in base.get_items()]
layers = route_args.get("layers") or [
i for i, layer in enumerate(base.layer_structure.arr) if layer.is_signal
]
labels = _pin_labels(base)
# the copper a window's routing can feel from outside it
context = 2 * (route_args["trace_width"] + route_args["clearance"]) + 2 * via_half + (
route_args.get("edge_clearance") or 0
)
def window_of(entry, reach: int) -> IntBox | None:
ends = [_end_point(labels, entry[0], label) for label in entry[1:3]]
ends = [p for p in ends if p is not None]
if not ends:
return None
return IntBox(
min(p.x for p in ends) - reach, min(p.y for p in ends) - reach,
max(p.x for p in ends) + reach, max(p.y for p in ends) + reach,
)
def area(box: IntBox | None) -> float:
return float("inf") if box is None else (box.ur.x - box.ll.x) * (box.ur.y - box.ll.y)
changed = out_of_time = False
for rnd in range(rounds):
reach = margin * (2 ** rnd)
# the cheap wins first: the smallest windows
todo = sorted(_open(list(failed)), key=lambda f: area(window_of(f, reach)))
for entry in todo[:max(int(max_windows), 1)]:
if budget_s is not None and time.monotonic() - started >= budget_s:
out_of_time = True
break
if entry not in failed:
continue # an earlier window routed it
net = entry[0]
window = window_of(entry, reach)
if window is None:
continue
victims = {n for n, c in copper.items() if _inside(c, window)}
affected = victims | {net}
before = _open(f for f in failed if f[0] in affected)
keep = set(copper) - victims
view = IntBox(window.ll.x - context, window.ll.y - context,
window.ur.x + context, window.ur.y + context)
board = _window_board(base, base_items, view, affected, copper, keep, half,
default_half, via_half, via_drill, layers, via_name)
sub = route_board_exact(
board, **route_args, resolution=resolution,
only_nets=frozenset(affected), bounds=window,
route_first=frozenset((f[0], f[1], f[2]) for f in before),
).result
after = _open(f for f in sub.failed if f[0] in affected)
if len(after) >= len(before):
continue
# the window board left out what lies away from it, so copper
# reaching out of the window was never checked against it
if any(not _inside((sub.wires.get(n, []), sub.vias.get(n, [])), window)
for n in affected if sub.wires.get(n) or sub.vias.get(n)):
continue
# a net keeping some of its copper is written at one width: the
# window's new copper has to have gone in at that width too
if any(
n not in victims and copper.get(n) and (sub.wires.get(n) or sub.vias.get(n))
and sub.net_half.get(n, default_half) != half.get(n, default_half)
for n in affected
):
continue
for n in affected:
kept_wires, kept_vias = copper.get(n, ([], [])) if n not in victims else ([], [])
copper[n] = (kept_wires + sub.wires.get(n, []), kept_vias + sub.vias.get(n, []))
if n in victims:
half.pop(n, None)
if n in sub.net_half:
half[n] = sub.net_half[n]
failed = [f for f in failed if f[0] not in affected] + [
f for f in sub.failed if f[0] in affected
]
changed = True
if out_of_time or not _open(failed):
break
if not changed:
return result
out = RouteResult(default_half)
for net, (wires, vias) in copper.items():
for layer, path in wires:
out.add_wire(net, layer, path)
for location in dict.fromkeys(vias):
out.add_via(net, location)
out.failed = failed
out.unrouted = len(_open(failed))
out.net_half = half
out.narrowed = list(result.narrowed)
return out
@@ -1,9 +1,9 @@
"""Specctra SES (session) writing for freeroute. """Specctra SES (session) writing for rpmroute.
Public API:: Public API::
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.ses import write_ses, RoutingResult, RoutedWire, RoutedVia from rpmroute.ses import write_ses, RoutingResult, RoutedWire, RoutedVia
board = parse_dsn(dsn_text) board = parse_dsn(dsn_text)
ses_text = write_ses(board) # valid no-op session ses_text = write_ses(board) # valid no-op session
@@ -1,8 +1,8 @@
"""Specctra SES session-file writer. """Specctra SES session-file writer.
Ports the write path of FreeRouting's ``io/specctra/SesWriter.java`` to operate Ports the write path of FreeRouting's ``io/specctra/SesWriter.java`` to operate
on this project's parsed :class:`~freeroute.dsn.model.DsnBoard` plus an on this project's parsed :class:`~rpmroute.dsn.model.DsnBoard` plus an
in-memory :class:`~freeroute.ses.model.RoutingResult`. The emitted structure is:: in-memory :class:`~rpmroute.ses.model.RoutingResult`. The emitted structure is::
(session <name> (session <name>
(base_design <name>) (base_design <name>)
@@ -30,8 +30,8 @@ from __future__ import annotations
import math import math
from freeroute.dsn.model import DsnBoard from rpmroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape from rpmroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from .indent import Identifier, IndentWriter from .indent import Identifier, IndentWriter
from .model import RoutedNet, RoutedVia, RoutedWire, RoutingResult from .model import RoutedNet, RoutedVia, RoutedWire, RoutingResult
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from freeroute.board import ( from rpmroute.board import (
ClearanceMatrix, ClearanceMatrix,
CoordinateTransform, CoordinateTransform,
Layer, Layer,
+6 -6
View File
@@ -15,9 +15,9 @@ import sys
import pytest import pytest
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board from rpmroute.board import BasicBoard, ObstacleArea, Pin, build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.geometry import IntPoint from rpmroute.geometry import IntPoint
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -184,7 +184,7 @@ def test_place_keepout_does_not_block_routing():
def test_grid_router_leaves_the_interior_of_a_ring_keepout_open(): def test_grid_router_leaves_the_interior_of_a_ring_keepout_open():
from freeroute.route.pipeline import route_dsn_board from rpmroute.route.pipeline import route_dsn_board
ring_only = "\n".join( ring_only = "\n".join(
line for line in _RING_DSN.splitlines() if "wire_keepout" not in line line for line in _RING_DSN.splitlines() if "wire_keepout" not in line
@@ -251,7 +251,7 @@ def test_oval_pad_keeps_its_width():
def test_via_size_comes_from_the_via_padstack(): def test_via_size_comes_from_the_via_padstack():
from freeroute.route.pipeline import _via_half_board from rpmroute.route.pipeline import _via_half_board
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
@@ -279,7 +279,7 @@ def test_a_keepout_named_rule_area_takes_no_clearance():
def _route_channel(name): def _route_channel(name):
from freeroute.route.pipeline import build_exact_routing_result from rpmroute.route.pipeline import build_exact_routing_result
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2) return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from freeroute.board import ShapeSearchTree, TreeShape from rpmroute.board import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox from rpmroute.geometry import IntBox
def shape(owner, net, layer, box, routed=False): def shape(owner, net, layer, box, routed=False):
+4 -4
View File
@@ -6,10 +6,10 @@ from pathlib import Path
import pytest import pytest
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.reader import DsnParseError from rpmroute.dsn.reader import DsnParseError
from freeroute.dsn.shapes import Circle, Rectangle from rpmroute.dsn.shapes import Circle, Rectangle
from freeroute.dsn.shapes import Path as DsnPath from rpmroute.dsn.shapes import Path as DsnPath
FIXTURES = Path(__file__).parent / "fixtures" FIXTURES = Path(__file__).parent / "fixtures"
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import pytest import pytest
from freeroute.dsn.sexp import SExp, parse from rpmroute.dsn.sexp import SExp, parse
from freeroute.dsn.tokenizer import DsnSyntaxError, Token from rpmroute.dsn.tokenizer import DsnSyntaxError, Token
def test_parse_simple_head_and_values(): def test_parse_simple_head_and_values():
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.dsn.shapes import ( from rpmroute.dsn.shapes import (
Circle, Circle,
Path, Path,
Polygon, Polygon,
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import pytest import pytest
from freeroute.dsn.tokenizer import DsnSyntaxError, TokenKind, tokenize from rpmroute.dsn.tokenizer import DsnSyntaxError, TokenKind, tokenize
def kinds(text: str) -> list[TokenKind]: def kinds(text: str) -> list[TokenKind]:
+1 -1
View File
@@ -6,7 +6,7 @@ containment and degenerate (empty / point / segment) tiles.
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import IntBox, IntPoint, IntVector, RationalPoint from rpmroute.geometry import IntBox, IntPoint, IntVector, RationalPoint
def test_measures(): def test_measures():
+1 -1
View File
@@ -5,7 +5,7 @@ Oracles hand-derived from ``geometry/planar/{Direction,IntDirection}.java``.
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import Direction, IntDirection, IntPoint, IntVector, Side from rpmroute.geometry import Direction, IntDirection, IntPoint, IntVector, Side
def test_normalized_direction_reduces_by_gcd(): def test_normalized_direction_reduces_by_gcd():
+2 -2
View File
@@ -5,7 +5,7 @@ Oracles hand-derived from ``geometry/planar/Line.java``.
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import IntPoint, Line, RationalPoint, Side from rpmroute.geometry import IntPoint, Line, RationalPoint, Side
def test_side_of_point(): def test_side_of_point():
@@ -79,7 +79,7 @@ def test_intersection_approx_matches_exact():
def test_translate_and_opposite(): def test_translate_and_opposite():
line = Line.from_coords(0, 0, 4, 0) line = Line.from_coords(0, 0, 4, 0)
assert line.opposite().direction().equals(line.direction()) is False assert line.opposite().direction().equals(line.direction()) is False
from freeroute.geometry import IntVector from rpmroute.geometry import IntVector
moved = line.translate_by(IntVector(0, 5)) moved = line.translate_by(IntVector(0, 5))
assert moved.side_of(IntPoint(2, 5)) == Side.COLLINEAR assert moved.side_of(IntPoint(2, 5)) == Side.COLLINEAR
+1 -1
View File
@@ -7,7 +7,7 @@ import random
import pytest import pytest
from freeroute.geometry import IntBox, IntPoint, ceil_sqrt2, overlaps_2d, segment_octagon from rpmroute.geometry import IntBox, IntPoint, ceil_sqrt2, overlaps_2d, segment_octagon
@pytest.mark.parametrize("r", [0, 1, 2, 3, 1000, 2000, 3000, 12345, 999999]) @pytest.mark.parametrize("r", [0, 1, 2, 3, 1000, 2000, 3000, 12345, 999999])
+2 -2
View File
@@ -8,7 +8,7 @@ Java ``long``, and rational equality.
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import ( from rpmroute.geometry import (
IntPoint, IntPoint,
IntVector, IntVector,
RationalPoint, RationalPoint,
@@ -139,7 +139,7 @@ def test_rational_point_infinite():
def test_rational_point_contained_in_box_exact(): def test_rational_point_contained_in_box_exact():
from freeroute.geometry import IntBox from rpmroute.geometry import IntBox
box = IntBox(0, 0, 2, 2) box = IntBox(0, 0, 2, 2)
assert RationalPoint(3, 2, 2).is_contained_in(box) # (1.5, 1.0) inside assert RationalPoint(3, 2, 2).is_contained_in(box) # (1.5, 1.0) inside
+3 -3
View File
@@ -13,9 +13,9 @@ from __future__ import annotations
import pytest import pytest
from freeroute.geometry import IntPoint, Side from rpmroute.geometry import IntPoint, Side
from freeroute.geometry.polygon import Polygon from rpmroute.geometry.polygon import Polygon
from freeroute.geometry.polygon_shape import PolygonShape from rpmroute.geometry.polygon_shape import PolygonShape
def pts(*coords): def pts(*coords):
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import ( from rpmroute.geometry import (
IntBox, IntBox,
IntPoint, IntPoint,
Polyline, Polyline,
+2 -2
View File
@@ -16,7 +16,7 @@ Source: ``geometry/planar/{TileShape,Simplex}.java``.
from __future__ import annotations from __future__ import annotations
from freeroute.geometry import IntBox, IntPoint, Line, Side, Simplex from rpmroute.geometry import IntBox, IntPoint, Line, Side, Simplex
def _grid(lo: int, hi: int): def _grid(lo: int, hi: int):
@@ -162,7 +162,7 @@ def test_get_instance_detects_empty_from_opposing_halfplanes():
def test_translate_preserves_shape(): def test_translate_preserves_shape():
tri = right_triangle() tri = right_triangle()
from freeroute.geometry import IntVector from rpmroute.geometry import IntVector
moved = tri.translate_by(IntVector(100, 50)) moved = tri.translate_by(IntVector(100, 50))
for p in _grid(-3, 13): for p in _grid(-3, 13):
+5 -5
View File
@@ -1,8 +1,8 @@
"""FreeRouting JAR oracle for behaviour-level validation. """FreeRouting JAR oracle for behaviour-level validation.
**Dev/test only — not part of the shipped package** (freeroute is Java-free by **Dev/test only — not part of the shipped package** (rpmroute is Java-free by
design). This runs the reference FreeRouting JAR to route a Specctra DSN so that design). This runs the reference FreeRouting JAR to route a Specctra DSN so that
freeroute's own output can be diffed against the reference implementation. There rpmroute's own output can be diffed against the reference implementation. There
are no unit-test oracles for FreeRouting's geometry/router, so the oracle is how are no unit-test oracles for FreeRouting's geometry/router, so the oracle is how
we validate at the behaviour level: same DSN in, compare routed connectivity. we validate at the behaviour level: same DSN in, compare routed connectivity.
@@ -10,7 +10,7 @@ Tests that use this skip automatically when a Java 21+ runtime or the JAR is not
available (e.g. CI without a JVM), so the normal suite stays Java-free. available (e.g. CI without a JVM), so the normal suite stays Java-free.
Overrides: Overrides:
- ``FREEROUTE_ORACLE_JAVA`` — explicit path to a ``java`` binary. - ``RPMROUTE_ORACLE_JAVA`` (or the older ``FREEROUTE_ORACLE_JAVA``) — explicit path to a ``java`` binary.
- ``FREEROUTING_JAR`` — explicit path to a freerouting JAR. - ``FREEROUTING_JAR`` — explicit path to a freerouting JAR.
""" """
@@ -27,7 +27,7 @@ import tempfile
import pytest import pytest
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
_MIN_JAVA_MAJOR = 21 _MIN_JAVA_MAJOR = 21
_REPO_ROOT = Path(__file__).resolve().parents[1] _REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -60,7 +60,7 @@ def _java_major(java: str) -> int:
def find_java() -> str | None: def find_java() -> str | None:
"""Return a path to a Java >= 21 runtime, or None.""" """Return a path to a Java >= 21 runtime, or None."""
candidates: list[str] = [] candidates: list[str] = []
override = os.environ.get("FREEROUTE_ORACLE_JAVA") override = os.environ.get("RPMROUTE_ORACLE_JAVA") or os.environ.get("FREEROUTE_ORACLE_JAVA")
if override: if override:
candidates.append(override) candidates.append(override)
java_home = os.environ.get("JAVA_HOME") java_home = os.environ.get("JAVA_HOME")
+113
View File
@@ -0,0 +1,113 @@
"""Pins of a fine-pitch part turned off the grid axes leave along their pads.
A QFN at 45 degrees has no straight exit: every horizontal or vertical step
from a pin passes its neighbours closer than the clearance (0.35 mm away on
each axis at 0.5 mm pitch). The grid leaves along the pad's own axis first,
past the pin row, and the exact engine checks that slanted leg on its exact
copper. Found on th-eda's lift ESC, where a QFN-24 at 45 degrees left 15
connections unroutable on an empty board.
"""
from __future__ import annotations
import math
import pytest
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.geometry import IntPoint, overlaps_2d, segment_slab
from rpmroute.route.grid_router import GridRouter
from rpmroute.route.pipeline import (
_rule_clearance_dsn,
_rule_width_dsn,
_via_half_board,
build_exact_routing_result,
)
def _qfn_side_dsn(rotation: float) -> str:
"""Two rows of five 0.25 x 0.8 mm pads at 0.5 mm pitch (one side of a
QFN and its opposite side), turned by ``rotation``; each pin of the top
row is its own net, routed to a pad 4.5 mm out, the pads fanned 1.5 mm
apart."""
cx, cy = 100000, -100000
t = math.radians(rotation)
out = (-math.sin(t), math.cos(t)) # the pads' outward axis after turning
along = (math.cos(t), math.sin(t)) # the pin row
pins, targets, nets = [], [], []
for i in range(5):
x = -1000 + 500 * i
pins += [f"(pin P {i + 1} {x} 1000)", f"(pin P {i + 6} {x} -1000)"]
px = cx + x * math.cos(t) - 1000 * math.sin(t)
py = cy + x * math.sin(t) + 1000 * math.cos(t)
spread = 1500 * (i - 2) - x # fan the far pads out to 1.5 mm apart
tx = px + 4500 * out[0] + spread * along[0]
ty = py + 4500 * out[1] + spread * along[1]
targets.append(f"(place T{i + 1} {round(tx)} {round(ty)} front 0)")
nets.append(f"(net N{i} (pins U1-{i + 1} T{i + 1}-1))")
return f"""(pcb qfn (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 115000 -115000 85000 -115000 85000 -85000 115000 -85000
115000 -115000))
(via "Via[0-1]_450:200_um")
(rule (width 150) (clearance 150)))
(placement
(component Q (place U1 {cx} {cy} front {rotation}))
(component T {" ".join(targets)}))
(library
(image Q {" ".join(pins)})
(image T (pin t 1 0 0))
(padstack P (shape (rect F.Cu -125 -400 125 400)) (attach off))
(padstack t (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack "Via[0-1]_450:200_um" (shape (circle F.Cu 450)) (shape (circle B.Cu 450))
(attach off)))
(network {" ".join(nets)}))"""
@pytest.mark.parametrize("rotation", [45, 30, -45])
def test_a_turned_fine_pitch_part_gets_its_pins_out(rotation):
result = build_exact_routing_result(parse_dsn(_qfn_side_dsn(rotation)), resolution=2, shove=True)
assert result.unrouted == []
def test_the_exit_runs_along_the_pad_axis_and_clears_the_neighbours():
dsn = parse_dsn(_qfn_side_dsn(45))
s = dsn.resolution.value
board = build_board(dsn)
router = GridRouter(
board,
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
via_half=_via_half_board(dsn, s),
orthogonal=True,
)
pins = {p.name: p for p in board.get_pins() if p.component_ref == "U1"}
pin = pins["3"]
exit_point = router._axis_exit(pin)
assert exit_point is not None
dx, dy = exit_point.x - pin.location.x, exit_point.y - pin.location.y
assert abs(abs(dx) - abs(dy)) <= 2 # along the 45-degree axis
leg = segment_slab(pin.location, exit_point, router.half_width + router.clearance)
for name in ("2", "4"):
assert not overlaps_2d(leg, pins[name].shape)
def test_a_pad_on_the_grid_axes_keeps_the_usual_exits():
dsn = parse_dsn(_qfn_side_dsn(0))
s = dsn.resolution.value
board = build_board(dsn)
router = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s), via_half=_via_half_board(dsn, s))
pin = next(p for p in board.get_pins() if p.component_ref == "U1" and p.name == "3")
assert router._axis_exit(pin) is None
def test_a_slanted_slab_is_tight_where_a_bounding_box_is_not():
slab = segment_slab(IntPoint(0, 0), IntPoint(10000, 10000), 750)
# a point beside the diagonal but inside its bounding box
from rpmroute.geometry import IntBox
assert not overlaps_2d(slab, IntBox(8000, 0, 8100, 100))
assert overlaps_2d(slab, IntBox(4900, 4900, 5100, 5100))
+6 -6
View File
@@ -16,11 +16,11 @@ from pathlib import Path
import pytest import pytest
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.geometry import IntBox, IntPoint, overlaps_2d, segment_octagon from rpmroute.geometry import IntBox, IntPoint, overlaps_2d, segment_octagon
from freeroute.route import route, route_dsn_board_exact from rpmroute.route import route, route_dsn_board_exact
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
DIAGONAL_WIN = FIXTURES / "diagonal_win.dsn" DIAGONAL_WIN = FIXTURES / "diagonal_win.dsn"
@@ -55,7 +55,7 @@ def _pins_by_net(fixture):
def _rule_clearance_board(fixture): def _rule_clearance_board(fixture):
from freeroute.route.pipeline import _rule_clearance_dsn from rpmroute.route.pipeline import _rule_clearance_dsn
dsn = parse_dsn(fixture.read_text()) dsn = parse_dsn(fixture.read_text())
return round(_rule_clearance_dsn(dsn) * max(dsn.resolution.value, 1)) return round(_rule_clearance_dsn(dsn) * max(dsn.resolution.value, 1))
+6 -6
View File
@@ -15,12 +15,12 @@ import sys
import pytest import pytest
from freeroute.board import ShapeSearchTree, TreeShape, build_board from rpmroute.board import ShapeSearchTree, TreeShape, build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.geometry import IntBox, Polyline, PolylineShape from rpmroute.geometry import IntBox, Polyline, PolylineShape
from freeroute.route import route, route_dsn_board, route_dsn_board_exact from rpmroute.route import route, route_dsn_board, route_dsn_board_exact
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+18 -3
View File
@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact from rpmroute.route.pipeline import route_dsn_board_exact
_DSN = """(pcb wired (parser) (resolution um 10) (unit um) _DSN = """(pcb wired (parser) (resolution um 10) (unit um)
(structure (structure
@@ -91,3 +91,18 @@ def test_a_fanned_out_signal_is_routed_from_its_via():
assert sig in exact.result.routed_net_numbers assert sig in exact.result.routed_net_numbers
assert not exact.result.vias.get(sig), "the fan-out via is already there" assert not exact.result.vias.get(sig), "the fan-out via is already there"
assert {names[layer] for layer, _ in exact.result.wires[sig]} == {"B.Cu"} assert {names[layer] for layer, _ in exact.result.wires[sig]} == {"B.Cu"}
def test_the_session_never_repeats_copper_already_on_the_board():
# the JAR writes the DSN's wiring back into its session, and an applier
# then adds it a second time on top of itself; rpmroute writes only
# what it routed
from rpmroute.route.pipeline import build_exact_routing_result
fixed_vias = {(5000.0, -11500.0), (10000.0, -11500.0)}
fixed_paths = {(5000.0, -10000.0, 5000.0, -11500.0), (10000.0, -10000.0, 10000.0, -11500.0),
(2000.0, -2000.0, 8000.0, -2000.0)}
result = build_exact_routing_result(parse_dsn(_FANOUT), resolution=2)
for net in result.nets:
assert not {(v.x, v.y) for v in net.vias} & fixed_vias
assert not {tuple(w.coords) for w in net.wires} & fixed_paths
+284
View File
@@ -0,0 +1,284 @@
"""The grid must not believe copper legal that the exact check then refuses.
Each case is one way the two used to disagree on roomba-ESP12E: a rotated oval
pad modelled as an inflated box (and opened to its own net), a via shared by
two connections of a net losing its footprint when one of them is ripped, and
a wider net class searched as if it were the default width.
"""
from __future__ import annotations
import math
import pytest
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.geometry import IntBox, Simplex
from rpmroute.route.grid_router import GridRouter
from rpmroute.route.pipeline import (
_rule_clearance_dsn,
_rule_width_dsn,
_via_half_board,
build_exact_routing_result,
)
# --- rotated oval pads -------------------------------------------------------
def _socket_dsn(rotation: float, layout: str) -> str:
"""A 1x4 2.54 mm socket strip (rect pin 1, oval pins 2-4) at ``rotation``,
each pin its own net, routed to a small SMD pad 0.9 mm away."""
cx, cy = 100000, -100000
t = math.radians(rotation)
ax = (math.cos(t), math.sin(t))
perp = (-math.sin(t), math.cos(t))
places = []
for i in range(4):
px, py = cx + 2540 * i * ax[0], cy + 2540 * i * ax[1]
side = 1 if i % 2 == 0 else -1
if layout == "y":
tx, ty = px, py + side * 9000
else:
tx, ty = px + side * 8000 * perp[0], py + side * 8000 * perp[1]
places.append(f"(place T{i + 1} {round(tx)} {round(ty)} front 0)")
nets = " ".join(f"(net N{i} (pins P3-{i + 1} T{i + 1}-1))" for i in range(4))
return f"""(pcb socket (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 125000 -115000 85000 -115000 85000 -75000 125000 -75000
125000 -115000))
(via "Via[0-1]_800:400_um")
(rule (width 300) (clearance 250)))
(placement
(component S (place P3 {cx} {cy} front {rotation}))
(component T {" ".join(places)}))
(library
(image S (pin R 1 0 0) (pin O 2 2540 0) (pin O 3 5080 0) (pin O 4 7620 0))
(image T (pin t 1 0 0))
(padstack O (shape (path F.Cu 1727.2 0 -152.4 0 152.4))
(shape (path B.Cu 1727.2 0 -152.4 0 152.4)) (attach off))
(padstack R (shape (rect F.Cu -863.6 -1016 863.6 1016))
(shape (rect B.Cu -863.6 -1016 863.6 1016)) (attach off))
(padstack t (shape (rect F.Cu -500 -500 500 500)) (attach off))
(padstack "Via[0-1]_800:400_um" (shape (circle F.Cu 800)) (shape (circle B.Cu 800))
(attach off)))
(network {nets}
(class c N0 N1 N2 N3 (circuit (use_via "Via[0-1]_800:400_um"))
(rule (width 300) (clearance 250)))))"""
def test_a_rotated_oval_pad_is_its_outline_not_its_bounding_box():
board = build_board(parse_dsn(_socket_dsn(60, "y")))
oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2")
assert isinstance(oval.shape, Simplex) and not oval.shape.is_int_box()
box = oval.shape.bounding_box()
assert oval.shape.contains(oval.location)
assert not oval.shape.contains(box.ll) and not oval.shape.contains(box.ur)
@pytest.mark.parametrize("layout", ["y", "perp"])
def test_routes_leaving_a_rotated_socket_pass_the_exact_check(layout):
result = build_exact_routing_result(
parse_dsn(_socket_dsn(60, layout)), resolution=2, shove=True
)
assert result.unrouted == []
# --- a via two connections share --------------------------------------------
_SHARED_VIA = """(pcb shared (parser) (resolution um 10) (unit um)
(structure
(layer Top (type signal)) (layer Bottom (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(via "Via[0-1]_800:400_um")
(rule (width 300) (clearance 250)))
(library
(padstack p (shape (rect Top -400 -400 400 400)) (attach off))
(padstack "Via[0-1]_800:400_um" (shape (circle Top 800)) (shape (circle Bottom 800))
(attach off))
(image PAD (pin p 1 0 0)))
(placement (component PAD
(place G1 3000 -5000 front 0) (place G2 3000 -15000 front 0)
(place G3 17000 -10000 front 0) (place S1 3000 -10000 front 0)
(place S2 17000 -3000 front 0)))
(network (net GND (pins G1-1 G2-1 G3-1)) (net SIG (pins S1-1 S2-1))))"""
def _line(a, b, layer):
(x, y), (x1, y1) = a, b
out = [(x, y, layer)]
while x != x1:
x += 1 if x1 > x else -1
out.append((x, y, layer))
while y != y1:
y += 1 if y1 > y else -1
out.append((x, y, layer))
return out
def test_a_shared_via_keeps_its_footprint_when_one_user_is_ripped():
dsn = parse_dsn(_SHARED_VIA)
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
via_half=_via_half_board(dsn, s),
)
conns = router._conns = router._build_connections()
gnd = [c for c in conns if len({c.a.component_ref, c.b.component_ref} & {"G1", "G2", "G3"})]
sig = next(c for c in conns if c not in gnd)
first, second = gnd
g3 = router._cell_of(next(p for p in router.board.get_pins() if p.component_ref == "G3").location)
via = (g3[0] - 10, g3[1])
def through_via(conn):
a, b = router._cell_of(conn.a.location), router._cell_of(conn.b.location)
return _line(a, via, 0) + _line(via, b, 1)
router._commit(first, through_via(first))
router._commit(second, through_via(second))
footprint = [c for c in router._via_cells(via) if c != via]
router._rip(first)
assert second.routed and via in second.via_cells
assert all(router._via_claims.get(c) == [second.index] for c in footprint)
# another net still keeps its distance from the via that is left
assert all(router._enter_cost_base(c, 0, sig.net_no, False, conns) is None for c in footprint)
def test_a_route_through_a_shared_via_rips_every_user():
"""Two connections of a net share a via. Another net routed through it
in rip-up mode has to rip both, or the one left keeps its via under the
new trace and the exact check throws one of them out."""
dsn = parse_dsn(_SHARED_VIA)
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
via_half=_via_half_board(dsn, s),
)
conns = router._conns = router._build_connections()
gnd = [c for c in conns if len({c.a.component_ref, c.b.component_ref} & {"G1", "G2", "G3"})]
sig = next(c for c in conns if c not in gnd)
first, second = gnd
g3 = router._cell_of(next(p for p in router.board.get_pins() if p.component_ref == "G3").location)
via = (g3[0] - 10, g3[1])
def through_via(conn):
a, b = router._cell_of(conn.a.location), router._cell_of(conn.b.location)
return _line(a, via, 0) + _line(via, b, 1)
router._commit(first, through_via(first))
router._commit(second, through_via(second))
# the via's copper alone: footprint cells no trace or trace halo covers
crossing = [
node
for c in router._via_cells(via)
for node in ((*c, 0), (*c, 1))
if node not in router._cell_owner and node not in router._halo
]
assert crossing
assert router._crossed_owners(crossing, sig.net_no, conns) == {first.index, second.index}
# --- a wider net class --------------------------------------------------------
_WIDE = """(pcb wide (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0))
(rule (width 300) (clearance 250)))
(placement (component S (place P1 2000 -5000 front 0) (place P2 18000 -5000 front 0)
(place Q1 10000 {ylo} front 0) (place Q2 10000 {yhi} front 0)
(place Q3 10000 -1000 front 0) (place Q4 10000 -9000 front 0)))
(library (image S (pin pad 1 0 0)) (padstack pad (shape (rect F.Cu -600 -600 600 600)) (attach off)))
(network (net PWR (pins P1-1 P2-1)) (net SIG (pins Q1-1 Q3-1)) (net SIG2 (pins Q2-1 Q4-1))
(class Power PWR (rule (width 500) (clearance 250)))))"""
@pytest.mark.parametrize("resolution", [1, 2])
@pytest.mark.parametrize("gap", [900, 1000, 1075])
def test_a_wide_net_past_pads_it_does_not_fit_between_is_not_refused(gap, resolution):
# 0.3 mm fits the gap between the two pads, 0.5 mm does not: the grid
# must see that, rather than lay it wide and have the exact check drop it
ylo, yhi = -5000 - gap // 2 - 600, -5000 + gap // 2 + 600
result = build_exact_routing_result(
parse_dsn(_WIDE.format(ylo=ylo, yhi=yhi)), resolution=resolution
)
assert result.unrouted == []
def _wide_router():
dsn = parse_dsn(_WIDE.format(ylo=-6050, yhi=-3950))
s = dsn.resolution.value
pwr = next(i for i, net in enumerate(dsn.nets, 1) if net.name == "PWR")
return GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
resolution=2,
net_widths={pwr: 5000},
), pwr
def test_a_wide_trace_keeps_clear_of_a_default_one_at_the_grid_diagonal():
router, _pwr = _wide_router()
# 0.5 mm against 0.3 mm needs 0.25 + 0.25 + 0.15 = 0.65 mm between centres,
# on either axis, since the exact check compares boxes
need = 2500 + router.clearance + router.half_width
for dx, dy in [(2, 2), (2, 1), (1, 2)]:
assert max(dx, dy) * router.step < need
assert (dx, dy) in router._halo_for(2500)
def test_a_wide_rip_up_route_counts_what_its_extra_width_crosses():
router, pwr_net = _wide_router()
conns = router._conns = router._build_connections()
pwr = next(c for c in conns if c.net_no == pwr_net)
other = next(c for c in conns if c.net_no != pwr_net)
# a default-width trace two cells from the wide route's path
x, y = 20, 5
router._commit(other, _line((x - 3, y + 2), (x + 3, y + 2), 0))
path = _line((x - 3, y), (x + 3, y), 0)
spread = router._spread_for(2500)
assert other.index not in router._crossed_owners(path, pwr.net_no, conns)
assert other.index in router._crossed_owners(path, pwr.net_no, conns, spread)
def test_a_pad_cell_check_uses_the_rotated_outline():
board = build_board(parse_dsn(_socket_dsn(60, "y")))
router = GridRouter(board, trace_width=3000, clearance=2500, resolution=2)
oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2")
box = oval.shape.bounding_box()
margin = router.clearance + router.half_width
exact = set(router._pad_cells(oval.shape, box, margin))
boxed = set(router._cells_in_box(box, margin))
assert exact < boxed
for cell in boxed - exact:
c = router._cell_center(cell)
square = IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin)
assert oval.shape.intersection(square).dimension() != 2
def test_a_via_by_a_chamfered_corner_is_judged_round():
"""The grid keeps a via's centre its radius plus the edge clearance from
the outline. The exact check used the via's square, whose corner reaches
past a 45-degree chamfer where the round via does not."""
from rpmroute.board.outline import BoardOutline
from rpmroute.geometry import IntBox, IntPoint
from rpmroute.route.exact_router import _inside_outline
# a 10 mm square with its lower-left corner cut at 45 degrees
outline = BoardOutline([IntPoint(*p) for p in
[(30, 0), (100, 0), (100, 100), (0, 100), (0, 30)]])
half, edge = 4, 1
via = IntPoint(19, 20) # its centre 6.4 from the chamfer x + y = 30
square = IntBox(via.x - half, via.y - half, via.x + half, via.y + half)
assert not outline.box_clear(square, edge) # the square's corner: 0.7 away
assert _inside_outline(outline, [], [via], half + edge, edge)
# 4.2 from the chamfer: the round via itself is too close
assert not _inside_outline(outline, [], [IntPoint(18, 18)], half + edge, edge)
+65
View File
@@ -0,0 +1,65 @@
"""--negotiate: PathFinder-style rounds on the empty board before routing."""
from __future__ import annotations
from pathlib import Path
import pytest
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.route.grid_router import GridRouter
from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
def _router(name, negotiate):
dsn = parse_dsn((FIXTURES / name).read_text())
s = dsn.resolution.value
return GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
negotiate=negotiate,
)
def test_negotiation_leaves_the_board_empty_and_the_ends_as_they_were():
router = _router("true_density.dsn", 3)
conns = router._conns = router._build_connections()
ends = [(c.a_end, c.b_end) for c in conns]
router._negotiate(list(conns), conns)
assert router._history # a dense board has contested cells
assert not router._cell_owner and not router._halo and not router._via_claims
assert not any(c.routed for c in conns)
assert [(c.a_end, c.b_end) for c in conns] == ends
assert router._present is None
def test_sharing_is_priced_only_for_other_nets():
router = _router("true_density.dsn", 1)
node = (3, 4, 0)
router._history[node] = 2.0
router._present, router._present_cost = {node: {7: 1, 9: 2}}, 0.5
assert router._congestion(node, 7) == pytest.approx(2.5) # one other net
assert router._congestion(node, 5) == pytest.approx(3.0) # two other nets
router._present = None
assert router._congestion(node, 5) == pytest.approx(2.0) # history only, after
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_negotiated_routing_keeps_the_occupancy_honest(name):
"""Only routed connections hold cells afterwards, as without negotiation."""
router = _router(name, 3)
router.route()
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for claims in router._halo.values() for i in claims)
def test_option_reaches_the_router_and_is_off_by_default():
text = (FIXTURES / "kicad_routable.dsn").read_text()
plain, _, _ = route_dsn_board(parse_dsn(text))
again, _, _ = route_dsn_board(parse_dsn(text), negotiate=0)
assert plain.unrouted == again.unrouted
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.route.pipeline import build_exact_routing_result from rpmroute.route.pipeline import build_exact_routing_result
_BOARD = """(pcb widths (parser) (resolution um 10) (unit um) _BOARD = """(pcb widths (parser) (resolution um 10) (unit um)
(structure (structure
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from freeroute.cli import main from rpmroute.cli import main
from freeroute.route.orders import ( from rpmroute.route.orders import (
best_of_orders, best_of_orders,
candidate_seeds, candidate_seeds,
promote_failures, promote_failures,
+16 -13
View File
@@ -6,9 +6,9 @@ import math
import pytest import pytest
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact from rpmroute.route.pipeline import route_dsn_board_exact
# 20 x 20 mm board whose corners are rounded with a 3 mm radius, as polylines # 20 x 20 mm board whose corners are rounded with a 3 mm radius, as polylines
_R = 3000.0 _R = 3000.0
@@ -51,23 +51,26 @@ def test_routes_keep_edge_clearance_at_a_rounded_corner():
assert outline.edge_distance(p) >= half + 500 * scale - 1 assert outline.edge_distance(p) >= half + 500 * scale - 1
@pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)]) @pytest.mark.parametrize("resolution", [1, 4])
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape): def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution):
# Five 0.3 mm pads at 0.5 mm pitch, each wired to its own pad 8 mm away. # 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 # 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 # wide. At resolution 4 the 0.1 mm columns cannot miss it. At resolution
# 0.1 mm columns cannot, and every pin gets out, as the JAR manages. # 1 the 0.4 mm grid misses most bands, but a stub leaves its pin straight
# along the band and jogs to the column only past the pads, so every pin
# gets out there too, as the JAR manages (only 2 did before).
places = " ".join(f"(place R{i} {6000 + 500 * i} -6000 front 0)" for i in range(5)) 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)) 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)) nets = " ".join(f"(net N{i} (pins R{i}-1 F{i}-1))" for i in range(5))
result, _, _ = route_dsn_board_exact( result, _, _ = route_dsn_board_exact(
parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution
) )
assert len(result.routed_net_numbers) == escape assert len(result.routed_net_numbers) == 5
assert result.drc_clean
def test_resolution_one_has_no_halo_so_the_grid_is_unchanged(): def test_resolution_one_has_no_halo_so_the_grid_is_unchanged():
from freeroute.route.grid_router import GridRouter from rpmroute.route.grid_router import GridRouter
board = build_board(parse_dsn(_dsn("", ""))) board = build_board(parse_dsn(_dsn("", "")))
assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == [] assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == []
@@ -98,7 +101,7 @@ def test_heuristic_weight_bounds_route_length():
# --- copper zones (DSN planes) ----------------------------------------------- # --- copper zones (DSN planes) -----------------------------------------------
# rc4 power stage: pads inside "keep router off" pours connect through the pour, # rc4 power stage: pads inside "keep router off" pours connect through the pour,
# and freeroute, not knowing zones, dropped every such connection. # and rpmroute, not knowing zones, dropped every such connection.
_ZONE = "(plane N (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000 4000 -4000))" _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))" _NO_TRACKS = "(wire_keepout \"pour\" (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000))"
@@ -118,7 +121,7 @@ def test_planes_are_read_and_are_not_obstacles():
def test_pads_in_one_zone_need_no_route_between_them(): def test_pads_in_one_zone_need_no_route_between_them():
from freeroute.route.grid_router import _connected_groups from rpmroute.route.grid_router import _connected_groups
board = build_board(_zone_board()) board = build_board(_zone_board())
pins = sorted(board.get_pins(), key=lambda p: p.component_ref) pins = sorted(board.get_pins(), key=lambda p: p.component_ref)
@@ -152,7 +155,7 @@ def test_a_pour_other_nets_can_cross_is_not_trusted_to_connect():
def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys): 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 # a keepout wall across the board on both layers: A and B cannot be joined
from freeroute.cli import main from rpmroute.cli import main
wall = " ".join( wall = " ".join(
f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))" f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))"
@@ -215,7 +218,7 @@ def test_a_pad_outside_the_plane_routes_to_it():
def test_a_through_hole_pad_inside_a_plane_needs_no_route(): def test_a_through_hole_pad_inside_a_plane_needs_no_route():
from freeroute.route.grid_router import _connected_groups from rpmroute.route.grid_router import _connected_groups
board = build_board(parse_dsn(_four_layer_dsn())) board = build_board(parse_dsn(_four_layer_dsn()))
plane = [z for z in board.get_conduction_areas() if z.name == "GND"] plane = [z for z in board.get_conduction_areas() if z.name == "GND"]
+123
View File
@@ -0,0 +1,123 @@
"""Via reduction: each net with vias is routed again with vias made dear,
kept only when it fails nothing new and has fewer vias."""
from __future__ import annotations
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.geometry import IntPoint
from rpmroute.route.grid_router import RouteResult
from rpmroute.route.pipeline import (
_rule_clearance_dsn,
_rule_width_dsn,
_via_half_board,
route_dsn_board_exact,
)
from rpmroute.route.vias import reduce_vias
VIA = "Via[0-1]_600:300_um"
def _board(second_on_bottom: bool = False) -> str:
"""Two pads 14 mm apart on an empty two-layer board; the second on the
bottom layer when ``second_on_bottom``."""
p2_stack = "Bot_Pad" if second_on_bottom else "Top_Pad"
return f"""(pcb "vias.dsn"
(parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1"))
(resolution um 10)
(unit um)
(structure
(layer F.Cu (type signal) (property (index 0)))
(layer B.Cu (type signal) (property (index 1)))
(boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0))
(via "{VIA}")
(rule (width 200) (clearance 200))
)
(library
(padstack Top_Pad (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack Bot_Pad (shape (rect B.Cu -300 -300 300 300)) (attach off))
(padstack "{VIA}" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off))
(image TOP (pin Top_Pad 1 0 0))
(image SECOND (pin {p2_stack} 1 0 0))
)
(placement
(component TOP (place P1 3000 -5000 front 0))
(component SECOND (place P2 17000 -5000 front 0))
)
(network
(net N (pins P1-1 P2-1))
(class default (rule (width 200) (clearance 200)))
)
(wiring)
)
"""
def _route_args(dsn) -> dict:
scale = max(dsn.resolution.value, 1)
return {
"heuristic_weight": 1.0,
"via_half": _via_half_board(dsn, scale),
"edge_clearance": None,
"trace_width": round(_rule_width_dsn(dsn) * scale),
"clearance": round(_rule_clearance_dsn(dsn) * scale),
"layers": None,
"max_passes": 10,
"rip_up": True,
"shove": False,
"diagonal": False,
"rollback": True,
"tree_nets": True,
"net_widths": {},
"via_drill": None,
"hole_to_hole": 0,
"via_cost": 10.0,
}
def _reduce(text: str, result: RouteResult) -> RouteResult:
dsn = parse_dsn(text)
args = _route_args(dsn)
return reduce_vias(
dsn, result, args, resolution=1, margin=20000,
via_half=args["via_half"], via_drill=2 * args["via_half"], via_name=VIA,
)
def _pins(text: str) -> tuple[IntPoint, IntPoint]:
pins = {p.component_ref: p.location for p in build_board(parse_dsn(text)).get_pins()}
return pins["P1"], pins["P2"]
def test_a_needless_via_pair_is_routed_out():
text = _board()
a, b = _pins(text)
# a detour: down to the bottom layer and back up, for no reason
v1, v2 = IntPoint(a.x + 40000, a.y), IntPoint(b.x - 40000, b.y)
detour = RouteResult(1000)
detour.add_wire(1, 0, [a, v1])
detour.add_via(1, v1)
detour.add_wire(1, 1, [v1, v2])
detour.add_via(1, v2)
detour.add_wire(1, 0, [v2, b])
out = _reduce(text, detour)
assert out.failed == []
assert sum(len(v) for v in out.vias.values()) == 0
assert out.wires.get(1)
def test_a_needed_via_stays():
text = _board(second_on_bottom=True)
exact, _scale, _layers = route_dsn_board_exact(parse_dsn(text), resolution=1)
routed = exact.result
assert routed.failed == []
vias_before = sum(len(set(v)) for v in routed.vias.values())
assert vias_before >= 1
out = _reduce(text, routed)
assert out.failed == []
assert sum(len(set(v)) for v in out.vias.values()) == vias_before
def test_the_flag_runs_the_stage_end_to_end():
exact, _scale, _layers = route_dsn_board_exact(parse_dsn(_board()), resolution=1, reduce_vias=True)
assert exact.result.failed == []
+5 -5
View File
@@ -6,10 +6,10 @@ from pathlib import Path
import pytest import pytest
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter from rpmroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
@@ -31,7 +31,7 @@ def test_occupancy_only_holds_routed_connections(name):
router, _ = _routed_router(name) router, _ = _routed_router(name)
conns = router._conns conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values()) assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for i in router._via_owner.values()) assert all(conns[i].routed for claims in router._via_claims.values() for i in claims)
assert all(conns[i].routed for claims in router._halo.values() for i in claims) assert all(conns[i].routed for claims in router._halo.values() for i in claims)
+6 -6
View File
@@ -23,11 +23,11 @@ from pathlib import Path
import pytest import pytest
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.geometry import Polyline, PolylineShape from rpmroute.geometry import Polyline, PolylineShape
from freeroute.route import route, route_dsn_board_rooms from rpmroute.route import route, route_dsn_board_rooms
from freeroute.route.pipeline import _rule_clearance_dsn from rpmroute.route.pipeline import _rule_clearance_dsn
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
CHANNEL_PACK = FIXTURES / "channel_pack.dsn" CHANNEL_PACK = FIXTURES / "channel_pack.dsn"
@@ -134,7 +134,7 @@ def test_channel_pack_is_deterministic():
def test_channel_pack_endpoints_stay_on_pads(): def test_channel_pack_endpoints_stay_on_pads():
dsn = parse_dsn(CHANNEL_PACK.read_text()) dsn = parse_dsn(CHANNEL_PACK.read_text())
board_pins: dict[int, set[tuple[int, int]]] = {} board_pins: dict[int, set[tuple[int, int]]] = {}
from freeroute.board import build_board from rpmroute.board import build_board
for pin in build_board(dsn).get_pins(): for pin in build_board(dsn).get_pins():
for net_no in pin.net_nos: for net_no in pin.net_nos:
+6 -6
View File
@@ -13,11 +13,11 @@ import sys
import pytest import pytest
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.geometry import Polyline from rpmroute.geometry import Polyline
from freeroute.route import route, route_dsn_board_exact, route_dsn_board_rooms from rpmroute.route import route, route_dsn_board_exact, route_dsn_board_rooms
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -31,7 +31,7 @@ WIDE_DOOR = FIXTURES / "rooms_wide_door.dsn"
def _clearance(dsn, scale): def _clearance(dsn, scale):
from freeroute.route.pipeline import _rule_clearance_dsn from rpmroute.route.pipeline import _rule_clearance_dsn
return round(_rule_clearance_dsn(dsn) * scale) return round(_rule_clearance_dsn(dsn) * scale)
+11 -11
View File
@@ -32,12 +32,12 @@ import sys
import pytest import pytest
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape from rpmroute.geometry import IntBox, IntPoint, Polyline, PolylineShape
from freeroute.route import route, route_dsn_board_exact, route_dsn_board_rooms from rpmroute.route import route, route_dsn_board_exact, route_dsn_board_rooms
from freeroute.route.pipeline import _rule_clearance_dsn from rpmroute.route.pipeline import _rule_clearance_dsn
def _emitted_drc_violations(result, clearance): def _emitted_drc_violations(result, clearance):
@@ -265,8 +265,8 @@ def test_shove_is_byte_identical_on_foundation_fixtures(fixture):
def test_project_gate_reduces_to_clamp_without_occupancy(): def test_project_gate_reduces_to_clamp_without_occupancy():
from freeroute.board.search_tree import ShapeSearchTree from rpmroute.board.search_tree import ShapeSearchTree
from freeroute.route.room_router import _Door, _edge_gate, _project_gate from rpmroute.route.room_router import _Door, _edge_gate, _project_gate
door = _Door(other=1, kind="edge", lo=IntPoint(0, 100), hi=IntPoint(200, 100)) door = _Door(other=1, kind="edge", lo=IntPoint(0, 100), hi=IntPoint(200, 100))
tree = ShapeSearchTree() tree = ShapeSearchTree()
@@ -277,8 +277,8 @@ def test_project_gate_reduces_to_clamp_without_occupancy():
def test_project_gate_avoids_other_net_copper_on_the_door(): def test_project_gate_avoids_other_net_copper_on_the_door():
from freeroute.board.search_tree import ShapeSearchTree, TreeShape from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.route.room_router import _Door, _project_gate from rpmroute.route.room_router import _Door, _project_gate
door = _Door(other=1, kind="edge", lo=IntPoint(0, 100), hi=IntPoint(200, 100)) door = _Door(other=1, kind="edge", lo=IntPoint(0, 100), hi=IntPoint(200, 100))
tree = ShapeSearchTree() tree = ShapeSearchTree()
@@ -293,7 +293,7 @@ def test_project_gate_avoids_other_net_copper_on_the_door():
def test_free_intervals_splits_around_forbidden_band(): def test_free_intervals_splits_around_forbidden_band():
from freeroute.route.room_router import _best_free_point, _free_intervals from rpmroute.route.room_router import _best_free_point, _free_intervals
free = _free_intervals(0, 200, [(87, 113)]) free = _free_intervals(0, 200, [(87, 113)])
assert free == [(0, 87), (113, 200)] assert free == [(0, 87), (113, 200)]
+8 -8
View File
@@ -16,11 +16,11 @@ import sys
import pytest import pytest
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.route import route, route_dsn_board from rpmroute.route import route, route_dsn_board
from freeroute.ses.writer import ses_scale from rpmroute.ses.writer import ses_scale
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -287,11 +287,11 @@ def test_connectivity_parity_on_congested_board():
if not HAS_ORACLE: if not HAS_ORACLE:
pytest.skip("FreeRouting oracle unavailable") pytest.skip("FreeRouting oracle unavailable")
our_nets = routed_net_set(route(RIPUP.read_text())) our_nets = routed_net_set(route(RIPUP.read_text()))
assert our_nets == {"NET_A", "NET_B"} # freeroute connects both via rip-up assert our_nets == {"NET_A", "NET_B"} # rpmroute connects both via rip-up
oracle_nets = routed_net_set(route_dsn(RIPUP, max_passes=3, timeout=300)) oracle_nets = routed_net_set(route_dsn(RIPUP, max_passes=3, timeout=300))
if not oracle_nets: if not oracle_nets:
# the reference JAR does not route this synthetic single-signal-layer # the reference JAR does not route this synthetic single-signal-layer
# board (it expects a routing/via layer); freeroute's rip-up connecting # board (it expects a routing/via layer); rpmroute's rip-up connecting
# both nets is already asserted above and by the fast tests. # both nets is already asserted above and by the fast tests.
pytest.skip("reference JAR routed nothing on the synthetic single-layer board") pytest.skip("reference JAR routed nothing on the synthetic single-layer board")
assert oracle_nets <= our_nets # rip-up matches the JAR's connectivity assert oracle_nets <= our_nets # rip-up matches the JAR's connectivity
@@ -306,7 +306,7 @@ def test_connectivity_parity_on_crossing_board():
our_nets = routed_net_set(route(CROSSING.read_text())) our_nets = routed_net_set(route(CROSSING.read_text()))
oracle_nets = routed_net_set(route_dsn(CROSSING, max_passes=3, timeout=300)) oracle_nets = routed_net_set(route_dsn(CROSSING, max_passes=3, timeout=300))
assert oracle_nets, "oracle routed nothing on a routable board" assert oracle_nets, "oracle routed nothing on a routable board"
assert oracle_nets <= our_nets # freeroute connects every net the JAR does assert oracle_nets <= our_nets # rpmroute connects every net the JAR does
@pytest.mark.oracle @pytest.mark.oracle
+5 -5
View File
@@ -17,10 +17,10 @@ import sys
import pytest import pytest
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse from rpmroute.dsn.sexp import parse
from freeroute.route import route, route_dsn_board_exact from rpmroute.route import route, route_dsn_board_exact
from freeroute.route.pipeline import _rule_clearance_dsn from rpmroute.route.pipeline import _rule_clearance_dsn
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -66,7 +66,7 @@ def test_shove_result_is_deterministic():
def test_shoved_trace_endpoints_still_on_pads(): def test_shoved_trace_endpoints_still_on_pads():
from freeroute.board import build_board from rpmroute.board import build_board
on, _, _ = route_dsn_board_exact(parse_dsn(SHOVE.read_text()), rip_up=False, shove=True) on, _, _ = route_dsn_board_exact(parse_dsn(SHOVE.read_text()), rip_up=False, shove=True)
board = build_board(parse_dsn(SHOVE.read_text())) board = build_board(parse_dsn(SHOVE.read_text()))
+162
View File
@@ -0,0 +1,162 @@
"""Smoothing: staircases become octilinear runs, corners are chamfered, and
nothing it keeps (ends, vias, joints, pads it crosses) moves."""
from __future__ import annotations
from rpmroute.dsn import parse_dsn
from rpmroute.route.smooth import smooth
from rpmroute.route.verify import verify_result
from rpmroute.ses.model import RoutedVia, RoutedWire, RoutingResult
VIA = "Via[0-1]_600:300_um"
def _board(extra_pads: str = "", extra_places: str = "", nets: str = "") -> str:
"""A 30 x 30 mm two-layer board, units um, 0.2 mm track and clearance.
A1 at (2, -2) mm and A2 at (22, -22) mm are net SIG."""
return f"""(pcb "smooth.dsn"
(parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1"))
(resolution um 10)
(unit um)
(structure
(layer F.Cu (type signal) (property (index 0)))
(layer B.Cu (type signal) (property (index 1)))
(boundary (path pcb 0 0 0 30000 0 30000 -30000 0 -30000 0 0))
(via "{VIA}")
(rule (width 200) (clearance 200))
)
(library
(padstack P (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack "{VIA}" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off))
(image PAD (pin P 1 0 0))
{extra_pads}
)
(placement
(component PAD (place A1 2000 -2000 front 0) (place A2 22000 -22000 front 0) {extra_places})
)
(network
(net SIG (pins A1-1 A2-1))
{nets}
(class default (rule (width 200) (clearance 200)))
)
(wiring)
)
"""
def _staircase(a, b, step):
"""Points from ``a`` to ``b`` (DSN um) in alternating x and y steps."""
(x, y), (bx, by) = a, b
pts = [(x, y)]
sx = step if bx > x else -step
sy = step if by > y else -step
while (x, y) != (bx, by):
if x != bx:
x = x + sx if abs(bx - x) > step else bx
pts.append((x, y))
if y != by:
y = y + sy if abs(by - y) > step else by
pts.append((x, y))
return pts
def _wire(pts, layer="F.Cu", fixed=False):
return RoutedWire(layer=layer, width=200, coords=[v for p in pts for v in p], fixed=fixed)
def _points(wire):
c = wire.coords
return [(round(c[i]), round(c[i + 1])) for i in range(0, len(c), 2)]
def _result(*wires, vias=(), net="SIG"):
result = RoutingResult()
for w in wires:
result.add_wire(net, w)
for x, y in vias:
result.net(net, create=True).vias.append(RoutedVia(padstack=VIA, x=x, y=y))
return result
def test_a_staircase_becomes_one_diagonal():
dsn = parse_dsn(_board())
stair = _staircase((2000, -2000), (22000, -22000), 1000)
result = _result(_wire(stair))
report = smooth(dsn, result)
pts = _points(result.nets[0].wires[0])
assert pts == [(2000, -2000), (22000, -22000)]
assert report.wires_changed == 1 and report.after.right_angles == 0
assert report.after.total_mm < report.before.total_mm
assert verify_result(dsn, result).clean
def test_a_pad_in_the_way_is_gone_round():
# another net's pad sits on the diagonal shortcut of an L-shaped route
dsn = parse_dsn(_board(extra_places="(place B1 12000 -12000 front 0) (place B2 28000 -28000 front 0)",
nets="(net OTHER (pins B1-1 B2-1))"))
result = _result(_wire([(2000, -2000), (22000, -2000), (22000, -22000)]))
report = smooth(dsn, result)
pts = _points(result.nets[0].wires[0])
assert pts[0] == (2000, -2000) and pts[-1] == (22000, -22000)
assert report.wires_changed == 1 # the corner is chamfered, short of the pad
assert verify_result(dsn, result).clean
def test_a_joint_and_a_via_stay():
dsn = parse_dsn(_board(extra_places="(place A3 22000 -2000 front 0)",
nets="").replace("(pins A1-1 A2-1)", "(pins A1-1 A2-1 A3-1)"))
stair = _staircase((2000, -2000), (22000, -22000), 1000)
joint = stair[11] # A3's leg ends here, on the staircase
leg = _wire([(22000, -2000), (joint[0], -2000), joint])
via = stair[25]
result = _result(_wire(stair), leg, vias=[via])
smooth(dsn, result)
pts = _points(result.nets[0].wires[0])
assert joint in pts and via in pts
assert pts[0] == (2000, -2000) and pts[-1] == (22000, -22000)
assert verify_result(dsn, result).clean
def test_the_board_edge_is_kept_clear_of():
# a staircase hugging the top edge: a chamfer may not push copper over it
dsn = parse_dsn(_board(extra_places="(place A3 22000 -500 front 0)").replace(
"(pins A1-1 A2-1)", "(pins A1-1 A3-1)"))
wire = _wire([(2000, -2000), (2000, -500), (22000, -500)])
result = _result(wire)
smooth(dsn, result, edge_clearance_mm=0.3)
for _x, y in _points(result.nets[0].wires[0]):
assert -y >= 300 + 100 # half width + edge clearance from y = 0
def test_fixed_wiring_is_left_alone():
dsn = parse_dsn(_board())
stair = _staircase((2000, -2000), (22000, -22000), 1000)
result = _result(_wire(stair, fixed=True))
report = smooth(dsn, result)
assert _points(result.nets[0].wires[0]) == stair
assert report.wires == 0
def test_a_detour_with_nothing_in_its_way_is_straightened():
dsn = parse_dsn(_board(extra_places="(place A3 22000 -2000 front 0)").replace(
"(pins A1-1 A2-1)", "(pins A3-1 A2-1)"))
# a U out to x = 12 mm and back, with nothing between its ends
result = _result(_wire([(22000, -2000), (12000, -2000), (12000, -22000), (22000, -22000)]))
report = smooth(dsn, result)
assert _points(result.nets[0].wires[0]) == [(22000, -2000), (22000, -22000)]
assert (report.before.right_angles, report.after.right_angles) == (2, 0)
assert verify_result(dsn, result).clean
def test_a_jog_too_small_for_a_long_diagonal_stays_square():
# a 0.1 mm jog halfway: its 45-degree leg would be shorter than two track
# widths, a kink beside the copper that leaves pour slivers
dsn = parse_dsn(_board(extra_places="(place A3 2100 -22000 front 0)").replace(
"(pins A1-1 A2-1)", "(pins A1-1 A3-1)"))
wire = [(2000, -2000), (2000, -12000), (2100, -12000), (2100, -22000)]
result = _result(_wire(wire))
smooth(dsn, result)
pts = _points(result.nets[0].wires[0])
for a, b in zip(pts, pts[1:], strict=False):
if a[0] != b[0] and a[1] != b[1]:
assert (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 >= (2 * 200) ** 2
+58
View File
@@ -0,0 +1,58 @@
"""Pins of a 1.27 mm pitch row routed at 1.0 mm (the RN2483 module on the
TTN Enschede mote). A trace centre has to stay within 0.06 mm of a pin's
centreline to pass its neighbours, which no grid column hits, so a route
leaves each pin straight along that line and only jogs to its column once
past the pads. FreeRouting routes this row in full the same way."""
from __future__ import annotations
import pytest
from rpmroute.board import build_board
from rpmroute.board.items import Pin
from rpmroute.dsn import parse_dsn
from rpmroute.geometry import IntPoint
from rpmroute.route.grid_router import GridRouter
from rpmroute.route.pipeline import route_dsn_board_exact
def _row(x0: int, pins: int = 6, skew: int = 3000) -> str:
places = " ".join(f"(place R{i} {x0 + 1270 * i} -5000 front 0)" for i in range(pins))
places += " " + " ".join(
f"(place T{i} {x0 + 1270 * i + skew} -15000 front 0)" for i in range(pins)
)
nets = " ".join(f"(net N{i} (pins R{i}-1 T{i}-1))" for i in range(pins))
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 0 0 30000 0 30000 -20000 0 -20000 0 0))
(via "Via[0-1]_600:400_um") (rule (width 1000) (clearance 200)))
(placement (component P {places}))
(library (image P (pin pad 1 0 0))
(padstack pad (shape (rect F.Cu -508 -762 508 762)) (attach off))
(padstack "Via[0-1]_600:400_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600))
(attach off)))
(network {nets}))"""
@pytest.mark.parametrize("x0", [5000, 5150, 5300])
def test_every_pin_of_a_row_leaves_straight_and_routes(x0):
# three offsets of the row against the grid; before, 2 or 3 of 6 routed
result, _, _ = route_dsn_board_exact(parse_dsn(_row(x0)), resolution=2)
assert len(result.routed_net_numbers) == 6
assert result.drc_clean
def test_a_walled_in_pin_may_escape_to_the_column_either_side():
# two pins side by side leaving the same way can then jog apart
board = build_board(parse_dsn(_row(5000)))
router = GridRouter(board, trace_width=10000, clearance=2000, orthogonal=True, resolution=2)
router._conns = router._build_connections()
pin = next(
p for p in board.get_items()
if isinstance(p, Pin) and p.location == IntPoint(50000 + 2 * 12700, -50000)
)
net = pin.net_nos[0]
cx, cy = router._cell_of(pin.location)
nodes = router._escape_nodes(pin, net, router._conns, [0], (cx, cy))
columns = {x for x, y, _ in nodes if y != cy}
assert {cx - 1, cx, cx + 1} <= columns
+170
View File
@@ -0,0 +1,170 @@
"""Multi-pin nets: spanning-tree pairs, and connections that end on copper
their net has already routed (a T into the tree)."""
from __future__ import annotations
import pytest
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.route import exact_router
from rpmroute.route.grid_router import GridRouter, RouteResult
from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
# P1 and P2 are nearest each other, so the tree pairs them first; P3 sits
# under the middle of that leg, 50 mm below it and 90 mm from either pin.
TEE = """(pcb "tee.dsn"
(parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1"))
(resolution um 10)
(unit um)
(structure
(layer F.Cu (type signal) (property (index 0)))
(boundary (path pcb 0 0 0 200000 0 200000 -100000 0 -100000 0 0))
(rule (width 2000) (clearance 2000))
)
(library
(padstack Rect_Pad (shape (rect F.Cu -1000 -1000 1000 1000)) (attach off))
(image PAD (pin Rect_Pad 1 0 0))
)
(placement
(component PAD
(place P1 60000 -20000 front 0)
(place P2 140000 -20000 front 0)
(place P3 100000 -70000 front 0)
)
)
(network
(net N (pins P1-1 P2-1 P3-1))
(class default (rule (width 2000) (clearance 2000)))
)
(wiring)
)
"""
def _router(tree_nets=True, text=TEE):
dsn = parse_dsn(text)
s = dsn.resolution.value
return GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
orthogonal=True,
tree_nets=tree_nets,
)
def test_pairs_follow_a_spanning_tree_not_the_pin_order():
router = _router()
conns = router._build_connections()
pairs = [(c.a.component_ref, c.b.component_ref) for c in conns]
assert pairs == [("P1", "P2"), ("P1", "P3")]
chain = _router(tree_nets=False)._build_connections()
assert [(c.a.component_ref, c.b.component_ref) for c in chain] == [("P1", "P2"), ("P2", "P3")]
def test_the_third_pin_tees_into_the_first_leg():
router = _router()
result = router.route()
first, second = router._conns
assert result.unrouted == 0
# the second connection starts on the first one's copper, mid-leg
start = second.path[0]
assert start in first.copper and second.a_end is None
assert second.anchors == {first.index}
assert router._dependents[first.index] == {second.index}
assert abs(router._cell_center(start[:2]).x - 1_000_000) <= router.step
# and so its copper is the 50 mm drop, not 90 mm back to a pin
assert len(second.path) < len(router._conns[0].path)
def test_without_trees_each_connection_ends_on_a_pin():
router = _router(tree_nets=False)
router.route()
assert all(c.anchors == frozenset() for c in router._conns)
assert all(c.a_end is not None and c.b_end is not None for c in router._conns)
def test_ripping_an_anchor_takes_what_hangs_on_it():
router = _router()
router.route()
first, second = router._conns
victims = router._with_dependents([first], router._conns)
assert victims == [first, second] # anchors first, as committed
for victim in victims:
router._rip(victim)
assert not first.routed and not second.routed
assert not router._dependents.get(first.index)
assert not router._cell_owner
def test_the_snapshot_lists_anchors_before_what_hangs_on_them():
router = _router()
result = router.route()
assert result.connection_anchors == [(), (0,)]
def test_the_exact_engine_drops_a_tee_whose_anchor_it_rejects(monkeypatch):
"""A connection teed onto copper the exact check refuses would dangle."""
dsn = parse_dsn(TEE)
board = build_board(dsn)
real = exact_router.route_board
def grid_then_spoil(*args, **kwargs):
result: RouteResult = real(*args, **kwargs)
assert result.connection_anchors == [(), (0,)]
# move the first leg off the board edge so the exact check rejects it
net, wires, vias = result.connections[0]
result.connections[0] = (net, [(layer, [type(p)(p.x, p.y + 2_000_000) for p in pts])
for layer, pts in wires], vias)
return result
monkeypatch.setattr(exact_router, "route_board", grid_then_spoil)
s = dsn.resolution.value
exact = exact_router.route_board_exact(
board,
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
)
assert len(exact.result.failed) == 2 # the leg and the T hanging on it
assert not exact.result.wires
@pytest.mark.parametrize("tree_nets", [True, False])
def test_occupancy_stays_honest(tree_nets):
router = _router(tree_nets)
router.route()
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
for anchor, hanging in router._dependents.items():
for i in hanging:
assert conns[i].routed and conns[anchor].routed
# P1-P2 along the top, P3 far off to the lower right, P4 just under the
# middle of the P1-P2 leg.
CHAIN_TEE = TEE.replace(
""" (place P3 100000 -70000 front 0)
""",
""" (place P3 190000 -90000 front 0)
(place P4 100000 -40000 front 0)
""",
).replace("(pins P1-1 P2-1 P3-1)", "(pins P1-1 P2-1 P3-1 P4-1)")
def test_a_tee_depends_on_every_connection_back_to_its_group():
"""Joined as P1-P2, P2-P3, P3-P4, P4's connection tees onto P1-P2 copper,
which reaches P3's group only through P2-P3. Ripping P2-P3 must take the T
too, or it would be counted routed while cut off from P3."""
router = _router(text=CHAIN_TEE)
conns = router._conns = router._build_connections()
groups = {c.a.component_ref: c.a_group for c in conns}
groups.update({c.b.component_ref: c.b_group for c in conns})
for conn, (a, b) in zip(conns, (("P1", "P2"), ("P2", "P3"), ("P3", "P4")), strict=True):
conn.a_group, conn.b_group = groups[a], groups[b]
for conn in conns:
assert router._try_route(conn, conns)
first, second, third = conns
assert third.path[0] in first.copper # the T lands on the far leg
assert third.anchors == {first.index, second.index}
assert router._with_dependents([second], conns) == [second, third]
+42
View File
@@ -0,0 +1,42 @@
"""verify_result: copper no engine checked is checked on exact shapes."""
from pathlib import Path
from rpmroute.dsn import parse_dsn
from rpmroute.route.verify import verify_result
from rpmroute.ses.model import RoutedWire, RoutingResult
FIXTURE = Path(__file__).parents[1] / "dsn" / "fixtures" / "crossing_2net.dsn"
# rule: 2000 um wide traces, 2000 um clearance; NET_A runs along y = -40000
A = RoutedWire(layer="Top", width=2000, coords=[2000, -40000, 198000, -40000])
def _check(b_wire):
result = RoutingResult()
result.add_wire("NET_A", A)
result.add_wire("NET_B", b_wire)
return verify_result(parse_dsn(FIXTURE.read_text()), result)
def test_a_crossing_on_one_layer_is_a_short():
report = _check(RoutedWire(layer="Top", width=2000, coords=[100000, -15000, 100000, -65000]))
assert report.shorts == 1 and report.clearance == 0
found = report.short_examples[0]
assert {found.net, found.other} == {"NET_A", "NET_B"} and found.layer == "Top"
assert abs(found.x_mm - 100.0) < 1.5 and abs(found.y_mm + 40.0) < 1.5
def test_copper_closer_than_the_rule_is_a_clearance_violation():
# edges 1500 um apart against a 2000 um rule
report = _check(RoutedWire(layer="Top", width=2000, coords=[50000, -36500, 150000, -36500]))
assert report.shorts == 0 and report.clearance == 1
def test_exactly_the_clearance_apart_is_clean():
report = _check(RoutedWire(layer="Top", width=2000, coords=[50000, -36000, 150000, -36000]))
assert report.clean
def test_a_crossing_on_another_layer_is_clean():
report = _check(RoutedWire(layer="Bottom", width=2000, coords=[100000, -15000, 100000, -65000]))
assert report.clean
+44 -8
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
import itertools import itertools
import math import math
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact from rpmroute.route.pipeline import route_dsn_board_exact
# four GND pads 0.5 mm apart over a GND plane: each drops a via to the plane # four GND pads 0.5 mm apart over a GND plane: each drops a via to the plane
_DSN = """(pcb holes (parser) (resolution um 10) (unit um) _DSN = """(pcb holes (parser) (resolution um 10) (unit um)
@@ -56,17 +56,17 @@ def test_connections_sharing_a_plane_via_write_it_once():
assert len(set(vias)) == len(vias), "a shared via written more than once" assert len(set(vias)) == len(vias), "a shared via written more than once"
def _router(dsn_text): def _router(dsn_text, via_drill_um=300):
from freeroute.board import build_board from rpmroute.board import build_board
from freeroute.route.grid_router import GridRouter from rpmroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
dsn = parse_dsn(dsn_text) dsn = parse_dsn(dsn_text)
s = dsn.resolution.value s = dsn.resolution.value
return GridRouter( return GridRouter(
build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s), build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s), resolution=2, orthogonal=True, clearance=round(_rule_clearance_dsn(dsn) * s), resolution=2, orthogonal=True,
via_drill=300 * s, hole_to_hole=250 * s, via_drill=via_drill_um * s, hole_to_hole=250 * s,
), s ), s
@@ -90,7 +90,7 @@ def test_no_via_goes_near_a_through_hole_pads_hole_even_of_its_own_net():
def test_a_via_in_a_pad_keeps_hole_spacing_from_a_fixed_via(): def test_a_via_in_a_pad_keeps_hole_spacing_from_a_fixed_via():
from freeroute.geometry import IntPoint from rpmroute.geometry import IntPoint
text = _THT.replace("(network (net SIG (pins J1-1))))", text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)))\n" "(network (net SIG (pins J1-1)))\n"
@@ -98,3 +98,39 @@ def test_a_via_in_a_pad_keeps_hole_spacing_from_a_fixed_via():
router, s = _router(text) router, s = _router(text)
assert not router._hole_clear_at(IntPoint(3400 * s, -3000 * s)) # 0.4 mm < 0.55 mm assert not router._hole_clear_at(IntPoint(3400 * s, -3000 * s)) # 0.4 mm < 0.55 mm
assert router._hole_clear_at(IntPoint(3600 * s, -3000 * s)) assert router._hole_clear_at(IntPoint(3600 * s, -3000 * s))
def test_hole_spacing_from_a_fixed_via_is_measured_from_where_it_really_is():
# a fixed via off the grid with its own 0.25 mm drill, and the router's
# via a smaller 0.2 mm one (the flight controller's case): a new via needs
# its centre 0.1 + 0.125 + 0.25 = 0.475 mm away, measured exactly, where
# one rule for all vias gave 0.2 + 0.25 = 0.45 from the cell centres
text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)) (net GND))\n"
" (wiring (via \"Via[0-1]_500:250_um\" 3030 -3030 (net GND) (type route))))")
text = text.replace(
'(attach off)))\n (network',
'(attach off))\n (padstack "Via[0-1]_500:250_um" (shape (circle F.Cu 500)) '
'(shape (circle B.Cu 500)) (attach off)))\n (network',
)
router, s = _router(text, via_drill_um=200)
via = next(v for v in router.board.get_items() if getattr(v, "drill", 0))
assert via.drill == 250 * s
loc = via.location
for cell in [(x, y) for x in range(router.cols) for y in range(router.rows)]:
c = router._cell_center(cell)
d = math.hypot(c.x - loc.x, c.y - loc.y)
if router.step * 1.5 < d < 470 * s:
assert not router._holes_clear(cell, 1), f"{d / s:.0f} um from the fixed via"
elif 560 * s < d < 700 * s and cell not in router._tht_block:
assert router._holes_clear(cell, 1)
def test_a_route_keeps_its_own_vias_apart():
# a route that changed layer at (10, 10) may not change layer again one
# cell away; the same cell is the same through via, and far is fine
router, _s = _router(_THT)
came_from = {(10, 10, 1): (10, 10, 0), (11, 10, 1): (10, 10, 1), (12, 10, 1): (11, 10, 1)}
assert router._near_path_via((12, 10, 1), (11, 10), came_from)
assert not router._near_path_via((12, 10, 1), (10, 10), came_from)
assert not router._near_path_via((12, 10, 1), (40, 10), came_from)
+128
View File
@@ -0,0 +1,128 @@
"""Window re-route: a finer grid around a stuck connection, kept only when
it leaves fewer connections unrouted."""
from __future__ import annotations
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.geometry import IntBox
from rpmroute.route.grid_router import GridRouter
from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board_exact
def _slot(gap: int = 640, far: bool = False) -> str:
"""A wall across the board with a slot in it. A trace fits the slot
only along a narrow band of heights that the board's 1x grid has no row
in and a 4x grid does. ``far`` adds a second net nowhere near the wall."""
yc = -5000
lo, hi = yc - gap // 2, yc + gap // 2
extra_place = "(place Q1 2000 -1000 front 0) (place Q2 6000 -1000 front 0)" if far else ""
extra_net = "(net M (pins Q1-1 Q2-1))" if far else ""
return f"""(pcb "slot.dsn"
(parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1"))
(resolution um 10)
(unit um)
(structure
(layer F.Cu (type signal) (property (index 0)))
(boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0))
(keepout "" (rect F.Cu 9000 -10000 11000 {lo}))
(keepout "" (rect F.Cu 9000 {hi} 11000 0))
(rule (width 200) (clearance 200))
)
(library
(padstack Rect_Pad (shape (rect F.Cu -100 -100 100 100)) (attach off))
(image PAD (pin Rect_Pad 1 0 0))
)
(placement
(component PAD
(place P1 3000 {yc} front 0)
(place P2 17000 {yc} front 0)
{extra_place}
)
)
(network
(net N (pins P1-1 P2-1))
{extra_net}
(class default (rule (width 200) (clearance 200)))
)
(wiring)
)
"""
def _route(text: str, rounds: int, **window):
exact, _scale, _layers = route_dsn_board_exact(
parse_dsn(text), resolution=1, window_reroute=rounds, window_resolution=4, **window,
)
return exact.result
def test_the_board_grid_misses_the_slot_and_a_finer_window_finds_it():
plain = _route(_slot(), 0)
assert len(plain.failed) == 1
windowed = _route(_slot(), 1)
assert windowed.failed == []
assert windowed.wires # and the copper it found is in the result
def test_a_window_that_cannot_help_leaves_the_board_as_it_was():
plain = _route(_slot(gap=500, far=True), 0) # too narrow for any grid
windowed = _route(_slot(gap=500, far=True), 2)
assert len(windowed.failed) == len(plain.failed) == 1
assert windowed.wires == plain.wires
assert windowed.vias == plain.vias
def test_copper_outside_the_window_is_kept_as_it_was():
plain = _route(_slot(far=True), 0)
windowed = _route(_slot(far=True), 1)
assert windowed.failed == []
far_net = 2 # M, the second net in the DSN
assert windowed.wires[far_net] == plain.wires[far_net]
def test_a_router_limited_to_some_nets_and_a_box_builds_only_those():
dsn = parse_dsn(_slot(far=True))
s = dsn.resolution.value
board = build_board(dsn)
whole = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s), orthogonal=True)
part = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s), orthogonal=True,
only_nets=frozenset({2}), bounds=IntBox(0, -20000, 80000, 0))
assert {c.net_no for c in whole._build_connections()} == {1, 2}
assert {c.net_no for c in part._build_connections()} == {2}
assert part.cols * part.rows < whole.cols * whole.rows
def test_a_spent_budget_starts_no_window():
assert len(_route(_slot(), 1, window_budget=0.0).failed) == 1
assert _route(_slot(), 1, window_budget=1000.0).failed == []
def test_window_board_keeps_far_items_of_the_rerouted_nets_only():
from rpmroute.route.window import _item_box, _window_board
board = build_board(parse_dsn(_slot(far=True)))
items = [(item, _item_box(item)) for item in board.get_items()]
view = IntBox(80000, -60000, 120000, -40000) # around the slot, far from Q1/Q2
sub = _window_board(board, items, view, {1}, {}, set(), {}, 100, 300, 300, [0], "via")
refs = {p.component_ref for p in sub.get_pins()}
assert {"P1", "P2"} <= refs # net N's pins, though outside the view
assert not refs & {"Q1", "Q2"} # net M's, far away, left out
def test_a_connection_ending_outside_the_window_gets_no_copper():
"""Its far end has no grid cells; a stub out to it would cross items the
window board never saw."""
from rpmroute.route.exact_router import route_board_exact
dsn = parse_dsn(_slot(far=True))
s = dsn.resolution.value
window = IntBox(70000, -70000, 130000, -30000) # the slot only; net M's pins are far off
result = route_board_exact(
build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s), only_nets=frozenset({2}), bounds=window,
).result
assert [f[0] for f in result.failed] == [2]
assert not result.wires.get(2) and not result.vias.get(2)
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from freeroute.ses.indent import Identifier, IndentWriter from rpmroute.ses.indent import Identifier, IndentWriter
def test_nested_scopes_indent_by_two_spaces(): def test_nested_scopes_indent_by_two_spaces():
+7 -7
View File
@@ -1,7 +1,7 @@
"""End-to-end tests for the SES writer. """End-to-end tests for the SES writer.
Where possible these re-parse the emitted SES with the project's own tokenizer / Where possible these re-parse the emitted SES with the project's own tokenizer /
S-expression parser (:mod:`freeroute.dsn.sexp`) and assert structural S-expression parser (:mod:`rpmroute.dsn.sexp`) and assert structural
invariants, mirroring what FreeRouting's ``SesReader`` and ``kicad-cli pcb invariants, mirroring what FreeRouting's ``SesReader`` and ``kicad-cli pcb
import specctra-ses`` walk: ``session > routes > network_out > net > import specctra-ses`` walk: ``session > routes > network_out > net >
wire(path)/via``. wire(path)/via``.
@@ -13,9 +13,9 @@ from pathlib import Path
import pytest import pytest
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.dsn.sexp import SExp, parse from rpmroute.dsn.sexp import SExp, parse
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from rpmroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
FIXTURES = Path(__file__).parent.parent / "dsn" / "fixtures" FIXTURES = Path(__file__).parent.parent / "dsn" / "fixtures"
@@ -142,7 +142,7 @@ def test_round_trip_from_empty_board_fixture():
# --- SES coordinate units --------------------------------------------------- # --- SES coordinate units ---------------------------------------------------
# FreeRouting's SesWriter writes coordinates in resolution units, and KiCad's # FreeRouting's SesWriter writes coordinates in resolution units, and KiCad's
# importer and mckicad's ses_apply read them that way. On 2026-09-26 freeroute # importer and mckicad's ses_apply read them that way. On 2026-09-26 rpmroute
# was found writing DSN units instead, so every routed board came back 10x small. # was found writing DSN units instead, so every routed board came back 10x small.
@@ -155,8 +155,8 @@ def test_round_trip_from_empty_board_fixture():
], ],
) )
def test_ses_scale(resolution, unit, expected): def test_ses_scale(resolution, unit, expected):
from freeroute.dsn import parse_dsn from rpmroute.dsn import parse_dsn
from freeroute.ses.writer import ses_scale from rpmroute.ses.writer import ses_scale
dsn = parse_dsn(f"(pcb t (parser) {resolution} {unit} (structure) (placement) (library) (network))") dsn = parse_dsn(f"(pcb t (parser) {resolution} {unit} (structure) (placement) (library) (network))")
assert ses_scale(dsn) == pytest.approx(expected) assert ses_scale(dsn) == pytest.approx(expected)
+39 -7
View File
@@ -1,4 +1,4 @@
"""Tests for the freeroute CLI (Specctra DSN in, SES out). """Tests for the rpmroute CLI (Specctra DSN in, SES out).
Covers the engine selection contract: every engine must route a fixture from the Covers the engine selection contract: every engine must route a fixture from the
command line, and every option the selected engine does not implement must fail command line, and every option the selected engine does not implement must fail
@@ -11,7 +11,7 @@ from pathlib import Path
import pytest import pytest
from freeroute.cli import ENGINE_OPTIONS, main from rpmroute.cli import ENGINE_OPTIONS, main
FIXTURES = Path(__file__).parent / "dsn" / "fixtures" FIXTURES = Path(__file__).parent / "dsn" / "fixtures"
FIXTURE = FIXTURES / "simple_2net.dsn" FIXTURE = FIXTURES / "simple_2net.dsn"
@@ -71,13 +71,39 @@ def test_cli_every_engine_routes_a_fixture(engine, tmp_path):
assert "(wire" in text assert "(wire" in text
def test_cli_default_engine_is_grid(tmp_path): def test_cli_default_engine_is_exact(tmp_path):
"""The default must stay grid — the drop-in JAR replacement behaviour.""" """The default is the engine that checks its copper: grid output can short
and say nothing (a user routing with no flags shipped 15 crossings)."""
default = tmp_path / "default.ses" default = tmp_path / "default.ses"
grid = tmp_path / "grid.ses" exact = tmp_path / "exact.ses"
assert main([str(FIXTURE), "-do", str(default)]) == 0 assert main([str(FIXTURE), "-do", str(default)]) == 0
assert main([str(FIXTURE), "--engine", "grid", "-do", str(grid)]) == 0 assert main([str(FIXTURE), "--engine", "exact", "-do", str(exact)]) == 0
assert default.read_text() == grid.read_text() assert default.read_text() == exact.read_text()
@pytest.mark.parametrize("engine", ["grid", "room"])
def test_unchecked_engines_are_checked_after_routing(tmp_path, capsys, engine):
assert main([str(FIXTURE), "--engine", engine, "-do", str(tmp_path / "out.ses")]) == 0
assert f"{engine} output checked on exact shapes: 0 short(s), 0 clearance" in capsys.readouterr().err
def test_strict_fails_a_run_whose_copper_is_not_clean(tmp_path, monkeypatch, capsys):
from rpmroute import cli
from rpmroute.route.verify import VerifyReport, Violation
bad = VerifyReport(shorts=1, short_examples=[Violation("A", "B", "F.Cu", 1.0, 2.0)])
monkeypatch.setattr(cli, "verify_result", lambda dsn, result: bad)
out = tmp_path / "out.ses"
assert main([str(FIXTURE), "--engine", "grid", "-do", str(out)]) == 0
assert main([str(FIXTURE), "--engine", "grid", "--strict", "-do", str(out)]) == 3
assert out.exists() # the session is still written
err = capsys.readouterr().err
assert "short: A x B on F.Cu at (1.00, 2.00) mm" in err and "not DRC-clean" in err
def test_the_exact_engine_does_not_warn(tmp_path, capsys):
assert main([str(FIXTURE), "-do", str(tmp_path / "out.ses")]) == 0
assert "WARNING" not in capsys.readouterr().err
def test_cli_room_pack_routes(tmp_path): def test_cli_room_pack_routes(tmp_path):
@@ -158,7 +184,13 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"orders": ["--orders", "2"], "orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"], "promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"], "access": ["--access-cost", "5"],
"negotiate": ["--negotiate", "2"],
"rollback": ["--no-rollback"], "rollback": ["--no-rollback"],
"tree": ["--no-tree"],
"window": ["--window-reroute", "1"],
"vias": ["--reduce-vias"],
"via_cost": ["--via-cost", "40"],
"smooth": ["--smooth"],
} }
for engine, options in ENGINE_OPTIONS.items(): for engine, options in ENGINE_OPTIONS.items():
for option in options: for option in options:

Some files were not shown because too many files have changed in this diff Show More