From 27f7202d2fb6e7f9ea7745ceb94659805e43fc8c Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 6 Oct 2026 15:04:02 -0600 Subject: [PATCH] --window-reroute N: route a finer grid around each connection still stuck After the exact engine, every net routed wholly inside a window around a stuck connection's pins is ripped, the rest of the board is laid down as fixed copper, and the window alone is routed again at --window-resolution (default 4) with the stuck net and the ripped nets only. The result is kept only if fewer connections are left unrouted; each round doubles the margin. The grid and exact routers gain only_nets and bounds for it. Off by default. --- src/freeroute/cli.py | 33 +++++- src/freeroute/route/exact_router.py | 11 +- src/freeroute/route/grid_router.py | 17 +++ src/freeroute/route/pipeline.py | 55 ++++++--- src/freeroute/route/window.py | 176 ++++++++++++++++++++++++++++ tests/route/test_window_reroute.py | 95 +++++++++++++++ tests/test_cli.py | 1 + 7 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 src/freeroute/route/window.py create mode 100644 tests/route/test_window_reroute.py diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 6dfa7ac..7b3bafb 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -31,7 +31,10 @@ from freeroute.ses import write_ses #: 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", "tree"}), - "exact": frozenset({"shove", "diagonal", "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", "window", + }), "room": frozenset({"shove", "pack"}), } @@ -49,6 +52,7 @@ _FLAG_NAMES = { "negotiate": "--negotiate", "rollback": "--no-rollback", "tree": "--no-tree", + "window": "--window-reroute", } @@ -194,6 +198,29 @@ def build_parser() -> argparse.ArgumentParser: "paired by a minimum spanning tree and a connection may end on copper its net " "has already routed", ) + parser.add_argument( + "--window-reroute", + type=int, + metavar="N", + help="exact engine: after routing, N rounds of window re-routes: around each " + "connection still unrouted, the nets routed wholly inside a window are ripped and " + "the window routed again on a finer grid, kept only if fewer connections are left " + "unrouted; each round doubles the window's margin (default 0: off)", + ) + parser.add_argument( + "--window-resolution", + type=int, + default=4, + metavar="K", + help="the window re-route's grid resolution (default 4)", + ) + parser.add_argument( + "--window-margin", + type=float, + default=1.5, + metavar="MM", + help="the first round's window margin around the stuck connection's pins (default 1.5)", + ) parser.add_argument( "--hole-to-hole", type=float, @@ -234,6 +261,7 @@ def main(argv: list[str] | None = None) -> int: ("negotiate", args.negotiate is not None), ("rollback", not args.rollback), ("tree", not args.tree_nets), + ("window", args.window_reroute is not None), ) if asked ] @@ -272,6 +300,9 @@ def main(argv: list[str] | None = None) -> int: "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, "tree_nets": args.tree_nets, + "window_reroute": args.window_reroute or 0, + "window_resolution": args.window_resolution, + "window_margin_mm": args.window_margin, } elif args.engine == "grid": options = { diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index bc80ec8..f842b71 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -106,6 +106,8 @@ def route_board_exact( net_widths: dict[int, int] | None = None, via_drill: int | None = None, hole_to_hole: int = 0, + only_nets: frozenset | None = None, + bounds: IntBox | None = None, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -143,6 +145,8 @@ def route_board_exact( net_widths=net_widths, via_drill=via_drill, hole_to_hole=hole_to_hole, + only_nets=only_nets, + bounds=bounds, ) edge = 0 if edge_clearance is None else edge_clearance outline = board.outline @@ -259,7 +263,10 @@ def route_board_exact( # a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer) # but was not accepted — whether the grid failed to route it or its exact # copper conflicted. - dropped = sorted(_routable_nets(board, signal_layers) - set(accepted)) + routable = _routable_nets(board, signal_layers) + if only_nets is not None: # a window re-route: the other nets are not ours to route + routable &= set(only_nets) + dropped = sorted(routable - set(accepted)) shoves = 0 if shove and dropped: @@ -284,7 +291,7 @@ def route_board_exact( if diagonal: outline = board.bounding_box - for net_no in sorted(_routable_nets(board, signal_layers)): + for net_no in sorted(routable): _diagonalize( net_no, board, tree, accepted, owner, signal_layers, half_width, clearance, outline ) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index a227a4f..5079cf9 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -203,8 +203,14 @@ class GridRouter: via_drill: int | None = None, hole_to_hole: int = 0, tree_nets: bool = True, + only_nets: frozenset | None = None, + bounds: IntBox | None = None, ) -> None: self.board = board + # A window re-route routes some nets inside part of the board: only + # these nets get connections, and the grid covers only ``bounds`` + # (cells outside it do not exist, so nothing is routed there). + self.only_nets = frozenset(only_nets) if only_nets is not None else None # 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. @@ -324,6 +330,11 @@ class GridRouter: self.layers = list(layers) box = board.bounding_box + if bounds is not None: + box = IntBox( + max(box.ll.x, bounds.ll.x), max(box.ll.y, bounds.ll.y), + min(box.ur.x, bounds.ur.x), min(box.ur.y, bounds.ur.y), + ) 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) @@ -1837,6 +1848,8 @@ class GridRouter: 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 + if self.only_nets is not None: + nets &= self.only_nets 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( @@ -2196,6 +2209,8 @@ def route_board( net_widths: dict[int, int] | None = None, via_drill: int | None = None, hole_to_hole: int = 0, + only_nets: frozenset | None = None, + bounds: IntBox | None = None, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -2220,5 +2235,7 @@ def route_board( net_widths=net_widths, via_drill=via_drill, hole_to_hole=hole_to_hole, + only_nets=only_nets, + bounds=bounds, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 5195431..3b477a6 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -21,6 +21,7 @@ from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from .exact_router import ExactRouteResult, route_board_exact from .grid_router import RouteResult, route_board from .room_router import route_board_rooms +from .window import window_reroute as window_reroute_stage __all__ = ["route", "route_dsn_board", "build_routing_result"] @@ -237,6 +238,9 @@ def route_dsn_board_exact( hole_to_hole_mm: float = 0.25, rollback: bool = True, tree_nets: bool = True, + window_reroute: int = 0, + window_resolution: int = 4, + window_margin_mm: float = 1.5, ) -> 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. @@ -248,29 +252,44 @@ def route_dsn_board_exact( edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm) width_board = round(_rule_width_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale) + # the settings a window re-route repeats, at its own resolution + common = { + "heuristic_weight": heuristic_weight, + "via_half": _via_half_board(dsn, scale), + "edge_clearance": edge_clearance_board, + "trace_width": width_board, + "clearance": clearance_board, + "layers": layers, + "max_passes": max_passes, + "rip_up": rip_up, + "shove": shove, + "diagonal": diagonal, + "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, + } exact = route_board_exact( board, resolution=resolution, - heuristic_weight=heuristic_weight, - via_half=_via_half_board(dsn, scale), - edge_clearance=edge_clearance_board, - trace_width=width_board, - clearance=clearance_board, - layers=layers, - max_passes=max_passes, - rip_up=rip_up, - shove=shove, - diagonal=diagonal, seed=seed, route_first=route_first, 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, + **common, ) + if window_reroute > 0: + via_half = common["via_half"] or max(width_board // 2, 1) + exact.result = window_reroute_stage( + dsn, exact.result, common, + rounds=window_reroute, + resolution=max(int(window_resolution), resolution), + margin=_mm_to_board(dsn, scale, window_margin_mm) or 0, + via_half=via_half, + via_drill=common["via_drill"] or 2 * via_half, + via_name=_via_padstack(dsn), + ) return exact, scale, [layer.name for layer in dsn.layers] @@ -292,6 +311,9 @@ def build_exact_routing_result( hole_to_hole_mm: float = 0.25, rollback: bool = True, tree_nets: bool = True, + window_reroute: int = 0, + window_resolution: int = 4, + window_margin_mm: float = 1.5, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( @@ -311,6 +333,9 @@ def build_exact_routing_result( hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, tree_nets=tree_nets, + window_reroute=window_reroute, + window_resolution=window_resolution, + window_margin_mm=window_margin_mm, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/src/freeroute/route/window.py b/src/freeroute/route/window.py new file mode 100644 index 0000000..4de530d --- /dev/null +++ b/src/freeroute/route/window.py @@ -0,0 +1,176 @@ +"""Window re-route: a last stage for the connections still unrouted. + +Around each stuck connection a window is cut: the box of its two end pins +plus a margin. Every net whose routed copper lies wholly inside it is +ripped; everything else stays, as fixed copper. The window is then routed +again on its own, on a finer grid than the whole board can afford, the +stuck connection's net and the ripped nets only. The result is kept only +if fewer connections are left unrouted than before; otherwise the board is +as it was. Each round doubles the margin for the connections still stuck. + +What makes it differ from re-running the same search near the failure: the +finer grid finds gaps the board's grid steps over, and the ripped nets go in +again in a new order around the stuck one. A finer grid on the whole board +costs routes elsewhere (its cells are slower and its order changes); inside +a small window it only ever replaces what it can improve. +""" + +from __future__ import annotations + +from typing import Any + +from freeroute.board import build_board +from freeroute.board.items import Trace, Via +from freeroute.dsn import DsnBoard +from freeroute.geometry import IntBox, IntPoint + +from .exact_router import route_board_exact +from .grid_router import RouteResult + +Copper = tuple[list[tuple[int, list[IntPoint]]], list[IntPoint]] + + +def _pin_labels(board) -> dict[tuple[int, str], IntPoint]: + """Each pin's location by (net, label), the label a failure names it by.""" + found: dict[tuple[int, str], IntPoint] = {} + for pin in board.get_pins(): + for net in pin.net_nos: + found.setdefault((net, f"{pin.component_ref}-{pin.name}"), pin.location) + return found + + +def _end_point(labels, net: int, label: str) -> IntPoint | None: + """Where a failure's end is: the first pin its label names.""" + return labels.get((net, label.split("/")[0])) + + +def _inside(copper: Copper, window: IntBox) -> bool: + wires, vias = copper + points = [p for _layer, path in wires for p in path] + list(vias) + return bool(points) and all( + window.ll.x <= p.x <= window.ur.x and window.ll.y <= p.y <= window.ur.y for p in points + ) + + +def _board_with( + dsn: DsnBoard, copper: dict[int, Copper], keep: set[int], half: dict[int, int], + default_half: int, via_half: int, via_drill: int, layers: list[int], via_name: str, +): + """The board with the routed copper of ``keep`` laid down as fixed copper.""" + board = build_board(dsn) + for net in keep: + wires, vias = copper.get(net, ([], [])) + width = half.get(net, default_half) + for layer, path in wires: + if len(path) >= 2: + board.insert(Trace(net_nos=[net], fixed=True, layer=layer, half_width=width, corners=list(path))) + for location in dict.fromkeys(vias): + board.insert(Via( + net_nos=[net], fixed=True, padstack_name=via_name, drill=via_drill, + location=location, layers=list(layers), + shape=IntBox(location.x - via_half, location.y - via_half, + location.x + via_half, location.y + via_half), + )) + return board + + +def _open(failed) -> list: + """Failures that are real opens, not connections left to a zone fill.""" + return [f for f in failed if not (len(f) > 3 and f[3])] + + +def window_reroute( + dsn: DsnBoard, + result: RouteResult, + route_args: dict[str, Any], + *, + rounds: int, + resolution: int, + margin: int, + via_half: int, + via_drill: int, + via_name: str, +) -> RouteResult: + """Re-route a window around each still-failing connection; keep only gains. + + ``route_args`` are the exact engine's settings for the whole board (width, + clearance, layers, shove, ...); the window runs them at ``resolution``. + ``margin`` is the first round's window margin in board units.""" + if rounds < 1 or not _open(result.failed): + return result + default_half = result.half_width + copper: dict[int, Copper] = { + net: (list(result.wires.get(net, [])), list(result.vias.get(net, []))) + for net in set(result.wires) | set(result.vias) + } + half = dict(result.net_half) + failed = list(result.failed) + layers = route_args.get("layers") or [ + i for i, layer in enumerate(build_board(dsn).layer_structure.arr) if layer.is_signal + ] + labels = _pin_labels(build_board(dsn)) + + changed = False + for rnd in range(rounds): + reach = margin * (2 ** rnd) + progress = False + for entry in _open(list(failed)): + if entry not in failed: + continue # an earlier window routed it + net = entry[0] + ends = [_end_point(labels, net, label) for label in entry[1:3]] + ends = [p for p in ends if p is not None] + if not ends: + continue + window = IntBox( + min(p.x for p in ends) - reach, min(p.y for p in ends) - reach, + max(p.x for p in ends) + reach, max(p.y for p in ends) + reach, + ) + victims = {n for n, c in copper.items() if _inside(c, window)} + affected = victims | {net} + before = _open(f for f in failed if f[0] in affected) + keep = set(copper) - victims + board = _board_with(dsn, copper, keep, half, default_half, via_half, via_drill, layers, via_name) + sub = route_board_exact( + board, **route_args, resolution=resolution, + only_nets=frozenset(affected), bounds=window, + route_first=frozenset((f[0], f[1], f[2]) for f in before), + ).result + after = _open(f for f in sub.failed if f[0] in affected) + if len(after) >= len(before): + continue + # a net keeping some of its copper is written at one width: the + # window's new copper has to have gone in at that width too + if any( + n not in victims and copper.get(n) and (sub.wires.get(n) or sub.vias.get(n)) + and sub.net_half.get(n, default_half) != half.get(n, default_half) + for n in affected + ): + continue + for n in affected: + kept_wires, kept_vias = copper.get(n, ([], [])) if n not in victims else ([], []) + copper[n] = (kept_wires + sub.wires.get(n, []), kept_vias + sub.vias.get(n, [])) + if n in victims: + half.pop(n, None) + if n in sub.net_half: + half[n] = sub.net_half[n] + failed = [f for f in failed if f[0] not in affected] + [ + f for f in sub.failed if f[0] in affected + ] + progress = changed = True + if not progress and not _open(failed): + break + + if not changed: + return result + out = RouteResult(default_half) + for net, (wires, vias) in copper.items(): + for layer, path in wires: + out.add_wire(net, layer, path) + for location in dict.fromkeys(vias): + out.add_via(net, location) + out.failed = failed + out.unrouted = len(_open(failed)) + out.net_half = half + out.narrowed = list(result.narrowed) + return out diff --git a/tests/route/test_window_reroute.py b/tests/route/test_window_reroute.py new file mode 100644 index 0000000..4908f1a --- /dev/null +++ b/tests/route/test_window_reroute.py @@ -0,0 +1,95 @@ +"""Window re-route: a finer grid around a stuck connection, kept only when +it leaves fewer connections unrouted.""" + +from __future__ import annotations + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.geometry import IntBox +from freeroute.route.grid_router import GridRouter +from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board_exact + + +def _slot(gap: int = 640, far: bool = False) -> str: + """A wall across the board with a slot in it. A trace fits the slot + only along a narrow band of heights that the board's 1x grid has no row + in and a 4x grid does. ``far`` adds a second net nowhere near the wall.""" + yc = -5000 + lo, hi = yc - gap // 2, yc + gap // 2 + extra_place = "(place Q1 2000 -1000 front 0) (place Q2 6000 -1000 front 0)" if far else "" + extra_net = "(net M (pins Q1-1 Q2-1))" if far else "" + return f"""(pcb "slot.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 20000 0 20000 -10000 0 -10000 0 0)) + (keepout "" (rect F.Cu 9000 -10000 11000 {lo})) + (keepout "" (rect F.Cu 9000 {hi} 11000 0)) + (rule (width 200) (clearance 200)) + ) + (library + (padstack Rect_Pad (shape (rect F.Cu -100 -100 100 100)) (attach off)) + (image PAD (pin Rect_Pad 1 0 0)) + ) + (placement + (component PAD + (place P1 3000 {yc} front 0) + (place P2 17000 {yc} front 0) + {extra_place} + ) + ) + (network + (net N (pins P1-1 P2-1)) + {extra_net} + (class default (rule (width 200) (clearance 200))) + ) + (wiring) +) +""" + + +def _route(text: str, rounds: int): + exact, _scale, _layers = route_dsn_board_exact( + parse_dsn(text), resolution=1, window_reroute=rounds, window_resolution=4, + ) + return exact.result + + +def test_the_board_grid_misses_the_slot_and_a_finer_window_finds_it(): + plain = _route(_slot(), 0) + assert len(plain.failed) == 1 + windowed = _route(_slot(), 1) + assert windowed.failed == [] + assert windowed.wires # and the copper it found is in the result + + +def test_a_window_that_cannot_help_leaves_the_board_as_it_was(): + plain = _route(_slot(gap=500, far=True), 0) # too narrow for any grid + windowed = _route(_slot(gap=500, far=True), 2) + assert len(windowed.failed) == len(plain.failed) == 1 + assert windowed.wires == plain.wires + assert windowed.vias == plain.vias + + +def test_copper_outside_the_window_is_kept_as_it_was(): + plain = _route(_slot(far=True), 0) + windowed = _route(_slot(far=True), 1) + assert windowed.failed == [] + far_net = 2 # M, the second net in the DSN + assert windowed.wires[far_net] == plain.wires[far_net] + + +def test_a_router_limited_to_some_nets_and_a_box_builds_only_those(): + dsn = parse_dsn(_slot(far=True)) + s = dsn.resolution.value + board = build_board(dsn) + whole = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), orthogonal=True) + part = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), orthogonal=True, + only_nets=frozenset({2}), bounds=IntBox(0, -20000, 80000, 0)) + assert {c.net_no for c in whole._build_connections()} == {1, 2} + assert {c.net_no for c in part._build_connections()} == {2} + assert part.cols * part.rows < whole.cols * whole.rows diff --git a/tests/test_cli.py b/tests/test_cli.py index 601f7bd..a01e587 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -161,6 +161,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "negotiate": ["--negotiate", "2"], "rollback": ["--no-rollback"], "tree": ["--no-tree"], + "window": ["--window-reroute", "1"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: