Make the grid and the exact check agree on rotated pads, shared vias and wide nets

Rotated oval pads were built from padded corners that are not in outline
order, so they fell back to their bounding box; neighbouring boxes of a
rotated socket overlap, and the grid's own-pad exemption opened the
overlap to the pin's net, which the exact check then refused. Pads are now
the convex hull of their corners, the exact tree checks a rotated pad
against that outline, and the grid blocks cells against the same outline.

A via reused by a second connection of its net left that connection with
no claim on the via's footprint, so ripping the first owner freed cells
around a via still on the board. Footprint cells now keep every claimant.

A net class wider than the default was searched as a default trace: its
spread was empty below half a cell, halos were circular while the exact
check compares boxes, and a rip-up route only counted what its centre
crossed. The spread now covers the extra width in whole cells, halos are
square, and rip-up victims include what the spread crosses.

bench/diagnose.py now routes with net-class widths and hole spacing, as
the CLI does.
This commit is contained in:
2026-09-30 12:08:25 -06:00
parent ef8398c9b2
commit f79134fee6
6 changed files with 331 additions and 21 deletions
+57 -14
View File
@@ -33,7 +33,8 @@ import random
from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry import IntBox, IntPoint, Simplex
from freeroute.geometry.octagon import overlaps_2d
from freeroute.geometry.polyline import corner_box
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -233,7 +234,7 @@ class GridRouter:
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step < need
if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need
]
# A via's copper is wider than a trace. Another net's trace needs its
# centre via_half + clearance + half_width from the via's centre, so a
@@ -299,6 +300,9 @@ class GridRouter:
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
self._via_owner: dict[Cell, 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
self._via_claims: dict[Cell, list[int]] = {}
# trace halos: node -> indices of the connections claiming it
self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles()
@@ -379,6 +383,19 @@ class GridRouter:
shape.contains(IntPoint(c.x + dx, c.y + dy)) for dx in (-h, h) for dy in (-h, h)
)
def _pad_cells(self, shape, box: IntBox, margin: int):
"""Cells where a trace square ``margin`` wide around the centre overlaps
the pad: its box when it is one, else its exact (rotated) outline,
which is what the exact engine checks against."""
cells = self._cells_in_box(box, margin)
if not isinstance(shape, Simplex) or shape.is_int_box():
yield from cells
return
for cell in cells:
c = self._cell_center(cell)
if overlaps_2d(shape, IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin)):
yield cell
def _rasterize_obstacles(self) -> None:
margin = self.clearance + self.half_width
outline = self.board.outline
@@ -404,7 +421,7 @@ class GridRouter:
continue
net = item.net_nos[0] if item.net_nos else 0
pin_layers = [layer for layer in item.layers if layer in self.layers]
for cell in self._cells_in_box(box, margin):
for cell in self._pad_cells(item.shape, box, margin):
for layer in pin_layers:
self._pad_block.setdefault((*cell, layer), set()).add(net)
# Copper of the pin's own net: a trace inside it adds nothing,
@@ -593,18 +610,19 @@ class GridRouter:
return self.half_width if conn.narrow else self._net_half.get(conn.net_no, self.half_width)
def _spread_for(self, half: int) -> list[tuple[int, int]]:
"""Cells a trace of ``half`` also needs free: those whose centre is
within its extra half width (plus half a cell, for the grid)."""
"""Cells a trace of ``half`` also needs free: every cell out to its
extra half width, rounded up to whole cells. Blocks, halos and via
footprints are sized for a default-width trace, and copper is checked
as boxes, so anything within that square could be too close."""
extra = half - self.half_width
if extra <= 0:
return []
reach = math.ceil(extra / self.step) + 1
limit = extra + self.step / 2
reach = math.ceil(extra / self.step)
return [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step <= limit
if dx or dy
]
def _halo_for(self, half: int) -> list[tuple[int, int]]:
@@ -617,7 +635,7 @@ class GridRouter:
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step < need
if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need
]
return offsets
@@ -1184,9 +1202,11 @@ class GridRouter:
conn.via_halo = []
for cell in conn.via_cells:
for c in self._via_cells(cell):
if c not in self._via_owner:
self._via_owner[c] = conn.index
claims = self._via_claims.setdefault(c, [])
if conn.index not in claims:
claims.append(conn.index)
conn.via_halo.append(c)
self._via_owner.setdefault(c, conn.index)
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:
@@ -1236,8 +1256,16 @@ class GridRouter:
if self._cell_owner.get((*cell, layer)) == conn.index:
del self._cell_owner[(*cell, layer)]
for cell in conn.via_halo:
claims = self._via_claims.get(cell)
if claims is not None and conn.index in claims:
claims.remove(conn.index)
if self._via_owner.get(cell) == conn.index:
del self._via_owner[cell]
if claims:
self._via_owner[cell] = claims[0]
else:
del self._via_owner[cell]
if claims is not None and not claims:
del self._via_claims[cell]
conn.via_halo = []
for node in conn.halo_nodes:
claims = self._halo.get(node)
@@ -1261,7 +1289,20 @@ class GridRouter:
conn.rip_count += 1
self._settle(conn, +1)
def _crossed_owners(self, path: list[Node], net_no: int, conns: list[_Connection]) -> set[int]:
def _crossed_owners(
self, path: list[Node], net_no: int, conns: list[_Connection], spread=None
) -> set[int]:
"""Connections whose copper or clearance ``path`` crosses; a trace
wider than the default crosses them on its ``spread`` cells too."""
spread = self._spread if spread is None else spread
if spread:
path = list(
dict.fromkeys(
(x + dx, y + dy, layer)
for x, y, layer in path
for dx, dy in ((0, 0), *spread)
)
)
owners: set[int] = set()
for cx, cy, cl in path:
owner = self._cell_owner.get((cx, cy, cl))
@@ -1380,7 +1421,9 @@ class GridRouter:
path = self._search(conn, allow_ripup=True, conns=conns)
if path is None:
return False
crossed = self._crossed_owners(path, conn.net_no, conns)
crossed = self._crossed_owners(
path, conn.net_no, conns, self._spread_for(self._conn_half(conn))
)
# 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):