Compare commits

..
Author SHA1 Message Date
rsp2k 783dd8f0d1 Test: a pad inside a via keepout reaches the plane past it 2026-09-28 21:09:43 -06:00
rsp2k 8a82143517 Check plane and in-pad vias where they will actually go
A start that was already a plane end joined the plane at once, with no
check that its via fits: under a sensor's via keepout the via went in
the pad and the exact check refused it. A via leaving or reaching a
walled-in pin goes in that pin's pad, but was only checked at the grid
cell's centre; it is now checked exactly at the pin against via
keepouts and other nets' pads and fixed copper.
2026-09-28 21:08:51 -06:00
rsp2k 8a50bde531 Read the DSN's wiring as fixed copper of its nets
Tracks and vias already on the board were ignored, so other nets could
be routed straight through them (the flight controller's 82 GND
stitching vias and its guard tracks). They are now copper of their net:
other nets keep a pad's distance from them in the grid, the exact check
and the room engine, and they add no connections to route.
2026-09-28 21:06:35 -06:00
rsp2k 0f6a2a0812 Merge fix/plane-goal-stub: routes ending on a plane no longer crash 2026-09-28 19:55:07 -06:00
rsp2k 245cd91c0e Don't cost a pin stub for a route that ends on a plane
A plane end is marked with its zone, not a pin, so a route from a pad
outside the plane's outline crashed looking for the pin's location
(the flight-controller board's first placement).
2026-09-28 19:55:07 -06:00
rsp2k 019b043cd5 Merge feat/ripup-rollback: undo rip-ups that lose connections (on by default) 2026-09-28 16:17:30 -06:00
rsp2k a2e108bc51 Rollback on by default; --no-rollback keeps every rip-up
Over 8 net orders it left 25 to 30 fewer connections unrouted on the
dense sense boards and 16 to 18 fewer on two PCBench boards, and was
never clearly worse. KiCad DRC on the sense harness: 41 opens to 28.
2026-09-28 16:17:28 -06:00
rsp2k 3462089e09 --rollback: undo a rip-up that leaves fewer connections routed
The connections a rip-up route crosses are rerouted at once in free
space. If that leaves fewer connections routed than before, the new
route comes out and every displaced connection goes back on its old
path, with its rip count as it was. Even trades are kept.
2026-09-28 14:54:29 -06:00
15 changed files with 393 additions and 180 deletions
+21
View File
@@ -70,6 +70,27 @@ class BasicBoard:
def get_traces(self) -> list[Trace]: def get_traces(self) -> list[Trace]:
return [it for it in self._items if isinstance(it, Trace)] return [it for it in self._items if isinstance(it, Trace)]
def get_fixed_copper(self) -> list[tuple[int, frozenset[int], IntBox]]:
"""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
out: list[tuple[int, frozenset[int], IntBox]] = []
for item in self._items:
if not item.fixed or not item.net_nos:
continue
net = item.net_nos[0]
if isinstance(item, Trace) and not item.is_empty():
layers = frozenset({item.layer})
for box in PolylineShape(Polyline(item.corners), item.half_width).tiles():
out.append((net, layers, box))
for end in (item.corners[0], item.corners[-1]):
out.append((net, layers, corner_box(end, item.half_width)))
elif isinstance(item, Via) and item.shape is not None:
out.append((net, frozenset(item.layers), item.shape.bounding_box()))
return out
def get_vias(self) -> list[Via]: def get_vias(self) -> list[Via]:
return [it for it in self._items if isinstance(it, Via)] return [it for it in self._items if isinstance(it, Via)]
+43 -1
View File
@@ -31,7 +31,7 @@ from freeroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard from .board import BasicBoard
from .clearance import ClearanceMatrix from .clearance import ClearanceMatrix
from .items import ConductionArea, ObstacleArea, Pin from .items import ConductionArea, ObstacleArea, Pin, Trace, Via
from .layer import Layer, LayerStructure from .layer import Layer, LayerStructure
from .net import Nets from .net import Nets
from .transform import CoordinateTransform from .transform import CoordinateTransform
@@ -56,6 +56,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
_insert_keepouts(dsn, board, transform) _insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform) _insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets) _insert_zones(dsn, board, transform, nets)
_insert_wiring(dsn, board, transform, nets)
return board return board
@@ -471,6 +472,47 @@ def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> l
return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()] return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()]
def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None:
"""Tracks and vias already on the board, as fixed copper of their nets."""
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
numbers = {net.name: net.net_number for net in nets}
for wire in dsn.wires:
if wire.net not in numbers or wire.layer not in layer_no:
continue
c = wire.coords
corners = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
if len(corners) < 2:
continue
board.insert(
Trace(
net_nos=[numbers[wire.net]], fixed=True, layer=layer_no[wire.layer],
half_width=max(round(transform.dsn_to_board(wire.width / 2)), 1), corners=corners,
)
)
for via in dsn.vias:
padstack = dsn.padstack(via.padstack)
if via.net not in numbers or padstack is None or not padstack.shapes:
continue
layers: set[int] = set()
for shape in padstack.shapes:
if shape.layer in ("signal", "pcb"):
layers.update(signal_layers)
elif shape.layer in layer_no:
layers.add(layer_no[shape.layer])
location = transform.dsn_to_board_point(via.x, via.y)
pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform)
box = pad.bounding_box()
board.insert(
Via(
net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack,
location=location, layers=sorted(layers or signal_layers),
shape=IntBox(box.ll.x + location.x, box.ll.y + location.y,
box.ur.x + location.x, box.ur.y + location.y),
)
)
def _insert_zones(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None: def _insert_zones(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None:
"""Each DSN plane as a ConductionArea of its net on its layer.""" """Each DSN plane as a ConductionArea of its net on its layer."""
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)} layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
+3
View File
@@ -114,6 +114,9 @@ class Via(Item):
padstack_name: str = "" padstack_name: str = ""
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0)) location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
#: layer indices the via's pads occupy, and the largest pad as a box
layers: list[int] = field(default_factory=list)
shape: object = None
@dataclass @dataclass
+12 -10
View File
@@ -30,8 +30,8 @@ from freeroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for #: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored. #: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = { ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "escapes"}), "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "escapes"}), "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"room": frozenset({"shove", "pack"}), "room": frozenset({"shove", "pack"}),
} }
@@ -46,7 +46,7 @@ _FLAG_NAMES = {
"orders": "--orders", "orders": "--orders",
"promote": "--promote-failures", "promote": "--promote-failures",
"access": "--access-cost", "access": "--access-cost",
"escapes": "--reserve-escapes", "rollback": "--no-rollback",
} }
@@ -168,10 +168,12 @@ def build_parser() -> argparse.ArgumentParser:
"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( parser.add_argument(
"--reserve-escapes", "--no-rollback",
action="store_true", dest="rollback",
help="grid and exact engines: before routing, reserve a way out for every fine-pitch " action="store_false",
"pin that no other net may use until that pin's connections are routed", help="grid and exact engines: keep every rip-up, as before. By default the "
"connections a rip-up displaces are rerouted at once and the rip-up is undone "
"if fewer connections end up routed",
) )
parser.add_argument( parser.add_argument(
"--jobs", "--jobs",
@@ -202,7 +204,7 @@ 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),
("escapes", args.reserve_escapes), ("rollback", not args.rollback),
) )
if asked if asked
] ]
@@ -237,7 +239,7 @@ 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,
"reserve_escapes": args.reserve_escapes, "rollback": args.rollback,
} }
elif args.engine == "grid": elif args.engine == "grid":
options = { options = {
@@ -247,7 +249,7 @@ 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,
"reserve_escapes": args.reserve_escapes, "rollback": args.rollback,
} }
if args.engine == "room": if args.engine == "room":
result = build_rooms_routing_result( result = build_rooms_routing_result(
+25
View File
@@ -27,6 +27,8 @@ __all__ = [
"NetPin", "NetPin",
"Net", "Net",
"NetClass", "NetClass",
"WiringVia",
"WiringWire",
"Keepout", "Keepout",
"DsnBoard", "DsnBoard",
] ]
@@ -186,6 +188,26 @@ class Keepout:
kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout
@dataclass
class WiringWire:
"""A track already on the board: ``(wire (path LAYER WIDTH x y ...) (net N))``."""
net: str
layer: str
width: float
coords: list[float] = field(default_factory=list)
@dataclass
class WiringVia:
"""A via already on the board: ``(via PADSTACK x y (net N))``."""
net: str
padstack: str
x: float
y: float
@dataclass @dataclass
class DsnBoard: class DsnBoard:
"""The fully parsed DSN file.""" """The fully parsed DSN file."""
@@ -197,6 +219,9 @@ class DsnBoard:
layers: list[Layer] = field(default_factory=list) layers: list[Layer] = field(default_factory=list)
#: copper zones, as planes of a net #: copper zones, as planes of a net
planes: list[Plane] = field(default_factory=list) planes: list[Plane] = field(default_factory=list)
#: copper already routed (the ``wiring`` scope), kept fixed
wires: list[WiringWire] = field(default_factory=list)
vias: list[WiringVia] = field(default_factory=list)
#: bounding-box boundary shape (layer ``pcb``), if present #: bounding-box boundary shape (layer ``pcb``), if present
boundary: Shape | None = None boundary: Shape | None = None
#: outline shapes (layer ``signal``) #: outline shapes (layer ``signal``)
+37 -1
View File
@@ -27,6 +27,8 @@ from .model import (
Pin, Pin,
Resolution, Resolution,
WidthRule, WidthRule,
WiringVia,
WiringWire,
) )
from .sexp import SExp, parse from .sexp import SExp, parse
from .shapes import Shape, read_area, read_shape from .shapes import Shape, read_area, read_shape
@@ -531,11 +533,45 @@ def parse_dsn(text: str) -> DsnBoard:
_read_placement(it, board) _read_placement(it, board)
elif head == "network": elif head == "network":
_read_network(it, board) _read_network(it, board)
# `wiring` and unknown scopes are intentionally ignored for now. elif head == "wiring":
_read_wiring(it, board)
# unknown scopes are ignored
return board return board
def _read_wiring(node: SExp, board: DsnBoard) -> None:
"""Tracks and vias already on the board. They stay where they are: the
router treats them as copper of their net that other nets keep clear of."""
for it in node.items:
if not isinstance(it, SExp):
continue
net_scope = it.child("net")
net = _first_value(net_scope) if net_scope is not None else None
if not net:
continue # netless copper would need a keepout, not a wire
if it.head == "wire":
path = it.child("path") or it.child("polyline_path")
vals = [v.text for v in path.values()] if path is not None else []
if len(vals) < 6:
continue
try:
width = float(vals[1])
coords = [float(v) for v in vals[2:]]
except ValueError:
continue
board.wires.append(WiringWire(net=net, layer=vals[0], width=width, coords=coords))
elif it.head == "via":
vals = [v.text for v in it.values()]
if len(vals) < 3:
continue
try:
x, y = float(vals[1]), float(vals[2])
except ValueError:
continue
board.vias.append(WiringVia(net=net, padstack=vals[0], x=x, y=y))
def _read_plane(node: SExp): def _read_plane(node: SExp):
"""``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`.""" """``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`."""
from .model import Plane from .model import Plane
+6 -2
View File
@@ -98,7 +98,7 @@ 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,
reserve_escapes: bool = False, rollback: bool = True,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -130,7 +130,7 @@ def route_board_exact(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
reserve_escapes=reserve_escapes, rollback=rollback,
) )
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
@@ -149,6 +149,10 @@ def route_board_exact(
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers) pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers: if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box)) 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(): for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers: if obstacle.layer not in signal_layers:
continue continue
+102 -92
View File
@@ -113,6 +113,7 @@ class _Connection:
"b_group", "b_group",
"a_end", "a_end",
"b_end", "b_end",
"path",
) )
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -134,6 +135,8 @@ class _Connection:
self.b_group: tuple[list[Pin], list] = ([b], []) self.b_group: tuple[list[Pin], list] = ([b], [])
self.a_end: Pin | None = a self.a_end: Pin | None = a
self.b_end: Pin | None = b self.b_end: Pin | None = b
# the node path last committed, so a rip-up can be undone
self.path: list[Node] | None = None
class GridRouter: class GridRouter:
@@ -159,7 +162,7 @@ class GridRouter:
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,
reserve_escapes: bool = False, rollback: bool = True,
) -> None: ) -> None:
self.board = board self.board = board
# Pin access: while a pin still has connections to route, the cells # Pin access: while a pin still has connections to route, the cells
@@ -169,12 +172,12 @@ class GridRouter:
self.access_cost = max(float(access_cost), 0.0) self.access_cost = max(float(access_cost), 0.0)
self._doorways: dict[Node, list[tuple[int, int]]] = {} self._doorways: dict[Node, list[tuple[int, int]]] = {}
self._pending: dict[int, int] = {} self._pending: dict[int, int] = {}
# Escape reservations: before routing, every crowded (fine-pitch) pin # Rip-up as a transaction: the connections a rip-up route crosses are
# gets a short way out to open board, and while it still has # rerouted at once, and if fewer connections are routed afterwards
# connections to route no other net may use those cells (nor come # than before, everything goes back as it was. On by default: over
# within clearance of them). # 8 net orders it left 25 to 30 fewer connections unrouted on dense
self.reserve_escapes = bool(reserve_escapes) # boards and was never clearly worse.
self._reserved: dict[Node, list[tuple[int, int]]] = {} self.rollback = bool(rollback)
# None keeps the deterministic order; a seed shuffles which connection # None keeps the deterministic order; a seed shuffles which connection
# routes first, to measure how much a result owes to the order # routes first, to measure how much a result owes to the order
self.seed = seed self.seed = seed
@@ -247,6 +250,7 @@ class GridRouter:
# trace halos: node -> indices of the connections claiming it # trace halos: node -> indices of the connections claiming it
self._halo: dict[Node, list[int]] = {} self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles() self._rasterize_obstacles()
self._rasterize_fixed_copper()
# --- grid helpers ------------------------------------------------------- # --- grid helpers -------------------------------------------------------
@@ -307,6 +311,9 @@ class GridRouter:
self._via_edge_block: set[Cell] = set() self._via_edge_block: set[Cell] = set()
# wire keepouts as boxes, to check a pin stub the way the exact engine will # wire keepouts as boxes, to check a pin stub the way the exact engine will
self._keepout_boxes: dict[int, list[tuple[IntBox, bool]]] = {} self._keepout_boxes: dict[int, list[tuple[IntBox, bool]]] = {}
# via keepouts as boxes, and other copper a via in a pad must clear
self._via_keepout_boxes: list[tuple[IntBox, bool]] = []
self._static_copper: list[tuple[IntBox, int]] | None = None
# pin end nodes no wire can reach without entering a keepout # pin end nodes no wire can reach without entering a keepout
self._via_only: set[Node] = set() self._via_only: set[Node] = set()
if outline is not None: if outline is not None:
@@ -356,6 +363,18 @@ class GridRouter:
self._keepout.add((*cell, item.layer)) self._keepout.add((*cell, item.layer))
if item.blocks_vias: if item.blocks_vias:
self._via_keepout.add((*cell, item.layer)) self._via_keepout.add((*cell, item.layer))
if item.blocks_vias:
self._via_keepout_boxes.append((box, item.clearance))
def _rasterize_fixed_copper(self) -> None:
"""Tracks and vias already on the board: copper of their net, which
other nets keep the same distance from as from a pad."""
margin = self.clearance + self.half_width
for net, layers, box in self.board.get_fixed_copper():
on = [layer for layer in layers if layer in self.layers]
for cell in self._cells_in_box(box, margin):
for layer in on:
self._pad_block.setdefault((*cell, layer), set()).add(net)
# --- occupancy cost ----------------------------------------------------- # --- occupancy cost -----------------------------------------------------
@@ -378,10 +397,6 @@ class GridRouter:
node = (*cell, layer) node = (*cell, layer)
if wire_keepout and node in self._keepout: if wire_keepout and node in self._keepout:
return None return None
if self._reserved:
for pin_id, owner_net in self._reserved.get(node, ()):
if owner_net != net_no and self._pending.get(pin_id, 0) > 0:
return None # another pin's reserved way out
pads = self._pad_block.get(node) pads = self._pad_block.get(node)
if pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()): if pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()):
return None return None
@@ -442,7 +457,14 @@ class GridRouter:
goals = self._group_ends(conn.b_group, conn.net_no, conns) goals = self._group_ends(conn.b_group, conn.net_no, conns)
if not starts or not goals: if not starts or not goals:
return None return None
common = sorted(set(starts) & set(goals)) # A start that is already an end joins at once, except that ending on
# a plane there needs a via: one that cannot be placed (a via keepout,
# another net's pad) is no end, and the search goes on from there.
common = [
n for n in sorted(set(starts) & set(goals))
if not isinstance(goals[n], ConductionArea)
or self._via_placeable((n[0], n[1]), conn.net_no, conns)
]
if common: if common:
conn.a_end, conn.b_end = starts[common[0]], goals[common[0]] conn.a_end, conn.b_end = starts[common[0]], goals[common[0]]
return [common[0]] return [common[0]]
@@ -452,10 +474,11 @@ class GridRouter:
g_score: dict[Node, float] = {} g_score: dict[Node, float] = {}
came_from: dict[Node, Node] = {} came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked # a stub costs its length, so the nearest cell wins unless it is blocked
goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if p is not None} # a pin end costs its stub; a zone or plane end is entered normally
goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if isinstance(p, Pin)}
for s in sorted(starts): for s in sorted(starts):
pin = starts[s] pin = starts[s]
g_score[s] = self._stub_cost(pin, s) if pin is not None else 0.0 g_score[s] = self._stub_cost(pin, s) if isinstance(pin, Pin) else 0.0
heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s)) heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s))
# Via footprints are checked lazily: a layer change goes on the heap as # Via footprints are checked lazily: a layer change goes on the heap as
@@ -474,6 +497,12 @@ class GridRouter:
ok = via_ok.get(cell) ok = via_ok.get(cell)
if ok is None: if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns) ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
# a via leaving or reaching a walled-in pin goes in its pad
# (see _commit), so it has to fit there exactly
for node, ends in ((source, starts), (target, goals)):
pin = ends.get(node)
if ok and isinstance(pin, Pin) and self._walled(node):
ok = self._via_fits_at(pin.location, net_no)
if ok: if ok:
self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap) self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap)
continue continue
@@ -537,6 +566,29 @@ class GridRouter:
# copper too, or a stub ends up closer than clearance to a trace in the # copper too, or a stub ends up closer than clearance to a trace in the
# cell beside it. # cell beside it.
def _walled(self, node: Node) -> bool:
"""A pin end no wire may leave or reach: only a via in its pad will do."""
return node in self._keepout or node in self._via_only
def _via_fits_at(self, point: IntPoint, net_no: int) -> bool:
"""A via centred exactly on ``point`` clears every via keepout and all
other nets' pads and fixed copper (what a via in a pad must meet)."""
via = IntBox(point.x - self.via_half, point.y - self.via_half,
point.x + self.via_half, point.y + self.via_half)
grown = via.offset(self.clearance)
for box, needs in self._via_keepout_boxes:
if box.overlaps(grown if needs else via):
return False
if self._static_copper is None:
copper = [
(p.shape.bounding_box(), p.net_nos[0] if p.net_nos else -1)
for p in self.board.get_pins()
if p.shape is not None and any(layer in self.layers for layer in p.layers)
]
copper += [(box, net) for net, _, box in self.board.get_fixed_copper()]
self._static_copper = copper
return not any(net != net_no and box.overlaps(grown) for box, net in self._static_copper)
def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> list[Cell]: def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> list[Cell]:
"""Cells where another net's trace would come too close to the stub.""" """Cells where another net's trace would come too close to the stub."""
from freeroute.board.outline import _point_segment from freeroute.board.outline import _point_segment
@@ -799,6 +851,7 @@ class GridRouter:
# --- commit / rip ------------------------------------------------------- # --- commit / rip -------------------------------------------------------
def _commit(self, conn: _Connection, path: list[Node]) -> None: def _commit(self, conn: _Connection, path: list[Node]) -> None:
conn.path = list(path)
wire_segs, via_cells = _split_path(path) wire_segs, via_cells = _split_path(path)
via_cells = list(via_cells) via_cells = list(via_cells)
if isinstance(conn.a_end, ConductionArea): if isinstance(conn.a_end, ConductionArea):
@@ -912,6 +965,7 @@ class GridRouter:
done: set[int] = set() done: set[int] = set()
for conn in conns: for conn in conns:
for pin in self._group_pins(conn): for pin in self._group_pins(conn):
self._pending[id(pin)] = self._pending.get(id(pin), 0) + 1
if id(pin) in done: if id(pin) in done:
continue continue
done.add(id(pin)) done.add(id(pin))
@@ -929,76 +983,6 @@ class GridRouter:
for node in cells: for node in cells:
self._doorways.setdefault(node, []).append(claim) self._doorways.setdefault(node, []).append(claim)
def _count_pending(self, conns: list[_Connection]) -> None:
"""How many unrouted connections each pin is an end of."""
for conn in conns:
for pin in self._group_pins(conn):
self._pending[id(pin)] = self._pending.get(id(pin), 0) + 1
def _reserve_escapes(self, conns: list[_Connection]) -> None:
"""Give every crowded pin a way out before anything is routed."""
blocked: set[Node] = set() # earlier escapes and their clearance
spread = [(0, 0), *self._halo_offsets]
seen: set[int] = set()
for conn in conns:
for pin in self._group_pins(conn):
if id(pin) in seen or not self._crowded(pin, conn.net_no):
continue
seen.add(id(pin))
path = self._escape_path(pin, conn.net_no, conns, blocked)
if not path:
continue
claim = (id(pin), conn.net_no)
for x, y, layer in path:
for dx, dy in spread:
node = (x + dx, y + dy, layer)
if 0 <= node[0] < self.cols and 0 <= node[1] < self.rows:
self._reserved.setdefault(node, []).append(claim)
blocked.add(node)
def _escape_path(self, pin: Pin, net_no: int, conns, blocked: set[Node]) -> list[Node]:
"""The shortest way from a pin to a cell clear of other nets' pads,
against pads, keepouts and earlier escapes only (breadth first, on
the pin's own layers, at most ``2 * _escape_cells`` long)."""
starts = [n for n in self._end_nodes(pin, net_no, conns) if n not in blocked]
came: dict[Node, Node | None] = dict.fromkeys(starts)
frontier = list(starts)
limit = 2 * self._escape_cells
for _depth in range(limit):
nxt: list[Node] = []
for node in frontier:
if node not in starts and self._open_ground(node, net_no):
path = [node]
while came[path[-1]] is not None:
path.append(came[path[-1]])
return path[::-1]
x, y, layer = node
for dx, dy in self._neighbours:
n = (x + dx, y + dy, layer)
if n in came or n in blocked:
continue
if not (0 <= n[0] < self.cols and 0 <= n[1] < self.rows):
continue
if not self._static_open(n, net_no):
continue
came[n] = node
nxt.append(n)
frontier = nxt
if not frontier:
break
return []
def _open_ground(self, node: Node, net_no: int) -> bool:
"""Clear of every other net's pad by a full trace spacing all round."""
x, y, layer = node
for dx, dy in self._halo_offsets:
n = (x + dx, y + dy, layer)
if not (0 <= n[0] < self.cols and 0 <= n[1] < self.rows):
return False
if not self._static_open(n, net_no):
return False
return True
def _settle(self, conn: _Connection, delta: int) -> None: def _settle(self, conn: _Connection, delta: int) -> None:
if not self._pending: if not self._pending:
return return
@@ -1021,12 +1005,8 @@ class GridRouter:
self._conns = conns self._conns = conns
if not conns: if not conns:
return RouteResult(self.half_width) return RouteResult(self.half_width)
if self.access_cost or self.reserve_escapes:
self._count_pending(conns)
if self.access_cost: if self.access_cost:
self._build_doorways(conns) self._build_doorways(conns)
if self.reserve_escapes:
self._reserve_escapes(conns)
passes = self.max_passes if self.rip_up else 1 passes = self.max_passes if self.rip_up else 1
best_count = -1 best_count = -1
@@ -1065,11 +1045,41 @@ class GridRouter:
path = self._search(conn, allow_ripup=True, conns=conns) path = self._search(conn, allow_ripup=True, conns=conns)
if path is None: if path is None:
return False return False
for idx in sorted(self._crossed_owners(path, conn.net_no, conns)): victims = [conns[i] for i in sorted(self._crossed_owners(path, conn.net_no, conns))]
self._rip(conns[idx]) if self.rollback:
return self._ripup_transaction(conn, path, victims, conns)
for victim in victims:
self._rip(victim)
self._commit(conn, path) self._commit(conn, path)
return True return True
def _ripup_transaction(self, conn, path, victims, conns) -> bool:
"""Route ``conn`` through ``victims``, reroute them at once in free
space, and undo it all if fewer connections end up routed."""
saved = [(v, v.path, v.a_end, v.b_end, v.rip_count) for v in victims]
own_rips = conn.rip_count
for victim in victims:
self._rip(victim)
self._commit(conn, path)
rerouted = 0
for victim in victims:
again = self._search(victim, allow_ripup=False, conns=conns)
if again is not None:
self._commit(victim, again)
rerouted += 1
if 1 + rerouted >= len(victims):
return True # a gain, or an even trade that may pay off later
for victim in victims:
if victim.routed:
self._rip(victim)
self._rip(conn)
conn.rip_count = own_rips
for victim, old_path, a_end, b_end, rips in saved:
victim.a_end, victim.b_end = a_end, b_end
self._commit(victim, old_path)
victim.rip_count = rips
return False
def _build_connections(self) -> list[_Connection]: def _build_connections(self) -> list[_Connection]:
pins_by_net = self._pins_by_net() pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {} zones_by_net: dict[int, list] = {}
@@ -1273,7 +1283,7 @@ def route_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,
reserve_escapes: bool = False, rollback: bool = True,
) -> RouteResult: ) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board.""" """Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter( router = GridRouter(
@@ -1292,6 +1302,6 @@ def route_board(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
reserve_escapes=reserve_escapes, rollback=rollback,
) )
return router.route() return router.route()
+8 -8
View File
@@ -83,7 +83,7 @@ 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,
reserve_escapes: bool = False, rollback: bool = True,
) -> 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
@@ -107,7 +107,7 @@ def route_dsn_board(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
reserve_escapes=reserve_escapes, rollback=rollback,
) )
return result, scale, [layer.name for layer in dsn.layers] return result, scale, [layer.name for layer in dsn.layers]
@@ -151,7 +151,7 @@ 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,
reserve_escapes: bool = False, rollback: bool = True,
) -> 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(
@@ -159,7 +159,7 @@ 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,
reserve_escapes=reserve_escapes, rollback=rollback,
) )
return _to_routing_result(route, dsn, scale, layer_names) return _to_routing_result(route, dsn, scale, layer_names)
@@ -178,7 +178,7 @@ 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,
reserve_escapes: bool = False, rollback: bool = True,
) -> 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.
@@ -206,7 +206,7 @@ def route_dsn_board_exact(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
reserve_escapes=reserve_escapes, rollback=rollback,
) )
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
@@ -225,7 +225,7 @@ 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,
reserve_escapes: bool = False, rollback: bool = True,
) -> 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(
@@ -241,7 +241,7 @@ 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,
reserve_escapes=reserve_escapes, rollback=rollback,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
+4
View File
@@ -515,6 +515,10 @@ def route_board_rooms(
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers) pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers: if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box)) 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(): for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers: if obstacle.layer not in signal_layers:
continue continue
-65
View File
@@ -1,65 +0,0 @@
"""--reserve-escapes: a crowded pin keeps a way out until it is routed."""
from __future__ import annotations
from pathlib import Path
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import (
_rule_clearance_dsn,
_rule_width_dsn,
build_exact_routing_result,
)
SMD = (Path(__file__).resolve().parent.parent / "dsn" / "fixtures" / "smd_demo.dsn").read_text()
def _router():
dsn = parse_dsn(SMD)
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
orthogonal=True,
resolution=2,
reserve_escapes=True,
)
conns = router._build_connections()
router._conns = conns
router._count_pending(conns)
router._reserve_escapes(conns)
return router, conns
def test_a_reserved_escape_is_closed_to_other_nets_and_open_to_its_own():
router, conns = _router()
assert router._reserved
node, claims = next(iter(router._reserved.items()))
pin_id, owner = claims[0]
other = next(c.net_no for c in conns if c.net_no != owner)
cell, layer = node[:2], node[2]
assert router._enter_cost(cell, layer, other, True, conns) is None # even for rip-up
assert router._enter_cost(cell, layer, owner, False, conns) is not None or node in router._keepout
def test_the_escape_is_released_once_its_pin_is_routed():
router, conns = _router()
node, claims = next(iter(router._reserved.items()))
other = next(c.net_no for c in conns if c.net_no != claims[0][1])
cell, layer = node[:2], node[2]
assert router._enter_cost(cell, layer, other, False, conns) is None
for c in conns: # every connection routed: no pin is pending any more
router._settle(c, -1)
released = router._enter_cost(cell, layer, other, False, conns)
reserved, router._reserved = router._reserved, {}
assert released == router._enter_cost(cell, layer, other, False, conns)
router._reserved = reserved
def test_reserving_escapes_loses_nothing_on_the_demo_board():
plain = build_exact_routing_result(parse_dsn(SMD), resolution=2)
reserved = build_exact_routing_result(parse_dsn(SMD), resolution=2, reserve_escapes=True)
assert len(reserved.unrouted) <= len(plain.unrouted)
+50
View File
@@ -0,0 +1,50 @@
"""Tracks and vias already on the board (the DSN's wiring) are fixed copper."""
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
_DSN = """(pcb wired (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:300_um") (rule (width 250) (clearance 200)))
(placement (component S (place A1 5000 -10000 front 0) (place A2 25000 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -400 -400 400 400)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net SIG (pins A1-1 A2-1)) (net GND))
(wiring
(wire (path F.Cu 400 15000 0 15000 -20000) (net GND) (type route))
(via "Via[0-1]_600:300_um" 8000 -14000 (net GND) (type route))))"""
def test_wiring_is_read_with_its_nets():
dsn = parse_dsn(_DSN)
assert [(w.net, w.layer, w.width) for w in dsn.wires] == [("GND", "F.Cu", 400.0)]
assert [(v.net, v.x, v.y) for v in dsn.vias] == [("GND", 8000.0, -14000.0)]
def test_wiring_is_fixed_copper_and_not_a_connection_to_route():
board = build_board(parse_dsn(_DSN))
copper = board.get_fixed_copper()
gnd = next(n.net_number for n in board.nets if n.name == "GND")
assert copper and all(net == gnd for net, _, _ in copper)
assert not any(gnd in p.net_nos for p in board.get_pins())
def test_other_nets_route_around_a_fixed_track():
dsn = parse_dsn(_DSN)
exact, scale, names = route_dsn_board_exact(dsn, resolution=2)
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
assert sig in exact.result.routed_net_numbers
assert exact.drc_clean
wall = 15000 * scale
for layer, pts in exact.result.wires.get(sig, []):
if names[layer] != "F.Cu":
continue
for p, q in zip(pts, pts[1:], strict=False):
assert not (min(p.x, q.x) < wall < max(p.x, q.x)), "crossed the GND track on F.Cu"
+38
View File
@@ -202,6 +202,18 @@ def test_smd_pads_reach_an_inner_plane_by_their_own_via():
assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs) assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs)
def test_a_pad_outside_the_plane_routes_to_it():
# G1 sits left of a plane that covers only the right half, so its route
# has to reach the plane's cells: those ends are the plane, not a pin
dsn_text = _four_layer_dsn().replace(
"(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))",
"(plane GND (polygon In1.Cu 0 15000 -500 29500 -500 29500 -29500 15000 -29500 15000 -500))",
)
result, _scale, _names = route_dsn_board_exact(parse_dsn(dsn_text))
gnd = next(i + 1 for i, n in enumerate(parse_dsn(dsn_text).nets) if n.name == "GND")
assert gnd in result.result.routed_net_numbers
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 freeroute.route.grid_router import _connected_groups
@@ -211,3 +223,29 @@ def test_a_through_hole_pad_inside_a_plane_needs_no_route():
groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane)) groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane))
hub = next(g for g in groups if g[1]) hub = next(g for g in groups if g[1])
assert [p.component_ref for p in hub[0]] == ["GT"] assert [p.component_ref for p in hub[0]] == ["GT"]
def test_a_pad_inside_a_via_keepout_reaches_the_plane_by_a_via_outside_it():
# a sensor's GND pad: vias are forbidden under the body, so the route
# has to leave on the top layer and drop to the plane past the keepout
dsn = parse_dsn("""(pcb repro (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
(layer B.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 19500 -500 19500 -19500 500 -19500 500 -500))
(via_keepout "" (polygon signal 0 8500 -8500 11500 -8500 11500 -11500 8500 -11500 8500 -8500))
(via "Via[0-3]_600:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 10000 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -175 -300 175 300)) (attach off))
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net GND (pins G1-1))))""")
exact, scale, _names = route_dsn_board_exact(dsn, resolution=2)
assert exact.result.failed == []
(via,) = exact.result.vias[1]
half = 300 * scale # the via's radius
outside = (via.x + half <= 8500 * scale or via.x - half >= 11500 * scale
or via.y - half >= -8500 * scale or via.y + half <= -11500 * scale)
assert outside, f"via at {via} is inside the via keepout"
+43
View File
@@ -0,0 +1,43 @@
"""--rollback: a rip-up that loses connections is undone, leaving no trace."""
from __future__ import annotations
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
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
def _routed_router(name):
dsn = parse_dsn((FIXTURES / name).read_text())
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
rollback=True,
)
return router, router.route()
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_occupancy_only_holds_routed_connections(name):
router, _ = _routed_router(name)
conns = router._conns
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._halo.values() for i in claims)
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_rollback_never_routes_fewer_than_plain_rip_up(name):
text = (FIXTURES / name).read_text()
plain, _, _ = route_dsn_board(parse_dsn(text), rollback=False)
undo, _, _ = route_dsn_board(parse_dsn(text))
assert undo.unrouted <= plain.unrouted
+1 -1
View File
@@ -158,7 +158,7 @@ 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"],
"escapes": ["--reserve-escapes"], "rollback": ["--no-rollback"],
} }
for engine, options in ENGINE_OPTIONS.items(): for engine, options in ENGINE_OPTIONS.items():
for option in options: for option in options: