Let pins on turned fine-pitch pads leave along the pad's axis

A QFN placed at 45 degrees had no exit on the grid: every horizontal or
vertical step from a pin passes its neighbours inside clearance. A pin whose
pad is turned more than ten degrees off the grid axes now leaves along the
pad's own axis, away from the part, until an L from there to a grid cell
clears every other net's pad, then searches as usual. The slanted leg is
checked and stored as an exact slab (segment_slab), not its bounding box,
in the grid stub checks and in the exact engine.

Where pins leave side by side, a cell names only its first owner, so stubs
are also indexed per cell for every connection near them; the exact
stub-vs-stub check, trace entry and rip-up all consult that index. A pin
with an axis exit and no clear cell reports no way out instead of falling
back to a cell on another net's route. diagnose.py replays with the exits.
This commit is contained in:
2026-10-03 18:49:36 -06:00
parent 4933b6f992
commit c6297656a6
7 changed files with 442 additions and 58 deletions
+221 -47
View File
@@ -34,8 +34,8 @@ import random
from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from freeroute.geometry import IntBox, IntPoint, Simplex
from freeroute.geometry.octagon import overlaps_2d
from freeroute.geometry.polyline import corner_box
from freeroute.geometry.octagon import overlaps_2d, segment_slab
from freeroute.geometry.polyline import corner_box, segment_box
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -126,6 +126,7 @@ class _Connection:
"narrow",
"half",
"stubs",
"stub_near",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -143,6 +144,8 @@ class _Connection:
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]]] = []
# every node its stubs come near, claimed or not (see GridRouter._stub_near)
self.stub_near: list[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], [])
@@ -218,6 +221,12 @@ class GridRouter:
self._escape_cells = 12
self._crowded_cache: dict = {}
self._trapped_cache: dict = {}
# pin location -> where a pin on a turned fine-pitch pad leaves its
# pin row along the pad's axis (None: it leaves like any other pin)
self._axis_exits: dict[IntPoint, IntPoint | None] = {}
# and the other nets' pads near each such pin, its stubs' obstacles
self._axis_pads: dict[IntPoint, list] = {}
self._part_centres: dict[int, tuple[float, float] | None] = {}
self.heuristic_weight = max(float(heuristic_weight), 1.0)
self.trace_width = max(trace_width, 1)
self.clearance = max(clearance, 0)
@@ -309,6 +318,12 @@ class GridRouter:
self._zone_cells: dict[int, list[Node]] = {}
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
# Node -> connections whose stub comes near it. A cell has one owner,
# and a stub claims only cells nobody owns yet, so where several pins
# leave side by side (a QFN turned 45 degrees: neighbours 0.35 mm apart
# on each axis) the cell two stubs share names only the first, and the
# exact check between the later stubs would never run.
self._stub_near: dict[Node, set[int]] = {}
# every connection whose via covers a cell, in claim order: a via two
# connections share still blocks after the first of them is ripped,
# and a route through it must rip all of them
@@ -536,6 +551,13 @@ class GridRouter:
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None:
# another net's pin stub comes too close, though the cell it once
# claimed may since have been freed by a rip of its first owner
for i in self._stub_near.get(node, ()):
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None:
return 0.0
if not allow_ripup:
@@ -857,30 +879,143 @@ class GridRouter:
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
head = [pin_location]
start = pin_location
exit_point = self._axis_exits.get(pin_location)
if exit_point is not None:
# a pin on a turned fine-pitch pad first leaves along the pad's
# axis, past the pin row, then takes the usual L to its cell
head = [pin_location, exit_point]
start = exit_point
dx, dy = end.x - start.x, end.y - start.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]
tail = [end]
elif abs(dx) >= abs(dy):
tail = [IntPoint(end.x, start.y), end]
else:
tail = [IntPoint(start.x, end.y), end]
points = []
for point in head + tail:
if not points or points[-1] != point:
points.append(point)
return points if len(points) > 1 else [pin_location, end]
def _leg_shapes(self, points: list[IntPoint], half: int, grow: int = 0) -> list:
"""A stub's copper, grown by ``grow``: a box per straight leg and per
inner corner, and an exact slab for a slanted leg (a pad-axis exit),
whose bounding box would reach the neighbouring pads."""
shapes: list = []
for a, b in zip(points, points[1:], strict=False):
if a == b:
continue
if a.x == b.x or a.y == b.y:
box = segment_box(a, b, half)
shapes.append(box.offset(grow) if grow else box)
else:
shapes.append(segment_slab(a, b, half + grow))
for c in points[1:-1]:
box = corner_box(c, half)
shapes.append(box.offset(grow) if grow else box)
return shapes
def _axis_exit(self, pin: Pin) -> IntPoint | None:
"""Where a pin on a turned fine-pitch pad clears its pin row.
A QFN placed at 45 degrees has no straight exit on the grid: every
horizontal or vertical step from a pin passes its neighbours closer
than the clearance. The way out is along the pad's own axis, away
from the part, until an L from there to a grid cell clears every
other net's pad (checked on exact shapes, as the exact engine will).
Any angle off the grid axes counts, not just 45 degrees.
"""
loc = pin.location
if loc in self._axis_exits:
return self._axis_exits[loc]
self._axis_exits[loc] = None
shape = pin.shape
if not isinstance(shape, Simplex) or shape.is_int_box():
return None
corners = [shape.corner_approx(i) for i in range(shape.border_line_count())]
edges = [(b.x - a.x, b.y - a.y) for a, b in zip(corners, corners[1:] + corners[:1], strict=False)]
lengths = [math.hypot(dx, dy) for dx, dy in edges]
if len(edges) < 3 or max(lengths) < 1.25 * min(lengths):
return None # no long axis to leave along
dx, dy = edges[lengths.index(max(lengths))]
ux, uy = dx / max(lengths), dy / max(lengths)
if min(abs(ux), abs(uy)) < 0.17:
return None # within ten degrees of a grid axis: the usual exits work
centre = self._part_centre(pin)
if centre is None:
return None
if (loc.x - centre[0]) * ux + (loc.y - centre[1]) * uy < 0:
ux, uy = -ux, -uy
reach = max((c.x - loc.x) * ux + (c.y - loc.y) * uy for c in corners)
h, cl = self.half_width, self.clearance
span = reach + h + cl + 16 * self.step
others = [
p.shape for p in self.board.get_pins()
if p.shape is not None and p is not pin and not set(p.net_nos) & set(pin.net_nos)
and set(p.layers) & set(pin.layers)
and abs(p.location.x - loc.x) < 2 * span and abs(p.location.y - loc.y) < 2 * span
]
self._axis_pads[loc] = others
for j in range(16):
d = reach + h + cl + j * self.step / 2
exit_point = IntPoint(round(loc.x + ux * d), round(loc.y + uy * d))
out = segment_slab(loc, exit_point, h + cl)
if any(overlaps_2d(out, o) for o in others):
continue
ex, ey = self._cell_of(exit_point)
self._axis_exits[loc] = exit_point
for ddx, ddy in itertools.product((0, -1, 1), repeat=2):
cell = (ex + ddx, ey + ddy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
legs = self._leg_shapes(self._stub_points(loc, cell)[1:], h, cl)
if not any(overlaps_2d(g, o) for g in legs for o in others):
return exit_point
self._axis_exits[loc] = None
return None
def _exit_clips_a_pad(self, pin: Pin, cell: Cell) -> bool:
"""Whether the stub from an axis exit to ``cell`` comes closer than the
clearance to another net's pad (exactly, as the exact engine checks):
the exit was chosen with one clear cell, not every cell around it."""
legs = self._leg_shapes(
self._stub_points(pin.location, cell)[1:], self._cur_half, self.clearance
)
return any(overlaps_2d(g, pad) for g in legs for pad in self._axis_pads.get(pin.location, ()))
def _part_centre(self, pin: Pin) -> tuple[float, float] | None:
key = pin.component_no
if key not in self._part_centres:
mates = [p.location for p in self.board.get_pins() if p.component_no == key] if key else []
self._part_centres[key] = (
(sum(m.x for m in mates) / len(mates), sum(m.y for m in mates) / len(mates))
if len(mates) > 2 else None
)
return self._part_centres[key]
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
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),
min(p.x for p in points), min(p.y for p in points),
max(p.x for p in points), max(p.y for p in points),
)
legs = list(zip(points, points[1:], strict=False))
return [
c
for c in self._cells_in_box(box, reach)
if min(_point_segment(self._cell_center(c), a, b) for a, b in legs) < reach
]
joints = points[1:] # each carries a square corner tile
def near(c) -> bool:
p = self._cell_center(c)
if min(_point_segment(p, a, b) for a, b in legs) < reach:
return True
return any(max(abs(p.x - j.x), abs(p.y - j.y)) < reach for j in joints)
return [c for c in self._cells_in_box(box, reach) if near(c)]
def _stub_clear(
self,
@@ -896,13 +1031,12 @@ class GridRouter:
if keepouts:
# the stub is off the grid, so check it exactly, as the exact
# engine will: its copper grown by clearance must miss every keepout
from freeroute.geometry.polyline import Polyline, PolylineShape
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):
legs = self._leg_shapes(points, self._cur_half)
grown_legs = self._leg_shapes(points, self._cur_half, self.clearance)
for tile, grown in zip(legs, grown_legs, strict=True):
if any(overlaps_2d(k, grown if needs else tile) for k, needs in keepouts):
return False
# the track's round end at the pin, for a rule area
cap = corner_box(pin.location, self._cur_half)
@@ -916,15 +1050,16 @@ class GridRouter:
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."""
def _stub_tiles(self, pin_location: IntPoint, cell: Cell, half: int, grow: int = 0) -> list:
"""The stub's copper (grown by ``grow``): 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:]]
tiles: list = [
corner_box(point, half).offset(grow) if grow else 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()
tiles += self._leg_shapes(points, half, grow)
return tiles
def _stub_owners(
@@ -944,13 +1079,23 @@ class GridRouter:
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):
grown = self._stub_tiles(
pin.location, cell, half or self._cur_half, self.clearance
)
if not any(overlaps_2d(g, t) for tiles in other for t in tiles for g in grown):
continue
owners.add(owner)
for idx in self._stub_near.get(node, ()):
if idx in owners or conns[idx].net_no == net_no:
continue
theirs = [tiles for lay, tiles, _ in conns[idx].stubs if lay == layer]
if theirs:
if grown is None:
grown = self._stub_tiles(
pin.location, cell, half or self._cur_half, self.clearance
)
if any(overlaps_2d(g, t) for tiles in theirs for t in tiles for g in grown):
owners.add(idx)
owners.update(self._via_others(c, net_no, conns))
return owners
@@ -1031,11 +1176,16 @@ class GridRouter:
return self._cell_of(IntPoint((box.ll.x + box.ur.x) // 2, (box.ll.y + box.ur.y) // 2))
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
if self._axis_exits.get(pin.location) is None:
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
points = self._stub_points(pin.location, node[:2])
return sum(
math.hypot(b.x - a.x, b.y - a.y) for a, b in zip(points, points[1:], strict=False)
) / self.step
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
@@ -1045,14 +1195,21 @@ class GridRouter:
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)
exit_point = None
if self._orthogonal and self._crowded(pin, net_no):
exit_point = self._axis_exit(pin)
cx, cy = self._cell_of(exit_point if exit_point is not None else 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
if exit_point is not None and self._exit_clips_a_pad(pin, cell):
continue
for layer in layers:
if (dx or dy) and self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None:
# the pin's own cell is in its pad; an exit cell is not
checked = dx or dy or exit_point is not None
if checked and self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None:
continue
if self._stub_clear(pin, cell, layer, net_no, conns, allow_ripup=allow_ripup):
nodes.add((*cell, layer))
@@ -1062,6 +1219,11 @@ class GridRouter:
# 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), allow_ripup)
if not nodes and exit_point is not None:
# every cell past the pin row is taken: report no way out, so the
# search goes on to rip-up, rather than fall back to a cell out in
# the open on top of another net's route
return nodes
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(
@@ -1135,7 +1297,6 @@ class GridRouter:
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
reach = self._escape_cells
near = [
@@ -1157,17 +1318,14 @@ class GridRouter:
cell = (cx + dx * k + sx, cy + dy * k + sy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
break
grown = [
tile.offset(self.clearance)
for tile in PolylineShape(
Polyline(self._stub_points(pin.location, cell)), self._cur_half
).tiles()
]
grown = self._leg_shapes(
self._stub_points(pin.location, cell), self._cur_half, self.clearance
)
for layer in layers:
if self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None:
continue
if any(
layer in box_layers and any(g.overlaps(box) for g in grown)
layer in box_layers and any(overlaps_2d(g, box) for g in grown)
for box, box_layers in near
):
continue
@@ -1230,6 +1388,7 @@ class GridRouter:
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 = []
self._forget_stubs(conn)
for pin, (layer, cells), at in ends:
if not isinstance(pin, Pin):
continue # ended on a zone or a plane: no stub
@@ -1237,6 +1396,8 @@ class GridRouter:
claimed: set[Node] = set()
for c in self._stub_cells(pin.location, cell, half):
node = (*c, layer)
self._stub_near.setdefault(node, set()).add(conn.index)
conn.stub_near.append(node)
if node not in self._cell_owner:
self._cell_owner[node] = conn.index
conn.trace_cells.append((c, layer))
@@ -1272,6 +1433,15 @@ class GridRouter:
conn.routed = True
self._settle(conn, -1)
def _forget_stubs(self, conn: _Connection) -> None:
for node in conn.stub_near:
near = self._stub_near.get(node)
if near is not None:
near.discard(conn.index)
if not near:
del self._stub_near[node]
conn.stub_near = []
def _rip(self, conn: _Connection) -> None:
for cell, layer in conn.trace_cells:
if self._cell_owner.get((*cell, layer)) == conn.index:
@@ -1292,6 +1462,7 @@ class GridRouter:
conn.halo_nodes = []
conn.trace_cells = []
conn.stubs = []
self._forget_stubs(conn)
for cell in conn.via_cells:
count = self._via_nets.get(cell)
if count and count.get(conn.net_no):
@@ -1328,6 +1499,9 @@ class GridRouter:
for i in self._halo.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
for i in self._stub_near.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
return owners
# --- pin access ---------------------------------------------------------