Give pin stubs clearance, and let a route leave a pin by any open side

Every route starts and ends with a stub from the pin to its first grid
cell, and the grid never saw that copper: on the rc4 sense board 24 of 39
connections the exact verifier rejected had a later route pass beside an
earlier stub. A route's stubs now claim the cells they come near, a stub
must be clear of other nets to be used, and a route may start from any
enterable neighbour of the pin cell, costed by stub length (Manhattan on
the orthogonal track, so a pin on a cell centre still leaves straight).

Tests: a route near a rounded corner keeps its edge clearance; and a
strict xfail that pins down the remaining gap, a 0.5 mm pin row the grid
cannot give a column to every pin of.
This commit is contained in:
2026-09-27 07:37:50 -06:00
parent c1df284147
commit 9fa5432953
2 changed files with 141 additions and 9 deletions
+75 -9
View File
@@ -148,6 +148,7 @@ class GridRouter:
# orthogonal routing (axis-aligned segments only) yields exact IntBox
# 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)
# A via's copper is wider than a trace. Another net's trace needs its
@@ -332,14 +333,11 @@ class GridRouter:
def _search(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection]
) -> list[Node] | None:
start_cell = self._cell_of(conn.a.location)
goal_cell = self._cell_of(conn.b.location)
a_layers = [layer for layer in conn.a.layers if layer in self.layers]
b_layers = [layer for layer in conn.b.layers if layer in self.layers]
if not a_layers or not b_layers:
starts = self._end_nodes(conn.a, conn.net_no, conns)
goals = self._end_nodes(conn.b, conn.net_no, conns)
if not starts or not goals:
return None
starts = {(*start_cell, layer) for layer in a_layers}
goals = {(*goal_cell, layer) for layer in b_layers}
if starts & goals:
return [min(starts & goals)]
net_no = conn.net_no
@@ -347,9 +345,11 @@ class GridRouter:
open_heap: list[tuple[float, Node]] = []
g_score: dict[Node, float] = {}
came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked
goal_stub = {n: self._stub_cost(conn.b, n) for n in goals}
for s in sorted(starts):
g_score[s] = 0.0
heapq.heappush(open_heap, (self._h(s, goal_cell), s))
g_score[s] = self._stub_cost(conn.a, s)
heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s))
while open_heap:
_, current = heapq.heappop(open_heap)
@@ -363,7 +363,7 @@ class GridRouter:
continue
nxt = (*cell, cl)
if nxt in goals:
enter = 0.0
enter = goal_stub[nxt]
else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
if enter is None:
@@ -382,6 +382,65 @@ class GridRouter:
)
return None
# --- pin stubs ----------------------------------------------------------
# A pin rarely sits on a cell centre, so every route starts and ends with a
# short stub from the pin to its first cell. The grid has to see that
# copper too, or a stub ends up closer than clearance to a trace in the
# cell beside it.
def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> 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)
reach = 2 * 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),
)
return [
c
for c in self._cells_in_box(box, reach)
if _point_segment(self._cell_center(c), pin_location, end) < reach
]
def _stub_clear(self, pin: Pin, cell: Cell, layer: int, net_no: int, conns) -> bool:
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
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
def _stub_cost(self, pin: Pin, node: Node) -> float:
c = self._cell_center(node[:2])
dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y)
if self._orthogonal: # measured the way the route itself is allowed to move
return (dx + dy) / self.step
return math.hypot(dx, dy) / self.step
def _end_nodes(self, pin: Pin, net_no: int, conns) -> 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."""
layers = [layer for layer in pin.layers if layer in self.layers]
cx, cy = self._cell_of(pin.location)
nodes: set[Node] = set()
for dx, dy in ((0, 0), *self._neighbours):
cell = (cx + dx, cy + dy)
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:
continue
if self._stub_clear(pin, cell, layer, net_no, conns):
nodes.add((*cell, layer))
if not nodes: # nothing clear: fall back to the nearest cell, as before
nodes = {(cx, cy, layer) for layer in layers}
return nodes
def _relax(self, current, nxt, tentative, goal_cell, g_score, came_from, open_heap):
if tentative < g_score.get(nxt, math.inf):
came_from[nxt] = current
@@ -405,6 +464,13 @@ class GridRouter:
if c not in self._via_owner:
self._via_owner[c] = conn.index
conn.via_halo.append(c)
for pin, (layer, cells) in ((conn.a, wire_segs[0]), (conn.b, wire_segs[-1])) if wire_segs else ():
cell = cells[0] if pin is conn.a else cells[-1]
for c in self._stub_cells(pin.location, cell):
node = (*c, layer)
if node not in self._cell_owner:
self._cell_owner[node] = conn.index
conn.trace_cells.append((c, layer))
conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs):
points = [self._cell_center(c) for c in cells]
+66
View File
@@ -0,0 +1,66 @@
"""Routing behaviours found on the TerraHawk rc4 boards (2026-09-27)."""
from __future__ import annotations
import math
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
# 20 x 20 mm board whose corners are rounded with a 3 mm radius, as polylines
_R = 3000.0
_arc = [(_R - _R * math.cos(math.radians(a)), _R - _R * math.sin(math.radians(a))) for a in range(0, 91, 15)]
def _outline_coords():
pts = []
for cx, cy, sx, sy in ((0, 0, 1, 1), (20000, 0, -1, 1), (20000, -20000, -1, -1), (0, -20000, 1, -1)):
corner = [(cx + sx * x, cy - sy * y) for x, y in _arc]
pts.extend(corner if sx * sy > 0 else corner[::-1])
return " ".join(f"{x:.1f} {y:.1f}" for x, y in pts)
def _dsn(placement, network, extra=""):
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 {_outline_coords()}))
(via "Via[0-1]_600:300_um") (rule (width 250) (clearance 150)) {extra})
(placement {placement})
(library (image P (pin pad 1 0 0))
(padstack pad (shape (rect F.Cu -150 -500 150 500)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network {network}))"""
def test_routes_keep_edge_clearance_at_a_rounded_corner():
# two pads near the top-left corner: the direct path cuts the corner arc
text = _dsn("(component P (place A 1800 -6000 front 0) (place B 6000 -1800 front 90))",
"(net N (pins A-1 B-1))")
dsn = parse_dsn(text)
result, scale, _ = route_dsn_board_exact(dsn, edge_clearance_mm=0.5)
assert result.routed_net_numbers
outline = build_board(dsn).outline
half = 125 * scale
for segs in result.result.wires.values():
for _layer, pts in segs:
for p in pts:
# every trace point, plus half the trace, keeps 0.5 mm to the edge
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
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