From 4a844e8cd7cfdd8158abddbcc943d58a8658e900 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 28 Sep 2026 21:16:40 -0600 Subject: [PATCH] Route wider net classes at their width, falling back where they don't fit The grid is laid out for the default width; a wider trace also needs the cells its extra half width reaches to be free, and claims a wider halo. A connection that does not fit at its class width (typically at a fine-pitch pin) is routed at the default instead and reported. A net is written at its class width when all of its connections went in at it, and the exact check verifies it at that width; nets that shove or the diagonal pass reshaped go out at the default. --- src/freeroute/cli.py | 8 ++ src/freeroute/route/exact_router.py | 23 ++++- src/freeroute/route/grid_router.py | 143 ++++++++++++++++++++++++++-- src/freeroute/route/pipeline.py | 29 +++++- src/freeroute/ses/model.py | 3 + tests/route/test_net_widths.py | 42 ++++++++ 6 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 tests/route/test_net_widths.py diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index f16740a..9eed329 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -287,6 +287,14 @@ def main(argv: list[str] | None = None) -> int: print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr) for net, a, b, _zone in missing: print(f" {net}: {a} to {b}", file=sys.stderr) + if result.narrowed: + print( + f"freeroute: {len(result.narrowed)} connection(s) of a wider net class only fitted " + "at the default width; their nets are written at the default width:", + file=sys.stderr, + ) + for net, a, b in result.narrowed: + print(f" {net}: {a} to {b}", file=sys.stderr) if in_zone: print( f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:", diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 2a8840e..3238280 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -99,6 +99,7 @@ def route_board_exact( route_first: frozenset | None = None, access_cost: float = 0.0, rollback: bool = True, + net_widths: dict[int, int] | None = None, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -131,6 +132,7 @@ def route_board_exact( route_first=route_first, access_cost=access_cost, rollback=rollback, + net_widths=net_widths, ) edge = 0 if edge_clearance is None else edge_clearance outline = board.outline @@ -177,18 +179,22 @@ def route_board_exact( # 25-pin supply net costs that one connection, not the whole net. rejected: list[tuple[int, str, str]] = [] ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections) + # each net is checked at the width it will be written at: its class's + # when all of its connections went in at that, else the default + net_half = dict(result.net_half) for (net_no, wires, vias), ends in sorted( zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0] ): - boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half) - wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half)) + wire_half = net_half.get(net_no, half_width) + boxes = _copper_boxes(wires, vias, wire_half, via_layers, via_half) + wire_count = len(_copper_boxes(wires, [], wire_half, via_layers, via_half)) if any( tree.clearance_conflict( box, net_no, layer, clearance, "wire" if i < wire_count else "via" ) for i, (box, box_layers) in enumerate(boxes) for layer in box_layers - ) or _end_in_rule_area(tree, wires, half_width) or ( + ) or _end_in_rule_area(tree, wires, wire_half) or ( outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes) ): rejected.append((net_no, *ends)) @@ -247,6 +253,17 @@ def route_board_exact( for net_no, acc in accepted.items(): clean.wires[net_no] = acc.wires clean.vias[net_no] = acc.vias + # shove and the diagonal pass lay copper at the default width: nets they + # recovered, and nets whose traces shove pushed aside + reshaped = (set(accepted) - accepted_before_recovery) | { + net for net, acc in accepted.items() if acc.shove_count + } + if diagonal: + reshaped = set(accepted) + clean.net_half = { + net: half for net, half in net_half.items() if net in accepted and net not in reshaped + } + clean.narrowed = list(result.narrowed) drc_clean = tree.has_violation(clearance) is None return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean, shoves=shoves) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index e99429f..9785677 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -61,7 +61,10 @@ class RouteResult: ``routed_net_numbers`` are the nets that got any wire or via. """ - __slots__ = ("wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed") + __slots__ = ( + "wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed", + "net_half", "narrowed", + ) def __init__(self, half_width: int) -> None: self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {} @@ -76,6 +79,10 @@ class RouteResult: 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]] = [] + #: 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)) @@ -114,6 +121,8 @@ class _Connection: "a_end", "b_end", "path", + "narrow", + "half", ) def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: @@ -137,6 +146,10 @@ class _Connection: 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 class GridRouter: @@ -163,6 +176,7 @@ class GridRouter: route_first: frozenset | None = None, access_cost: float = 0.0, rollback: bool = True, + net_widths: dict[int, int] | None = None, ) -> None: self.board = board # Pin access: while a pin still has connections to route, the cells @@ -223,6 +237,17 @@ class GridRouter: 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} if layers is None: layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal] @@ -386,6 +411,30 @@ class GridRouter: 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. @@ -440,14 +489,58 @@ class GridRouter: 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: + if self._enter_cost_base(c, layer, net_no, False, conns, wire_keepout=False) != 0.0: return False return True # --- 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: those whose centre is + within its extra half width (plus half a cell, for the grid).""" + extra = half - self.half_width + if extra <= 0: + return [] + reach = math.ceil(extra / self.step) + 1 + limit = extra + self.step / 2 + return [ + (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 <= limit + ] + + 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 math.hypot(dx, 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]): @@ -589,12 +682,12 @@ class GridRouter: self._static_copper = copper return not any(net != net_no and box.overlaps(grown) for box, net in self._static_copper) - def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> list[Cell]: + def _stub_cells(self, pin_location: IntPoint, cell: Cell, 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 end = self._cell_center(cell) - reach = 2 * self.half_width + self.clearance + reach = (half or self._cur_half) + 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), @@ -616,12 +709,12 @@ class GridRouter: end = self._cell_center(cell) if end != pin.location: - for tile in PolylineShape(Polyline([pin.location, end]), self.half_width).tiles(): + for tile in PolylineShape(Polyline([pin.location, end]), self._cur_half).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) + 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: @@ -862,9 +955,11 @@ class GridRouter: conn.via_cells = list(via_cells) 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 self._halo_offsets: + 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) @@ -881,7 +976,7 @@ class GridRouter: 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): + for c in self._stub_cells(pin.location, cell, half): node = (*c, layer) if node not in self._cell_owner: self._cell_owner[node] = conn.index @@ -1012,6 +1107,7 @@ class GridRouter: 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: @@ -1031,13 +1127,25 @@ class GridRouter: 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 - return self._result_from(best_snapshot or [], best_unrouted) + 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: @@ -1124,6 +1232,21 @@ class GridRouter: 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]: return [ (c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias), @@ -1284,6 +1407,7 @@ def route_board( route_first: frozenset | None = None, access_cost: float = 0.0, rollback: bool = True, + net_widths: dict[int, int] | None = None, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -1303,5 +1427,6 @@ def route_board( route_first=route_first, access_cost=access_cost, rollback=rollback, + net_widths=net_widths, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index b2b046e..2311bd2 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -34,6 +34,25 @@ def _rule_width_dsn(dsn: DsnBoard) -> float: return rules[0].value if rules else _DEFAULT_WIDTH_DSN +def _net_widths_board(dsn: DsnBoard, scale: int) -> dict[int, int]: + """Track width of every net whose class asks for more than the default, + in board units, by net number (build_board numbers nets in DSN order).""" + default = _rule_width_dsn(dsn) + by_name: dict[str, float] = {} + for net_class in dsn.net_classes: + if not net_class.width_rules: + continue + width = net_class.width_rules[0].value + if width > default: + for name in net_class.net_names: + by_name[name] = max(by_name.get(name, 0.0), width) + return { + i + 1: round(by_name[net.name] * scale) + for i, net in enumerate(dsn.nets) + if net.name in by_name + } + + def _rule_clearance_dsn(dsn: DsnBoard) -> float: for rule in dsn.structure_rules.clearance_rules: if not rule.class_pairs: # the layer-wide default clearance @@ -108,6 +127,7 @@ def route_dsn_board( route_first=route_first, access_cost=access_cost, rollback=rollback, + net_widths=_net_widths_board(dsn, scale), ) return result, scale, [layer.name for layer in dsn.layers] @@ -122,6 +142,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) result = RoutingResult() for net_no, segments in route.wires.items(): name = numbers_to_names.get(net_no, str(net_no)) + # its class's width when every connection of the net went in at it + half = getattr(route, "net_half", {}).get(net_no) + net_width = 2 * half / scale if half else width_dsn for layer_index, path in segments: layer_name = layer_names[layer_index] if layer_index < len(layer_names) else "F.Cu" coords: list[float] = [] @@ -129,7 +152,7 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) 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)) + result.add_wire(name, RoutedWire(layer=layer_name, width=net_width, coords=coords)) for net_no, locations in route.vias.items(): name = numbers_to_names.get(net_no, str(net_no)) for loc in locations: @@ -137,6 +160,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) result.unrouted = [ (numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", []) ] + result.narrowed = [ + (numbers_to_names.get(n, str(n)), a, b) for n, a, b in getattr(route, "narrowed", []) + ] return result @@ -207,6 +233,7 @@ def route_dsn_board_exact( route_first=route_first, access_cost=access_cost, rollback=rollback, + net_widths=_net_widths_board(dsn, scale), ) return exact, scale, [layer.name for layer in dsn.layers] diff --git a/src/freeroute/ses/model.py b/src/freeroute/ses/model.py index 760b3e2..72976c6 100644 --- a/src/freeroute/ses/model.py +++ b/src/freeroute/ses/model.py @@ -60,6 +60,9 @@ class RoutingResult: #: connections the router could not make: (net name, one end, other end, #: the zone both ends sit in or "") -- a zone's fill may join the latter unrouted: list[tuple[str, str, str, str]] = field(default_factory=list) + #: connections of a wider net class that only fitted at the default width + #: (net name, one end, other end); their net is written at the default + narrowed: list[tuple[str, str, str]] = field(default_factory=list) def net(self, name: str, *, create: bool = False) -> RoutedNet | None: """Return the :class:`RoutedNet` with ``name`` (creating it if asked).""" diff --git a/tests/route/test_net_widths.py b/tests/route/test_net_widths.py new file mode 100644 index 0000000..0096a2a --- /dev/null +++ b/tests/route/test_net_widths.py @@ -0,0 +1,42 @@ +"""Net classes wider than the default are routed at their width where it fits.""" + +from __future__ import annotations + +from freeroute.dsn import parse_dsn +from freeroute.route.pipeline import build_exact_routing_result + +_BOARD = """(pcb widths (parser) (resolution um 10) (unit um) + (structure + (layer F.Cu (type signal)) + (boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0)) + {keepouts} + (rule (width 150) (clearance 150))) + (placement (component S (place P1 2000 -5000 front 0) (place P2 18000 -5000 front 0))) + (library (image S (pin pad 1 0 0)) (padstack pad (shape (rect F.Cu -600 -600 600 600)) (attach off))) + (network (net PWR (pins P1-1 P2-1)) + (class Power PWR (rule (width 400) (clearance 150)))))""" + +# two keepouts leave a 0.65 mm channel: room for 0.15 mm (0.45), not for 0.4 mm (0.7) +_CHANNEL = ( + '(keepout "" (polygon F.Cu 0 9000 0 11000 0 11000 -4675 9000 -4675))\n' + ' (keepout "" (polygon F.Cu 0 9000 -5325 11000 -5325 11000 -10000 9000 -10000))' +) + + +def _route(keepouts=""): + return build_exact_routing_result(parse_dsn(_BOARD.format(keepouts=keepouts)), resolution=2) + + +def test_a_wider_class_is_routed_and_written_at_its_width(): + result = _route() + assert result.unrouted == [] and result.narrowed == [] + widths = {w.width for net in result.nets for w in net.wires} + assert widths == {400.0} + + +def test_a_wider_class_that_does_not_fit_falls_back_to_the_default(): + result = _route(_CHANNEL) + assert result.unrouted == [] + assert [net for net, _a, _b in result.narrowed] == ["PWR"] + widths = {w.width for net in result.nets for w in net.wires} + assert widths == {150.0}