"""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 math import random from freeroute.board.board import BasicBoard from freeroute.board.items import ConductionArea, ObstacleArea, Pin from freeroute.geometry import IntBox, IntPoint from freeroute.geometry.polyline import corner_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") 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]] = [] #: connections left unrouted: (net_no, one end, the other end) self.failed: 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", ) 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] = [] # 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 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, rollback: bool = True, ) -> None: self.board = board # 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) 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 = {} 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 math.hypot(dx, 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) 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] = {} self._via_owner: dict[Cell, int] = {} # trace halos: node -> indices of the connections claiming it self._halo: dict[Node, list[int]] = {} self._rasterize_obstacles() # --- 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 _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]]] = {} # 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._cells_in_box(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)) # --- 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: """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: via_owner = self._via_owner.get(cell) if via_owner is not None and conns[via_owner].net_no != net_no: blocking = via_owner 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: 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_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(c, layer, net_no, False, conns, wire_keepout=False) != 0.0: return False return True # --- A* search ---------------------------------------------------------- def _search( 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) 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)) if common: conn.a_end, conn.b_end = starts[common[0]], goals[common[0]] 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 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 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) 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) else: ok = True if ok: path = _reconstruct(came_from, current) conn.a_end, conn.b_end = starts.get(path[0]), end 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) 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) if g_via < g_score.get(nxt, math.inf): heapq.heappush( open_heap, (g_via + self._h(nxt, goal_cell), nxt, g_via, current) ) return None # --- 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 _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 end = self._cell_center(cell) reach = 2 * self.half_width + self.clearance box = IntBox( min(pin_location.x, end.x), min(pin_location.y, end.y), max(pin_location.x, end.x), max(pin_location.y, end.y), ) return [ c for c in self._cells_in_box(box, reach) if _point_segment(self._cell_center(c), pin_location, end) < reach ] def _stub_clear( self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, keepouts_only: 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 from freeroute.geometry.polyline import Polyline, PolylineShape end = self._cell_center(cell) if end != pin.location: for tile in PolylineShape(Polyline([pin.location, end]), self.half_width).tiles(): grown = tile.offset(self.clearance) if any(k.overlaps(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.half_width) if any(not needs and k.overlaps(cap) for k, needs in keepouts): return False if keepouts_only: return True for c in self._stub_cells(pin.location, cell): owner = self._cell_owner.get((*c, layer)) if owner is not None and conns[owner].net_no != net_no: return False via_owner = self._via_owner.get(c) if via_owner is not None and conns[via_owner].net_no != net_no: return False return True 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) -> 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)): 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: 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 def _end_nodes(self, pin: Pin, net_no: int, conns) -> 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.""" layers = [layer for layer in pin.layers if layer in self.layers] cx, cy = self._cell_of(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 for layer in layers: if (dx or dy) and self._enter_cost(cell, layer, net_no, False, conns) is None: continue if self._stub_clear(pin, cell, layer, net_no, conns): 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)) 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) -> 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 straight stub from the pin clears every other net's pad by the full margin, measured exactly rather than on the grid.""" from freeroute.board.outline import _segment_box margin = self.half_width + self.clearance 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() for dx, dy in _NEIGHBOURS[:4]: for k in range(2, reach + 1): cell = (cx + dx * k, cy + dy * k) if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): break end = self._cell_center(cell) for layer in layers: if self._enter_cost(cell, layer, net_no, False, conns) is None: continue if any( layer in box_layers and _segment_box(pin.location, end, box) < margin for box, box_layers in near ): continue if self._stub_clear(pin, cell, layer, net_no, conns): nodes.add((*cell, layer)) if any(n[:2] == cell for n in nodes): break # the first open cell along this axis 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, layer in conn.trace_cells: self._cell_owner[(*cell, layer)] = conn.index conn.halo_nodes = [] for (cx, cy), layer in conn.trace_cells: for dx, dy in self._halo_offsets: 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): if c not in self._via_owner: self._via_owner[c] = 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 () 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] for c in self._stub_cells(pin.location, cell): node = (*c, layer) if node not in self._cell_owner: self._cell_owner[node] = conn.index conn.trace_cells.append((c, layer)) # 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.insert(0, conn.a_end.location) if idx == len(wire_segs) - 1 and isinstance(conn.b_end, Pin): points.append(conn.b_end.location) 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._settle(conn, -1) 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: if self._via_owner.get(cell) == conn.index: del self._via_owner[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.via_cells = [] conn.wires = [] conn.vias = [] conn.routed = False conn.rip_count += 1 self._settle(conn, +1) def _crossed_owners(self, path: list[Node], net_no: int, conns: list[_Connection]) -> set[int]: 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) via_owner = self._via_owner.get((cx, cy)) if via_owner is not None and conns[via_owner].net_no != net_no: owners.add(via_owner) for i in self._halo.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 # --- 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) # 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) 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) if routed_count == len(conns): break if not changed: break return self._result_from(best_snapshot or [], best_unrouted) def _try_route(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 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] = {} 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] = [] 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) 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]] 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) return conns 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 _snapshot(self, conns: list[_Connection]) -> list[tuple]: return [ (c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias), (*_ends_of(c), self._left_to_zone(c))) for c in conns if c.routed ] def _result_from(self, snapshot, unrouted: int) -> RouteResult: result = RouteResult(self.half_width) result.unrouted = unrouted for net_no, wires, vias, ends in snapshot: for layer, points in wires: result.add_wire(net_no, layer, points) for location in vias: result.add_via(net_no, location) result.connections.append((net_no, wires, vias)) result.connection_ends.append(ends) routed = {ends[:2] + (net_no,) for net_no, _w, _v, ends 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 _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 _connected_groups( pins: list[Pin], zones: list, planes: frozenset = frozenset() ) -> list[tuple[list[Pin], list]]: """Pins and zones of one net that copper already joins: a pad in a zone is connected to it, and two zones sharing a pad are one. Only the groups need routing between them. Pin groups come first, in pin order.""" parent = list(range(len(pins) + len(zones))) 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) 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) return [groups[r] for r in order] 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, rollback: bool = True, ) -> 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, rollback=rollback, ) return router.route()