Compare commits

...
Author SHA1 Message Date
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
97 changed files with 679 additions and 376 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
# CLAUDE.md
Guidance for Claude Code working in the `freeroute` repository.
Guidance for Claude Code working in the `rpmroute` repository.
## What this is
A native Python PCB autorouter: a Java-free reimplementation of the
[FreeRouting](https://github.com/freerouting/freerouting) engine. Specctra DSN
in, Specctra SES out, no JVM anywhere. Published on PyPI as `freeroute`
in, Specctra SES out, no JVM anywhere. Published on PyPI as `rpmroute`
(GPL-3.0-or-later, CalVer).
## 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 oracle # differential tests against the real JAR (needs Java)
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
@@ -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
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
a hard error (exit 2), never a silent no-op.** Downstream code (`mckicad`) reads
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
[FreeRouting](https://github.com/freerouting/freerouting) engine. Specctra DSN
in, Specctra SES out, no JVM anywhere in the pipeline.
A native Python PCB autorouter. Specctra DSN 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
Toolchains that autoroute (`kicad-mcp` among them) do it by shelling out to
`freerouting.jar`, which drags a JVM into every install and container image.
`freeroute` reimplements the routing engine in pure Python, 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`.
## 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:
```
board.dsn ──▶ freeroute ──▶ board.ses
board.dsn ──▶ rpmroute ──▶ board.ses
(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
changes. The `-de` / `-do` / `-mp` flags are the JAR's, on purpose.
## Install
```bash
uv tool install freeroute # or: pipx install freeroute
uv tool install rpmroute # or: pipx install rpmroute
```
## Usage
```bash
# 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)
freeroute board.dsn --engine room --pack --shove -do board.ses
rpmroute -de board.dsn -do board.ses # the FreeRouting JAR spelling (grid engine)
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,
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.
@@ -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
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:
@@ -125,7 +134,7 @@ SES coordinates are written in resolution units, exactly as the JAR writes them.
## Library use
```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)
```
@@ -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
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 |
| sense board (55 x 25 mm, 0.5 mm pitch MCU) | 41 unconnected, ~2.5 min | 32 | 0 |
In the board owner's production flow (hand-routed power lanes kept, pours added
after routing), freeroute completed the power stage with no unconnected items.
after routing), rpmroute completed the power stage with no unconnected items.
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
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
+5 -5
View File
@@ -37,8 +37,8 @@ import tempfile
import threading
import time
NOT_ROUTED = re.compile(r"freeroute: (\d+) connection\(s\) not routed")
TO_ZONE = re.compile(r"freeroute: (\d+) more connection\(s\) left to a zone fill")
NOT_ROUTED = re.compile(r"rpmroute: (\d+) connection\(s\) not routed")
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]]]:
@@ -61,8 +61,8 @@ def parse_variant(spec: str) -> tuple[str, str | None, list[str]]:
if not sep or not head:
raise SystemExit(f"variant {spec!r}: want NAME=FLAGS or NAME@SRC=FLAGS")
name, _, src = head.partition("@")
if src and not (Path(src) / "freeroute").is_dir():
raise SystemExit(f"variant {name}: {src} has no freeroute package")
if src and not (Path(src) / "rpmroute").is_dir():
raise SystemExit(f"variant {name}: {src} has no rpmroute package")
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", "")
with tempfile.TemporaryDirectory() as tmp:
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),
]
start = time.monotonic()
+8 -8
View File
@@ -23,14 +23,14 @@ import json
from pathlib import Path
import sys
from freeroute.board import build_board
from freeroute.board.search_tree import ShapeSearchTree
from freeroute.dsn import parse_dsn
from freeroute.geometry.octagon import overlaps_2d
from freeroute.route import grid_router
from freeroute.route.exact_router import route_board_exact
from freeroute.route.grid_router import GridRouter, _ends_of
from freeroute.route.pipeline import (
from rpmroute.board import build_board
from rpmroute.board.search_tree import ShapeSearchTree
from rpmroute.dsn import parse_dsn
from rpmroute.geometry.octagon import overlaps_2d
from rpmroute.route import grid_router
from rpmroute.route.exact_router import route_board_exact
from rpmroute.route.grid_router import GridRouter, _ends_of
from rpmroute.route.pipeline import (
_mm_to_board,
_net_widths_board,
_rule_clearance_dsn,
+5 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Route one KiCad board with freeroute and judge the result with KiCad's DRC.
# Route one KiCad board with rpmroute and judge the result with KiCad's DRC.
#
# bench/drc_check.sh [--keep] <board.kicad_pcb> <out-dir> [freeroute args...]
# 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),
@@ -10,14 +10,14 @@
# shorts, opens, hole problems (whatever severity the board gives them) and
# the other errors. The source board is never touched.
#
# FREEROUTE (default: this checkout's .venv/bin/freeroute) and MCK (default
# 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)"
FREEROUTE="${FREEROUTE:-$here/.venv/bin/freeroute}"
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
@@ -43,7 +43,7 @@ assert FreeRoutingEngine().export_dsn_from_kicad('$out/board.kicad_pcb', '$out/b
>/dev/null 2>&1 || { echo "$out: DSN export failed"; exit 1; }
start=$(date +%s.%N)
"$FREEROUTE" "$out/board.dsn" -do "$out/board.ses" "$@" >"$out/route.log" 2>&1 || true
"$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; }
+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
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/`
@@ -175,7 +175,7 @@ converge toward FreeRouting's free-space rooms. Not started this session.
into the read result).
## 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
end-to-end through `kicad-cli pcb import specctra-ses`. See `SesWriter.java`
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.
4. **Board model** — `board` items + spatial index.
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>
</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
Manhattan/45° for an MVP; converge toward FreeRouting's approach. _(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.
## Validation strategy
+14 -11
View File
@@ -3,9 +3,9 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "freeroute"
version = "2026.07.14"
description = "Native Python PCB autorouter — a Java-free port of the FreeRouting engine (Specctra DSN in, SES out)"
name = "rpmroute"
version = "2026.10.07"
description = "Native Python PCB autorouter with negotiated congestion and tree routing (Specctra DSN in, SES out)"
readme = "README.md"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
@@ -40,14 +40,17 @@ requires-python = ">=3.10"
dependencies = []
[project.urls]
"Homepage" = "https://git.supported.systems/warehack.ing/freeroute"
"Repository" = "https://git.supported.systems/warehack.ing/freeroute"
"Issues" = "https://git.supported.systems/warehack.ing/freeroute/issues"
# The Java engine this is a port of; freeroute ships no upstream code.
"Upstream" = "https://github.com/freerouting/freerouting"
"Homepage" = "https://git.supported.systems/warehack.ing/rpmroute"
"Repository" = "https://git.supported.systems/warehack.ing/rpmroute"
"Issues" = "https://git.supported.systems/warehack.ing/rpmroute/issues"
# Where it started: a port of the Java FreeRouting engine. It has since grown
# its own router (negotiation, tree nets, exact checks) and ships no upstream code.
"Origin" = "https://github.com/freerouting/freerouting"
[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]
dev = [
@@ -57,7 +60,7 @@ dev = [
]
[tool.hatch.build.targets.wheel]
packages = ["src/freeroute"]
packages = ["src/rpmroute"]
[tool.hatch.build.targets.sdist]
# 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"]
[tool.ruff.lint.isort]
known-first-party = ["freeroute"]
known-first-party = ["rpmroute"]
force-sort-within-sections = true
[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:
layers, units, coordinate transform, nets, clearance matrix, the item hierarchy,
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``
belong to the router phase.
@@ -9,7 +9,7 @@ board-model invariants check.
from __future__ import annotations
from freeroute.geometry import IntBox
from rpmroute.geometry import IntBox
from .clearance import ClearanceMatrix
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
``(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."""
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]] = []
for item in self._items:
@@ -1,6 +1,6 @@
"""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`` /
``Component`` / ``Network`` readers do collectively, but on the already-parsed
``DsnBoard``:
@@ -26,10 +26,10 @@ from __future__ import annotations
import math
import re
from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex
from freeroute.geometry.polygon_shape import PolygonShape
from rpmroute.dsn.model import DsnBoard
from rpmroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from rpmroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex
from rpmroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard
from .clearance import ClearanceMatrix
@@ -3,22 +3,22 @@
Ports the *data model* of ``board/Item.java`` and its subclasses (``Pin``,
``ObstacleArea``/``ConductionArea``, ``Via``, ``PolylineTrace``). The routing
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.
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
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 dataclasses import dataclass, field
from freeroute.geometry import IntBox, IntPoint
from rpmroute.geometry import IntBox, IntPoint
__all__ = ["Item", "Pin", "ObstacleArea", "ConductionArea", "Via", "Trace"]
@@ -126,7 +126,7 @@ class Trace(Item):
"""A routed trace (router-produced).
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
@@ -9,7 +9,7 @@ from __future__ import annotations
import math
from freeroute.geometry import IntBox, IntPoint
from rpmroute.geometry import IntBox, IntPoint
class BoardOutline:
@@ -2,7 +2,7 @@
Ports the query role of ``board/ShapeSearchTree*.java``. It replaces the exact
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
**exact** narrow phase (exact ``IntBox`` intersection). Insert and remove keep
the index in step as traces are added.
@@ -18,7 +18,7 @@ from __future__ import annotations
from dataclasses import dataclass
from freeroute.geometry import IntBox, IntPoint, Simplex, overlaps_2d, segment_octagon, segment_slab
from rpmroute.geometry import IntBox, IntPoint, Simplex, overlaps_2d, segment_octagon, segment_slab
__all__ = ["TreeShape", "ShapeSearchTree"]
@@ -34,7 +34,7 @@ class TreeShape:
``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
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
every orthogonal tile both are ``None`` and all code paths are byte-identical
to the box-only tree.
@@ -237,3 +237,36 @@ class ShapeSearchTree:
if overlaps_2d(grown, other_tile):
return shape
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 freeroute.geometry import FloatPoint, IntPoint
from rpmroute.geometry import FloatPoint, IntPoint
__all__ = ["CoordinateTransform"]
+51 -18
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
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
truth; an unsupported combination is an error, never a silent no-op::
@@ -18,14 +18,15 @@ from __future__ import annotations
import argparse
import sys
from freeroute.dsn.reader import parse_dsn
from freeroute.route.orders import best_of_orders, promote_failures
from freeroute.route.pipeline import (
from rpmroute.dsn.reader import parse_dsn
from rpmroute.route.orders import best_of_orders, promote_failures
from rpmroute.route.pipeline import (
build_exact_routing_result,
build_rooms_routing_result,
build_routing_result,
)
from freeroute.ses import write_ses
from rpmroute.route.verify import verify_result
from rpmroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
@@ -74,7 +75,7 @@ def _validate(parser: argparse.ArgumentParser, engine: str, requested: list[str]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="freeroute",
prog="rpmroute",
description="Native PCB autorouter — route a Specctra DSN and write an SES session file.",
)
parser.add_argument("dsn", nargs="?", help="input Specctra .dsn file")
@@ -94,11 +95,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--engine",
choices=sorted(ENGINE_OPTIONS),
default="grid",
default="exact",
help=(
"routing track: grid (default; multi-layer rip-up maze, highest coverage), "
"exact (orthogonal, exact-geometry DRC-verified output), "
"room (continuous expansion rooms, routes off-grid channels)"
"routing track: exact (default; every route checked against exact pad and "
"copper shapes, DRC-clean output), grid (quick maze on the grid alone: its "
"copper is NOT clearance-checked and can short), room (continuous expansion "
"rooms, routes off-grid channels; not clearance-checked either)"
),
)
parser.add_argument(
@@ -250,6 +252,15 @@ def build_parser() -> argparse.ArgumentParser:
metavar="J",
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
@@ -291,7 +302,7 @@ def main(argv: list[str] | None = None) -> int:
with open(dsn_path, encoding="utf-8") as f:
dsn_text = f.read()
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
dsn = parse_dsn(dsn_text)
@@ -346,14 +357,14 @@ def main(argv: list[str] | None = None) -> int:
dsn_text, args.engine, options, rounds=args.promote_failures, seed=args.seed
)
counts = ", ".join(str(m) for m, _z in rounds)
print(f"freeroute: {len(rounds)} rounds, unrouted {counts}; kept the best", file=sys.stderr)
print(f"rpmroute: {len(rounds)} rounds, unrouted {counts}; kept the best", file=sys.stderr)
elif args.orders and args.orders > 1:
result, kept, tried = best_of_orders(
dsn_text, args.engine, options, orders=args.orders, seed=args.seed, jobs=args.jobs
)
counts = ", ".join(f"{'default' if s is None else s}: {m}" for s, (m, _z) in tried)
print(
f"freeroute: tried {len(tried)} orders, kept "
f"rpmroute: tried {len(tried)} orders, kept "
f"{'the default order' if kept is None else f'--seed {kept}'} (unrouted {counts})",
file=sys.stderr,
)
@@ -367,12 +378,12 @@ def main(argv: list[str] | None = None) -> int:
in_zone = [u for u in result.unrouted if u[3]]
if missing:
# the SES is still written; say plainly what it is missing
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
print(f"rpmroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
for net, a, b, _zone in missing:
print(f" {net}: {a} to {b}", file=sys.stderr)
if result.narrowed:
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:",
file=sys.stderr,
)
@@ -380,18 +391,40 @@ def main(argv: list[str] | None = None) -> int:
print(f" {net}: {a} to {b}", file=sys.stderr)
if in_zone:
print(
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
f"rpmroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
file=sys.stderr,
)
for net, a, b, zone in in_zone:
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
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:
with open(args.output, "w", encoding="utf-8") as f:
f.write(ses_text)
else:
sys.stdout.write(ses_text)
return 0
return 3 if (flawed and args.strict) else 0
if __name__ == "__main__":
@@ -1,13 +1,13 @@
"""Specctra DSN parsing for freeroute.
"""Specctra DSN parsing for rpmroute.
Public API::
from freeroute.dsn import parse_dsn
from rpmroute.dsn import parse_dsn
board = parse_dsn(dsn_text)
``parse_dsn`` returns a typed :class:`~freeroute.dsn.model.DsnBoard`. The
lower-level :func:`~freeroute.dsn.tokenizer.tokenize` and
:func:`~freeroute.dsn.sexp.parse` are exposed for tooling and tests.
``parse_dsn`` returns a typed :class:`~rpmroute.dsn.model.DsnBoard`. The
lower-level :func:`~rpmroute.dsn.tokenizer.tokenize` and
:func:`~rpmroute.dsn.sexp.parse` are exposed for tooling and tests.
"""
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``,
``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`.
"""
@@ -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
needs, with the exact-arithmetic types preserved. Arithmetic model per type:
@@ -231,7 +231,7 @@ class IntBox:
return [FloatPoint(c.x, c.y) for c in self.corners()]
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
(ports ``IntBox.to_Simplex``).
@@ -6,7 +6,7 @@ Ports ``geometry/planar/FloatPoint.java``.
keeps ``FloatPoint`` *outside* the exact ``Point`` hierarchy because float math
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
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
@@ -137,13 +137,13 @@ class FloatPoint:
# --- rounding to integer points ----------------------------------------
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
return IntPoint(_round_half_up(self.x), _round_half_up(self.y))
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
return IntBox(
@@ -2,12 +2,12 @@
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
:class:`~freeroute.geometry.point.IntPoint` endpoints.
:class:`~rpmroute.geometry.point.IntPoint` endpoints.
**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
: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
the fast ``float`` path.
"""
@@ -1,7 +1,7 @@
"""``Polygon`` — a normalized corner list (no duplicate or collinear corners).
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
**exact** (exact ``Point.side_of``); the winding-number test is approximate
@@ -2,7 +2,7 @@
Ports ``geometry/planar/PolygonShape.java``, in particular the recursive
``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.
**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
*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
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
@@ -1,10 +1,10 @@
"""``Simplex`` — a convex region as the intersection of directed half-planes.
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
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
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``
that is defined purely in terms of the border lines and corners: point
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``.
**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.
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.
Mirrors the upstream ``BigInteger`` triple. ``BigIntDirection`` is not needed
because ``int`` is unbounded, so normalized directions are always
:class:`~freeroute.geometry.direction.IntDirection`.
:class:`~rpmroute.geometry.direction.IntDirection`.
"""
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
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)
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.
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
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**
one: every item's copper is an exact :class:`~freeroute.geometry.IntBox` tile
indexed in a :class:`~freeroute.board.search_tree.ShapeSearchTree`, and the
one: every item's copper is an exact :class:`~rpmroute.geometry.IntBox` tile
indexed in a :class:`~rpmroute.board.search_tree.ShapeSearchTree`, and the
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
DRC-clean by construction.
**Shove (opt-in, ``shove=True``).** Before a dropped net is abandoned, the
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
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
@@ -30,10 +30,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
import math
from freeroute.board.board import BasicBoard
from freeroute.board.items import Pin, Via
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import (
from rpmroute.board.board import BasicBoard
from rpmroute.board.items import Pin, Via
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from rpmroute.geometry import (
IntBox,
IntPoint,
Polyline,
@@ -44,7 +44,7 @@ from freeroute.geometry import (
segment_slab,
shove_segment,
)
from freeroute.geometry.polyline import corner_box
from rpmroute.geometry.polyline import corner_box
from .grid_router import RouteResult, route_board
@@ -155,38 +155,7 @@ def route_board_exact(
owner = _counter()
# static items: pad and keepout copper (routed=False)
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,
)
)
insert_static_copper(tree, board, signal_layers, owner)
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
@@ -325,6 +294,43 @@ def route_board_exact(
# --- 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]]:
"""Octilinear (0/45/90/135-degree) polylines from ``a`` to ``b``.
@@ -633,7 +639,7 @@ def _conflicting_segment(corners, d_boxes, half_width, clearance):
horizontal = a.y == b.y
if not horizontal and a.x != b.x:
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)
if any(seg.overlaps(box) for box in d_boxes):
@@ -31,11 +31,11 @@ import itertools
import math
import random
from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from freeroute.geometry import IntBox, IntPoint, Simplex
from freeroute.geometry.octagon import overlaps_2d, segment_slab
from freeroute.geometry.polyline import corner_box, segment_box
from rpmroute.board.board import BasicBoard
from rpmroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from rpmroute.geometry import IntBox, IntPoint, Simplex
from rpmroute.geometry.octagon import overlaps_2d, segment_slab
from rpmroute.geometry.polyline import corner_box, segment_box
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -1096,7 +1096,7 @@ class GridRouter:
def _stub_cells(self, pin_location: IntPoint, cell: Cell, half: int | None = None) -> list[Cell]:
"""Cells where another net's trace would come too close to the stub."""
from freeroute.board.outline import _point_segment
from rpmroute.board.outline import _point_segment
points = self._stub_points(pin_location, cell)
reach = (half or self._cur_half) + self.half_width + self.clearance
@@ -1889,8 +1889,8 @@ class GridRouter:
"""The net's tracks and vias already on the board, as connectivity."""
cache = getattr(self, "_fixed_cache", None)
if cache is None:
from freeroute.board.items import Trace
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box
from rpmroute.board.items import Trace
from rpmroute.geometry.polyline import Polyline, PolylineShape, corner_box
cache = self._fixed_cache = {}
for item in self.board.get_items():
@@ -15,8 +15,8 @@ from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor
import os
from freeroute.dsn import parse_dsn
from freeroute.ses import RoutingResult
from rpmroute.dsn import parse_dsn
from rpmroute.ses import RoutingResult
from .pipeline import build_exact_routing_result, build_routing_result
@@ -15,9 +15,9 @@ from __future__ import annotations
import re
import time
from freeroute.board import build_board
from freeroute.dsn import DsnBoard, parse_dsn
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
from rpmroute.board import build_board
from rpmroute.dsn import DsnBoard, parse_dsn
from rpmroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
from .exact_router import ExactRouteResult, route_board_exact
from .grid_router import RouteResult, route_board
@@ -30,10 +30,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
import heapq
from freeroute.board.board import BasicBoard
from freeroute.board.items import Pin
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, shove_segment
from rpmroute.board.board import BasicBoard
from rpmroute.board.items import Pin
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from rpmroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, shove_segment
from .exact_router import ExactRouteResult, _routable_nets
from .grid_router import RouteResult
@@ -56,7 +56,7 @@ class _Accepted:
``wires`` / ``vias`` are the *same* list objects as ``RouteResult.wires`` /
``.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
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
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.
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
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
@@ -19,8 +19,8 @@ from __future__ import annotations
from collections.abc import Iterator
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from rpmroute.geometry import IntBox, IntPoint, Polyline, PolylineShape
from .exact_router import _conflicting_segment as conflicting_segment
+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)
@@ -26,10 +26,10 @@ from __future__ import annotations
import time
from typing import Any
from freeroute.board import BasicBoard, build_board
from freeroute.board.items import ObstacleArea, Pin, Trace, Via
from freeroute.dsn import DsnBoard
from freeroute.geometry import IntBox, IntPoint
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
@@ -1,9 +1,9 @@
"""Specctra SES (session) writing for freeroute.
"""Specctra SES (session) writing for rpmroute.
Public API::
from freeroute.dsn import parse_dsn
from freeroute.ses import write_ses, RoutingResult, RoutedWire, RoutedVia
from rpmroute.dsn import parse_dsn
from rpmroute.ses import write_ses, RoutingResult, RoutedWire, RoutedVia
board = parse_dsn(dsn_text)
ses_text = write_ses(board) # valid no-op session
@@ -1,8 +1,8 @@
"""Specctra SES session-file writer.
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
in-memory :class:`~freeroute.ses.model.RoutingResult`. The emitted structure is::
on this project's parsed :class:`~rpmroute.dsn.model.DsnBoard` plus an
in-memory :class:`~rpmroute.ses.model.RoutingResult`. The emitted structure is::
(session <name>
(base_design <name>)
@@ -30,8 +30,8 @@ from __future__ import annotations
import math
from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from rpmroute.dsn.model import DsnBoard
from rpmroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
from .indent import Identifier, IndentWriter
from .model import RoutedNet, RoutedVia, RoutedWire, RoutingResult
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from freeroute.board import (
from rpmroute.board import (
ClearanceMatrix,
CoordinateTransform,
Layer,
+6 -6
View File
@@ -15,9 +15,9 @@ import sys
import pytest
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
from rpmroute.board import BasicBoard, ObstacleArea, Pin, build_board
from rpmroute.dsn import parse_dsn
from rpmroute.geometry import IntPoint
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():
from freeroute.route.pipeline import route_dsn_board
from rpmroute.route.pipeline import route_dsn_board
ring_only = "\n".join(
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():
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
@@ -279,7 +279,7 @@ def test_a_keepout_named_rule_area_takes_no_clearance():
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)
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
from freeroute.board import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox
from rpmroute.board import ShapeSearchTree, TreeShape
from rpmroute.geometry import IntBox
def shape(owner, net, layer, box, routed=False):
+4 -4
View File
@@ -6,10 +6,10 @@ from pathlib import Path
import pytest
from freeroute.dsn import parse_dsn
from freeroute.dsn.reader import DsnParseError
from freeroute.dsn.shapes import Circle, Rectangle
from freeroute.dsn.shapes import Path as DsnPath
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.reader import DsnParseError
from rpmroute.dsn.shapes import Circle, Rectangle
from rpmroute.dsn.shapes import Path as DsnPath
FIXTURES = Path(__file__).parent / "fixtures"
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import pytest
from freeroute.dsn.sexp import SExp, parse
from freeroute.dsn.tokenizer import DsnSyntaxError, Token
from rpmroute.dsn.sexp import SExp, parse
from rpmroute.dsn.tokenizer import DsnSyntaxError, Token
def test_parse_simple_head_and_values():
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
from freeroute.dsn.sexp import parse
from freeroute.dsn.shapes import (
from rpmroute.dsn.sexp import parse
from rpmroute.dsn.shapes import (
Circle,
Path,
Polygon,
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import pytest
from freeroute.dsn.tokenizer import DsnSyntaxError, TokenKind, tokenize
from rpmroute.dsn.tokenizer import DsnSyntaxError, TokenKind, tokenize
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 freeroute.geometry import IntBox, IntPoint, IntVector, RationalPoint
from rpmroute.geometry import IntBox, IntPoint, IntVector, RationalPoint
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 freeroute.geometry import Direction, IntDirection, IntPoint, IntVector, Side
from rpmroute.geometry import Direction, IntDirection, IntPoint, IntVector, Side
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 freeroute.geometry import IntPoint, Line, RationalPoint, Side
from rpmroute.geometry import IntPoint, Line, RationalPoint, Side
def test_side_of_point():
@@ -79,7 +79,7 @@ def test_intersection_approx_matches_exact():
def test_translate_and_opposite():
line = Line.from_coords(0, 0, 4, 0)
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))
assert moved.side_of(IntPoint(2, 5)) == Side.COLLINEAR
+1 -1
View File
@@ -7,7 +7,7 @@ import random
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])
+2 -2
View File
@@ -8,7 +8,7 @@ Java ``long``, and rational equality.
from __future__ import annotations
from freeroute.geometry import (
from rpmroute.geometry import (
IntPoint,
IntVector,
RationalPoint,
@@ -139,7 +139,7 @@ def test_rational_point_infinite():
def test_rational_point_contained_in_box_exact():
from freeroute.geometry import IntBox
from rpmroute.geometry import IntBox
box = IntBox(0, 0, 2, 2)
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
from freeroute.geometry import IntPoint, Side
from freeroute.geometry.polygon import Polygon
from freeroute.geometry.polygon_shape import PolygonShape
from rpmroute.geometry import IntPoint, Side
from rpmroute.geometry.polygon import Polygon
from rpmroute.geometry.polygon_shape import PolygonShape
def pts(*coords):
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from freeroute.geometry import (
from rpmroute.geometry import (
IntBox,
IntPoint,
Polyline,
+2 -2
View File
@@ -16,7 +16,7 @@ Source: ``geometry/planar/{TileShape,Simplex}.java``.
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):
@@ -162,7 +162,7 @@ def test_get_instance_detects_empty_from_opposing_halfplanes():
def test_translate_preserves_shape():
tri = right_triangle()
from freeroute.geometry import IntVector
from rpmroute.geometry import IntVector
moved = tri.translate_by(IntVector(100, 50))
for p in _grid(-3, 13):
+5 -5
View File
@@ -1,8 +1,8 @@
"""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
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
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.
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.
"""
@@ -27,7 +27,7 @@ import tempfile
import pytest
from freeroute.dsn.sexp import parse
from rpmroute.dsn.sexp import parse
_MIN_JAVA_MAJOR = 21
_REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -60,7 +60,7 @@ def _java_major(java: str) -> int:
def find_java() -> str | None:
"""Return a path to a Java >= 21 runtime, or None."""
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:
candidates.append(override)
java_home = os.environ.get("JAVA_HOME")
+6 -6
View File
@@ -14,11 +14,11 @@ import math
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint, overlaps_2d, segment_slab
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import (
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,
@@ -107,7 +107,7 @@ def test_a_pad_on_the_grid_axes_keeps_the_usual_exits():
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 freeroute.geometry import IntBox
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
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.geometry import IntBox, IntPoint, overlaps_2d, segment_octagon
from freeroute.route import route, route_dsn_board_exact
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import parse
from rpmroute.geometry import IntBox, IntPoint, overlaps_2d, segment_octagon
from rpmroute.route import route, route_dsn_board_exact
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
DIAGONAL_WIN = FIXTURES / "diagonal_win.dsn"
@@ -55,7 +55,7 @@ def _pins_by_net(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())
return round(_rule_clearance_dsn(dsn) * max(dsn.resolution.value, 1))
+6 -6
View File
@@ -15,12 +15,12 @@ import sys
import pytest
from freeroute.board import ShapeSearchTree, TreeShape, build_board
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.geometry import IntBox, Polyline, PolylineShape
from freeroute.route import route, route_dsn_board, route_dsn_board_exact
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
from rpmroute.board import ShapeSearchTree, TreeShape, build_board
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import parse
from rpmroute.geometry import IntBox, Polyline, PolylineShape
from rpmroute.route import route, route_dsn_board, route_dsn_board_exact
from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+5 -5
View File
@@ -2,9 +2,9 @@
from __future__ import annotations
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.route.pipeline import route_dsn_board_exact
_DSN = """(pcb wired (parser) (resolution um 10) (unit um)
(structure
@@ -95,9 +95,9 @@ def test_a_fanned_out_signal_is_routed_from_its_via():
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; freeroute writes only
# then adds it a second time on top of itself; rpmroute writes only
# what it routed
from freeroute.route.pipeline import build_exact_routing_result
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),
+8 -8
View File
@@ -12,11 +12,11 @@ import math
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntBox, Simplex
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import (
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,
@@ -268,9 +268,9 @@ 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 freeroute.board.outline import BoardOutline
from freeroute.geometry import IntBox, IntPoint
from freeroute.route.exact_router import _inside_outline
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
+4 -4
View File
@@ -6,10 +6,10 @@ from pathlib import Path
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
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"
+2 -2
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import build_exact_routing_result
from rpmroute.dsn import parse_dsn
from rpmroute.route.pipeline import build_exact_routing_result
_BOARD = """(pcb widths (parser) (resolution um 10) (unit um)
(structure
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from pathlib import Path
from freeroute.cli import main
from freeroute.route.orders import (
from rpmroute.cli import main
from rpmroute.route.orders import (
best_of_orders,
candidate_seeds,
promote_failures,
+8 -8
View File
@@ -6,9 +6,9 @@ import math
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
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
_R = 3000.0
@@ -70,7 +70,7 @@ def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution):
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("", "")))
assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == []
@@ -101,7 +101,7 @@ def test_heuristic_weight_bounds_route_length():
# --- copper zones (DSN planes) -----------------------------------------------
# rc4 power stage: pads inside "keep router off" pours connect through the pour,
# and freeroute, not knowing zones, dropped every such connection.
# 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))"
_NO_TRACKS = "(wire_keepout \"pour\" (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000))"
@@ -121,7 +121,7 @@ def test_planes_are_read_and_are_not_obstacles():
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())
pins = sorted(board.get_pins(), key=lambda p: p.component_ref)
@@ -155,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):
# 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(
f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))"
@@ -218,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():
from freeroute.route.grid_router import _connected_groups
from rpmroute.route.grid_router import _connected_groups
board = build_board(parse_dsn(_four_layer_dsn()))
plane = [z for z in board.get_conduction_areas() if z.name == "GND"]
+4 -4
View File
@@ -6,10 +6,10 @@ from pathlib import Path
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
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"
+6 -6
View File
@@ -23,11 +23,11 @@ from pathlib import Path
import pytest
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.geometry import Polyline, PolylineShape
from freeroute.route import route, route_dsn_board_rooms
from freeroute.route.pipeline import _rule_clearance_dsn
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import parse
from rpmroute.geometry import Polyline, PolylineShape
from rpmroute.route import route, route_dsn_board_rooms
from rpmroute.route.pipeline import _rule_clearance_dsn
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
CHANNEL_PACK = FIXTURES / "channel_pack.dsn"
@@ -134,7 +134,7 @@ def test_channel_pack_is_deterministic():
def test_channel_pack_endpoints_stay_on_pads():
dsn = parse_dsn(CHANNEL_PACK.read_text())
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 net_no in pin.net_nos:
+6 -6
View File
@@ -13,11 +13,11 @@ import sys
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.geometry import Polyline
from freeroute.route import route, route_dsn_board_exact, route_dsn_board_rooms
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import parse
from rpmroute.geometry import Polyline
from rpmroute.route import route, route_dsn_board_exact, route_dsn_board_rooms
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):
from freeroute.route.pipeline import _rule_clearance_dsn
from rpmroute.route.pipeline import _rule_clearance_dsn
return round(_rule_clearance_dsn(dsn) * scale)
+11 -11
View File
@@ -32,12 +32,12 @@ import sys
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape
from freeroute.route import route, route_dsn_board_exact, route_dsn_board_rooms
from freeroute.route.pipeline import _rule_clearance_dsn
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import parse
from rpmroute.geometry import IntBox, IntPoint, Polyline, PolylineShape
from rpmroute.route import route, route_dsn_board_exact, route_dsn_board_rooms
from rpmroute.route.pipeline import _rule_clearance_dsn
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():
from freeroute.board.search_tree import ShapeSearchTree
from freeroute.route.room_router import _Door, _edge_gate, _project_gate
from rpmroute.board.search_tree import ShapeSearchTree
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))
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():
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.route.room_router import _Door, _project_gate
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from rpmroute.route.room_router import _Door, _project_gate
door = _Door(other=1, kind="edge", lo=IntPoint(0, 100), hi=IntPoint(200, 100))
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():
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)])
assert free == [(0, 87), (113, 200)]
+8 -8
View File
@@ -16,11 +16,11 @@ import sys
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.route import route, route_dsn_board
from freeroute.ses.writer import ses_scale
from rpmroute.board import build_board
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import parse
from rpmroute.route import route, route_dsn_board
from rpmroute.ses.writer import ses_scale
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:
pytest.skip("FreeRouting oracle unavailable")
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))
if not oracle_nets:
# 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.
pytest.skip("reference JAR routed nothing on the synthetic single-layer board")
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()))
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 <= our_nets # freeroute connects every net the JAR does
assert oracle_nets <= our_nets # rpmroute connects every net the JAR does
@pytest.mark.oracle
+5 -5
View File
@@ -17,10 +17,10 @@ import sys
import pytest
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.route import route, route_dsn_board_exact
from freeroute.route.pipeline import _rule_clearance_dsn
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import parse
from rpmroute.route import route, route_dsn_board_exact
from rpmroute.route.pipeline import _rule_clearance_dsn
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():
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)
board = build_board(parse_dsn(SHOVE.read_text()))
+6 -6
View File
@@ -8,12 +8,12 @@ from __future__ import annotations
import pytest
from freeroute.board import build_board
from freeroute.board.items import Pin
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import route_dsn_board_exact
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:
+5 -5
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route import exact_router
from freeroute.route.grid_router import GridRouter, RouteResult
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
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.
+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
+6 -6
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
import itertools
import math
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
from rpmroute.dsn import parse_dsn
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
_DSN = """(pcb holes (parser) (resolution um 10) (unit um)
@@ -57,9 +57,9 @@ def test_connections_sharing_a_plane_via_write_it_once():
def _router(dsn_text, via_drill_um=300):
from freeroute.board import build_board
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
from rpmroute.board import build_board
from rpmroute.route.grid_router import GridRouter
from rpmroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
dsn = parse_dsn(dsn_text)
s = dsn.resolution.value
@@ -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():
from freeroute.geometry import IntPoint
from rpmroute.geometry import IntPoint
text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)))\n"
+7 -7
View File
@@ -3,11 +3,11 @@ it leaves fewer connections unrouted."""
from __future__ import annotations
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntBox
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board_exact
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:
@@ -101,7 +101,7 @@ def test_a_spent_budget_starts_no_window():
def test_window_board_keeps_far_items_of_the_rerouted_nets_only():
from freeroute.route.window import _item_box, _window_board
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()]
@@ -115,7 +115,7 @@ def test_window_board_keeps_far_items_of_the_rerouted_nets_only():
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 freeroute.route.exact_router import route_board_exact
from rpmroute.route.exact_router import route_board_exact
dsn = parse_dsn(_slot(far=True))
s = dsn.resolution.value
+1 -1
View File
@@ -2,7 +2,7 @@
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():
+7 -7
View File
@@ -1,7 +1,7 @@
"""End-to-end tests for the SES writer.
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
import specctra-ses`` walk: ``session > routes > network_out > net >
wire(path)/via``.
@@ -13,9 +13,9 @@ from pathlib import Path
import pytest
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import SExp, parse
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
from rpmroute.dsn import parse_dsn
from rpmroute.dsn.sexp import SExp, parse
from rpmroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
FIXTURES = Path(__file__).parent.parent / "dsn" / "fixtures"
@@ -142,7 +142,7 @@ def test_round_trip_from_empty_board_fixture():
# --- SES coordinate units ---------------------------------------------------
# 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.
@@ -155,8 +155,8 @@ def test_round_trip_from_empty_board_fixture():
],
)
def test_ses_scale(resolution, unit, expected):
from freeroute.dsn import parse_dsn
from freeroute.ses.writer import ses_scale
from rpmroute.dsn import parse_dsn
from rpmroute.ses.writer import ses_scale
dsn = parse_dsn(f"(pcb t (parser) {resolution} {unit} (structure) (placement) (library) (network))")
assert ses_scale(dsn) == pytest.approx(expected)
+33 -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
command line, and every option the selected engine does not implement must fail
@@ -11,7 +11,7 @@ from pathlib import Path
import pytest
from freeroute.cli import ENGINE_OPTIONS, main
from rpmroute.cli import ENGINE_OPTIONS, main
FIXTURES = Path(__file__).parent / "dsn" / "fixtures"
FIXTURE = FIXTURES / "simple_2net.dsn"
@@ -71,13 +71,39 @@ def test_cli_every_engine_routes_a_fixture(engine, tmp_path):
assert "(wire" in text
def test_cli_default_engine_is_grid(tmp_path):
"""The default must stay grid — the drop-in JAR replacement behaviour."""
def test_cli_default_engine_is_exact(tmp_path):
"""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"
grid = tmp_path / "grid.ses"
exact = tmp_path / "exact.ses"
assert main([str(FIXTURE), "-do", str(default)]) == 0
assert main([str(FIXTURE), "--engine", "grid", "-do", str(grid)]) == 0
assert default.read_text() == grid.read_text()
assert main([str(FIXTURE), "--engine", "exact", "-do", str(exact)]) == 0
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):
Generated
+21 -21
View File
@@ -126,27 +126,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "freeroute"
version = "2026.7.14"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" },
{ name = "ruff", specifier = ">=0.1.0" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -215,6 +194,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "rpmroute"
version = "2026.10.7"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" },
{ name = "ruff", specifier = ">=0.1.0" },
]
[[package]]
name = "ruff"
version = "0.15.21"