From b91e44e360213a57edf88618e5cd1f1bc2616567 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 5 Oct 2026 12:21:00 -0600 Subject: [PATCH 1/2] Route multi-pin nets as trees: spanning-tree pairs, T into routed copper A net's pin groups were joined as a chain in pin order, and each connection had to end on one chosen pin. Groups are now paired by a minimum spanning tree (Manhattan distance between their nearest pins), and a connection may start or end on any copper its net has already routed on that side (path cells, via cells on every layer; not walled cells, whose via moves into the pad). Plane nets keep their drops to the plane. A connection that tees onto another records it as an anchor. Ripping a connection rips what hangs on it, transitively, inside the same rip-up transaction, so rollback restores all of them. The exact engine gets the connections in commit order with their anchors and drops a T whose anchor it rejects. --no-tree restores the chain. --- src/freeroute/cli.py | 17 ++- src/freeroute/route/exact_router.py | 17 ++- src/freeroute/route/grid_router.py | 168 ++++++++++++++++++++++++++-- src/freeroute/route/pipeline.py | 8 ++ tests/route/test_tree_nets.py | 141 +++++++++++++++++++++++ tests/test_cli.py | 1 + 6 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 tests/route/test_tree_nets.py diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 0fc7290..6dfa7ac 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -30,8 +30,8 @@ from freeroute.ses import write_ses #: which optional flags each engine actually implements. Anything not listed for #: an engine is rejected by :func:`_validate` rather than quietly ignored. ENGINE_OPTIONS: dict[str, frozenset[str]] = { - "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}), - "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}), + "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback", "tree"}), + "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback", "tree"}), "room": frozenset({"shove", "pack"}), } @@ -48,6 +48,7 @@ _FLAG_NAMES = { "access": "--access-cost", "negotiate": "--negotiate", "rollback": "--no-rollback", + "tree": "--no-tree", } @@ -184,6 +185,15 @@ def build_parser() -> argparse.ArgumentParser: "connections a rip-up displaces are rerouted at once and the rip-up is undone " "if fewer connections end up routed", ) + parser.add_argument( + "--no-tree", + dest="tree_nets", + action="store_false", + help="grid and exact engines: join a multi-pin net's pins as a chain in pin " + "order, each connection ending on one chosen pin, as before. By default they are " + "paired by a minimum spanning tree and a connection may end on copper its net " + "has already routed", + ) parser.add_argument( "--hole-to-hole", type=float, @@ -223,6 +233,7 @@ def main(argv: list[str] | None = None) -> int: ("access", args.access_cost is not None), ("negotiate", args.negotiate is not None), ("rollback", not args.rollback), + ("tree", not args.tree_nets), ) if asked ] @@ -260,6 +271,7 @@ def main(argv: list[str] | None = None) -> int: "negotiate": args.negotiate or 0, "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, + "tree_nets": args.tree_nets, } elif args.engine == "grid": options = { @@ -272,6 +284,7 @@ def main(argv: list[str] | None = None) -> int: "negotiate": args.negotiate or 0, "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, + "tree_nets": args.tree_nets, } if args.engine == "room": result = build_rooms_routing_result( diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 2b0f9b9..bc80ec8 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -102,6 +102,7 @@ def route_board_exact( access_cost: float = 0.0, negotiate: int = 0, rollback: bool = True, + tree_nets: bool = True, net_widths: dict[int, int] | None = None, via_drill: int | None = None, hole_to_hole: int = 0, @@ -138,6 +139,7 @@ def route_board_exact( access_cost=access_cost, negotiate=negotiate, rollback=rollback, + tree_nets=tree_nets, net_widths=net_widths, via_drill=via_drill, hole_to_hole=hole_to_hole, @@ -194,12 +196,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) + anchors_list = getattr(result, "connection_anchors", None) 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] + # a connection that ended on another's copper (a T into its net's tree) + # dangles if that one is dropped, so it is dropped too; connections come + # in commit order, anchors first + dropped_at: set[int] = set() + for pos, ((net_no, wires, vias), ends, anchors) in sorted( + enumerate(zip(result.connections, ends_list, anchors_list, strict=False)), + key=lambda c: c[1][0][0], ): + if any(a in dropped_at for a in anchors): + dropped_at.add(pos) + rejected.append((net_no, *ends)) + continue wire_half = net_half.get(net_no, half_width) pieces = _copper_pieces(wires, vias, wire_half, via_layers, via_half) boxes = [(box, box_layers) for box, box_layers, _ in pieces] @@ -220,6 +232,7 @@ def route_board_exact( ) ): rejected.append((net_no, *ends)) + dropped_at.add(pos) continue # exact-clearance conflict -> drop (may be recovered by shove) acc = accepted.get(net_no) if acc is None: diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index ce5540a..28529ff 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -65,7 +65,7 @@ class RouteResult: __slots__ = ( "wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed", - "net_half", "narrowed", + "net_half", "narrowed", "connection_anchors", ) def __init__(self, half_width: int) -> None: @@ -79,6 +79,9 @@ class RouteResult: #: what each routed connection joins, and the zone both ends sit in #: (or ""), parallel to ``connections`` self.connection_ends: list[tuple[str, str, str]] = [] + #: for each routed connection, the earlier ones (positions in + #: ``connections``) whose copper it ended on, parallel to ``connections`` + self.connection_anchors: list[tuple[int, ...]] = [] #: connections left unrouted: (net_no, one end, the other end) self.failed: list[tuple[int, str, str]] = [] #: half width of each net routed wider than the default (its class) @@ -127,6 +130,9 @@ class _Connection: "half", "stubs", "stub_near", + "anchors", + "copper", + "seq", ) def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: @@ -158,6 +164,14 @@ class _Connection: # routed at the default width instead; the half width it went in at self.narrow = False self.half = 0 + # connections whose copper this one ended on (a T into its net's + # tree): ripping one of them leaves this one dangling + self.anchors: frozenset[int] = frozenset() + # the nodes of its committed copper (path cells, via cells on every + # layer), where another connection of its net may end + self.copper: frozenset[Node] = frozenset() + # commit order: a connection is always committed after its anchors + self.seq = 0 class GridRouter: @@ -188,8 +202,17 @@ class GridRouter: net_widths: dict[int, int] | None = None, via_drill: int | None = None, hole_to_hole: int = 0, + tree_nets: bool = True, ) -> None: self.board = board + # Multi-pin nets: pair a net's pin groups by a minimum spanning tree, + # and let a connection start or end on copper its net already routed + # (a T into the tree), not only on one chosen pin. + self.tree = bool(tree_nets) + self._dependents: dict[int, set[int]] = {} + self._commits = 0 + self._plane_nets: set[int] = set() + self._net_conns: dict[int, list[_Connection]] = {} # Pin access: while a pin still has connections to route, the cells # its route would leave by (its end cells, the via it may need there, # and the clearance around both) cost other nets this much extra, so @@ -699,6 +722,13 @@ class GridRouter: goal_cell = self._group_target(conn.a_group) starts = self._group_ends(conn.a_group, conn.net_no, conns, allow_ripup) goals = self._group_ends(conn.b_group, conn.net_no, conns, allow_ripup) + # either side may also start or end on copper its net already routed + a_tree = self._tree_ends(conn, conn.a_group) + b_tree = self._tree_ends(conn, conn.b_group) + for node in a_tree: + starts.setdefault(node, None) + for node in b_tree: + goals.setdefault(node, None) if not starts or not goals: return None # A start that is already an end joins at once, except that ending on @@ -711,6 +741,7 @@ class GridRouter: ] if common: conn.a_end, conn.b_end = starts[common[0]], goals[common[0]] + conn.anchors = _anchors([common[0]], starts, goals, a_tree, b_tree) return [common[0]] net_no = conn.net_no @@ -776,6 +807,7 @@ class GridRouter: if ok: path = _reconstruct(came_from, current) conn.a_end, conn.b_end = starts.get(path[0]), end + conn.anchors = _anchors(path, starts, goals, a_tree, b_tree) return path cx, cy, cl = current base = g_score[current] @@ -822,6 +854,49 @@ class GridRouter: ) return None + def _tree_ends(self, conn: _Connection, group) -> dict[Node, int]: + """Copper its net has already routed on ``group``'s side of ``conn``, + each node mapped to the connection that laid it: a route may start or + end anywhere on it. Empty for plane nets, which keep their drops to + the plane, and when tree routing is off.""" + if not self.tree or conn.net_no in self._plane_nets: + return {} + adjacent: dict[int, list[tuple[int, _Connection]]] = {} + for c in self._net_conns.get(conn.net_no, ()): + if c is conn or not c.routed: + continue + a, b = id(c.a_group), id(c.b_group) + adjacent.setdefault(a, []).append((b, c)) + adjacent.setdefault(b, []).append((a, c)) + owners: dict[Node, int] = {} + seen = {id(group)} + stack = [id(group)] + while stack: + for other, c in adjacent.get(stack.pop(), ()): + for node in c.copper: + # a walled cell's via moved into the pad: no copper there + if node not in self._keepout and node not in self._via_only: + owners.setdefault(node, c.index) + if other not in seen: + seen.add(other) + stack.append(other) + return owners + + def _with_dependents(self, victims: list[_Connection], conns: list[_Connection]) -> list[_Connection]: + """``victims`` and every routed connection that ended on their copper, + directly or through another: ripping a route rips the routes teed onto + it, which would otherwise dangle. Anchors come before what hangs on + them, as they were committed.""" + found = {v.index: v for v in victims} + stack = list(victims) + while stack: + for i in self._dependents.get(stack.pop().index, ()): + c = conns[i] + if c.routed and i not in found: + found[i] = c + stack.append(c) + return sorted(found.values(), key=lambda c: (c.seq, c.index)) + # --- pin stubs ---------------------------------------------------------- # A pin rarely sits on a cell centre, so every route starts and ends with a # short stub from the pin to its first cell. The grid has to see that @@ -1431,6 +1506,13 @@ class GridRouter: # via k joins wire segments k and k + 1 conn.vias[0 if idx == 0 else idx - 1] = location conn.routed = True + self._commits += 1 + conn.seq = self._commits + conn.copper = frozenset(path) | frozenset( + (*cell, layer) for cell in conn.via_cells for layer in self.layers + ) + for anchor in conn.anchors: + self._dependents.setdefault(anchor, set()).add(conn.index) self._settle(conn, -1) def _forget_stubs(self, conn: _Connection) -> None: @@ -1473,6 +1555,12 @@ class GridRouter: conn.wires = [] conn.vias = [] conn.routed = False + for anchor in conn.anchors: + hanging = self._dependents.get(anchor) + if hanging is not None: + hanging.discard(conn.index) + conn.anchors = frozenset() + conn.copper = frozenset() conn.rip_count += 1 self._settle(conn, +1) @@ -1688,7 +1776,7 @@ class GridRouter: crossed |= self._stub_owners( end, node[:2], node[2], conn.net_no, conns, self._conn_half(conn) ) - victims = [conns[i] for i in sorted(crossed)] + victims = self._with_dependents([conns[i] for i in sorted(crossed)], conns) if self.rollback: return self._ripup_transaction(conn, path, victims, conns) for victim in victims: @@ -1699,7 +1787,7 @@ class GridRouter: 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] + saved = [(v, v.path, v.a_end, v.b_end, v.rip_count, v.anchors) for v in victims] own_rips = conn.rip_count for victim in victims: self._rip(victim) @@ -1717,8 +1805,8 @@ class GridRouter: 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 + for victim, old_path, a_end, b_end, rips, anchors in saved: + victim.a_end, victim.b_end, victim.anchors = a_end, b_end, anchors self._commit(victim, old_path) victim.rip_count = rips return False @@ -1740,6 +1828,7 @@ class GridRouter: planes_by_net.setdefault(zone.net_nos[0], []).append(zone) plane_ids = frozenset(id(z) for zs in planes_by_net.values() for z in zs) conns: list[_Connection] = [] + self._plane_nets: set[int] = set() nets = set(pins_by_net) | set(zones_by_net) | set(planes_by_net) # deterministic: fewer-pin nets first, then by net number for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)): @@ -1752,12 +1841,18 @@ class GridRouter: # a plane net: every other group goes straight to the plane # (usually a stub and a via), not pad to pad across the board pairs = [(g, hubs[0]) for g in groups if g is not hubs[0]] + self._plane_nets.add(net_no) + elif self.tree: + pairs = _spanning_pairs(groups) else: pairs = list(zip(groups, groups[1:], strict=False)) for ga, gb in pairs: conn = _Connection(len(conns), net_no, _first(ga[0]), _first(gb[0])) conn.a_group, conn.b_group = ga, gb conns.append(conn) + self._net_conns: dict[int, list[_Connection]] = {} + for conn in conns: + self._net_conns.setdefault(conn.net_no, []).append(conn) return conns def _fixed_pieces(self, net_no: int) -> list[_FixedPiece]: @@ -1807,18 +1902,22 @@ class GridRouter: return wide, narrowed def _snapshot(self, conns: list[_Connection]) -> list[tuple]: + # in commit order, so a connection teed onto another comes after it; + # its anchors as positions in this list + routed = sorted((c for c in conns if c.routed), key=lambda c: (c.seq, c.index)) + position = {c.index: i for i, c in enumerate(routed)} return [ (c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias), - (*_ends_of(c), self._left_to_zone(c))) - for c in conns - if c.routed + (*_ends_of(c), self._left_to_zone(c)), + tuple(sorted(position[a] for a in c.anchors if a in position))) + for c in routed ] def _result_from(self, snapshot, unrouted: int) -> RouteResult: result = RouteResult(self.half_width) written: set[tuple[int, IntPoint]] = set() result.unrouted = unrouted - for net_no, wires, vias, ends in snapshot: + for net_no, wires, vias, ends, anchors in snapshot: for layer, points in wires: result.add_wire(net_no, layer, points) for location in vias: @@ -1829,7 +1928,8 @@ class GridRouter: 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.connection_anchors.append(anchors) + routed = {ends[:2] + (net_no,) for net_no, _w, _v, ends, _a in snapshot} result.failed = [ (c.net_no, *_ends_of(c), self._left_to_zone(c)) for c in self._conns @@ -1842,6 +1942,52 @@ def _first(items): return items[0] if items else None +def _anchors(path, starts, goals, a_tree, b_tree) -> frozenset[int]: + """The connections whose copper a found path starts or ends on.""" + found = set() + if starts.get(path[0]) is None and path[0] in a_tree: + found.add(a_tree[path[0]]) + if goals.get(path[-1]) is None and path[-1] in b_tree: + found.add(b_tree[path[-1]]) + return frozenset(found) + + +def _group_points(group) -> list[tuple[int, int]]: + pins, zones = group + if pins: + return [(p.location.x, p.location.y) for p in pins] + points = [] + for zone in zones: + box = zone.bounding_box() + points.append(((box.ll.x + box.ur.x) // 2, (box.ll.y + box.ur.y) // 2)) + return points or [(0, 0)] + + +def _spanning_pairs(groups) -> list[tuple]: + """A minimum spanning tree over a net's groups (Prim, Manhattan distance + between their nearest pins), as (in the tree, joining it) pairs: each + connection joins the nearest group not yet joined, not the next in pin + order.""" + if len(groups) < 2: + return [] + points = [_group_points(g) for g in groups] + + def gap(i: int, j: int) -> int: + return min(abs(ax - bx) + abs(ay - by) for ax, ay in points[i] for bx, by in points[j]) + + best = {j: (gap(0, j), 0) for j in range(1, len(groups))} + pairs = [] + while best: + j = min(best, key=lambda k: (best[k][0], k)) + _, i = best.pop(j) + pairs.append((groups[i], groups[j])) + for k in best: + d = gap(j, k) + if d < best[k][0]: + best[k] = (d, j) + return pairs + + def _group_label(group) -> str: pins, zones = group parts = [f"{p.component_ref}-{p.name}" for p in pins[:3]] @@ -2039,6 +2185,7 @@ def route_board( access_cost: float = 0.0, negotiate: int = 0, rollback: bool = True, + tree_nets: bool = True, net_widths: dict[int, int] | None = None, via_drill: int | None = None, hole_to_hole: int = 0, @@ -2062,6 +2209,7 @@ def route_board( access_cost=access_cost, negotiate=negotiate, rollback=rollback, + tree_nets=tree_nets, net_widths=net_widths, via_drill=via_drill, hole_to_hole=hole_to_hole, diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 5d46d14..5195431 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -121,6 +121,7 @@ def route_dsn_board( negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, + tree_nets: bool = True, ) -> tuple[RouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, and the layer names. ``layers`` restricts routing to those signal-layer @@ -146,6 +147,7 @@ def route_dsn_board( access_cost=access_cost, negotiate=negotiate, rollback=rollback, + tree_nets=tree_nets, net_widths=_net_widths_board(dsn, scale), via_drill=_via_drill_board(dsn, scale), hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, @@ -201,6 +203,7 @@ def build_routing_result( negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, + tree_nets: bool = True, ) -> RoutingResult: """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" route, scale, layer_names = route_dsn_board( @@ -211,6 +214,7 @@ def build_routing_result( negotiate=negotiate, hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, + tree_nets=tree_nets, ) return _to_routing_result(route, dsn, scale, layer_names) @@ -232,6 +236,7 @@ def route_dsn_board_exact( negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, + tree_nets: bool = True, ) -> tuple[ExactRouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, DRC-verified) track; return the exact result, the scale, and layer names. @@ -261,6 +266,7 @@ def route_dsn_board_exact( access_cost=access_cost, negotiate=negotiate, rollback=rollback, + tree_nets=tree_nets, net_widths=_net_widths_board(dsn, scale), via_drill=_via_drill_board(dsn, scale), hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, @@ -285,6 +291,7 @@ def build_exact_routing_result( negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, + tree_nets: bool = True, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( @@ -303,6 +310,7 @@ def build_exact_routing_result( negotiate=negotiate, hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, + tree_nets=tree_nets, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/tests/route/test_tree_nets.py b/tests/route/test_tree_nets.py new file mode 100644 index 0000000..0fdf6f2 --- /dev/null +++ b/tests/route/test_tree_nets.py @@ -0,0 +1,141 @@ +"""Multi-pin nets: spanning-tree pairs, and connections that end on copper +their net has already routed (a T into the tree).""" + +from __future__ import annotations + +import pytest + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.route import exact_router +from freeroute.route.grid_router import GridRouter, RouteResult +from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn + +# P1 and P2 are nearest each other, so the tree pairs them first; P3 sits +# under the middle of that leg, 50 mm below it and 90 mm from either pin. +TEE = """(pcb "tee.dsn" + (parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1")) + (resolution um 10) + (unit um) + (structure + (layer F.Cu (type signal) (property (index 0))) + (boundary (path pcb 0 0 0 200000 0 200000 -100000 0 -100000 0 0)) + (rule (width 2000) (clearance 2000)) + ) + (library + (padstack Rect_Pad (shape (rect F.Cu -1000 -1000 1000 1000)) (attach off)) + (image PAD (pin Rect_Pad 1 0 0)) + ) + (placement + (component PAD + (place P1 60000 -20000 front 0) + (place P2 140000 -20000 front 0) + (place P3 100000 -70000 front 0) + ) + ) + (network + (net N (pins P1-1 P2-1 P3-1)) + (class default (rule (width 2000) (clearance 2000))) + ) + (wiring) +) +""" + + +def _router(tree_nets=True, text=TEE): + dsn = parse_dsn(text) + s = dsn.resolution.value + return GridRouter( + build_board(dsn), + trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), + orthogonal=True, + tree_nets=tree_nets, + ) + + +def test_pairs_follow_a_spanning_tree_not_the_pin_order(): + router = _router() + conns = router._build_connections() + pairs = [(c.a.component_ref, c.b.component_ref) for c in conns] + assert pairs == [("P1", "P2"), ("P1", "P3")] + chain = _router(tree_nets=False)._build_connections() + assert [(c.a.component_ref, c.b.component_ref) for c in chain] == [("P1", "P2"), ("P2", "P3")] + + +def test_the_third_pin_tees_into_the_first_leg(): + router = _router() + result = router.route() + first, second = router._conns + assert result.unrouted == 0 + # the second connection starts on the first one's copper, mid-leg + start = second.path[0] + assert start in first.copper and second.a_end is None + assert second.anchors == {first.index} + assert router._dependents[first.index] == {second.index} + assert abs(router._cell_center(start[:2]).x - 1_000_000) <= router.step + # and so its copper is the 50 mm drop, not 90 mm back to a pin + assert len(second.path) < len(router._conns[0].path) + + +def test_without_trees_each_connection_ends_on_a_pin(): + router = _router(tree_nets=False) + router.route() + assert all(c.anchors == frozenset() for c in router._conns) + assert all(c.a_end is not None and c.b_end is not None for c in router._conns) + + +def test_ripping_an_anchor_takes_what_hangs_on_it(): + router = _router() + router.route() + first, second = router._conns + victims = router._with_dependents([first], router._conns) + assert victims == [first, second] # anchors first, as committed + for victim in victims: + router._rip(victim) + assert not first.routed and not second.routed + assert not router._dependents.get(first.index) + assert not router._cell_owner + + +def test_the_snapshot_lists_anchors_before_what_hangs_on_them(): + router = _router() + result = router.route() + assert result.connection_anchors == [(), (0,)] + + +def test_the_exact_engine_drops_a_tee_whose_anchor_it_rejects(monkeypatch): + """A connection teed onto copper the exact check refuses would dangle.""" + dsn = parse_dsn(TEE) + board = build_board(dsn) + real = exact_router.route_board + + def grid_then_spoil(*args, **kwargs): + result: RouteResult = real(*args, **kwargs) + assert result.connection_anchors == [(), (0,)] + # move the first leg off the board edge so the exact check rejects it + net, wires, vias = result.connections[0] + result.connections[0] = (net, [(layer, [type(p)(p.x, p.y + 2_000_000) for p in pts]) + for layer, pts in wires], vias) + return result + + monkeypatch.setattr(exact_router, "route_board", grid_then_spoil) + s = dsn.resolution.value + exact = exact_router.route_board_exact( + board, + trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), + ) + assert len(exact.result.failed) == 2 # the leg and the T hanging on it + assert not exact.result.wires + + +@pytest.mark.parametrize("tree_nets", [True, False]) +def test_occupancy_stays_honest(tree_nets): + router = _router(tree_nets) + router.route() + conns = router._conns + assert all(conns[i].routed for i in router._cell_owner.values()) + for anchor, hanging in router._dependents.items(): + for i in hanging: + assert conns[i].routed and conns[anchor].routed diff --git a/tests/test_cli.py b/tests/test_cli.py index a866609..601f7bd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -160,6 +160,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "access": ["--access-cost", "5"], "negotiate": ["--negotiate", "2"], "rollback": ["--no-rollback"], + "tree": ["--no-tree"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: From 61f51129e12cc603a03cb7a3bb798d23518d5561 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 5 Oct 2026 12:58:58 -0600 Subject: [PATCH 2/2] A T depends on every connection between its copper and its group Anchoring a T only on the connection whose copper it touched missed the connections that tie that copper to the T's own group. When one of those was ripped, the T stayed routed but cut off: on charge-sense freeroute counted 15 unrouted while KiCad found 27 opens. Routed connections of a net form a forest, so the path back is unique; the T now depends on all of it. KiCad's opens and freeroute's count agree again (18 against 17, the extra one a GND pour island left to the fill). --- src/freeroute/route/grid_router.py | 39 ++++++++++++++++++------------ tests/route/test_tree_nets.py | 29 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 28529ff..a227a4f 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -854,11 +854,14 @@ class GridRouter: ) return None - def _tree_ends(self, conn: _Connection, group) -> dict[Node, int]: - """Copper its net has already routed on ``group``'s side of ``conn``, - each node mapped to the connection that laid it: a route may start or - end anywhere on it. Empty for plane nets, which keep their drops to - the plane, and when tree routing is off.""" + def _tree_ends(self, conn: _Connection, group) -> dict[Node, frozenset[int]]: + """Copper its net has already routed on ``group``'s side of ``conn``: a + route may start or end anywhere on it. Each node maps to what a route + ending there relies on: the connection that laid the copper and every + one between it and ``group`` (routed connections of a net form a + forest, so that path is unique). Ripping any of them would cut the + route off. Empty for plane nets, which keep their drops to the plane, + and when tree routing is off.""" if not self.tree or conn.net_no in self._plane_nets: return {} adjacent: dict[int, list[tuple[int, _Connection]]] = {} @@ -868,18 +871,21 @@ class GridRouter: a, b = id(c.a_group), id(c.b_group) adjacent.setdefault(a, []).append((b, c)) adjacent.setdefault(b, []).append((a, c)) - owners: dict[Node, int] = {} - seen = {id(group)} + owners: dict[Node, frozenset[int]] = {} + path_to: dict[int, frozenset[int]] = {id(group): frozenset()} stack = [id(group)] while stack: - for other, c in adjacent.get(stack.pop(), ()): + here = stack.pop() + for other, c in adjacent.get(here, ()): + if other in path_to: + continue + path = path_to[here] | {c.index} + path_to[other] = path + stack.append(other) for node in c.copper: # a walled cell's via moved into the pad: no copper there if node not in self._keepout and node not in self._via_only: - owners.setdefault(node, c.index) - if other not in seen: - seen.add(other) - stack.append(other) + owners.setdefault(node, path) return owners def _with_dependents(self, victims: list[_Connection], conns: list[_Connection]) -> list[_Connection]: @@ -1943,12 +1949,13 @@ def _first(items): def _anchors(path, starts, goals, a_tree, b_tree) -> frozenset[int]: - """The connections whose copper a found path starts or ends on.""" - found = set() + """What a found path relies on: the connections between the copper it + starts or ends on and its own groups.""" + found: set[int] = set() if starts.get(path[0]) is None and path[0] in a_tree: - found.add(a_tree[path[0]]) + found |= a_tree[path[0]] if goals.get(path[-1]) is None and path[-1] in b_tree: - found.add(b_tree[path[-1]]) + found |= b_tree[path[-1]] return frozenset(found) diff --git a/tests/route/test_tree_nets.py b/tests/route/test_tree_nets.py index 0fdf6f2..74c197a 100644 --- a/tests/route/test_tree_nets.py +++ b/tests/route/test_tree_nets.py @@ -139,3 +139,32 @@ def test_occupancy_stays_honest(tree_nets): for anchor, hanging in router._dependents.items(): for i in hanging: assert conns[i].routed and conns[anchor].routed + + +# P1-P2 along the top, P3 far off to the lower right, P4 just under the +# middle of the P1-P2 leg. +CHAIN_TEE = TEE.replace( + """ (place P3 100000 -70000 front 0) +""", + """ (place P3 190000 -90000 front 0) + (place P4 100000 -40000 front 0) +""", +).replace("(pins P1-1 P2-1 P3-1)", "(pins P1-1 P2-1 P3-1 P4-1)") + + +def test_a_tee_depends_on_every_connection_back_to_its_group(): + """Joined as P1-P2, P2-P3, P3-P4, P4's connection tees onto P1-P2 copper, + which reaches P3's group only through P2-P3. Ripping P2-P3 must take the T + too, or it would be counted routed while cut off from P3.""" + router = _router(text=CHAIN_TEE) + conns = router._conns = router._build_connections() + groups = {c.a.component_ref: c.a_group for c in conns} + groups.update({c.b.component_ref: c.b_group for c in conns}) + for conn, (a, b) in zip(conns, (("P1", "P2"), ("P2", "P3"), ("P3", "P4")), strict=True): + conn.a_group, conn.b_group = groups[a], groups[b] + for conn in conns: + assert router._try_route(conn, conns) + first, second, third = conns + assert third.path[0] in first.copper # the T lands on the far leg + assert third.anchors == {first.index, second.index} + assert router._with_dependents([second], conns) == [second, third]