From 45b3753cff1af0322b4955d66df580c44ed0e314 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 29 Sep 2026 00:16:56 -0600 Subject: [PATCH] Count copper already on the board as connections already made Fixed tracks and vias only kept other nets away, so a pin fanned out to its plane was routed to the plane again, beside its own fan-out. Pins, zones and planes that fixed copper of their net touches now form one connected group: a fanned-out supply pin needs nothing more, a fanned-out signal routes from its via, and copper touching no pin or zone adds nothing to route. --- src/freeroute/route/grid_router.py | 101 +++++++++++++++++++++++++++-- tests/route/test_fixed_wiring.py | 43 ++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 4a49bc5..8f71ba7 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -1253,7 +1253,9 @@ class GridRouter: # 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) + 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 @@ -1267,6 +1269,28 @@ class GridRouter: conns.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(): @@ -1361,13 +1385,49 @@ def _pin_touches_zone(pin: Pin, zone, plane: bool = False) -> bool: 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() + 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, 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))) + 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: @@ -1379,6 +1439,24 @@ def _connected_groups( 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): @@ -1393,9 +1471,22 @@ def _connected_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]]] = [] diff --git a/tests/route/test_fixed_wiring.py b/tests/route/test_fixed_wiring.py index 2888de5..aa1bee8 100644 --- a/tests/route/test_fixed_wiring.py +++ b/tests/route/test_fixed_wiring.py @@ -48,3 +48,46 @@ def test_other_nets_route_around_a_fixed_track(): continue for p, q in zip(pts, pts[1:], strict=False): assert not (min(p.x, q.x) < wall < max(p.x, q.x)), "crossed the GND track on F.Cu" + + +# --- fixed wiring as connectivity ------------------------------------------ + +_FANOUT = """(pcb fanout (parser) (resolution um 10) (unit um) + (structure + (layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power)) + (layer B.Cu (type signal)) + (boundary (path pcb 0 0 0 30000 0 30000 -20000 0 -20000 0 0)) + (plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -19500 500 -19500 500 -500)) + (via "Via[0-3]_600:300_um") (rule (width 150) (clearance 150))) + (placement (component S (place G1 5000 -10000 front 0) (place A 10000 -10000 front 0) + (place B 25000 -11500 back 0))) + (library + (image S (pin smd 1 0 0)) + (padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off)) + (padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off))) + (network (net GND (pins G1-1)) (net SIG (pins A-1 B-1))) + (wiring + (wire (path F.Cu 150 5000 -10000 5000 -11500) (net GND) (type route)) + (via "Via[0-3]_600:300_um" 5000 -11500 (net GND) (type route)) + (wire (path F.Cu 150 10000 -10000 10000 -11500) (net SIG) (type route)) + (via "Via[0-3]_600:300_um" 10000 -11500 (net SIG) (type route)) + (wire (path F.Cu 300 2000 -2000 8000 -2000) (net GND) (type route))))""" + + +def test_a_pad_fanned_out_to_its_plane_needs_no_route(): + dsn = parse_dsn(_FANOUT) + exact, _scale, _names = route_dsn_board_exact(dsn, resolution=2) + gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND") + assert exact.result.failed == [] + assert not exact.result.vias.get(gnd) and not exact.result.wires.get(gnd) + + +def test_a_fanned_out_signal_is_routed_from_its_via(): + # B is on the bottom face, level with A's fan-out via: starting from that + # via on B.Cu is the cheapest way, and needs no via of its own + dsn = parse_dsn(_FANOUT) + exact, scale, names = route_dsn_board_exact(dsn, resolution=2) + sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG") + assert sig in exact.result.routed_net_numbers + assert not exact.result.vias.get(sig), "the fan-out via is already there" + assert {names[layer] for layer, _ in exact.result.wires[sig]} == {"B.Cu"}