Merge fix/ttn-9: straight pin exits, rip-up can reach a pin's way out, exact checks between stubs

This commit is contained in:
2026-09-30 11:11:39 -06:00
3 changed files with 220 additions and 44 deletions
+154 -39
View File
@@ -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:
+8 -5
View File
@@ -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():
+58
View File
@@ -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