From 8b3ebc53cf6428ccd3c4bb4183c5d5553b1d5a6f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 30 Sep 2026 09:49:05 -0600 Subject: [PATCH] Leave fine-pitch pins straight and let rip-up reach a pin's way out A 1.0 mm trace from a 1.27 mm pitch pin (the RN2483 row on the TTN Enschede mote) has to stay within 0.06 mm of the pin's centreline until it is past the neighbouring pads. No grid column is that close, and the router could not get there: - A stub ran as one slanted segment to its cell centre. The exact check sees that as its bounding box, which lands on the next pad. Orthogonal stubs now leave straight along the pin's axis and jog to the cell only at the end. - End nodes were chosen as in free space even in the rip-up search, so once another net crossed a pin's only exit, the pin had no end left and rip-up could never take the exit back. In rip-up mode those cells and stubs are now enterable at the rip penalty, and routes on a stub are ripped along with the ones the path crosses. - Two neighbouring pins' stubs each claim the cells a centred trace would crowd, so their claims overlapped well before their copper did. Stub against stub is now checked on the copper. - Escapes also look one column either side, so two pins leaving the same way can jog apart. TTN Enschede mote, exact engine with --shove: 12 unrouted before, 0 after; KiCad DRC on the result has no clearance violations and no unconnected items. The 0.5 mm pitch row test at resolution 1 now routes all 5 pins (it pinned 2). --- src/freeroute/route/grid_router.py | 193 +++++++++++++++++++----- tests/route/test_real_board_geometry.py | 13 +- tests/route/test_straight_pin_exits.py | 58 +++++++ 3 files changed, 220 insertions(+), 44 deletions(-) create mode 100644 tests/route/test_straight_pin_exits.py diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 96facb7..9056bae 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -27,6 +27,7 @@ DRC-clean geometry, and blind/buried via spans. from __future__ import annotations import heapq +import itertools import math import random @@ -123,6 +124,7 @@ class _Connection: "path", "narrow", "half", + "stubs", ) def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: @@ -138,6 +140,8 @@ class _Connection: self.via_cells: list[Cell] = [] self.via_halo: list[Cell] = [] self.halo_nodes: list[Node] = [] + # each end's stub: its layer, its copper tiles, and the nodes it claims + self.stubs: list[tuple[int, list[IntBox], set[Node]]] = [] # the pins and zones each end may use, and which pin the route ended # on (None when it ended on a zone) self.a_group: tuple[list[Pin], list] = ([a], []) @@ -634,8 +638,8 @@ class GridRouter: if not conn.b_group[0] and any(z.layer in self._power_layers for z in conn.b_group[1]): # the plane is everywhere: aim at where the route starts instead goal_cell = self._group_target(conn.a_group) - starts = self._group_ends(conn.a_group, conn.net_no, conns) - goals = self._group_ends(conn.b_group, conn.net_no, conns) + 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) if not starts or not goals: return None # A start that is already an end joins at once, except that ending on @@ -656,10 +660,19 @@ class GridRouter: came_from: dict[Node, Node] = {} # a stub costs its length, so the nearest cell wins unless it is blocked # a pin end costs its stub; a zone or plane end is entered normally - goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if isinstance(p, Pin)} + # (and in rip-up mode the rip penalty of whatever lies on it) + goal_stub = { + n: self._stub_cost(p, n) + self._end_penalty(p, n, net_no, allow_ripup, conns) + for n, p in goals.items() + if isinstance(p, Pin) + } for s in sorted(starts): pin = starts[s] - g_score[s] = self._stub_cost(pin, s) if isinstance(pin, Pin) else 0.0 + g_score[s] = ( + self._stub_cost(pin, s) + self._end_penalty(pin, s, net_no, allow_ripup, conns) + if isinstance(pin, Pin) + else 0.0 + ) heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s)) # Via footprints are checked lazily: a layer change goes on the heap as @@ -790,24 +803,52 @@ class GridRouter: math.hypot(loc.x - point.x, loc.y - point.y) < reach for loc, reach in self._tht_holes ) + def _stub_points(self, pin_location: IntPoint, cell: Cell) -> list[IntPoint]: + """The stub's corners, from the pin to its cell centre. + + The direct segment is fine for 45-degree routing. Orthogonal routing + leaves the pin straight along the axis it is further off the cell on + and jogs across to the centre at the end: an L of two flush boxes, + which is what the exact check sees. A single slanted segment is + checked as its bounding box there, and a 1 mm trace to a column + 0.1 mm off a 1.27 mm pitch pin's centreline then lands inside the + clearance of the pad next door, though the straight exit clears it. + """ + end = self._cell_center(cell) + dx, dy = end.x - pin_location.x, end.y - pin_location.y + if not self._orthogonal or dx == 0 or dy == 0: + return [pin_location, end] + if abs(dx) >= abs(dy): + return [pin_location, IntPoint(end.x, pin_location.y), end] + return [pin_location, IntPoint(pin_location.x, end.y), end] + 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) + points = self._stub_points(pin_location, cell) + end = points[-1] 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), ) + legs = list(zip(points, points[1:], strict=False)) return [ c for c in self._cells_in_box(box, reach) - if _point_segment(self._cell_center(c), pin_location, end) < reach + if min(_point_segment(self._cell_center(c), a, b) for a, b in legs) < reach ] def _stub_clear( - self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, keepouts_only: bool = False + self, + pin: Pin, + cell: Cell, + layer: int, + net_no: int, + conns, + keepouts_only: bool = False, + allow_ripup: bool = False, ) -> bool: keepouts = self._keepout_boxes.get(layer) if keepouts: @@ -815,9 +856,9 @@ class GridRouter: # engine will: its copper grown by clearance must miss every keepout from freeroute.geometry.polyline import Polyline, PolylineShape - end = self._cell_center(cell) - if end != pin.location: - for tile in PolylineShape(Polyline([pin.location, end]), self._cur_half).tiles(): + points = self._stub_points(pin.location, cell) + if points[-1] != pin.location: + for tile in PolylineShape(Polyline(points), 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 @@ -827,14 +868,60 @@ class GridRouter: return False if keepouts_only: return True - for c in self._stub_cells(pin.location, cell): - owner = self._cell_owner.get((*c, layer)) - if owner is not None and conns[owner].net_no != net_no: - return False + owners = self._stub_owners(pin, cell, layer, net_no, conns) + # in rip-up mode a route on the stub is one more to rip, unless it may not be + return not owners or ( + allow_ripup and all(conns[i].rip_count < self.rip_cap for i in owners) + ) + + def _stub_tiles(self, pin_location: IntPoint, cell: Cell, half: int) -> list[IntBox]: + """The stub's copper: its legs, and a square at each corner and at the + cell end, where the route carries on.""" + points = self._stub_points(pin_location, cell) + tiles = [corner_box(point, half) for point in points[1:]] + if len(points) > 1 and points[-1] != pin_location: + from freeroute.geometry.polyline import Polyline, PolylineShape + + tiles += PolylineShape(Polyline(points), half).tiles() + return tiles + + def _stub_owners( + self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, half: int | None = None + ) -> set[int]: + """Other nets' routes whose copper the stub from ``pin`` to ``cell`` would touch.""" + owners: set[int] = set() + grown = None + for c in self._stub_cells(pin.location, cell, half): + node = (*c, layer) + owner = self._cell_owner.get(node) + if owner is not None and conns[owner].net_no != net_no and owner not in owners: + # Both stubs are off the grid, and each claims the cells a + # trace centred there would crowd, so two stubs side by side + # (neighbouring pins leaving straight) overlap in claims well + # before their copper comes near. Check those exactly. + other = [tiles for lay, tiles, nodes in conns[owner].stubs if node in nodes] + if other: + if grown is None: + grown = [ + t.offset(self.clearance) + for t in self._stub_tiles(pin.location, cell, half or self._cur_half) + ] + if not any(g.overlaps(t) for tiles in other for t in tiles for g in grown): + continue + owners.add(owner) via_owner = self._via_owner.get(c) if via_owner is not None and conns[via_owner].net_no != net_no: - return False - return True + owners.add(via_owner) + return owners + + def _end_penalty(self, pin: Pin, node: Node, net_no: int, allow_ripup: bool, conns) -> float: + """Rip-up cost of starting or ending on ``node``: the routes on its + cell and on the stub to it, each at the penalty entering it would cost.""" + if not allow_ripup: + return 0.0 + owners = self._stub_owners(pin, node[:2], node[2], net_no, conns) + owners |= self._crossed_owners([node], net_no, conns) + return sum(self.rip_penalty * (1 + conns[i].rip_count) for i in owners) def _zone_nodes(self, zone) -> list[Node]: """Cells of a zone where a via of this net fits inside its copper; for a @@ -882,11 +969,13 @@ class GridRouter: return zone.name or "zone" return "" - def _group_ends(self, group, net_no: int, conns) -> dict[Node, Pin | None]: + def _group_ends( + self, group, net_no: int, conns, allow_ripup: bool = False + ) -> dict[Node, Pin | None]: """End nodes of a group, each mapped to its pin (None for a zone cell).""" ends: dict[Node, Pin | None] = {} for pin in group[0]: - for node in sorted(self._end_nodes(pin, net_no, conns)): + for node in sorted(self._end_nodes(pin, net_no, conns, allow_ripup)): ends.setdefault(node, pin) for zone in group[1]: plane = zone.layer in self._power_layers @@ -908,10 +997,13 @@ class GridRouter: return (dx + dy) / self.step return math.hypot(dx, dy) / self.step - def _end_nodes(self, pin: Pin, net_no: int, conns) -> set[Node]: + def _end_nodes(self, pin: Pin, net_no: int, conns, allow_ripup: bool = False) -> set[Node]: """Nodes a route may start or end on for ``pin``: the nearest cell and its neighbours, where the cell is enterable and the stub to it is clear. - A crowded fine-pitch pin gets more than one way out.""" + A crowded fine-pitch pin gets more than one way out. In rip-up mode a + cell or stub another route is on counts as enterable, as it does + everywhere else, or a pin whose only way out another net has taken + could never have it back.""" layers = [layer for layer in pin.layers if layer in self.layers] cx, cy = self._cell_of(pin.location) nodes: set[Node] = set() @@ -920,16 +1012,16 @@ class GridRouter: if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): continue for layer in layers: - if (dx or dy) and self._enter_cost(cell, layer, net_no, False, conns) is None: + if (dx or dy) and self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None: continue - if self._stub_clear(pin, cell, layer, net_no, conns): + if self._stub_clear(pin, cell, layer, net_no, conns, allow_ripup=allow_ripup): nodes.add((*cell, layer)) # Also offer exact escape stubs: a fine-pitch pin's nearby cells can be # a pocket inside its own pad that leads nowhere (the grid columns miss # the narrow legal band past the pad tip). The stub's length is its # cost, so it is only taken where a short step would not do. if self._orthogonal and self._crowded(pin, net_no) and self._trapped(pin, net_no, nodes): - nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy)) + nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy), allow_ripup) if not nodes: # nothing clear: fall back to the nearest cell, as before nodes = {(cx, cy, layer) for layer in layers} if self._keepout_boxes and not any( @@ -995,14 +1087,16 @@ class GridRouter: self._trapped_cache[key] = trapped return trapped - def _escape_nodes(self, pin: Pin, net_no: int, conns, layers, origin: Cell) -> set[Node]: + def _escape_nodes( + self, pin: Pin, net_no: int, conns, layers, origin: Cell, allow_ripup: bool = False + ) -> set[Node]: """For a pin walled in on the grid (a fine-pitch pin whose legal band the grid columns miss): look along each axis for the first open cell - whose straight stub from the pin clears every other net's pad by the - full margin, measured exactly rather than on the grid.""" - from freeroute.board.outline import _segment_box + whose stub from the pin clears every other net's pad by the full + clearance, checked on the stub's copper tiles as the exact engine + will rather than on the grid.""" + from freeroute.geometry.polyline import Polyline, PolylineShape - margin = self.half_width + self.clearance reach = self._escape_cells near = [ (p.shape.bounding_box(), set(p.layers)) @@ -1014,24 +1108,33 @@ class GridRouter: ] cx, cy = origin nodes: set[Node] = set() - for dx, dy in _NEIGHBOURS[:4]: + # the pin's own column or row and the one either side: two pins side + # by side can then jog apart at the end of their straight exits + for (dx, dy), side in itertools.product(_NEIGHBOURS[:4], (0, -1, 1)): + sx, sy = (side, 0) if dx == 0 else (0, side) + nodes_before = len(nodes) for k in range(2, reach + 1): - cell = (cx + dx * k, cy + dy * k) + cell = (cx + dx * k + sx, cy + dy * k + sy) if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): break - end = self._cell_center(cell) + grown = [ + tile.offset(self.clearance) + for tile in PolylineShape( + Polyline(self._stub_points(pin.location, cell)), self._cur_half + ).tiles() + ] for layer in layers: - if self._enter_cost(cell, layer, net_no, False, conns) is None: + if self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None: continue if any( - layer in box_layers and _segment_box(pin.location, end, box) < margin + layer in box_layers and any(g.overlaps(box) for g in grown) for box, box_layers in near ): continue - if self._stub_clear(pin, cell, layer, net_no, conns): + if self._stub_clear(pin, cell, layer, net_no, conns, allow_ripup=allow_ripup): nodes.add((*cell, layer)) - if any(n[:2] == cell for n in nodes): - break # the first open cell along this axis is enough + if len(nodes) > nodes_before: + break # the first open cell along this line is enough return nodes def _relax(self, current, nxt, tentative, goal_cell, g_score, came_from, open_heap): @@ -1085,15 +1188,19 @@ class GridRouter: self._via_owner[c] = conn.index conn.via_halo.append(c) ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else () + conn.stubs = [] for pin, (layer, cells), at in ends: if not isinstance(pin, Pin): continue # ended on a zone or a plane: no stub cell = cells[at] + claimed: set[Node] = set() 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 conn.trace_cells.append((c, layer)) + claimed.add(node) + conn.stubs.append((layer, self._stub_tiles(pin.location, cell, half), claimed)) # A pin inside a wire keepout can only leave by a via. Its stub to the # cell centre would be track inside the keepout, so the via goes on # the pin itself (a via in its own pad) and the stub is left out. @@ -1111,9 +1218,9 @@ class GridRouter: continue points = [self._cell_center(c) for c in cells] if idx == 0 and isinstance(conn.a_end, Pin): - points.insert(0, conn.a_end.location) + points[0:0] = self._stub_points(conn.a_end.location, cells[0])[:-1] if idx == len(wire_segs) - 1 and isinstance(conn.b_end, Pin): - points.append(conn.b_end.location) + points.extend(self._stub_points(conn.b_end.location, cells[-1])[-2::-1]) points = _simplify(points) if len(points) >= 2: conn.wires.append((layer, points)) @@ -1140,6 +1247,7 @@ class GridRouter: del self._halo[node] conn.halo_nodes = [] conn.trace_cells = [] + conn.stubs = [] for cell in conn.via_cells: count = self._via_nets.get(cell) if count and count.get(conn.net_no): @@ -1272,7 +1380,14 @@ class GridRouter: path = self._search(conn, allow_ripup=True, conns=conns) if path is None: return False - victims = [conns[i] for i in sorted(self._crossed_owners(path, conn.net_no, conns))] + crossed = self._crossed_owners(path, conn.net_no, conns) + # and the routes on the stubs from the pins to the path's ends + for end, node in ((conn.a_end, path[0]), (conn.b_end, path[-1])): + if isinstance(end, Pin): + 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)] if self.rollback: return self._ripup_transaction(conn, path, victims, conns) for victim in victims: diff --git a/tests/route/test_real_board_geometry.py b/tests/route/test_real_board_geometry.py index c193cc6..c146370 100644 --- a/tests/route/test_real_board_geometry.py +++ b/tests/route/test_real_board_geometry.py @@ -51,19 +51,22 @@ def test_routes_keep_edge_clearance_at_a_rounded_corner(): assert outline.edge_distance(p) >= half + 500 * scale - 1 -@pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)]) -def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape): +@pytest.mark.parametrize("resolution", [1, 4]) +def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution): # 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. + # wide. At resolution 4 the 0.1 mm columns cannot miss it. At resolution + # 1 the 0.4 mm grid misses most bands, but a stub leaves its pin straight + # along the band and jogs to the column only past the pads, so every pin + # gets out there too, as the JAR manages (only 2 did before). 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)), resolution=resolution ) - assert len(result.routed_net_numbers) == escape + assert len(result.routed_net_numbers) == 5 + assert result.drc_clean def test_resolution_one_has_no_halo_so_the_grid_is_unchanged(): diff --git a/tests/route/test_straight_pin_exits.py b/tests/route/test_straight_pin_exits.py new file mode 100644 index 0000000..89020ab --- /dev/null +++ b/tests/route/test_straight_pin_exits.py @@ -0,0 +1,58 @@ +"""Pins of a 1.27 mm pitch row routed at 1.0 mm (the RN2483 module on the +TTN Enschede mote). A trace centre has to stay within 0.06 mm of a pin's +centreline to pass its neighbours, which no grid column hits, so a route +leaves each pin straight along that line and only jogs to its column once +past the pads. FreeRouting routes this row in full the same way.""" + +from __future__ import annotations + +import pytest + +from freeroute.board import build_board +from freeroute.board.items import Pin +from freeroute.dsn import parse_dsn +from freeroute.geometry import IntPoint +from freeroute.route.grid_router import GridRouter +from freeroute.route.pipeline import route_dsn_board_exact + + +def _row(x0: int, pins: int = 6, skew: int = 3000) -> str: + places = " ".join(f"(place R{i} {x0 + 1270 * i} -5000 front 0)" for i in range(pins)) + places += " " + " ".join( + f"(place T{i} {x0 + 1270 * i + skew} -15000 front 0)" for i in range(pins) + ) + nets = " ".join(f"(net N{i} (pins R{i}-1 T{i}-1))" for i in range(pins)) + return f"""(pcb t (parser) (resolution um 10) (unit um) + (structure (layer F.Cu (type signal)) (layer B.Cu (type signal)) + (boundary (path pcb 0 0 0 30000 0 30000 -20000 0 -20000 0 0)) + (via "Via[0-1]_600:400_um") (rule (width 1000) (clearance 200))) + (placement (component P {places})) + (library (image P (pin pad 1 0 0)) + (padstack pad (shape (rect F.Cu -508 -762 508 762)) (attach off)) + (padstack "Via[0-1]_600:400_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) + (attach off))) + (network {nets}))""" + + +@pytest.mark.parametrize("x0", [5000, 5150, 5300]) +def test_every_pin_of_a_row_leaves_straight_and_routes(x0): + # three offsets of the row against the grid; before, 2 or 3 of 6 routed + result, _, _ = route_dsn_board_exact(parse_dsn(_row(x0)), resolution=2) + assert len(result.routed_net_numbers) == 6 + assert result.drc_clean + + +def test_a_walled_in_pin_may_escape_to_the_column_either_side(): + # two pins side by side leaving the same way can then jog apart + board = build_board(parse_dsn(_row(5000))) + router = GridRouter(board, trace_width=10000, clearance=2000, orthogonal=True, resolution=2) + router._conns = router._build_connections() + pin = next( + p for p in board.get_items() + if isinstance(p, Pin) and p.location == IntPoint(50000 + 2 * 12700, -50000) + ) + net = pin.net_nos[0] + cx, cy = router._cell_of(pin.location) + nodes = router._escape_nodes(pin, net, router._conns, [0], (cx, cy)) + columns = {x for x, y, _ in nodes if y != cy} + assert {cx - 1, cx, cx + 1} <= columns