Finer routing grid: --grid-resolution K with trace halos

A cell was exactly one trace plus its clearance, so a trace owning its cell
was the whole clearance model, and the 0.4 mm grid could not reach most
pins of a 0.5 mm pitch package: between two 0.3 mm pads a trace's centre
has a legal band only 0.15 mm wide. Resolution K makes cells K times
finer and has every trace claim a halo, the cells whose centre is nearer
than trace + clearance to one of its own, released again on rip-up.

K = 1 has an empty halo and is the old grid exactly (tested). On a 0.5 mm
row, K = 4 gets every pin out where K = 1 gets two. On the rc4 boards, K = 2
cuts the sense board's opens from 66 to 41 (JAR: 32) and the power board's
to 7 (JAR: 12), still with no DRC errors from freeroute's own copper.
This commit is contained in:
2026-09-27 07:59:05 -06:00
parent 9fa5432953
commit 8929fd367e
5 changed files with 90 additions and 16 deletions
+10 -1
View File
@@ -110,6 +110,14 @@ def build_parser() -> argparse.ArgumentParser:
help="disable rip-up-and-retry (grid and exact engines)", help="disable rip-up-and-retry (grid and exact engines)",
) )
parser.add_argument("--layers", help="comma-separated signal layer indices to route on") parser.add_argument("--layers", help="comma-separated signal layer indices to route on")
parser.add_argument(
"--grid-resolution",
type=int,
default=1,
metavar="K",
help="grid and exact engines: cells K times finer than trace + clearance "
"(default 1); 2 or more reaches fine-pitch pins, at some cost in time",
)
parser.add_argument( parser.add_argument(
"--edge-clearance", "--edge-clearance",
type=float, type=float,
@@ -166,6 +174,7 @@ def main(argv: list[str] | None = None) -> int:
rip_up=rip_up, rip_up=rip_up,
max_passes=max_passes, max_passes=max_passes,
edge_clearance_mm=args.edge_clearance, edge_clearance_mm=args.edge_clearance,
resolution=args.grid_resolution,
) )
elif args.engine == "room": elif args.engine == "room":
result = build_rooms_routing_result( result = build_rooms_routing_result(
@@ -175,7 +184,7 @@ def main(argv: list[str] | None = None) -> int:
else: else:
result = build_routing_result( result = build_routing_result(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
edge_clearance_mm=args.edge_clearance, edge_clearance_mm=args.edge_clearance, resolution=args.grid_resolution,
) )
ses_text = write_ses(dsn, result) ses_text = write_ses(dsn, result)
+2
View File
@@ -92,6 +92,7 @@ def route_board_exact(
shove_cap: int = 3, shove_cap: int = 3,
via_half: int | None = None, via_half: int | None = None,
edge_clearance: int | None = None, edge_clearance: int | None = None,
resolution: int = 1,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -118,6 +119,7 @@ def route_board_exact(
orthogonal=True, orthogonal=True,
via_half=via_half, via_half=via_half,
edge_clearance=edge_clearance, edge_clearance=edge_clearance,
resolution=resolution,
) )
edge = 0 if edge_clearance is None else edge_clearance edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline outline = board.outline
+45 -2
View File
@@ -101,6 +101,7 @@ class _Connection:
"trace_cells", "trace_cells",
"via_cells", "via_cells",
"via_halo", "via_halo",
"halo_nodes",
) )
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -115,6 +116,7 @@ class _Connection:
self.trace_cells: list[tuple[Cell, int]] = [] self.trace_cells: list[tuple[Cell, int]] = []
self.via_cells: list[Cell] = [] self.via_cells: list[Cell] = []
self.via_halo: list[Cell] = [] self.via_halo: list[Cell] = []
self.halo_nodes: list[Node] = []
class GridRouter: class GridRouter:
@@ -135,6 +137,7 @@ class GridRouter:
orthogonal: bool = False, orthogonal: bool = False,
via_half: int | None = None, via_half: int | None = None,
edge_clearance: int | None = None, edge_clearance: int | None = None,
resolution: int = 1,
) -> None: ) -> None:
self.board = board self.board = board
self.trace_width = max(trace_width, 1) self.trace_width = max(trace_width, 1)
@@ -149,8 +152,21 @@ class GridRouter:
# trace copper; 8-connected also produces 45-degree segments. # trace copper; 8-connected also produces 45-degree segments.
self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS
self._orthogonal = orthogonal self._orthogonal = orthogonal
# cell size must fit a trace plus its clearance to a neighbouring trace # At resolution 1 a cell is exactly one trace plus its clearance, so a
self.step = max(self.trace_width + self.clearance, 1) # trace owning its cell is the whole clearance model. At resolution k
# cells are k times finer, fine enough to give every pin of a 0.5 mm
# pitch row its own column, and a trace also claims a halo: the cells
# whose centre is nearer than trace + clearance to one of its own.
self.resolution = max(int(resolution), 1)
self.step = max((self.trace_width + self.clearance) // self.resolution, 1)
need = self.trace_width + self.clearance
reach = math.ceil(need / self.step)
self._halo_offsets = [
(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
]
# A via's copper is wider than a trace. Another net's trace needs its # 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 # centre via_half + clearance + half_width from the via's centre, so a
# via also claims every cell within via_reach of its own. # via also claims every cell within via_reach of its own.
@@ -177,6 +193,8 @@ class GridRouter:
# per-connection occupancy (owner = connection index) # per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {} self._cell_owner: dict[Node, int] = {}
self._via_owner: dict[Cell, int] = {} self._via_owner: dict[Cell, int] = {}
# trace halos: node -> indices of the connections claiming it
self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles() self._rasterize_obstacles()
# --- grid helpers ------------------------------------------------------- # --- grid helpers -------------------------------------------------------
@@ -299,6 +317,11 @@ class GridRouter:
via_owner = self._via_owner.get(cell) via_owner = self._via_owner.get(cell)
if via_owner is not None and conns[via_owner].net_no != net_no: if via_owner is not None and conns[via_owner].net_no != net_no:
blocking = via_owner blocking = via_owner
if blocking is None:
for i in self._halo.get(node, ()):
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None: if blocking is None:
return 0.0 return 0.0
if not allow_ripup: if not allow_ripup:
@@ -458,6 +481,14 @@ class GridRouter:
conn.via_cells = list(via_cells) conn.via_cells = list(via_cells)
for cell, layer in conn.trace_cells: for cell, layer in conn.trace_cells:
self._cell_owner[(*cell, layer)] = conn.index self._cell_owner[(*cell, layer)] = conn.index
conn.halo_nodes = []
for (cx, cy), layer in conn.trace_cells:
for dx, dy in self._halo_offsets:
x, y = cx + dx, cy + dy
if 0 <= x < self.cols and 0 <= y < self.rows:
node = (x, y, layer)
self._halo.setdefault(node, []).append(conn.index)
conn.halo_nodes.append(node)
conn.via_halo = [] conn.via_halo = []
for cell in conn.via_cells: for cell in conn.via_cells:
for c in self._via_cells(cell): for c in self._via_cells(cell):
@@ -492,6 +523,13 @@ class GridRouter:
if self._via_owner.get(cell) == conn.index: if self._via_owner.get(cell) == conn.index:
del self._via_owner[cell] del self._via_owner[cell]
conn.via_halo = [] conn.via_halo = []
for node in conn.halo_nodes:
claims = self._halo.get(node)
if claims is not None:
claims.remove(conn.index)
if not claims:
del self._halo[node]
conn.halo_nodes = []
conn.trace_cells = [] conn.trace_cells = []
conn.via_cells = [] conn.via_cells = []
conn.wires = [] conn.wires = []
@@ -508,6 +546,9 @@ class GridRouter:
via_owner = self._via_owner.get((cx, cy)) via_owner = self._via_owner.get((cx, cy))
if via_owner is not None and conns[via_owner].net_no != net_no: if via_owner is not None and conns[via_owner].net_no != net_no:
owners.add(via_owner) owners.add(via_owner)
for i in self._halo.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
return owners return owners
# --- passes ------------------------------------------------------------- # --- passes -------------------------------------------------------------
@@ -645,6 +686,7 @@ def route_board(
orthogonal: bool = False, orthogonal: bool = False,
via_half: int | None = None, via_half: int | None = None,
edge_clearance: int | None = None, edge_clearance: int | None = None,
resolution: int = 1,
) -> RouteResult: ) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board.""" """Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter( router = GridRouter(
@@ -658,5 +700,6 @@ def route_board(
orthogonal=orthogonal, orthogonal=orthogonal,
via_half=via_half, via_half=via_half,
edge_clearance=edge_clearance, edge_clearance=edge_clearance,
resolution=resolution,
) )
return router.route() return router.route()
+14 -3
View File
@@ -75,6 +75,7 @@ def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
def route_dsn_board( def route_dsn_board(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
edge_clearance_mm: float | None = None, edge_clearance_mm: float | None = None,
layers: list[int] | None = None, layers: list[int] | None = None,
rip_up: bool = True, rip_up: bool = True,
@@ -91,6 +92,7 @@ def route_dsn_board(
clearance_board = round(_rule_clearance_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale)
result = route_board( result = route_board(
board, board,
resolution=resolution,
via_half=_via_half_board(dsn, scale), via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board, edge_clearance=edge_clearance_board,
trace_width=width_board, trace_width=width_board,
@@ -130,6 +132,7 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
def build_routing_result( def build_routing_result(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
layers: list[int] | None = None, layers: list[int] | None = None,
rip_up: bool = True, rip_up: bool = True,
max_passes: int = 10, max_passes: int = 10,
@@ -138,7 +141,7 @@ def build_routing_result(
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board( route, scale, layer_names = route_dsn_board(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm, edge_clearance_mm=edge_clearance_mm, resolution=resolution,
) )
return _to_routing_result(route, dsn, scale, layer_names) return _to_routing_result(route, dsn, scale, layer_names)
@@ -146,6 +149,7 @@ def build_routing_result(
def route_dsn_board_exact( def route_dsn_board_exact(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
edge_clearance_mm: float | None = None, edge_clearance_mm: float | None = None,
layers: list[int] | None = None, layers: list[int] | None = None,
max_passes: int = 10, max_passes: int = 10,
@@ -165,6 +169,7 @@ def route_dsn_board_exact(
clearance_board = round(_rule_clearance_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale)
exact = route_board_exact( exact = route_board_exact(
board, board,
resolution=resolution,
via_half=_via_half_board(dsn, scale), via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board, edge_clearance=edge_clearance_board,
trace_width=width_board, trace_width=width_board,
@@ -181,6 +186,7 @@ def route_dsn_board_exact(
def build_exact_routing_result( def build_exact_routing_result(
dsn: DsnBoard, dsn: DsnBoard,
*, *,
resolution: int = 1,
layers: list[int] | None = None, layers: list[int] | None = None,
shove: bool = False, shove: bool = False,
diagonal: bool = False, diagonal: bool = False,
@@ -197,6 +203,7 @@ def build_exact_routing_result(
rip_up=rip_up, rip_up=rip_up,
max_passes=max_passes, max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm, edge_clearance_mm=edge_clearance_mm,
resolution=resolution,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
@@ -255,6 +262,7 @@ def route(
pack: bool = False, pack: bool = False,
diagonal: bool = False, diagonal: bool = False,
edge_clearance_mm: float | None = None, edge_clearance_mm: float | None = None,
resolution: int = 1,
) -> str: ) -> str:
"""Route a Specctra DSN string and return the routed SES string. """Route a Specctra DSN string and return the routed SES string.
@@ -269,12 +277,15 @@ def route(
dsn = parse_dsn(dsn_text) dsn = parse_dsn(dsn_text)
if engine == "exact": if engine == "exact":
result = build_exact_routing_result( result = build_exact_routing_result(
dsn, layers=layers, shove=shove, diagonal=diagonal, edge_clearance_mm=edge_clearance_mm dsn, layers=layers, shove=shove, diagonal=diagonal,
edge_clearance_mm=edge_clearance_mm, resolution=resolution,
) )
elif engine == "room": elif engine == "room":
result = build_rooms_routing_result( result = build_rooms_routing_result(
dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm
) )
else: else:
result = build_routing_result(dsn, layers=layers, edge_clearance_mm=edge_clearance_mm) result = build_routing_result(
dsn, layers=layers, edge_clearance_mm=edge_clearance_mm, resolution=resolution
)
return write_ses(dsn, result) return write_ses(dsn, result)
+19 -10
View File
@@ -51,16 +51,25 @@ def test_routes_keep_edge_clearance_at_a_rounded_corner():
assert outline.edge_distance(p) >= half + 500 * scale - 1 assert outline.edge_distance(p) >= half + 500 * scale - 1
@pytest.mark.xfail( @pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)])
strict=True, def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape):
reason="known gap: the grid step (trace + clearance, 0.4 mm here) does not line up " # Five 0.3 mm pads at 0.5 mm pitch, each wired to its own pad 8 mm away.
"with a 0.5 mm pin row, so only the outer pins get a column; the FreeRouting JAR, " # Between two neighbours a trace's centre has a legal band only 0.15 mm
"being gridless, routes all five. This is most of the gap on an LQFP.", # 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.
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 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)) 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)) 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))) result, _, _ = route_dsn_board_exact(
assert len(result.routed_net_numbers) == 5 parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution
)
assert len(result.routed_net_numbers) == escape
def test_resolution_one_has_no_halo_so_the_grid_is_unchanged():
from freeroute.route.grid_router import GridRouter
board = build_board(parse_dsn(_dsn("", "")))
assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == []
fine = GridRouter(board, trace_width=2500, clearance=1500, resolution=2)
assert fine.step == 2000 and (1, 0) in fine._halo_offsets and (2, 0) not in fine._halo_offsets