From 8929fd367ebe111cbd0edcc703f3f92e7da7165c Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 27 Sep 2026 07:59:05 -0600 Subject: [PATCH] Finer routing grid: --grid-resolution K with trace halos A cell was exactly one trace plus its clearance, so a trace owning its cell was the whole clearance model, and the 0.4 mm grid could not reach most pins of a 0.5 mm pitch package: between two 0.3 mm pads a trace's centre has a legal band only 0.15 mm wide. Resolution K makes cells K times finer and has every trace claim a halo, the cells whose centre is nearer than trace + clearance to one of its own, released again on rip-up. K = 1 has an empty halo and is the old grid exactly (tested). On a 0.5 mm row, K = 4 gets every pin out where K = 1 gets two. On the rc4 boards, K = 2 cuts the sense board's opens from 66 to 41 (JAR: 32) and the power board's to 7 (JAR: 12), still with no DRC errors from freeroute's own copper. --- src/freeroute/cli.py | 11 +++++- src/freeroute/route/exact_router.py | 2 ++ src/freeroute/route/grid_router.py | 47 +++++++++++++++++++++++-- src/freeroute/route/pipeline.py | 17 +++++++-- tests/route/test_real_board_geometry.py | 29 +++++++++------ 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index e5e10fb..938cd55 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -110,6 +110,14 @@ def build_parser() -> argparse.ArgumentParser: help="disable rip-up-and-retry (grid and exact engines)", ) parser.add_argument("--layers", help="comma-separated signal layer indices to route on") + parser.add_argument( + "--grid-resolution", + type=int, + default=1, + metavar="K", + help="grid and exact engines: cells K times finer than trace + clearance " + "(default 1); 2 or more reaches fine-pitch pins, at some cost in time", + ) parser.add_argument( "--edge-clearance", type=float, @@ -166,6 +174,7 @@ def main(argv: list[str] | None = None) -> int: rip_up=rip_up, max_passes=max_passes, edge_clearance_mm=args.edge_clearance, + resolution=args.grid_resolution, ) elif args.engine == "room": result = build_rooms_routing_result( @@ -175,7 +184,7 @@ def main(argv: list[str] | None = None) -> int: else: result = build_routing_result( dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, - edge_clearance_mm=args.edge_clearance, + edge_clearance_mm=args.edge_clearance, resolution=args.grid_resolution, ) ses_text = write_ses(dsn, result) diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 522cf90..db79c49 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -92,6 +92,7 @@ def route_board_exact( shove_cap: int = 3, via_half: int | None = None, edge_clearance: int | None = None, + resolution: int = 1, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -118,6 +119,7 @@ def route_board_exact( orthogonal=True, via_half=via_half, edge_clearance=edge_clearance, + resolution=resolution, ) edge = 0 if edge_clearance is None else edge_clearance outline = board.outline diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 921a444..8d6c8e2 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -101,6 +101,7 @@ class _Connection: "trace_cells", "via_cells", "via_halo", + "halo_nodes", ) def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: @@ -115,6 +116,7 @@ class _Connection: self.trace_cells: list[tuple[Cell, int]] = [] self.via_cells: list[Cell] = [] self.via_halo: list[Cell] = [] + self.halo_nodes: list[Node] = [] class GridRouter: @@ -135,6 +137,7 @@ class GridRouter: orthogonal: bool = False, via_half: int | None = None, edge_clearance: int | None = None, + resolution: int = 1, ) -> None: self.board = board self.trace_width = max(trace_width, 1) @@ -149,8 +152,21 @@ class GridRouter: # trace copper; 8-connected also produces 45-degree segments. self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS self._orthogonal = orthogonal - # cell size must fit a trace plus its clearance to a neighbouring trace - self.step = max(self.trace_width + self.clearance, 1) + # At resolution 1 a cell is exactly one trace plus its clearance, so a + # trace owning its cell is the whole clearance model. At resolution k + # cells are k times finer, fine enough to give every pin of a 0.5 mm + # pitch row its own column, and a trace also claims a halo: the cells + # whose centre is nearer than trace + clearance to one of its own. + self.resolution = max(int(resolution), 1) + self.step = max((self.trace_width + self.clearance) // self.resolution, 1) + need = self.trace_width + self.clearance + reach = math.ceil(need / self.step) + self._halo_offsets = [ + (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 + ] # A via's copper is wider than a trace. Another net's trace needs its # centre via_half + clearance + half_width from the via's centre, so a # via also claims every cell within via_reach of its own. @@ -177,6 +193,8 @@ class GridRouter: # per-connection occupancy (owner = connection index) self._cell_owner: dict[Node, int] = {} self._via_owner: dict[Cell, int] = {} + # trace halos: node -> indices of the connections claiming it + self._halo: dict[Node, list[int]] = {} self._rasterize_obstacles() # --- grid helpers ------------------------------------------------------- @@ -299,6 +317,11 @@ class GridRouter: via_owner = self._via_owner.get(cell) if via_owner is not None and conns[via_owner].net_no != net_no: blocking = via_owner + if blocking is None: + for i in self._halo.get(node, ()): + if conns[i].net_no != net_no: + blocking = i + break if blocking is None: return 0.0 if not allow_ripup: @@ -458,6 +481,14 @@ class GridRouter: conn.via_cells = list(via_cells) for cell, layer in conn.trace_cells: self._cell_owner[(*cell, layer)] = conn.index + conn.halo_nodes = [] + for (cx, cy), layer in conn.trace_cells: + for dx, dy in self._halo_offsets: + x, y = cx + dx, cy + dy + if 0 <= x < self.cols and 0 <= y < self.rows: + node = (x, y, layer) + self._halo.setdefault(node, []).append(conn.index) + conn.halo_nodes.append(node) conn.via_halo = [] for cell in conn.via_cells: for c in self._via_cells(cell): @@ -492,6 +523,13 @@ class GridRouter: if self._via_owner.get(cell) == conn.index: del self._via_owner[cell] conn.via_halo = [] + for node in conn.halo_nodes: + claims = self._halo.get(node) + if claims is not None: + claims.remove(conn.index) + if not claims: + del self._halo[node] + conn.halo_nodes = [] conn.trace_cells = [] conn.via_cells = [] conn.wires = [] @@ -508,6 +546,9 @@ class GridRouter: via_owner = self._via_owner.get((cx, cy)) if via_owner is not None and conns[via_owner].net_no != net_no: owners.add(via_owner) + for i in self._halo.get((cx, cy, cl), ()): + if conns[i].net_no != net_no: + owners.add(i) return owners # --- passes ------------------------------------------------------------- @@ -645,6 +686,7 @@ def route_board( orthogonal: bool = False, via_half: int | None = None, edge_clearance: int | None = None, + resolution: int = 1, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -658,5 +700,6 @@ def route_board( orthogonal=orthogonal, via_half=via_half, edge_clearance=edge_clearance, + resolution=resolution, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 60e92f0..94725af 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -75,6 +75,7 @@ def _via_half_board(dsn: DsnBoard, scale: int) -> int | None: def route_dsn_board( dsn: DsnBoard, *, + resolution: int = 1, edge_clearance_mm: float | None = None, layers: list[int] | None = None, rip_up: bool = True, @@ -91,6 +92,7 @@ def route_dsn_board( clearance_board = round(_rule_clearance_dsn(dsn) * scale) result = route_board( board, + resolution=resolution, via_half=_via_half_board(dsn, scale), edge_clearance=edge_clearance_board, trace_width=width_board, @@ -130,6 +132,7 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) def build_routing_result( dsn: DsnBoard, *, + resolution: int = 1, layers: list[int] | None = None, rip_up: bool = True, max_passes: int = 10, @@ -138,7 +141,7 @@ def build_routing_result( """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" route, scale, layer_names = route_dsn_board( dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, - edge_clearance_mm=edge_clearance_mm, + edge_clearance_mm=edge_clearance_mm, resolution=resolution, ) return _to_routing_result(route, dsn, scale, layer_names) @@ -146,6 +149,7 @@ def build_routing_result( def route_dsn_board_exact( dsn: DsnBoard, *, + resolution: int = 1, edge_clearance_mm: float | None = None, layers: list[int] | None = None, max_passes: int = 10, @@ -165,6 +169,7 @@ def route_dsn_board_exact( clearance_board = round(_rule_clearance_dsn(dsn) * scale) exact = route_board_exact( board, + resolution=resolution, via_half=_via_half_board(dsn, scale), edge_clearance=edge_clearance_board, trace_width=width_board, @@ -181,6 +186,7 @@ def route_dsn_board_exact( def build_exact_routing_result( dsn: DsnBoard, *, + resolution: int = 1, layers: list[int] | None = None, shove: bool = False, diagonal: bool = False, @@ -197,6 +203,7 @@ def build_exact_routing_result( rip_up=rip_up, max_passes=max_passes, edge_clearance_mm=edge_clearance_mm, + resolution=resolution, ) return _to_routing_result(exact.result, dsn, scale, layer_names) @@ -255,6 +262,7 @@ def route( pack: bool = False, diagonal: bool = False, edge_clearance_mm: float | None = None, + resolution: int = 1, ) -> str: """Route a Specctra DSN string and return the routed SES string. @@ -269,12 +277,15 @@ def route( dsn = parse_dsn(dsn_text) if engine == "exact": result = build_exact_routing_result( - dsn, layers=layers, shove=shove, diagonal=diagonal, edge_clearance_mm=edge_clearance_mm + dsn, layers=layers, shove=shove, diagonal=diagonal, + edge_clearance_mm=edge_clearance_mm, resolution=resolution, ) elif engine == "room": result = build_rooms_routing_result( dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm ) else: - result = build_routing_result(dsn, layers=layers, edge_clearance_mm=edge_clearance_mm) + result = build_routing_result( + dsn, layers=layers, edge_clearance_mm=edge_clearance_mm, resolution=resolution + ) return write_ses(dsn, result) diff --git a/tests/route/test_real_board_geometry.py b/tests/route/test_real_board_geometry.py index 617ec0c..1a9eb86 100644 --- a/tests/route/test_real_board_geometry.py +++ b/tests/route/test_real_board_geometry.py @@ -51,16 +51,25 @@ def test_routes_keep_edge_clearance_at_a_rounded_corner(): assert outline.edge_distance(p) >= half + 500 * scale - 1 -@pytest.mark.xfail( - strict=True, - reason="known gap: the grid step (trace + clearance, 0.4 mm here) does not line up " - "with a 0.5 mm pin row, so only the outer pins get a column; the FreeRouting JAR, " - "being gridless, routes all five. This is most of the gap on an LQFP.", -) -def test_every_pin_of_a_half_millimetre_pitch_row_escapes(): - # five pads at 0.5 mm pitch, each wired to its own pad 8 mm away +@pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)]) +def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape): + # Five 0.3 mm pads at 0.5 mm pitch, each wired to its own pad 8 mm away. + # Between two neighbours a trace's centre has a legal band only 0.15 mm + # wide. At resolution 1 the 0.4 mm grid misses most bands; at 4 its + # 0.1 mm columns cannot, and every pin gets out, as the JAR manages. places = " ".join(f"(place R{i} {6000 + 500 * i} -6000 front 0)" for i in range(5)) places += " " + " ".join(f"(place F{i} {6000 + 500 * i} -14000 front 0)" for i in range(5)) nets = " ".join(f"(net N{i} (pins R{i}-1 F{i}-1))" for i in range(5)) - result, _, _ = route_dsn_board_exact(parse_dsn(_dsn(f"(component P {places})", nets))) - assert len(result.routed_net_numbers) == 5 + result, _, _ = route_dsn_board_exact( + parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution + ) + assert len(result.routed_net_numbers) == escape + + +def test_resolution_one_has_no_halo_so_the_grid_is_unchanged(): + from freeroute.route.grid_router import GridRouter + + board = build_board(parse_dsn(_dsn("", ""))) + assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == [] + fine = GridRouter(board, trace_width=2500, clearance=1500, resolution=2) + assert fine.step == 2000 and (1, 0) in fine._halo_offsets and (2, 0) not in fine._halo_offsets