Files
rpmroute/src/freeroute/route/grid_router.py
T
rsp2k 61f51129e1 A T depends on every connection between its copper and its group
Anchoring a T only on the connection whose copper it touched missed the
connections that tie that copper to the T's own group. When one of those
was ripped, the T stayed routed but cut off: on charge-sense freeroute
counted 15 unrouted while KiCad found 27 opens. Routed connections of a net
form a forest, so the path back is unique; the T now depends on all of it.
KiCad's opens and freeroute's count agree again (18 against 17, the extra
one a GND pour island left to the fill).
2026-10-05 12:58:58 -06:00

2225 lines
101 KiB
Python

"""A grid-based maze router (MVP): multi-layer, vias, and rip-up-and-retry.
Still an *MVP* — not a port of FreeRouting's expansion-room maze / ripper — but
it routes across the board's signal layers, changes layers through vias, and
recovers from bad greedy net orderings with rip-up-and-retry:
* the occupancy grid has a layer axis; A* nodes are ``(col, row, layer)``;
* in-plane moves (8-connected, 45-degree) cost distance; a **via move** changes
layer at a cell for a configurable ``via_cost``; a through via must be clear on
every signal layer;
* occupancy is tracked *per connection* so a specific routed connection can be
removed. When a connection cannot reach its target through free space, the
router runs a **rip-up** search that may pass through other nets' traces (at a
penalty), rips up the connections it crosses, routes the failing connection,
and re-queues the ripped connections for a later pass;
* passes iterate up to ``max_passes``, keeping the best (fewest-unrouted) result
and stopping on full success or when a pass changes nothing;
* **thrash prevention:** each connection may be ripped at most ``rip_cap`` times,
and the penalty to rip a connection escalates with its rip count, so
repeatedly-ripped connections harden into walls. Everything is deterministic
(no RNG).
Deferred (still): FreeRouting's free-space expansion rooms, exact shove /
DRC-clean geometry, and blind/buried via spans.
"""
from __future__ import annotations
import heapq
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
__all__ = ["GridRouter", "RouteResult", "route_board"]
# 8-connected in-plane neighbourhood (orthogonal + diagonal) for 45-degree routing.
_NEIGHBOURS = [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(1, -1),
(-1, 1),
(-1, -1),
]
Cell = tuple[int, int]
Node = tuple[int, int, int] # (col, row, layer)
class RouteResult:
"""Per-net routing outcome in *board* units.
``wires`` maps a net number to a list of ``(layer_index, [IntPoint, ...])``
trace segments; ``vias`` maps a net number to a list of via locations.
``routed_net_numbers`` are the nets that got any wire or via.
"""
__slots__ = (
"wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed",
"net_half", "narrowed", "connection_anchors",
)
def __init__(self, half_width: int) -> None:
self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {}
self.vias: dict[int, list[IntPoint]] = {}
self.half_width = half_width
#: number of ratsnest connections left unrouted
self.unrouted = 0
#: the same copper per routed connection: (net_no, wires, vias)
self.connections: list[tuple[int, list[tuple[int, list[IntPoint]]], list[IntPoint]]] = []
#: what each routed connection joins, and the zone both ends sit in
#: (or ""), parallel to ``connections``
self.connection_ends: list[tuple[str, str, str]] = []
#: for each routed connection, the earlier ones (positions in
#: ``connections``) whose copper it ended on, parallel to ``connections``
self.connection_anchors: list[tuple[int, ...]] = []
#: connections left unrouted: (net_no, one end, the other end)
self.failed: list[tuple[int, str, str]] = []
#: half width of each net routed wider than the default (its class)
self.net_half: dict[int, int] = {}
#: connections of a wider class that only fitted at the default width
self.narrowed: list[tuple[int, str, str]] = []
def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None:
self.wires.setdefault(net_no, []).append((layer, path))
def add_via(self, net_no: int, location: IntPoint) -> None:
self.vias.setdefault(net_no, []).append(location)
@property
def routed_net_numbers(self) -> set[int]:
nets = {n for n, w in self.wires.items() if w}
nets |= {n for n, v in self.vias.items() if v}
return nets
def via_count(self) -> int:
return sum(len(v) for v in self.vias.values())
class _Connection:
"""One ratsnest connection (a pin pair) and its routed state."""
__slots__ = (
"index",
"net_no",
"a",
"b",
"routed",
"rip_count",
"wires",
"vias",
"trace_cells",
"via_cells",
"via_halo",
"halo_nodes",
"a_group",
"b_group",
"a_end",
"b_end",
"path",
"narrow",
"half",
"stubs",
"stub_near",
"anchors",
"copper",
"seq",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
self.index = index
self.net_no = net_no
self.a = a
self.b = b
self.routed = False
self.rip_count = 0
self.wires: list[tuple[int, list[IntPoint]]] = []
self.vias: list[IntPoint] = []
self.trace_cells: list[tuple[Cell, int]] = []
self.via_cells: list[Cell] = []
self.via_halo: list[Cell] = []
self.halo_nodes: list[Node] = []
# each end's stub: its layer, its copper tiles, and the nodes it claims
self.stubs: list[tuple[int, list[IntBox], set[Node]]] = []
# every node its stubs come near, claimed or not (see GridRouter._stub_near)
self.stub_near: list[Node] = []
# the pins and zones each end may use, and which pin the route ended
# on (None when it ended on a zone)
self.a_group: tuple[list[Pin], list] = ([a], [])
self.b_group: tuple[list[Pin], list] = ([b], [])
self.a_end: Pin | None = a
self.b_end: Pin | None = b
# the node path last committed, so a rip-up can be undone
self.path: list[Node] | None = None
# a connection of a wider class that did not fit at that width is
# routed at the default width instead; the half width it went in at
self.narrow = False
self.half = 0
# connections whose copper this one ended on (a T into its net's
# tree): ripping one of them leaves this one dangling
self.anchors: frozenset[int] = frozenset()
# the nodes of its committed copper (path cells, via cells on every
# layer), where another connection of its net may end
self.copper: frozenset[Node] = frozenset()
# commit order: a connection is always committed after its anchors
self.seq = 0
class GridRouter:
"""Routes a :class:`BasicBoard` with a grid A*, vias, and rip-up-and-retry."""
def __init__(
self,
board: BasicBoard,
*,
trace_width: int,
clearance: int,
layers: list[int] | None = None,
via_cost: float = 10.0,
rip_up: bool = True,
max_passes: int = 10,
rip_cap: int = 4,
rip_penalty: float = 30.0,
orthogonal: bool = False,
via_half: int | None = None,
edge_clearance: int | None = None,
resolution: int = 1,
heuristic_weight: float = 1.0,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
tree_nets: bool = True,
) -> None:
self.board = board
# Multi-pin nets: pair a net's pin groups by a minimum spanning tree,
# and let a connection start or end on copper its net already routed
# (a T into the tree), not only on one chosen pin.
self.tree = bool(tree_nets)
self._dependents: dict[int, set[int]] = {}
self._commits = 0
self._plane_nets: set[int] = set()
self._net_conns: dict[int, list[_Connection]] = {}
# Pin access: while a pin still has connections to route, the cells
# its route would leave by (its end cells, the via it may need there,
# and the clearance around both) cost other nets this much extra, so
# they route round a fine-pitch pin's way out instead of sealing it.
self.access_cost = max(float(access_cost), 0.0)
# Negotiated congestion (PathFinder): before the real routing, every
# connection is routed this many times on the empty board, where routes
# may overlap at a price that grows where they compete. The cells that
# stay contested keep a history cost the real routing pays, so the first
# routes leave room around the places later routes have no way round.
self.negotiate = max(int(negotiate), 0)
self._history: dict[Node, float] = {}
self._present: dict[Node, dict[int, int]] | None = None
self._present_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
# connections, as (net_no, one end, other end), to route before the rest
self.route_first = frozenset(route_first or ())
# how far (in cells) an escape stub may run to leave a walled-in pin
self._escape_cells = 12
self._crowded_cache: dict = {}
self._trapped_cache: dict = {}
# pin location -> where a pin on a turned fine-pitch pad leaves its
# pin row along the pad's axis (None: it leaves like any other pin)
self._axis_exits: dict[IntPoint, IntPoint | None] = {}
# and the other nets' pads near each such pin, its stubs' obstacles
self._axis_pads: dict[IntPoint, list] = {}
self._part_centres: dict[int, tuple[float, float] | None] = {}
self.heuristic_weight = max(float(heuristic_weight), 1.0)
self.trace_width = max(trace_width, 1)
self.clearance = max(clearance, 0)
self.half_width = self.trace_width // 2
self.via_cost = via_cost
self.rip_up = rip_up
self.max_passes = max(max_passes, 1)
self.rip_cap = max(rip_cap, 0)
self.rip_penalty = rip_penalty
# orthogonal routing (axis-aligned segments only) yields exact IntBox
# trace copper; 8-connected also produces 45-degree segments.
self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS
self._orthogonal = orthogonal
# At resolution 1 a cell is exactly one trace plus its clearance, so a
# trace owning its cell is the whole clearance model. At resolution k
# cells are k times finer, fine enough to give every pin of a 0.5 mm
# pitch row its own column, and a trace also claims a halo: the cells
# whose centre is nearer than trace + clearance to one of its own.
self.resolution = max(int(resolution), 1)
self.step = max((self.trace_width + self.clearance) // self.resolution, 1)
need = self.trace_width + self.clearance
reach = math.ceil(need / self.step)
self._halo_offsets = [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need
]
# A via's copper is wider than a trace. Another net's trace needs its
# centre via_half + clearance + half_width from the via's centre, so a
# via also claims every cell within via_reach of its own.
self.via_half = via_half or self.half_width
# copper to board edge; KiCad's rule is usually wider than clearance
self.edge_clearance = 0 if edge_clearance is None else edge_clearance
need = self.via_half + self.clearance + self.half_width
self.via_reach = max(0, math.ceil(need / self.step) - 1)
# Net classes wider than the default: the grid is laid out for the
# default width, so a wider trace must also find the cells around its
# own free, out to its extra half width, and claims a wider halo.
self._net_half = {
net: max(width // 2, 1)
for net, width in (net_widths or {}).items()
if width // 2 > self.half_width
}
self._spread: list[tuple[int, int]] = [] # the current search's
self._cur_half = self.half_width
self._halo_by_half: dict[int, list[tuple[int, int]]] = {self.half_width: self._halo_offsets}
# Hole-to-hole spacing holds between any two vias, of one net or two.
# Other nets' vias are kept further apart already; a net's own vias
# (every pin of a supply dropping to its plane) are not, so a via
# must keep its drill plus the spacing from every via centre, except
# an own-net via at the very same cell, which it simply reuses.
drill = via_drill if via_drill else 2 * self.via_half
need = drill + max(hole_to_hole, 0)
self._via_drill, self._hole_gap, self._hole_need = drill, max(hole_to_hole, 0), need
reach = math.ceil(need / self.step)
self._hole_offsets = [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step < need
]
self._via_nets: dict[Cell, dict[int, int]] = {} # routed via centres
self._via_centres: list[IntPoint] | None = None # cache for _hole_clear_at
self._fixed_vias: dict[Cell, int] = {}
# fixed vias sit anywhere, not on cell centres: exact hole checks
self._fixed_holes: list[tuple[IntPoint, int]] = []
self._fixed_via_points: set[tuple[int, IntPoint]] = set()
if layers is None:
layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal]
self.layers = list(layers)
box = board.bounding_box
self.origin_x = box.ll.x
self.origin_y = box.ll.y
self.cols = max(1, math.ceil((box.ur.x - box.ll.x) / self.step) + 1)
self.rows = max(1, math.ceil((box.ur.y - box.ll.y) / self.step) + 1)
# static obstacles
self._pad_block: dict[Node, set[int]] = {}
self._pad_own: dict[Node, set[int]] = {}
self._keepout: set[Node] = set()
self._via_keepout: set[Node] = set()
# inner power (plane) layers: not routed on, reached by vias
self._power_layers = {
i for i, layer in enumerate(board.layer_structure.arr) if not layer.is_signal
}
self._zone_cells: dict[int, list[Node]] = {}
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
# Node -> connections whose stub comes near it. A cell has one owner,
# and a stub claims only cells nobody owns yet, so where several pins
# leave side by side (a QFN turned 45 degrees: neighbours 0.35 mm apart
# on each axis) the cell two stubs share names only the first, and the
# exact check between the later stubs would never run.
self._stub_near: dict[Node, set[int]] = {}
# every connection whose via covers a cell, in claim order: a via two
# connections share still blocks after the first of them is ripped,
# and a route through it must rip all of them
self._via_claims: dict[Cell, list[int]] = {}
# trace halos: node -> indices of the connections claiming it
self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles()
self._rasterize_fixed_copper()
for item in board.get_items():
if isinstance(item, Via) and item.fixed and item.net_nos:
self._fixed_vias[self._cell_of(item.location)] = item.net_nos[0]
self._fixed_via_points.add((item.net_nos[0], item.location))
# its own drill, from KiCad's padstack name (Via[0-3]_500:250_um)
reach = self._via_drill // 2 + (item.drill or self._via_drill) // 2 + self._hole_gap
self._fixed_holes.append((item.location, reach))
# Through-hole pads have holes too, and a route changes layer at one
# for free, so no via (of any net) goes within hole spacing of one.
# The DSN has no drill for a pad; its smaller half size stands in.
self._tht_holes: list[tuple[IntPoint, int]] = []
self._tht_block: set[Cell] = set()
for pin in board.get_pins():
if pin.shape is None or sum(1 for layer in pin.layers if layer in self.layers) < 2:
continue
box = pin.shape.bounding_box()
radius = min(box.ur.x - box.ll.x, box.ur.y - box.ll.y) // 2
reach = radius + self._via_drill // 2 + self._hole_gap
self._tht_holes.append((pin.location, reach))
ring = IntBox(pin.location.x, pin.location.y, pin.location.x, pin.location.y)
for cell in self._cells_in_box(ring, reach):
self._tht_block.add(cell)
# --- grid helpers -------------------------------------------------------
def _cell_of(self, point: IntPoint) -> Cell:
gx = round((point.x - self.origin_x) / self.step)
gy = round((point.y - self.origin_y) / self.step)
gx = min(max(gx, 0), self.cols - 1)
gy = min(max(gy, 0), self.rows - 1)
return gx, gy
def _cell_center(self, cell: Cell) -> IntPoint:
return IntPoint(self.origin_x + cell[0] * self.step, self.origin_y + cell[1] * self.step)
def _cells_in_box(self, box, margin: int):
"""Cells whose centre lies strictly inside ``box`` grown by ``margin``.
A trace centred there would come closer than ``margin`` to the box.
Rounding the bounds outward instead blocked cells up to half a step
clear of it, which walled in every pin of a 0.5 mm pitch package.
"""
lo_x = math.floor((box.ll.x - margin - self.origin_x) / self.step) + 1
hi_x = math.ceil((box.ur.x + margin - self.origin_x) / self.step) - 1
lo_y = math.floor((box.ll.y - margin - self.origin_y) / self.step) + 1
hi_y = math.ceil((box.ur.y + margin - self.origin_y) / self.step) - 1
for gx in range(max(lo_x, 0), min(hi_x, self.cols - 1) + 1):
for gy in range(max(lo_y, 0), min(hi_y, self.rows - 1) + 1):
yield gx, gy
def _outline_blocked(self, reach: int) -> set[Cell]:
"""Cells whose centre is outside the outline or within ``reach`` of its edge."""
outline = self.board.outline
blocked: set[Cell] = set()
for gy in range(self.rows): # outside: one scanline per row
y = self.origin_y + gy * self.step
spans = outline.row_inside_spans(y)
for gx in range(self.cols):
x = self.origin_x + gx * self.step
if not any(lo < x < hi for lo, hi in spans):
blocked.add((gx, gy))
for a, b in outline.edges(): # too close: only cells near each edge
edge_box = IntBox(min(a.x, b.x), min(a.y, b.y), max(a.x, b.x), max(a.y, b.y))
for cell in self._cells_in_box(edge_box, reach):
if cell not in blocked and outline.edge_distance(self._cell_center(cell)) < reach:
blocked.add(cell)
return blocked
def _inside_own_pad(self, cell: Cell, shape) -> bool:
"""A trace centred on ``cell`` lies entirely on the pad's own copper."""
c = self._cell_center(cell)
h = self.half_width
return all(
shape.contains(IntPoint(c.x + dx, c.y + dy)) for dx in (-h, h) for dy in (-h, h)
)
def _pad_cells(self, shape, box: IntBox, margin: int):
"""Cells where a trace square ``margin`` wide around the centre overlaps
the pad: its box when it is one, else its exact (rotated) outline,
which is what the exact engine checks against."""
cells = self._cells_in_box(box, margin)
if not isinstance(shape, Simplex) or shape.is_int_box():
yield from cells
return
for cell in cells:
c = self._cell_center(cell)
if overlaps_2d(shape, IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin)):
yield cell
def _rasterize_obstacles(self) -> None:
margin = self.clearance + self.half_width
outline = self.board.outline
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:
# cells too close to the real outline (rounded corners included):
# for a trace on every layer, and separately for a via's wider copper
for cell in self._outline_blocked(self.half_width + self.edge_clearance):
for layer in self.layers:
self._keepout.add((*cell, layer))
self._via_edge_block = self._outline_blocked(self.via_half + self.edge_clearance)
for item in self.board.get_items():
if isinstance(item, Pin):
box = item.shape.bounding_box() if item.shape is not None else None
if box is None or box.is_empty():
continue
net = item.net_nos[0] if item.net_nos else 0
pin_layers = [layer for layer in item.layers if layer in self.layers]
for cell in self._pad_cells(item.shape, box, margin):
for layer in pin_layers:
self._pad_block.setdefault((*cell, layer), set()).add(net)
# Copper of the pin's own net: a trace inside it adds nothing,
# so it is open to that net however close the neighbours are.
# That is how a route leaves a fine-pitch pin.
for cell in self._cells_in_box(box, 0):
if self._inside_own_pad(cell, item.shape):
for layer in pin_layers:
self._pad_own.setdefault((*cell, layer), set()).add(net)
elif isinstance(item, ConductionArea):
continue # a zone is copper of its net, not an obstacle
elif isinstance(item, ObstacleArea):
if item.layer not in self.layers:
continue
# Tile by tile: the area's own bounding box of a ring (a
# no-track border along the board edge) is the whole board.
for tile in item.tiles:
box = tile.bounding_box()
if box.is_empty():
continue
# the same distance the exact engine verifies against
if item.blocks_wires:
self._keepout_boxes.setdefault(item.layer, []).append(
(box, item.clearance)
)
# a rule area takes no clearance: a trace may touch it
reach = margin if item.clearance else self.half_width
for cell in self._cells_in_box(box, reach):
if item.blocks_wires:
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 -----------------------------------------------------
def _enter_cost(
self,
cell: Cell,
layer: int,
net_no: int,
allow_ripup: bool,
conns: list[_Connection],
wire_keepout: bool = True,
) -> float | None:
""":meth:`_enter_cost_base`, and for a trace wider than the default
the same for every cell its extra width reaches (the worst of them)."""
cost = self._enter_cost_base(cell, layer, net_no, allow_ripup, conns, wire_keepout)
if cost is None or not self._spread:
return cost
for dx, dy in self._spread:
c = (cell[0] + dx, cell[1] + dy)
if not (0 <= c[0] < self.cols and 0 <= c[1] < self.rows):
return None
more = self._enter_cost_base(c, layer, net_no, allow_ripup, conns, wire_keepout)
if more is None:
return None
cost = max(cost, more)
return cost
def _enter_cost_base(
self,
cell: Cell,
layer: int,
net_no: int,
allow_ripup: bool,
conns: list[_Connection],
wire_keepout: bool = True,
) -> float | None:
"""Cost to enter ``(cell, layer)`` for ``net_no``; ``None`` if blocked.
Static pads/keepouts always block. A cell occupied by another net's trace
or via is impassable in free-space mode; in rip-up mode it is passable at
a penalty that escalates with the owner's rip count, unless the owner has
already hit ``rip_cap`` (then it hardens into a wall).
"""
node = (*cell, layer)
if wire_keepout and node in self._keepout:
return None
pads = self._pad_block.get(node)
if pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()):
return None
blocking = None
owner = self._cell_owner.get(node)
if owner is not None and conns[owner].net_no != net_no:
blocking = owner
else:
blocking = self._via_blocker(cell, net_no, conns)
if blocking is None:
for i in self._halo.get(node, ()):
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None:
# another net's pin stub comes too close, though the cell it once
# claimed may since have been freed by a rip of its first owner
for i in self._stub_near.get(node, ()):
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None:
return 0.0
if not allow_ripup:
return None
rip_count = conns[blocking].rip_count
if rip_count >= self.rip_cap:
return None
return self.rip_penalty * (1 + rip_count)
def _via_others(self, cell: Cell, net_no: int, conns: list[_Connection]) -> list[int]:
"""Connections of other nets whose via covers ``cell``: all of them,
since two connections of a net can share one via."""
return [i for i in self._via_claims.get(cell, ()) if conns[i].net_no != net_no]
def _via_blocker(self, cell: Cell, net_no: int, conns: list[_Connection]) -> int | None:
"""The other net's via connection on ``cell`` hardest to rip (the
penalty and the rip cap go by it), or None."""
others = self._via_others(cell, net_no, conns)
return max(others, key=lambda i: conns[i].rip_count) if others else None
def _via_cells(self, cell: Cell) -> list[Cell]:
"""The cell a via sits in plus the neighbours its copper reaches."""
r = self.via_reach
cx, cy = cell
return [
(x, y)
for x in range(max(cx - r, 0), min(cx + r, self.cols - 1) + 1)
for y in range(max(cy - r, 0), min(cy + r, self.rows - 1) + 1)
]
def _via_placeable(self, cell: Cell, net_no: int, conns: list[_Connection]) -> bool:
"""A through via needs its whole footprint free of other nets on every layer."""
if cell in self._via_edge_block:
return False
for c in self._via_cells(cell):
for layer in self.layers:
if (*c, layer) in self._via_keepout:
return False
if self._enter_cost_base(c, layer, net_no, False, conns, wire_keepout=False) != 0.0:
return False
return self._holes_clear(cell, net_no)
def _near_path_via(self, node: Node, cell: Cell, came_from: dict) -> bool:
"""A layer change earlier on the route that reaches ``node`` sits
within hole spacing of ``cell`` (and is not the same through via).
Committed vias are checked elsewhere; a route's own are not yet
committed while it is searched. Looks back far enough to cover the
hole spacing twice over, not along the whole route."""
limit = 4 * (math.ceil(self._hole_need / self.step) + 1)
need = self._hole_need / self.step
steps = 0
while node in came_from and steps < limit:
prev = came_from[node]
changes_layer = prev[:2] == node[:2] and prev[2] != node[2]
if changes_layer and node[:2] != cell and (
math.hypot(node[0] - cell[0], node[1] - cell[1]) < need
):
return True
node = prev
steps += 1
return False
def _via_nets_at(self, cell: Cell) -> set[int]:
nets = {net for net, n in self._via_nets.get(cell, {}).items() if n > 0}
fixed = self._fixed_vias.get(cell)
if fixed is not None:
nets.add(fixed)
return nets
def _holes_clear(self, cell: Cell, net_no: int) -> bool:
"""No via centre within hole-to-hole reach, but an own-net one here,
and no through-hole pad's hole within reach at all."""
if cell in self._tht_block:
return False
if self._fixed_holes:
here = self._cell_center(cell)
for loc, reach in self._fixed_holes:
d = math.hypot(loc.x - here.x, loc.y - here.y)
if d < reach and d > self.step: # beyond its own cell: too close
return False
if self._via_nets_at(cell) - {net_no}:
return False
cx, cy = cell
return not any(self._via_nets_at((cx + dx, cy + dy)) for dx, dy in self._hole_offsets)
# --- A* search ----------------------------------------------------------
def _conn_half(self, conn: _Connection) -> int:
"""The half width a connection is routed at: its class's, unless it
has fallen back to the default."""
return self.half_width if conn.narrow else self._net_half.get(conn.net_no, self.half_width)
def _spread_for(self, half: int) -> list[tuple[int, int]]:
"""Cells a trace of ``half`` also needs free: every cell out to its
extra half width, rounded up to whole cells. Blocks, halos and via
footprints are sized for a default-width trace, and copper is checked
as boxes, so anything within that square could be too close."""
extra = half - self.half_width
if extra <= 0:
return []
reach = math.ceil(extra / self.step)
return [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if dx or dy
]
def _halo_for(self, half: int) -> list[tuple[int, int]]:
"""Cells a committed trace of ``half`` claims from default-width traces."""
offsets = self._halo_by_half.get(half)
if offsets is None:
need = half + self.clearance + self.half_width
reach = math.ceil(need / self.step)
offsets = self._halo_by_half[half] = [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need
]
return offsets
def _search(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection]
) -> list[Node] | None:
half = self._conn_half(conn)
self._cur_half, self._spread = half, self._spread_for(half)
try:
return self._search_at_width(conn, allow_ripup, conns)
finally:
self._cur_half, self._spread = self.half_width, []
def _search_at_width(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection]
) -> list[Node] | None:
goal_cell = self._group_target(conn.b_group)
if not conn.b_group[0] and any(z.layer in self._power_layers for z in conn.b_group[1]):
# the plane is everywhere: aim at where the route starts instead
goal_cell = self._group_target(conn.a_group)
starts = self._group_ends(conn.a_group, conn.net_no, conns, allow_ripup)
goals = self._group_ends(conn.b_group, conn.net_no, conns, allow_ripup)
# either side may also start or end on copper its net already routed
a_tree = self._tree_ends(conn, conn.a_group)
b_tree = self._tree_ends(conn, conn.b_group)
for node in a_tree:
starts.setdefault(node, None)
for node in b_tree:
goals.setdefault(node, None)
if not starts or not goals:
return None
# 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]]
conn.anchors = _anchors([common[0]], starts, goals, a_tree, b_tree)
return [common[0]]
net_no = conn.net_no
open_heap: list[tuple[float, Node]] = []
g_score: dict[Node, float] = {}
came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked
# a pin end costs its stub; a zone or plane end is entered normally
# (and in rip-up mode the rip penalty of whatever lies on it)
goal_stub = {
n: self._stub_cost(p, n) + self._end_penalty(p, n, net_no, allow_ripup, conns)
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) + self._end_penalty(pin, s, net_no, allow_ripup, conns)
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
# a candidate and is checked only if it is popped. Most never are (a
# via costs a lot), and the check is the single most expensive thing
# the search does. A check can only rule a via out, never make it
# cheaper, so deferring it cannot change which route is found.
via_ok: dict[Cell, bool] = {}
while open_heap:
entry = heapq.heappop(open_heap)
if len(entry) == 4: # a deferred via: (f, target, g, source)
_, target, g_via, source = entry
if g_via >= g_score.get(target, math.inf):
continue
cell = (target[0], target[1])
ok = via_ok.get(cell)
if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
# 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 and self._near_path_via(source, cell, came_from):
ok = False # too close to a via earlier on this same route
if ok:
self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap)
continue
current = entry[1]
if current in goals:
end = goals[current]
cell = (current[0], current[1])
if isinstance(end, ConductionArea): # a plane end needs its via to fit
ok = via_ok.get(cell)
if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
if ok and self._near_path_via(current, cell, came_from):
ok = False
else:
ok = True
if ok:
path = _reconstruct(came_from, current)
conn.a_end, conn.b_end = starts.get(path[0]), end
conn.anchors = _anchors(path, starts, goals, a_tree, b_tree)
return path
cx, cy, cl = current
base = g_score[current]
# A cell inside a wire keepout (a pin there, or where a via
# landed) may only be left by another via: a wire from it would
# start inside the keepout.
blocked = current in self._keepout or current in self._via_only
neighbours = () if blocked else self._neighbours
for dx, dy in neighbours:
cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
nxt = (*cell, cl)
if nxt in goal_stub: # a pin end; a zone end is entered normally
if nxt in self._keepout or nxt in self._via_only:
continue # a pin inside a keepout is reached by a via only
enter = goal_stub[nxt]
else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
if enter is None:
continue
if self._doorways:
enter += self._access(nxt, net_no)
if self._history or self._present is not None:
enter += self._congestion(nxt, net_no)
step = 1.0 if dx == 0 or dy == 0 else math.sqrt(2)
self._relax(
current, nxt, base + step + enter, goal_cell, g_score, came_from, open_heap
)
if len(self.layers) > 1:
g_via = base + self.via_cost
if self._doorways:
g_via += self._access(current, net_no)
for layer in self.layers:
if layer == cl:
continue
nxt = (cx, cy, layer)
g_layer = g_via
if self._history or self._present is not None:
g_layer += self._congestion(nxt, net_no)
if g_layer < g_score.get(nxt, math.inf):
heapq.heappush(
open_heap, (g_layer + self._h(nxt, goal_cell), nxt, g_layer, current)
)
return None
def _tree_ends(self, conn: _Connection, group) -> dict[Node, frozenset[int]]:
"""Copper its net has already routed on ``group``'s side of ``conn``: a
route may start or end anywhere on it. Each node maps to what a route
ending there relies on: the connection that laid the copper and every
one between it and ``group`` (routed connections of a net form a
forest, so that path is unique). Ripping any of them would cut the
route off. Empty for plane nets, which keep their drops to the plane,
and when tree routing is off."""
if not self.tree or conn.net_no in self._plane_nets:
return {}
adjacent: dict[int, list[tuple[int, _Connection]]] = {}
for c in self._net_conns.get(conn.net_no, ()):
if c is conn or not c.routed:
continue
a, b = id(c.a_group), id(c.b_group)
adjacent.setdefault(a, []).append((b, c))
adjacent.setdefault(b, []).append((a, c))
owners: dict[Node, frozenset[int]] = {}
path_to: dict[int, frozenset[int]] = {id(group): frozenset()}
stack = [id(group)]
while stack:
here = stack.pop()
for other, c in adjacent.get(here, ()):
if other in path_to:
continue
path = path_to[here] | {c.index}
path_to[other] = path
stack.append(other)
for node in c.copper:
# a walled cell's via moved into the pad: no copper there
if node not in self._keepout and node not in self._via_only:
owners.setdefault(node, path)
return owners
def _with_dependents(self, victims: list[_Connection], conns: list[_Connection]) -> list[_Connection]:
"""``victims`` and every routed connection that ended on their copper,
directly or through another: ripping a route rips the routes teed onto
it, which would otherwise dangle. Anchors come before what hangs on
them, as they were committed."""
found = {v.index: v for v in victims}
stack = list(victims)
while stack:
for i in self._dependents.get(stack.pop().index, ()):
c = conns[i]
if c.routed and i not in found:
found[i] = c
stack.append(c)
return sorted(found.values(), key=lambda c: (c.seq, c.index))
# --- pin stubs ----------------------------------------------------------
# A pin rarely sits on a cell centre, so every route starts and ends with a
# short stub from the pin to its first cell. The grid has to see that
# copper too, or a stub ends up closer than clearance to a trace in the
# cell beside it.
def _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
if any(net != net_no and box.overlaps(grown) for box, net in self._static_copper):
return False
return self._hole_clear_at(point)
def _hole_clear_at(self, point: IntPoint) -> bool:
"""Hole spacing measured exactly from ``point`` to every via and
through-hole pad (a via moved into a pad is not on its cell's centre)."""
need = self._hole_need
if self._via_centres is None: # rebuilt only after vias change
self._via_centres = [loc for _net, loc in self._fixed_via_points] + [
self._cell_center(c) for c, count in self._via_nets.items() if any(count.values())
]
if any(math.hypot(c.x - point.x, c.y - point.y) < need for c in self._via_centres):
return False
return not any(
math.hypot(loc.x - point.x, loc.y - point.y) < reach for loc, reach in self._tht_holes
)
def _stub_points(self, pin_location: IntPoint, cell: Cell) -> list[IntPoint]:
"""The stub's corners, from the pin to its cell centre.
The direct segment is fine for 45-degree routing. Orthogonal routing
leaves the pin straight along the axis it is further off the cell on
and jogs across to the centre at the end: an L of two flush boxes,
which is what the exact check sees. A single slanted segment is
checked as its bounding box there, and a 1 mm trace to a column
0.1 mm off a 1.27 mm pitch pin's centreline then lands inside the
clearance of the pad next door, though the straight exit clears it.
"""
end = self._cell_center(cell)
head = [pin_location]
start = pin_location
exit_point = self._axis_exits.get(pin_location)
if exit_point is not None:
# a pin on a turned fine-pitch pad first leaves along the pad's
# axis, past the pin row, then takes the usual L to its cell
head = [pin_location, exit_point]
start = exit_point
dx, dy = end.x - start.x, end.y - start.y
if not self._orthogonal or dx == 0 or dy == 0:
tail = [end]
elif abs(dx) >= abs(dy):
tail = [IntPoint(end.x, start.y), end]
else:
tail = [IntPoint(start.x, end.y), end]
points = []
for point in head + tail:
if not points or points[-1] != point:
points.append(point)
return points if len(points) > 1 else [pin_location, end]
def _leg_shapes(self, points: list[IntPoint], half: int, grow: int = 0) -> list:
"""A stub's copper, grown by ``grow``: a box per straight leg and per
inner corner, and an exact slab for a slanted leg (a pad-axis exit),
whose bounding box would reach the neighbouring pads."""
shapes: list = []
for a, b in zip(points, points[1:], strict=False):
if a == b:
continue
if a.x == b.x or a.y == b.y:
box = segment_box(a, b, half)
shapes.append(box.offset(grow) if grow else box)
else:
shapes.append(segment_slab(a, b, half + grow))
for c in points[1:-1]:
box = corner_box(c, half)
shapes.append(box.offset(grow) if grow else box)
return shapes
def _axis_exit(self, pin: Pin) -> IntPoint | None:
"""Where a pin on a turned fine-pitch pad clears its pin row.
A QFN placed at 45 degrees has no straight exit on the grid: every
horizontal or vertical step from a pin passes its neighbours closer
than the clearance. The way out is along the pad's own axis, away
from the part, until an L from there to a grid cell clears every
other net's pad (checked on exact shapes, as the exact engine will).
Any angle off the grid axes counts, not just 45 degrees.
"""
loc = pin.location
if loc in self._axis_exits:
return self._axis_exits[loc]
self._axis_exits[loc] = None
shape = pin.shape
if not isinstance(shape, Simplex) or shape.is_int_box():
return None
corners = [shape.corner_approx(i) for i in range(shape.border_line_count())]
edges = [(b.x - a.x, b.y - a.y) for a, b in zip(corners, corners[1:] + corners[:1], strict=False)]
lengths = [math.hypot(dx, dy) for dx, dy in edges]
if len(edges) < 3 or max(lengths) < 1.25 * min(lengths):
return None # no long axis to leave along
dx, dy = edges[lengths.index(max(lengths))]
ux, uy = dx / max(lengths), dy / max(lengths)
if min(abs(ux), abs(uy)) < 0.17:
return None # within ten degrees of a grid axis: the usual exits work
centre = self._part_centre(pin)
if centre is None:
return None
if (loc.x - centre[0]) * ux + (loc.y - centre[1]) * uy < 0:
ux, uy = -ux, -uy
reach = max((c.x - loc.x) * ux + (c.y - loc.y) * uy for c in corners)
h, cl = self.half_width, self.clearance
span = reach + h + cl + 16 * self.step
others = [
p.shape for p in self.board.get_pins()
if p.shape is not None and p is not pin and not set(p.net_nos) & set(pin.net_nos)
and set(p.layers) & set(pin.layers)
and abs(p.location.x - loc.x) < 2 * span and abs(p.location.y - loc.y) < 2 * span
]
self._axis_pads[loc] = others
for j in range(16):
d = reach + h + cl + j * self.step / 2
exit_point = IntPoint(round(loc.x + ux * d), round(loc.y + uy * d))
out = segment_slab(loc, exit_point, h + cl)
if any(overlaps_2d(out, o) for o in others):
continue
ex, ey = self._cell_of(exit_point)
self._axis_exits[loc] = exit_point
for ddx, ddy in itertools.product((0, -1, 1), repeat=2):
cell = (ex + ddx, ey + ddy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
legs = self._leg_shapes(self._stub_points(loc, cell)[1:], h, cl)
if not any(overlaps_2d(g, o) for g in legs for o in others):
return exit_point
self._axis_exits[loc] = None
return None
def _exit_clips_a_pad(self, pin: Pin, cell: Cell) -> bool:
"""Whether the stub from an axis exit to ``cell`` comes closer than the
clearance to another net's pad (exactly, as the exact engine checks):
the exit was chosen with one clear cell, not every cell around it."""
legs = self._leg_shapes(
self._stub_points(pin.location, cell)[1:], self._cur_half, self.clearance
)
return any(overlaps_2d(g, pad) for g in legs for pad in self._axis_pads.get(pin.location, ()))
def _part_centre(self, pin: Pin) -> tuple[float, float] | None:
key = pin.component_no
if key not in self._part_centres:
mates = [p.location for p in self.board.get_pins() if p.component_no == key] if key else []
self._part_centres[key] = (
(sum(m.x for m in mates) / len(mates), sum(m.y for m in mates) / len(mates))
if len(mates) > 2 else None
)
return self._part_centres[key]
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
points = self._stub_points(pin_location, cell)
reach = (half or self._cur_half) + self.half_width + self.clearance
box = IntBox(
min(p.x for p in points), min(p.y for p in points),
max(p.x for p in points), max(p.y for p in points),
)
legs = list(zip(points, points[1:], strict=False))
joints = points[1:] # each carries a square corner tile
def near(c) -> bool:
p = self._cell_center(c)
if min(_point_segment(p, a, b) for a, b in legs) < reach:
return True
return any(max(abs(p.x - j.x), abs(p.y - j.y)) < reach for j in joints)
return [c for c in self._cells_in_box(box, reach) if near(c)]
def _stub_clear(
self,
pin: Pin,
cell: Cell,
layer: int,
net_no: int,
conns,
keepouts_only: bool = False,
allow_ripup: bool = False,
) -> bool:
keepouts = self._keepout_boxes.get(layer)
if keepouts:
# the stub is off the grid, so check it exactly, as the exact
# engine will: its copper grown by clearance must miss every keepout
points = self._stub_points(pin.location, cell)
if points[-1] != pin.location:
legs = self._leg_shapes(points, self._cur_half)
grown_legs = self._leg_shapes(points, self._cur_half, self.clearance)
for tile, grown in zip(legs, grown_legs, strict=True):
if any(overlaps_2d(k, grown if needs else tile) for k, needs in keepouts):
return False
# the track's round end at the pin, for a rule area
cap = corner_box(pin.location, self._cur_half)
if any(not needs and k.overlaps(cap) for k, needs in keepouts):
return False
if keepouts_only:
return True
owners = self._stub_owners(pin, cell, layer, net_no, conns)
# in rip-up mode a route on the stub is one more to rip, unless it may not be
return not owners or (
allow_ripup and all(conns[i].rip_count < self.rip_cap for i in owners)
)
def _stub_tiles(self, pin_location: IntPoint, cell: Cell, half: int, grow: int = 0) -> list:
"""The stub's copper (grown by ``grow``): its legs, and a square at each
corner and at the cell end, where the route carries on."""
points = self._stub_points(pin_location, cell)
tiles: list = [
corner_box(point, half).offset(grow) if grow else corner_box(point, half)
for point in points[1:]
]
if len(points) > 1 and points[-1] != pin_location:
tiles += self._leg_shapes(points, half, grow)
return tiles
def _stub_owners(
self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, half: int | None = None
) -> set[int]:
"""Other nets' routes whose copper the stub from ``pin`` to ``cell`` would touch."""
owners: set[int] = set()
grown = None
for c in self._stub_cells(pin.location, cell, half):
node = (*c, layer)
owner = self._cell_owner.get(node)
if owner is not None and conns[owner].net_no != net_no and owner not in owners:
# Both stubs are off the grid, and each claims the cells a
# trace centred there would crowd, so two stubs side by side
# (neighbouring pins leaving straight) overlap in claims well
# before their copper comes near. Check those exactly.
other = [tiles for lay, tiles, nodes in conns[owner].stubs if node in nodes]
if other:
if grown is None:
grown = self._stub_tiles(
pin.location, cell, half or self._cur_half, self.clearance
)
if not any(overlaps_2d(g, t) for tiles in other for t in tiles for g in grown):
continue
owners.add(owner)
for idx in self._stub_near.get(node, ()):
if idx in owners or conns[idx].net_no == net_no:
continue
theirs = [tiles for lay, tiles, _ in conns[idx].stubs if lay == layer]
if theirs:
if grown is None:
grown = self._stub_tiles(
pin.location, cell, half or self._cur_half, self.clearance
)
if any(overlaps_2d(g, t) for tiles in theirs for t in tiles for g in grown):
owners.add(idx)
owners.update(self._via_others(c, net_no, conns))
return owners
def _end_penalty(self, pin: Pin, node: Node, net_no: int, allow_ripup: bool, conns) -> float:
"""Rip-up cost of starting or ending on ``node``: the routes on its
cell and on the stub to it, each at the penalty entering it would cost."""
if not allow_ripup:
return 0.0
owners = self._stub_owners(pin, node[:2], node[2], net_no, conns)
owners |= self._crossed_owners([node], net_no, conns)
return sum(self.rip_penalty * (1 + conns[i].rip_count) for i in owners)
def _zone_nodes(self, zone) -> list[Node]:
"""Cells of a zone where a via of this net fits inside its copper; for a
plane on a power layer, on every routing layer (a via reaches it)."""
key = id(zone)
if key not in self._zone_cells:
h = self.via_half
layers = [zone.layer] if zone.layer in self.layers else list(self.layers)
nodes = []
for tile in zone.tiles:
for cell in self._cells_in_box(tile.bounding_box(), 0):
c = self._cell_center(cell)
if all(
zone.polygon.contains(IntPoint(c.x + dx, c.y + dy))
for dx in (-h, h) for dy in (-h, h)
):
nodes.extend((*cell, layer) for layer in layers)
self._zone_cells[key] = sorted(set(nodes))
return self._zone_cells[key]
def _zone_is_solid(self, zone, coverage: float = 0.95) -> bool:
"""Whether a zone's fill can be trusted to be one piece of copper.
A zone outline is where copper may go, not where it will be: other
nets' tracks through a board-wide pour break its fill into islands,
and a pad inside the outline need not reach the rest of the net. It
is safe to count on only when no track can cross it, which is what a
no-tracks rule area over the zone guarantees (the rc4 power pours).
Otherwise the zone is left out and its pads are routed with tracks.
"""
cells = [cell for tile in zone.tiles for cell in self._cells_in_box(tile.bounding_box(), 0)
if zone.polygon.contains(self._cell_center(cell))]
if not cells:
return False
covered = sum((*cell, zone.layer) in self._keepout for cell in cells)
return covered >= coverage * len(cells)
def _left_to_zone(self, conn) -> str:
"""The zone both ends of an unrouted connection sit in, if any: its
fill may well join them, even when it is not trusted for routing."""
for zone in self._every_zone_by_net.get(conn.net_no, []):
if any(_pin_touches_zone(p, zone) for p in conn.a_group[0]) and any(
_pin_touches_zone(p, zone) for p in conn.b_group[0]
):
return zone.name or "zone"
return ""
def _group_ends(
self, group, net_no: int, conns, allow_ripup: bool = False
) -> dict[Node, Pin | None]:
"""End nodes of a group, each mapped to its pin (None for a zone cell)."""
ends: dict[Node, Pin | None] = {}
for pin in group[0]:
for node in sorted(self._end_nodes(pin, net_no, conns, allow_ripup)):
ends.setdefault(node, pin)
for zone in group[1]:
plane = zone.layer in self._power_layers
for node in self._zone_nodes(zone):
# a plane end is marked with the plane: the route ends in a via
ends.setdefault(node, zone if plane else None)
return ends
def _group_target(self, group) -> Cell:
if group[0]:
return self._cell_of(group[0][0].location)
box = group[1][0].bounding_box()
return self._cell_of(IntPoint((box.ll.x + box.ur.x) // 2, (box.ll.y + box.ur.y) // 2))
def _stub_cost(self, pin: Pin, node: Node) -> float:
if self._axis_exits.get(pin.location) is None:
c = self._cell_center(node[:2])
dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y)
if self._orthogonal: # measured the way the route itself is allowed to move
return (dx + dy) / self.step
return math.hypot(dx, dy) / self.step
points = self._stub_points(pin.location, node[:2])
return sum(
math.hypot(b.x - a.x, b.y - a.y) for a, b in zip(points, points[1:], strict=False)
) / self.step
def _end_nodes(self, pin: Pin, net_no: int, conns, allow_ripup: bool = False) -> set[Node]:
"""Nodes a route may start or end on for ``pin``: the nearest cell and
its neighbours, where the cell is enterable and the stub to it is clear.
A crowded fine-pitch pin gets more than one way out. In rip-up mode a
cell or stub another route is on counts as enterable, as it does
everywhere else, or a pin whose only way out another net has taken
could never have it back."""
layers = [layer for layer in pin.layers if layer in self.layers]
exit_point = None
if self._orthogonal and self._crowded(pin, net_no):
exit_point = self._axis_exit(pin)
cx, cy = self._cell_of(exit_point if exit_point is not None else pin.location)
nodes: set[Node] = set()
for dx, dy in ((0, 0), *self._neighbours):
cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
if exit_point is not None and self._exit_clips_a_pad(pin, cell):
continue
for layer in layers:
# the pin's own cell is in its pad; an exit cell is not
checked = dx or dy or exit_point is not None
if checked and self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None:
continue
if self._stub_clear(pin, cell, layer, net_no, conns, allow_ripup=allow_ripup):
nodes.add((*cell, layer))
# Also offer exact escape stubs: a fine-pitch pin's nearby cells can be
# a pocket inside its own pad that leads nowhere (the grid columns miss
# the narrow legal band past the pad tip). The stub's length is its
# cost, so it is only taken where a short step would not do.
if self._orthogonal and self._crowded(pin, net_no) and self._trapped(pin, net_no, nodes):
nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy), allow_ripup)
if not nodes and exit_point is not None:
# every cell past the pin row is taken: report no way out, so the
# search goes on to rip-up, rather than fall back to a cell out in
# the open on top of another net's route
return nodes
if not nodes: # nothing clear: fall back to the nearest cell, as before
nodes = {(cx, cy, layer) for layer in layers}
if self._keepout_boxes and not any(
self._stub_clear(pin, (cx, cy), layer, net_no, conns, keepouts_only=True)
for layer in layers
):
# no wire can reach the pin without entering a keepout: the
# route has to arrive by a via, which goes in the pad
self._via_only.update(nodes)
return nodes
def _crowded(self, pin: Pin, net_no: int) -> bool:
"""A pin whose neighbour pad of another net is too close for a trace
to pass between them: a fine-pitch pin, the case escapes are for."""
key = (id(pin), net_no)
cached = self._crowded_cache.get(key)
if cached is not None:
return cached
box = pin.shape.bounding_box() if pin.shape is not None else None
gap = self.trace_width + 2 * self.clearance
crowded = False
if box is not None:
for other in self.board.get_pins():
if other is pin or net_no in other.net_nos or other.shape is None:
continue
if not set(other.layers) & set(pin.layers):
continue
ob = other.shape.bounding_box()
dx = max(ob.ll.x - box.ur.x, box.ll.x - ob.ur.x, 0)
dy = max(ob.ll.y - box.ur.y, box.ll.y - ob.ur.y, 0)
if max(dx, dy) < gap and min(dx, dy) == 0:
crowded = True
break
self._crowded_cache[key] = crowded
return crowded
def _static_open(self, node: Node, net_no: int) -> bool:
"""Open to ``net_no`` as far as pads and keepouts go (routes ignored)."""
if node in self._keepout:
return False
pads = self._pad_block.get(node)
return not (pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()))
def _trapped(self, pin: Pin, net_no: int, nodes: set[Node], limit: int = 60) -> bool:
"""Whether a pin's nearby cells are a dead-end pocket, judged against
pads and keepouts only (not other routes), so it is fixed per pin."""
key = (id(pin), net_no)
cached = self._trapped_cache.get(key)
if cached is not None:
return cached
seen = set(nodes)
frontier = list(nodes)
while frontier and len(seen) < limit:
x, y, layer = frontier.pop()
for dx, dy in self._neighbours:
n = (x + dx, y + dy, layer)
if n in seen or not (0 <= n[0] < self.cols and 0 <= n[1] < self.rows):
continue
if self._static_open(n, net_no):
seen.add(n)
frontier.append(n)
trapped = len(seen) < limit
self._trapped_cache[key] = trapped
return trapped
def _escape_nodes(
self, pin: Pin, net_no: int, conns, layers, origin: Cell, allow_ripup: bool = False
) -> set[Node]:
"""For a pin walled in on the grid (a fine-pitch pin whose legal band
the grid columns miss): look along each axis for the first open cell
whose stub from the pin clears every other net's pad by the full
clearance, checked on the stub's copper tiles as the exact engine
will rather than on the grid."""
reach = self._escape_cells
near = [
(p.shape.bounding_box(), set(p.layers))
for p in self.board.get_pins()
if p.shape is not None
and net_no not in p.net_nos
and abs(p.location.x - pin.location.x) < (reach + 2) * self.step * 2
and abs(p.location.y - pin.location.y) < (reach + 2) * self.step * 2
]
cx, cy = origin
nodes: set[Node] = set()
# the pin's own column or row and the one either side: two pins side
# by side can then jog apart at the end of their straight exits
for (dx, dy), side in itertools.product(_NEIGHBOURS[:4], (0, -1, 1)):
sx, sy = (side, 0) if dx == 0 else (0, side)
nodes_before = len(nodes)
for k in range(2, reach + 1):
cell = (cx + dx * k + sx, cy + dy * k + sy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
break
grown = self._leg_shapes(
self._stub_points(pin.location, cell), self._cur_half, self.clearance
)
for layer in layers:
if self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None:
continue
if any(
layer in box_layers and any(overlaps_2d(g, box) for g in grown)
for box, box_layers in near
):
continue
if self._stub_clear(pin, cell, layer, net_no, conns, allow_ripup=allow_ripup):
nodes.add((*cell, layer))
if len(nodes) > nodes_before:
break # the first open cell along this line is enough
return nodes
def _relax(self, current, nxt, tentative, goal_cell, g_score, came_from, open_heap):
if tentative < g_score.get(nxt, math.inf):
came_from[nxt] = current
g_score[nxt] = tentative
heapq.heappush(open_heap, (tentative + self._h(nxt, goal_cell), nxt))
def _h(self, node: Node, goal_cell: Cell) -> float:
dx, dy = abs(node[0] - goal_cell[0]), abs(node[1] - goal_cell[1])
# the tightest estimate the allowed moves admit (Manhattan when only
# orthogonal steps exist), scaled by heuristic_weight: above 1 the
# search heads for the goal instead of flooding open board, for a
# route at most that factor longer than the shortest
base = dx + dy if self._orthogonal else math.hypot(dx, dy)
return self.heuristic_weight * base
# --- commit / rip -------------------------------------------------------
def _commit(self, conn: _Connection, path: list[Node]) -> None:
conn.path = list(path)
wire_segs, via_cells = _split_path(path)
via_cells = list(via_cells)
if isinstance(conn.a_end, ConductionArea):
via_cells.append((path[0][0], path[0][1]))
if isinstance(conn.b_end, ConductionArea):
via_cells.append((path[-1][0], path[-1][1]))
conn.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells]
conn.via_cells = list(via_cells)
for cell in conn.via_cells:
count = self._via_nets.setdefault(cell, {})
count[conn.net_no] = count.get(conn.net_no, 0) + 1
if conn.via_cells:
self._via_centres = None
for cell, layer in conn.trace_cells:
self._cell_owner[(*cell, layer)] = conn.index
conn.half = half = self._conn_half(conn)
halo = self._halo_for(half)
conn.halo_nodes = []
for (cx, cy), layer in conn.trace_cells:
for dx, dy in halo:
x, y = cx + dx, cy + dy
if 0 <= x < self.cols and 0 <= y < self.rows:
node = (x, y, layer)
self._halo.setdefault(node, []).append(conn.index)
conn.halo_nodes.append(node)
conn.via_halo = []
for cell in conn.via_cells:
for c in self._via_cells(cell):
claims = self._via_claims.setdefault(c, [])
if conn.index not in claims:
claims.append(conn.index)
conn.via_halo.append(c)
ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else ()
conn.stubs = []
self._forget_stubs(conn)
for pin, (layer, cells), at in ends:
if not isinstance(pin, Pin):
continue # ended on a zone or a plane: no stub
cell = cells[at]
claimed: set[Node] = set()
for c in self._stub_cells(pin.location, cell, half):
node = (*c, layer)
self._stub_near.setdefault(node, set()).add(conn.index)
conn.stub_near.append(node)
if node not in self._cell_owner:
self._cell_owner[node] = conn.index
conn.trace_cells.append((c, layer))
claimed.add(node)
conn.stubs.append((layer, self._stub_tiles(pin.location, cell, half), claimed))
# A pin inside a wire keepout can only leave by a via. Its stub to the
# cell centre would be track inside the keepout, so the via goes on
# the pin itself (a via in its own pad) and the stub is left out.
in_pad: dict[int, IntPoint] = {}
if len(wire_segs) > 1:
for idx, pin in ((0, conn.a_end), (len(wire_segs) - 1, conn.b_end)):
layer, cells = wire_segs[idx]
node = (*cells[0], layer)
walled = node in self._keepout or node in self._via_only
if isinstance(pin, Pin) and len(cells) == 1 and walled:
in_pad[idx] = pin.location
conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs):
if idx in in_pad:
continue
points = [self._cell_center(c) for c in cells]
if idx == 0 and isinstance(conn.a_end, Pin):
points[0:0] = self._stub_points(conn.a_end.location, cells[0])[:-1]
if idx == len(wire_segs) - 1 and isinstance(conn.b_end, Pin):
points.extend(self._stub_points(conn.b_end.location, cells[-1])[-2::-1])
points = _simplify(points)
if len(points) >= 2:
conn.wires.append((layer, points))
conn.vias = [self._cell_center(c) for c in conn.via_cells]
for idx, location in in_pad.items():
# via k joins wire segments k and k + 1
conn.vias[0 if idx == 0 else idx - 1] = location
conn.routed = True
self._commits += 1
conn.seq = self._commits
conn.copper = frozenset(path) | frozenset(
(*cell, layer) for cell in conn.via_cells for layer in self.layers
)
for anchor in conn.anchors:
self._dependents.setdefault(anchor, set()).add(conn.index)
self._settle(conn, -1)
def _forget_stubs(self, conn: _Connection) -> None:
for node in conn.stub_near:
near = self._stub_near.get(node)
if near is not None:
near.discard(conn.index)
if not near:
del self._stub_near[node]
conn.stub_near = []
def _rip(self, conn: _Connection) -> None:
for cell, layer in conn.trace_cells:
if self._cell_owner.get((*cell, layer)) == conn.index:
del self._cell_owner[(*cell, layer)]
for cell in conn.via_halo:
claims = self._via_claims.get(cell)
if claims is not None and conn.index in claims:
claims.remove(conn.index)
if claims is not None and not claims:
del self._via_claims[cell]
conn.via_halo = []
for node in conn.halo_nodes:
claims = self._halo.get(node)
if claims is not None:
claims.remove(conn.index)
if not claims:
del self._halo[node]
conn.halo_nodes = []
conn.trace_cells = []
conn.stubs = []
self._forget_stubs(conn)
for cell in conn.via_cells:
count = self._via_nets.get(cell)
if count and count.get(conn.net_no):
count[conn.net_no] -= 1
if conn.via_cells:
self._via_centres = None
conn.via_cells = []
conn.wires = []
conn.vias = []
conn.routed = False
for anchor in conn.anchors:
hanging = self._dependents.get(anchor)
if hanging is not None:
hanging.discard(conn.index)
conn.anchors = frozenset()
conn.copper = frozenset()
conn.rip_count += 1
self._settle(conn, +1)
def _crossed_owners(
self, path: list[Node], net_no: int, conns: list[_Connection], spread=None
) -> set[int]:
"""Connections whose copper or clearance ``path`` crosses; a trace
wider than the default crosses them on its ``spread`` cells too."""
spread = self._spread if spread is None else spread
if spread:
path = list(
dict.fromkeys(
(x + dx, y + dy, layer)
for x, y, layer in path
for dx, dy in ((0, 0), *spread)
)
)
owners: set[int] = set()
for cx, cy, cl in path:
owner = self._cell_owner.get((cx, cy, cl))
if owner is not None and conns[owner].net_no != net_no:
owners.add(owner)
owners.update(self._via_others((cx, cy), net_no, conns))
for i in self._halo.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
for i in self._stub_near.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
return owners
# --- pin access ---------------------------------------------------------
@staticmethod
def _group_pins(conn: _Connection):
return [*conn.a_group[0], *conn.b_group[0]]
def _build_doorways(self, conns: list[_Connection]) -> None:
"""Map every cell that would close a pin's way out to that pin."""
spread = [(0, 0), *self._halo_offsets]
done: set[int] = set()
for conn in conns:
for pin in self._group_pins(conn):
self._pending[id(pin)] = self._pending.get(id(pin), 0) + 1
if id(pin) in done:
continue
done.add(id(pin))
claim = (id(pin), conn.net_no)
exits: set[Node] = set()
for x, y, layer in self._end_nodes(pin, conn.net_no, conns):
exits.add((x, y, layer))
for c in self._via_cells((x, y)):
exits.update((*c, other) for other in self.layers)
cells: set[Node] = set()
for x, y, layer in exits:
for dx, dy in spread:
if 0 <= x + dx < self.cols and 0 <= y + dy < self.rows:
cells.add((x + dx, y + dy, layer))
for node in cells:
self._doorways.setdefault(node, []).append(claim)
def _settle(self, conn: _Connection, delta: int) -> None:
if not self._pending:
return
for pin in self._group_pins(conn):
if id(pin) in self._pending:
self._pending[id(pin)] += delta
def _access(self, node: Node, net_no: int) -> float:
"""Extra cost for ``net_no`` to use a cell another pin still needs."""
for pin_id, owner_net in self._doorways.get(node, ()):
if owner_net != net_no and self._pending.get(pin_id, 0) > 0:
return self.access_cost
return 0.0
# --- negotiated congestion --------------------------------------------
def _congestion(self, node: Node, net_no: int) -> float:
"""History of a contested cell, plus while negotiating the price of
sharing it with the other nets on it now."""
cost = self._history.get(node, 0.0)
if self._present is not None:
users = self._present.get(node)
if users:
others = len(users) - (1 if net_no in users else 0)
cost += self._present_cost * others
return cost
def _footprint(self, path: list[Node]) -> set[Node]:
"""The nodes a route claims: its cells and their clearance halo on
its layer, and every via's footprint on all layers."""
wire_segs, via_cells = _split_path(path)
halo = [(0, 0), *self._halo_offsets]
nodes: set[Node] = set()
for layer, cells in wire_segs:
for cx, cy in cells:
for dx, dy in halo:
nodes.add((cx + dx, cy + dy, layer))
for cell in via_cells:
for c in self._via_cells(cell):
for layer in self.layers:
nodes.add((*c, layer))
return nodes
def _negotiate(self, order: list[_Connection], conns: list[_Connection]) -> None:
"""Route every connection on the empty board ``self.negotiate`` times,
letting routes share cells at a price: the number of other nets on a
cell times a factor that doubles every round, plus the cell's history,
which grows by one for every extra net each round it stays shared.
Only the history is kept; the board is left empty."""
present: dict[Node, dict[int, int]] = {}
claimed: dict[int, set[Node]] = {}
ends = [(c, c.a_end, c.b_end) for c in conns]
self._present, self._present_cost = present, 0.5
try:
for _ in range(self.negotiate):
for conn in order:
for node in claimed.pop(conn.index, ()):
users = present[node]
users[conn.net_no] -= 1
if not users[conn.net_no]:
del users[conn.net_no]
path = self._search(conn, allow_ripup=False, conns=conns)
if path is None:
continue
nodes = self._footprint(path)
claimed[conn.index] = nodes
for node in nodes:
users = present.setdefault(node, {})
users[conn.net_no] = users.get(conn.net_no, 0) + 1
shared = 0
for node, users in present.items():
if len(users) > 1:
shared += 1
self._history[node] = self._history.get(node, 0.0) + len(users) - 1
if not shared:
break
self._present_cost *= 2
finally:
self._present = None
for conn, a_end, b_end in ends: # the search picks ends; undo that
conn.a_end, conn.b_end = a_end, b_end
# --- passes -------------------------------------------------------------
def route(self) -> RouteResult:
"""Route all ratsnest connections, using rip-up-and-retry across passes."""
conns = self._build_connections()
self._conns = conns
if not conns:
return RouteResult(self.half_width)
if self.access_cost:
self._build_doorways(conns)
passes = self.max_passes if self.rip_up else 1
best_count = -1
best_snapshot: list[tuple[int, list, list]] | None = None
best_unrouted = len(conns)
best_widths: tuple[dict[int, int], list] = ({}, [])
# conns[i].index == i is what rip-up relies on, so shuffle a view
order = list(conns)
if self.seed is not None:
random.Random(self.seed).shuffle(order)
if self.route_first:
# stable: the promoted keep their order among themselves, as do the rest
order.sort(key=lambda c: (c.net_no, *_ends_of(c)) not in self.route_first)
if self.negotiate:
self._negotiate(order, conns)
for _ in range(passes):
changed = False
for conn in order:
if conn.routed:
continue
if self._try_route(conn, conns):
changed = True
routed_count = sum(1 for c in conns if c.routed)
if routed_count > best_count:
best_count = routed_count
best_unrouted = len(conns) - routed_count
best_snapshot = self._snapshot(conns)
best_widths = self._net_widths(conns)
if routed_count == len(conns):
break
if not changed:
break
result = self._result_from(best_snapshot or [], best_unrouted)
result.net_half, result.narrowed = best_widths
return result
def _try_route(self, conn: _Connection, conns: list[_Connection]) -> bool:
if self._route_once(conn, conns):
return True
if conn.narrow or conn.net_no not in self._net_half:
return False
# a wider class that does not fit: route it at the default width
conn.narrow = True
return self._route_once(conn, conns)
def _route_once(self, conn: _Connection, conns: list[_Connection]) -> bool:
path = self._search(conn, allow_ripup=False, conns=conns)
if path is None:
if not self.rip_up:
return False
path = self._search(conn, allow_ripup=True, conns=conns)
if path is None:
return False
crossed = self._crossed_owners(
path, conn.net_no, conns, self._spread_for(self._conn_half(conn))
)
# and the routes on the stubs from the pins to the path's ends
for end, node in ((conn.a_end, path[0]), (conn.b_end, path[-1])):
if isinstance(end, Pin):
crossed |= self._stub_owners(
end, node[:2], node[2], conn.net_no, conns, self._conn_half(conn)
)
victims = self._with_dependents([conns[i] for i in sorted(crossed)], 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, v.anchors) 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, anchors in saved:
victim.a_end, victim.b_end, victim.anchors = a_end, b_end, anchors
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] = {}
self._every_zone_by_net: dict[int, list] = {}
for zone in self.board.get_conduction_areas():
if zone.layer in self.layers and zone.net_nos:
self._every_zone_by_net.setdefault(zone.net_nos[0], []).append(zone)
for zone in self.board.get_conduction_areas():
if zone.layer in self.layers and zone.net_nos and self._zone_is_solid(zone):
zones_by_net.setdefault(zone.net_nos[0], []).append(zone)
# planes: zones of a net on an inner power layer, solid by construction
planes_by_net: dict[int, list] = {}
for zone in self.board.get_conduction_areas():
if zone.net_nos and zone.layer in self._power_layers:
planes_by_net.setdefault(zone.net_nos[0], []).append(zone)
plane_ids = frozenset(id(z) for zs in planes_by_net.values() for z in zs)
conns: list[_Connection] = []
self._plane_nets: set[int] = set()
nets = set(pins_by_net) | set(zones_by_net) | set(planes_by_net)
# deterministic: fewer-pin nets first, then by net number
for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)):
zones = zones_by_net.get(net_no, []) + planes_by_net.get(net_no, [])
groups = _connected_groups(
pins_by_net.get(net_no, []), zones, plane_ids, self._fixed_pieces(net_no)
)
hubs = [g for g in groups if any(id(z) in plane_ids for z in g[1])]
if hubs:
# a plane net: every other group goes straight to the plane
# (usually a stub and a via), not pad to pad across the board
pairs = [(g, hubs[0]) for g in groups if g is not hubs[0]]
self._plane_nets.add(net_no)
elif self.tree:
pairs = _spanning_pairs(groups)
else:
pairs = list(zip(groups, groups[1:], strict=False))
for ga, gb in pairs:
conn = _Connection(len(conns), net_no, _first(ga[0]), _first(gb[0]))
conn.a_group, conn.b_group = ga, gb
conns.append(conn)
self._net_conns: dict[int, list[_Connection]] = {}
for conn in conns:
self._net_conns.setdefault(conn.net_no, []).append(conn)
return conns
def _fixed_pieces(self, net_no: int) -> list[_FixedPiece]:
"""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
cache = self._fixed_cache = {}
for item in self.board.get_items():
if not item.fixed or not item.net_nos:
continue
if isinstance(item, Via) and item.shape is not None:
piece = _FixedPiece(item.layers, [item.shape.bounding_box()], via=item)
elif isinstance(item, Trace) and not item.is_empty():
boxes = list(PolylineShape(Polyline(item.corners), item.half_width).tiles())
boxes += [corner_box(e, item.half_width) for e in (item.corners[0], item.corners[-1])]
piece = _FixedPiece([item.layer], boxes)
else:
continue
cache.setdefault(item.net_nos[0], []).append(piece)
return cache.get(net_no, [])
def _pins_by_net(self) -> dict[int, list[Pin]]:
by_net: dict[int, list[Pin]] = {}
for pin in self.board.get_pins():
if not any(layer in self.layers for layer in pin.layers):
continue
for net_no in pin.net_nos:
by_net.setdefault(net_no, []).append(pin)
return by_net
def _net_widths(self, conns: list[_Connection]) -> tuple[dict[int, int], list]:
"""Half width of each wider-class net whose routed connections all went
in at it (a net is written at one width), and the connections that
had to fall back to the default."""
halves: dict[int, int] = {}
narrowed = []
for c in conns:
if not c.routed:
continue
halves[c.net_no] = min(halves.get(c.net_no, c.half), c.half)
if c.narrow:
narrowed.append((c.net_no, *_ends_of(c)))
wide = {net: half for net, half in halves.items() if half > self.half_width}
return wide, narrowed
def _snapshot(self, conns: list[_Connection]) -> list[tuple]:
# in commit order, so a connection teed onto another comes after it;
# its anchors as positions in this list
routed = sorted((c for c in conns if c.routed), key=lambda c: (c.seq, c.index))
position = {c.index: i for i, c in enumerate(routed)}
return [
(c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias),
(*_ends_of(c), self._left_to_zone(c)),
tuple(sorted(position[a] for a in c.anchors if a in position)))
for c in routed
]
def _result_from(self, snapshot, unrouted: int) -> RouteResult:
result = RouteResult(self.half_width)
written: set[tuple[int, IntPoint]] = set()
result.unrouted = unrouted
for net_no, wires, vias, ends, anchors in snapshot:
for layer, points in wires:
result.add_wire(net_no, layer, points)
for location in vias:
spot = (net_no, location)
if spot in written or spot in self._fixed_via_points:
continue # a via reused by another connection, or already on the board
written.add(spot)
result.add_via(net_no, location)
result.connections.append((net_no, wires, vias))
result.connection_ends.append(ends)
result.connection_anchors.append(anchors)
routed = {ends[:2] + (net_no,) for net_no, _w, _v, ends, _a in snapshot}
result.failed = [
(c.net_no, *_ends_of(c), self._left_to_zone(c))
for c in self._conns
if _ends_of(c) + (c.net_no,) not in routed
]
return result
def _first(items):
return items[0] if items else None
def _anchors(path, starts, goals, a_tree, b_tree) -> frozenset[int]:
"""What a found path relies on: the connections between the copper it
starts or ends on and its own groups."""
found: set[int] = set()
if starts.get(path[0]) is None and path[0] in a_tree:
found |= a_tree[path[0]]
if goals.get(path[-1]) is None and path[-1] in b_tree:
found |= b_tree[path[-1]]
return frozenset(found)
def _group_points(group) -> list[tuple[int, int]]:
pins, zones = group
if pins:
return [(p.location.x, p.location.y) for p in pins]
points = []
for zone in zones:
box = zone.bounding_box()
points.append(((box.ll.x + box.ur.x) // 2, (box.ll.y + box.ur.y) // 2))
return points or [(0, 0)]
def _spanning_pairs(groups) -> list[tuple]:
"""A minimum spanning tree over a net's groups (Prim, Manhattan distance
between their nearest pins), as (in the tree, joining it) pairs: each
connection joins the nearest group not yet joined, not the next in pin
order."""
if len(groups) < 2:
return []
points = [_group_points(g) for g in groups]
def gap(i: int, j: int) -> int:
return min(abs(ax - bx) + abs(ay - by) for ax, ay in points[i] for bx, by in points[j])
best = {j: (gap(0, j), 0) for j in range(1, len(groups))}
pairs = []
while best:
j = min(best, key=lambda k: (best[k][0], k))
_, i = best.pop(j)
pairs.append((groups[i], groups[j]))
for k in best:
d = gap(j, k)
if d < best[k][0]:
best[k] = (d, j)
return pairs
def _group_label(group) -> str:
pins, zones = group
parts = [f"{p.component_ref}-{p.name}" for p in pins[:3]]
if len(pins) > 3:
parts.append(f"+{len(pins) - 3} pins")
parts += [f"zone {z.name}" for z in zones[:1]]
return "/".join(parts) or "?"
def _ends_of(conn) -> tuple[str, str]:
"""A readable description of what a connection joins."""
return _group_label(conn.a_group), _group_label(conn.b_group)
def _pin_touches_zone(pin: Pin, zone, plane: bool = False) -> bool:
"""A pad on the zone's layer whose centre or a corner lies in the zone.
For a plane on an inner power layer: a through-hole pad (on more than one
copper layer) inside it passes through the plane and is joined to it; an
SMD pad is not, and needs a via.
"""
if plane:
return len(pin.layers) > 1 and zone.polygon is not None and zone.polygon.contains(pin.location)
if zone.layer not in pin.layers or zone.polygon is None:
return False
if zone.polygon.contains(pin.location):
return True
box = pin.shape.bounding_box() if pin.shape is not None else None
if box is None:
return False
corners = (box.ll, IntPoint(box.ur.x, box.ll.y), box.ur, IntPoint(box.ll.x, box.ur.y))
return any(zone.polygon.contains(c) for c in corners)
def _touches(a: IntBox, b: IntBox) -> bool:
return not (a.ur.x < b.ll.x or b.ur.x < a.ll.x or a.ur.y < b.ll.y or b.ur.y < a.ll.y)
def _fixed_joins_zone(piece, zone, plane: bool) -> bool:
"""A through via inside a plane joins it; copper inside a zone on its
own layer joins that zone."""
if zone.polygon is None:
return False
if piece.via is not None:
if plane or zone.layer in piece.layers:
return zone.polygon.contains(piece.via.location)
return False
return zone.layer in piece.layers and any(
zone.polygon.contains(IntPoint((b.ll.x + b.ur.x) // 2, (b.ll.y + b.ur.y) // 2))
for b in piece.boxes
)
class _FixedPiece:
"""One track or via already on the board, as connectivity."""
__slots__ = ("layers", "boxes", "via")
def __init__(self, layers, boxes, via=None) -> None:
self.layers = frozenset(layers)
self.boxes = boxes
self.via = via # the Via item, for a via
def _connected_groups(
pins: list[Pin], zones: list, planes: frozenset = frozenset(), fixed: list | None = None
) -> list[tuple[list[Pin], list]]:
"""Pins and zones of one net that copper already joins: a pad in a zone
is connected to it, two zones sharing a pad are one, and copper already
on the board (tracks and vias, ``fixed``) joins whatever it touches. Only
the groups need routing between them. Pin groups come first, in pin
order. A group's fixed vias join its pins as extra route ends, so a
fanned-out pin is routed from its via; copper that touches no pin and no
zone adds nothing to route."""
fixed = fixed or []
n_pins, n_zones = len(pins), len(zones)
parent = list(range(n_pins + n_zones + len(fixed)))
def find(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
for j, zone in enumerate(zones):
for i, pin in enumerate(pins):
if _pin_touches_zone(pin, zone, id(zone) in planes):
parent[find(i)] = find(len(pins) + j)
base = n_pins + n_zones
for k, piece in enumerate(fixed):
me = base + k
for i, pin in enumerate(pins):
if pin.shape is None or not piece.layers & set(pin.layers):
continue
pad = pin.shape.bounding_box()
if any(_touches(pad, b) for b in piece.boxes):
parent[find(i)] = find(me)
for j, zone in enumerate(zones):
if _fixed_joins_zone(piece, zone, id(zone) in planes):
parent[find(n_pins + j)] = find(me)
for k2 in range(k):
other = fixed[k2]
if piece.layers & other.layers and any(
_touches(a, b) for a in piece.boxes for b in other.boxes
):
parent[find(base + k2)] = find(me)
groups: dict[int, tuple[list[Pin], list]] = {}
order: list[int] = []
for i, pin in enumerate(pins):
root = find(i)
if root not in groups:
groups[root] = ([], [])
order.append(root)
groups[root][0].append(pin)
for j, zone in enumerate(zones):
root = find(len(pins) + j)
if root not in groups:
groups[root] = ([], [])
order.append(root)
groups[root][1].append(zone)
for k, piece in enumerate(fixed):
root = find(base + k)
if piece.via is not None and root in groups:
groups[root][0].append(_via_end(piece.via))
return [groups[r] for r in order]
def _via_end(via) -> Pin:
"""A fixed via as a place a route may start or end (not a pad to route)."""
return Pin(
net_nos=list(via.net_nos), fixed=True, name="via", component_ref="",
padstack_name=via.padstack_name, location=via.location,
layers=list(via.layers), shape=via.shape,
)
def _split_path(path: list[Node]) -> tuple[list[tuple[int, list[Cell]]], list[Cell]]:
"""Split a node path into per-layer wire segments and via cells."""
wires: list[tuple[int, list[Cell]]] = []
vias: list[Cell] = []
i = 0
n = len(path)
while i < n:
layer = path[i][2]
cells: list[Cell] = []
while i < n and path[i][2] == layer:
cells.append((path[i][0], path[i][1]))
i += 1
wires.append((layer, cells))
if i < n: # a via connects this segment's last cell to the next layer
vias.append(cells[-1])
return wires, vias
def _reconstruct(came_from: dict[Node, Node], current: Node) -> list[Node]:
path = [current]
while current in came_from:
current = came_from[current]
path.append(current)
path.reverse()
return path
def _simplify(points: list[IntPoint]) -> list[IntPoint]:
"""Drop duplicate and collinear points from a polyline."""
out: list[IntPoint] = []
for p in points:
if out and out[-1].x == p.x and out[-1].y == p.y:
continue
if len(out) >= 2:
a, b = out[-2], out[-1]
if (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) == 0:
out[-1] = p
continue
out.append(p)
return out
def route_board(
board: BasicBoard,
*,
trace_width: int,
clearance: int,
layers: list[int] | None = None,
via_cost: float = 10.0,
rip_up: bool = True,
max_passes: int = 10,
orthogonal: bool = False,
via_half: int | None = None,
edge_clearance: int | None = None,
resolution: int = 1,
heuristic_weight: float = 1.0,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
rollback: bool = True,
tree_nets: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
board,
trace_width=trace_width,
clearance=clearance,
layers=layers,
via_cost=via_cost,
rip_up=rip_up,
max_passes=max_passes,
orthogonal=orthogonal,
via_half=via_half,
edge_clearance=edge_clearance,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
negotiate=negotiate,
rollback=rollback,
tree_nets=tree_nets,
net_widths=net_widths,
via_drill=via_drill,
hole_to_hole=hole_to_hole,
)
return router.route()