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