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
rsp2k 35891c02ff Merge feat/rule-area-keepouts: keepout exits, via-only walled pins, rule-area support 2026-09-28 10:32:44 -06:00
14 changed files with 394 additions and 9 deletions
+21
View File
@@ -70,6 +70,27 @@ class BasicBoard:
def get_traces(self) -> list[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]:
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 .clearance import ClearanceMatrix
from .items import ConductionArea, ObstacleArea, Pin
from .items import ConductionArea, ObstacleArea, Pin, Trace, Via
from .layer import Layer, LayerStructure
from .net import Nets
from .transform import CoordinateTransform
@@ -56,6 +56,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
_insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets)
_insert_wiring(dsn, board, transform, nets)
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()]
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:
"""Each DSN plane as a ConductionArea of its net on its layer."""
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 = ""
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
+14 -2
View File
@@ -30,8 +30,8 @@ from freeroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access"}),
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"room": frozenset({"shove", "pack"}),
}
@@ -46,6 +46,7 @@ _FLAG_NAMES = {
"orders": "--orders",
"promote": "--promote-failures",
"access": "--access-cost",
"rollback": "--no-rollback",
}
@@ -166,6 +167,14 @@ def build_parser() -> argparse.ArgumentParser:
help="grid and exact engines: extra cost for a route to pass where it would close "
"another pin's way out while that pin still has connections to route (default 0: off)",
)
parser.add_argument(
"--no-rollback",
dest="rollback",
action="store_false",
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(
"--jobs",
type=int,
@@ -195,6 +204,7 @@ def main(argv: list[str] | None = None) -> int:
("orders", args.orders is not None),
("promote", args.promote_failures is not None),
("access", args.access_cost is not None),
("rollback", not args.rollback),
)
if asked
]
@@ -229,6 +239,7 @@ def main(argv: list[str] | None = None) -> int:
"resolution": 2 if args.grid_resolution is None else args.grid_resolution,
"heuristic_weight": args.heuristic_weight,
"access_cost": args.access_cost or 0.0,
"rollback": args.rollback,
}
elif args.engine == "grid":
options = {
@@ -238,6 +249,7 @@ def main(argv: list[str] | None = None) -> int:
"edge_clearance_mm": args.edge_clearance,
"resolution": 1 if args.grid_resolution is None else args.grid_resolution,
"access_cost": args.access_cost or 0.0,
"rollback": args.rollback,
}
if args.engine == "room":
result = build_rooms_routing_result(
+25
View File
@@ -27,6 +27,8 @@ __all__ = [
"NetPin",
"Net",
"NetClass",
"WiringVia",
"WiringWire",
"Keepout",
"DsnBoard",
]
@@ -186,6 +188,26 @@ class 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
class DsnBoard:
"""The fully parsed DSN file."""
@@ -197,6 +219,9 @@ class DsnBoard:
layers: list[Layer] = field(default_factory=list)
#: copper zones, as planes of a net
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
boundary: Shape | None = None
#: outline shapes (layer ``signal``)
+37 -1
View File
@@ -27,6 +27,8 @@ from .model import (
Pin,
Resolution,
WidthRule,
WiringVia,
WiringWire,
)
from .sexp import SExp, parse
from .shapes import Shape, read_area, read_shape
@@ -531,11 +533,45 @@ def parse_dsn(text: str) -> DsnBoard:
_read_placement(it, board)
elif head == "network":
_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
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):
"""``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`."""
from .model import Plane
+6
View File
@@ -98,6 +98,7 @@ def route_board_exact(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry.
@@ -129,6 +130,7 @@ def route_board_exact(
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline
@@ -147,6 +149,10 @@ def route_board_exact(
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
+101 -5
View File
@@ -113,6 +113,7 @@ class _Connection:
"b_group",
"a_end",
"b_end",
"path",
)
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.a_end: Pin | None = a
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:
@@ -159,6 +162,7 @@ class GridRouter:
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> None:
self.board = board
# Pin access: while a pin still has connections to route, the cells
@@ -168,6 +172,12 @@ class GridRouter:
self.access_cost = max(float(access_cost), 0.0)
self._doorways: dict[Node, list[tuple[int, int]]] = {}
self._pending: dict[int, int] = {}
# Rip-up as a transaction: the connections a rip-up route crosses are
# rerouted at once, and if fewer connections are routed afterwards
# than before, everything goes back as it was. On by default: over
# 8 net orders it left 25 to 30 fewer connections unrouted on dense
# boards and was never clearly worse.
self.rollback = bool(rollback)
# None keeps the deterministic order; a seed shuffles which connection
# routes first, to measure how much a result owes to the order
self.seed = seed
@@ -240,6 +250,7 @@ class GridRouter:
# trace halos: node -> indices of the connections claiming it
self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles()
self._rasterize_fixed_copper()
# --- grid helpers -------------------------------------------------------
@@ -300,6 +311,9 @@ class GridRouter:
self._via_edge_block: set[Cell] = set()
# wire keepouts as boxes, to check a pin stub the way the exact engine will
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
self._via_only: set[Node] = set()
if outline is not None:
@@ -349,6 +363,18 @@ class GridRouter:
self._keepout.add((*cell, item.layer))
if item.blocks_vias:
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 -----------------------------------------------------
@@ -431,7 +457,14 @@ class GridRouter:
goals = self._group_ends(conn.b_group, conn.net_no, conns)
if not starts or not goals:
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:
conn.a_end, conn.b_end = starts[common[0]], goals[common[0]]
return [common[0]]
@@ -441,10 +474,11 @@ class GridRouter:
g_score: dict[Node, float] = {}
came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked
goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if p is not None}
# 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):
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))
# Via footprints are checked lazily: a layer change goes on the heap as
@@ -463,6 +497,12 @@ class GridRouter:
ok = via_ok.get(cell)
if ok is None:
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:
self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap)
continue
@@ -526,6 +566,29 @@ class GridRouter:
# copper too, or a stub ends up closer than clearance to a trace in the
# 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]:
"""Cells where another net's trace would come too close to the stub."""
from freeroute.board.outline import _point_segment
@@ -788,6 +851,7 @@ class GridRouter:
# --- commit / rip -------------------------------------------------------
def _commit(self, conn: _Connection, path: list[Node]) -> None:
conn.path = list(path)
wire_segs, via_cells = _split_path(path)
via_cells = list(via_cells)
if isinstance(conn.a_end, ConductionArea):
@@ -981,11 +1045,41 @@ class GridRouter:
path = self._search(conn, allow_ripup=True, conns=conns)
if path is None:
return False
for idx in sorted(self._crossed_owners(path, conn.net_no, conns)):
self._rip(conns[idx])
victims = [conns[i] for i in sorted(self._crossed_owners(path, conn.net_no, conns))]
if self.rollback:
return self._ripup_transaction(conn, path, victims, conns)
for victim in victims:
self._rip(victim)
self._commit(conn, path)
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]:
pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {}
@@ -1189,6 +1283,7 @@ def route_board(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
@@ -1207,5 +1302,6 @@ def route_board(
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return router.route()
+8
View File
@@ -83,6 +83,7 @@ def route_dsn_board(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer
@@ -106,6 +107,7 @@ def route_dsn_board(
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return result, scale, [layer.name for layer in dsn.layers]
@@ -149,6 +151,7 @@ def build_routing_result(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board(
@@ -156,6 +159,7 @@ def build_routing_result(
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return _to_routing_result(route, dsn, scale, layer_names)
@@ -174,6 +178,7 @@ def route_dsn_board_exact(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names.
@@ -201,6 +206,7 @@ def route_dsn_board_exact(
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return exact, scale, [layer.name for layer in dsn.layers]
@@ -219,6 +225,7 @@ def build_exact_routing_result(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(
@@ -234,6 +241,7 @@ def build_exact_routing_result(
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
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)
if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
+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)
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():
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))
hub = next(g for g in groups if g[1])
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
View File
@@ -158,6 +158,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"],
"rollback": ["--no-rollback"],
}
for engine, options in ENGINE_OPTIONS.items():
for option in options: