From 992bba82a04ca7ecc39cf766f15ea23fa175f9eb Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 12 Jul 2026 13:20:52 -0600 Subject: [PATCH] Add multi-layer routing with vias Extends the grid router with a layer axis: A* nodes are (col, row, layer), in-plane moves stay on a layer, and a via move transitions between layers at a cell for a configurable via_cost (so the router prefers one layer but changes layers to get through). Occupancy is tracked per (cell, layer); a through via must be clear on every signal layer and then blocks all of them for other nets. RouteResult now carries per-layer wire segments and per-net via locations. The pipeline emits each segment on its layer and each via as (via x y), using the DSN's via padstack; wire and via coordinates are converted from board units back to DSN units. route() and route_dsn_board() take an optional layers= to restrict routing (e.g. a single layer) for comparison. --- src/freeroute/route/grid_router.py | 279 +++++++++++++++++++---------- src/freeroute/route/pipeline.py | 43 +++-- 2 files changed, 213 insertions(+), 109 deletions(-) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 2adaa8f..587d083 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -1,21 +1,20 @@ -"""A grid-based maze router (MVP). +"""A grid-based maze router (MVP), now multi-layer with vias. -This is an *MVP* autorouter, not a port of FreeRouting's expansion-room maze. -Per the phase brief the milestone is connectivity parity with the reference JAR -on a simple board, not FreeRouting-quality optimization. It routes on a single -signal layer with an A* search over a uniform occupancy grid: +Still an *MVP* — not a port of FreeRouting's expansion-room maze — but it routes +across the board's signal layers and changes layers through vias when a net +cannot get through on one layer: -* a cell is blocked if it overlaps another net's pad or a keepout, inflated by - ``clearance + half trace width``; -* each net's ratsnest is connected pin-to-pin; a routed trace's cells then block - other nets (so routes do not overlap); -* the resulting cell path is turned into a trace polyline whose endpoints are the - exact pin locations. +* the occupancy grid has a layer axis; A* nodes are ``(col, row, layer)``; +* in-plane moves (8-connected, 45-degree) cost distance and stay on a layer; +* a **via move** transitions between layers at a cell for a configurable + ``via_cost`` (so the router prefers one layer but will change to get through); +* a cell is blocked per-layer by other-net pads/traces/vias on that layer plus + keepouts; a via cell must be clear on **all** signal layers (a through via); +* each net's ratsnest is connected pin-to-pin; routed traces and vias then block + other nets. Trace endpoints land exactly on the connected pads. -Deferred (router phase, exact-geometry track): FreeRouting's free-space -expansion rooms, rip-up-and-retry, multi-layer via search, and shove. Those -raise quality/completeness; this reaches connectivity on boards whose nets route -on one layer without crossing. +Deferred (still): FreeRouting's free-space expansion rooms, rip-up-and-retry, +blind/buried via spans, and shove. Those raise coverage/quality on dense boards. """ from __future__ import annotations @@ -29,7 +28,7 @@ from freeroute.geometry import IntPoint __all__ = ["GridRouter", "RouteResult", "route_board"] -# 8-connected neighbourhood (orthogonal + diagonal) for 45-degree routing. +# 8-connected in-plane neighbourhood (orthogonal + diagonal) for 45-degree routing. _NEIGHBOURS = [ (1, 0), (-1, 0), @@ -41,31 +40,43 @@ _NEIGHBOURS = [ (-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 trace paths (each a list of - :class:`IntPoint`). ``routed_net_numbers`` are the nets that got >= 1 trace. + ``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", "half_width", "layer") + __slots__ = ("wires", "vias", "half_width") - def __init__(self, half_width: int, layer: int) -> None: - self.wires: dict[int, list[list[IntPoint]]] = {} + 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 - self.layer = layer - def add(self, net_no: int, path: list[IntPoint]) -> None: - self.wires.setdefault(net_no, []).append(path) + 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]: - return {n for n, paths in self.wires.items() if paths} + 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 GridRouter: - """Routes a :class:`BasicBoard` on one layer with a uniform-grid A* search.""" + """Routes a :class:`BasicBoard` across signal layers with a grid A* + vias.""" def __init__( self, @@ -73,39 +84,45 @@ class GridRouter: *, trace_width: int, clearance: int, - layer: int = 0, + layers: list[int] | None = None, + via_cost: float = 10.0, ) -> None: self.board = board self.trace_width = max(trace_width, 1) self.clearance = max(clearance, 0) - self.layer = layer self.half_width = self.trace_width // 2 + self.via_cost = via_cost # cell size must fit a trace plus its clearance to a neighbouring trace self.step = max(self.trace_width + self.clearance, 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) - # cell -> set of net numbers whose pad blocks it; keepout cells; trace cells - self._pad_block: dict[tuple[int, int], set[int]] = {} - self._keepout: set[tuple[int, int]] = set() - self._trace_block: dict[tuple[int, int], int] = {} + # per-(cell, layer) occupancy + self._pad_block: dict[Node, set[int]] = {} + self._keepout: set[Node] = set() + self._trace_block: dict[Node, int] = {} + self._via_block: dict[Cell, int] = {} self._rasterize_obstacles() # --- grid helpers ------------------------------------------------------- - def _cell_of(self, point: IntPoint) -> tuple[int, int]: + 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, gx: int, gy: int) -> IntPoint: - return IntPoint(self.origin_x + gx * self.step, self.origin_y + gy * self.step) + 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): lo_x = round((box.ll.x - margin - self.origin_x) / self.step) @@ -120,113 +137,175 @@ class GridRouter: margin = self.clearance + self.half_width for item in self.board.get_items(): if isinstance(item, Pin): - if self.layer not in item.layers: - continue 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): - self._pad_block.setdefault(cell, set()).add(net) + for layer in pin_layers: + self._pad_block.setdefault((*cell, layer), set()).add(net) elif isinstance(item, ObstacleArea): - if item.layer != self.layer: + if item.layer not in self.layers: continue box = item.bounding_box() if box.is_empty(): continue for cell in self._cells_in_box(box, self.clearance): - self._keepout.add(cell) + self._keepout.add((*cell, item.layer)) - def _blocked(self, cell: tuple[int, int], net_no: int) -> bool: - if cell in self._keepout: + def _blocked(self, cell: Cell, layer: int, net_no: int) -> bool: + node = (*cell, layer) + if node in self._keepout: return True - pads = self._pad_block.get(cell) + pads = self._pad_block.get(node) if pads and any(n != net_no for n in pads): return True - trace = self._trace_block.get(cell) - return trace is not None and trace != net_no + trace = self._trace_block.get(node) + if trace is not None and trace != net_no: + return True + via = self._via_block.get(cell) + return via is not None and via != net_no + + def _via_placeable(self, cell: Cell, net_no: int) -> bool: + """A through via at ``cell`` needs every signal layer clear of other nets.""" + return all(not self._blocked(cell, layer, net_no) for layer in self.layers) # --- A* search ---------------------------------------------------------- def _search( - self, start: tuple[int, int], goal: tuple[int, int], net_no: int - ) -> list[tuple[int, int]] | None: - if start == goal: - return [start] - open_heap: list[tuple[float, tuple[int, int]]] = [] - heapq.heappush(open_heap, (0.0, start)) - came_from: dict[tuple[int, int], tuple[int, int]] = {} - g_score: dict[tuple[int, int], float] = {start: 0.0} - - def h(cell): - return math.hypot(cell[0] - goal[0], cell[1] - goal[1]) + self, starts: set[Node], goal_cell: Cell, goals: set[Node], net_no: int + ) -> list[Node] | None: + if starts & goals: + return [next(iter(starts & goals))] + open_heap: list[tuple[float, Node]] = [] + g_score: dict[Node, float] = {} + came_from: dict[Node, Node] = {} + for s in starts: + g_score[s] = 0.0 + heapq.heappush(open_heap, (self._h(s, goal_cell), s)) while open_heap: _, current = heapq.heappop(open_heap) - if current == goal: + if current in goals: return _reconstruct(came_from, current) - cx, cy = current + cx, cy, cl = current + base = g_score[current] + # in-plane moves for dx, dy in _NEIGHBOURS: - nxt = (cx + dx, cy + dy) - if not (0 <= nxt[0] < self.cols and 0 <= nxt[1] < self.rows): + cell = (cx + dx, cy + dy) + if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): continue - # the goal cell may be blocked by its own pad's net-agnostic - # rasterization; always allow stepping onto the goal - if nxt != goal and self._blocked(nxt, net_no): + nxt = (*cell, cl) + if nxt not in goals and self._blocked(cell, cl, net_no): continue - step_cost = 1.0 if dx == 0 or dy == 0 else math.sqrt(2) - tentative = g_score[current] + step_cost - if tentative < g_score.get(nxt, math.inf): - came_from[nxt] = current - g_score[nxt] = tentative - heapq.heappush(open_heap, (tentative + h(nxt), nxt)) + cost = 1.0 if dx == 0 or dy == 0 else math.sqrt(2) + self._relax(current, nxt, base + cost, goal_cell, g_score, came_from, open_heap) + # via moves (change layer at the same cell) + if len(self.layers) > 1 and self._via_placeable((cx, cy), net_no): + for layer in self.layers: + if layer == cl: + continue + nxt = (cx, cy, layer) + self._relax( + current, + nxt, + base + self.via_cost, + goal_cell, + g_score, + came_from, + open_heap, + ) return None - def _mark_trace(self, cells: list[tuple[int, int]], net_no: int) -> None: - for cell in cells: - self._trace_block[cell] = net_no + 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: + return math.hypot(node[0] - goal_cell[0], node[1] - goal_cell[1]) + + # --- commit / marking --------------------------------------------------- + + def _mark(self, wires, vias, net_no: int) -> None: + for layer, cells in wires: + for cell in cells: + self._trace_block[(*cell, layer)] = net_no + for cell in vias: + self._via_block[cell] = net_no # --- public routing ----------------------------------------------------- def route(self) -> RouteResult: - """Route every net with >= 2 pins on this router's layer.""" - result = RouteResult(self.half_width, self.layer) + """Route every net with >= 2 pins across the router's signal layers.""" + result = RouteResult(self.half_width) pins_by_net = self._pins_by_net() - # route nets with fewer pins first (usually easier / shorter) for net_no in sorted(pins_by_net, key=lambda n: len(pins_by_net[n])): pins = pins_by_net[net_no] if len(pins) < 2: continue for a, b in zip(pins, pins[1:], strict=False): - path = self._route_connection(a, b, net_no) - if path is not None: - result.add(net_no, path) + self._route_connection(a, b, net_no, result) return result def _pins_by_net(self) -> dict[int, list[Pin]]: by_net: dict[int, list[Pin]] = {} for pin in self.board.get_pins(): - if self.layer not in pin.layers: + 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 _route_connection(self, a: Pin, b: Pin, net_no: int) -> list[IntPoint] | None: - start = self._cell_of(a.location) - goal = self._cell_of(b.location) - cells = self._search(start, goal, net_no) - if cells is None: - return None - self._mark_trace(cells, net_no) - # cell centres, with exact pad locations as the true endpoints - points = [a.location] - points.extend(self._cell_center(gx, gy) for gx, gy in cells) - points.append(b.location) - return _simplify(points) + def _route_connection(self, a: Pin, b: Pin, net_no: int, result: RouteResult) -> None: + start_cell = self._cell_of(a.location) + goal_cell = self._cell_of(b.location) + a_layers = [layer for layer in a.layers if layer in self.layers] + b_layers = [layer for layer in b.layers if layer in self.layers] + if not a_layers or not b_layers: + return + starts = {(*start_cell, layer) for layer in a_layers} + goals = {(*goal_cell, layer) for layer in b_layers} + path = self._search(starts, goal_cell, goals, net_no) + if path is None: + return + wires, vias = _split_path(path) + self._mark(wires, vias, net_no) + # emit wires with exact pad endpoints; emit vias at their cell centres + for idx, (layer, cells) in enumerate(wires): + points = [self._cell_center(c) for c in cells] + if idx == 0: + points.insert(0, a.location) + if idx == len(wires) - 1: + points.append(b.location) + points = _simplify(points) + if len(points) >= 2: + result.add_wire(net_no, layer, points) + for cell in vias: + result.add_via(net_no, self._cell_center(cell)) -def _reconstruct(came_from, current): +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] @@ -243,7 +322,6 @@ def _simplify(points: list[IntPoint]) -> list[IntPoint]: continue if len(out) >= 2: a, b = out[-2], out[-1] - # collinear if cross product of (b-a) and (p-a) is zero if (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) == 0: out[-1] = p continue @@ -252,8 +330,19 @@ def _simplify(points: list[IntPoint]) -> list[IntPoint]: def route_board( - board: BasicBoard, *, trace_width: int, clearance: int, layer: int = 0 + board: BasicBoard, + *, + trace_width: int, + clearance: int, + layers: list[int] | None = None, + via_cost: float = 10.0, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" - router = GridRouter(board, trace_width=trace_width, clearance=clearance, layer=layer) + router = GridRouter( + board, + trace_width=trace_width, + clearance=clearance, + layers=layers, + via_cost=via_cost, + ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index f2cff0a..9ec9933 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -5,15 +5,16 @@ writer — the Java-free replacement for the ``freerouting.jar`` step: dsn_text -> parse_dsn -> build_board -> route_board -> write_ses -> ses_text -Router output is in board units; it is converted back to DSN units (dividing by -the resolution) for the SES ``(wire (path ...))`` scopes. +Router output is in board units; wire paths and via locations are converted back +to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and +``(via x y)`` scopes. """ from __future__ import annotations from freeroute.board import build_board from freeroute.dsn import DsnBoard, parse_dsn -from freeroute.ses import RoutedWire, RoutingResult, write_ses +from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from .grid_router import RouteResult, route_board @@ -22,6 +23,8 @@ __all__ = ["route", "route_dsn_board", "build_routing_result"] #: fallback trace width / clearance in DSN units when the DSN has no rules _DEFAULT_WIDTH_DSN = 2000 _DEFAULT_CLEARANCE_DSN = 2000 +#: fallback via padstack name when the DSN declares none +_DEFAULT_VIA = "Via" def _rule_width_dsn(dsn: DsnBoard) -> float: @@ -36,41 +39,53 @@ def _rule_clearance_dsn(dsn: DsnBoard) -> float: return _DEFAULT_CLEARANCE_DSN -def route_dsn_board(dsn: DsnBoard) -> tuple[RouteResult, int, list[str]]: +def _via_padstack(dsn: DsnBoard) -> str: + return dsn.via_padstack_names[0] if dsn.via_padstack_names else _DEFAULT_VIA + + +def route_dsn_board( + dsn: DsnBoard, *, layers: list[int] | None = None +) -> tuple[RouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, - and the layer names.""" + and the layer names. ``layers`` restricts routing to those signal-layer + indices (default: all signal layers, i.e. multi-layer with vias).""" board = build_board(dsn) scale = max(dsn.resolution.value, 1) width_board = round(_rule_width_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale) - result = route_board(board, trace_width=width_board, clearance=clearance_board, layer=0) + result = route_board(board, trace_width=width_board, clearance=clearance_board, layers=layers) return result, scale, [layer.name for layer in dsn.layers] -def build_routing_result(dsn: DsnBoard) -> RoutingResult: - """Route ``dsn`` and convert the board-unit paths to a DSN-unit +def build_routing_result(dsn: DsnBoard, *, layers: list[int] | None = None) -> RoutingResult: + """Route ``dsn`` and convert the board-unit paths + vias to a DSN-unit :class:`~freeroute.ses.RoutingResult` for SES emission.""" - route, scale, layer_names = route_dsn_board(dsn) - layer_name = layer_names[route.layer] if layer_names else "F.Cu" + route, scale, layer_names = route_dsn_board(dsn, layers=layers) width_dsn = _rule_width_dsn(dsn) + via_name = _via_padstack(dsn) # net_number is assigned in DSN order by build_board, so index i -> number i+1 numbers_to_names = {i + 1: n.name for i, n in enumerate(dsn.nets)} result = RoutingResult() - for net_no, paths in route.wires.items(): + for net_no, segments in route.wires.items(): name = numbers_to_names.get(net_no, str(net_no)) - for path in paths: + for layer_index, path in segments: + layer_name = layer_names[layer_index] if layer_index < len(layer_names) else "F.Cu" coords: list[float] = [] for point in path: coords.append(point.x / scale) coords.append(point.y / scale) if len(coords) >= 4: result.add_wire(name, RoutedWire(layer=layer_name, width=width_dsn, coords=coords)) + for net_no, locations in route.vias.items(): + name = numbers_to_names.get(net_no, str(net_no)) + for loc in locations: + result.add_via(name, RoutedVia(padstack=via_name, x=loc.x / scale, y=loc.y / scale)) return result -def route(dsn_text: str) -> str: +def route(dsn_text: str, *, layers: list[int] | None = None) -> str: """Route a Specctra DSN string and return the routed SES string.""" dsn = parse_dsn(dsn_text) - result = build_routing_result(dsn) + result = build_routing_result(dsn, layers=layers) return write_ses(dsn, result)