Keep hole-to-hole spacing between every two vias, one net's included

Every pin of a supply drops its own via to the plane, and nothing kept
two of them apart: the flight controller came back with 199 co-located
holes and 109 hole-to-hole errors, nearly all VDD_3V3 and GND. A via now
keeps its drill plus --hole-to-hole (default 0.25 mm, KiCad's) from
every via centre, fixed ones included, except an own-net via at the same
cell, which it reuses; such a via is written once, and none is written
on top of a via already on the board.
This commit is contained in:
2026-09-28 21:52:52 -06:00
parent 4a844e8cd7
commit 714fa873ae
5 changed files with 131 additions and 2 deletions
+10
View File
@@ -175,6 +175,14 @@ def build_parser() -> argparse.ArgumentParser:
"connections a rip-up displaces are rerouted at once and the rip-up is undone " "connections a rip-up displaces are rerouted at once and the rip-up is undone "
"if fewer connections end up routed", "if fewer connections end up routed",
) )
parser.add_argument(
"--hole-to-hole",
type=float,
default=0.25,
metavar="MM",
help="grid and exact engines: least distance between the edges of any two via "
"holes, of one net or two (KiCad's min_hole_to_hole; default 0.25)",
)
parser.add_argument( parser.add_argument(
"--jobs", "--jobs",
type=int, type=int,
@@ -239,6 +247,7 @@ def main(argv: list[str] | None = None) -> int:
"resolution": 2 if args.grid_resolution is None else args.grid_resolution, "resolution": 2 if args.grid_resolution is None else args.grid_resolution,
"heuristic_weight": args.heuristic_weight, "heuristic_weight": args.heuristic_weight,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback, "rollback": args.rollback,
} }
elif args.engine == "grid": elif args.engine == "grid":
@@ -249,6 +258,7 @@ def main(argv: list[str] | None = None) -> int:
"edge_clearance_mm": args.edge_clearance, "edge_clearance_mm": args.edge_clearance,
"resolution": 1 if args.grid_resolution is None else args.grid_resolution, "resolution": 1 if args.grid_resolution is None else args.grid_resolution,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback, "rollback": args.rollback,
} }
if args.engine == "room": if args.engine == "room":
+4
View File
@@ -100,6 +100,8 @@ def route_board_exact(
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True, rollback: bool = True,
net_widths: dict[int, int] | None = None, net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -133,6 +135,8 @@ def route_board_exact(
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, rollback=rollback,
net_widths=net_widths, net_widths=net_widths,
via_drill=via_drill,
hole_to_hole=hole_to_hole,
) )
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
+55 -2
View File
@@ -31,7 +31,7 @@ import math
import random import random
from freeroute.board.board import BasicBoard from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from freeroute.geometry import IntBox, IntPoint from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry.polyline import corner_box from freeroute.geometry.polyline import corner_box
@@ -177,6 +177,8 @@ class GridRouter:
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True, rollback: bool = True,
net_widths: dict[int, int] | None = None, net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> None: ) -> None:
self.board = board self.board = board
# Pin access: while a pin still has connections to route, the cells # Pin access: while a pin still has connections to route, the cells
@@ -248,6 +250,23 @@ class GridRouter:
self._spread: list[tuple[int, int]] = [] # the current search's self._spread: list[tuple[int, int]] = [] # the current search's
self._cur_half = self.half_width self._cur_half = self.half_width
self._halo_by_half: dict[int, list[tuple[int, int]]] = {self.half_width: self._halo_offsets} self._halo_by_half: dict[int, list[tuple[int, int]]] = {self.half_width: self._halo_offsets}
# Hole-to-hole spacing holds between any two vias, of one net or two.
# Other nets' vias are kept further apart already; a net's own vias
# (every pin of a supply dropping to its plane) are not, so a via
# must keep its drill plus the spacing from every via centre, except
# an own-net via at the very same cell, which it simply reuses.
drill = via_drill if via_drill else 2 * self.via_half
need = drill + max(hole_to_hole, 0)
reach = math.ceil(need / self.step)
self._hole_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
]
self._via_nets: dict[Cell, dict[int, int]] = {} # routed via centres
self._fixed_vias: dict[Cell, int] = {}
self._fixed_via_points: set[tuple[int, IntPoint]] = set()
if layers is None: if layers is None:
layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal] layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal]
@@ -276,6 +295,10 @@ class GridRouter:
self._halo: dict[Node, list[int]] = {} self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles() self._rasterize_obstacles()
self._rasterize_fixed_copper() self._rasterize_fixed_copper()
for item in board.get_items():
if isinstance(item, Via) and item.fixed and item.net_nos:
self._fixed_vias[self._cell_of(item.location)] = item.net_nos[0]
self._fixed_via_points.add((item.net_nos[0], item.location))
# --- grid helpers ------------------------------------------------------- # --- grid helpers -------------------------------------------------------
@@ -491,7 +514,21 @@ class GridRouter:
return False return False
if self._enter_cost_base(c, layer, net_no, False, conns, wire_keepout=False) != 0.0: if self._enter_cost_base(c, layer, net_no, False, conns, wire_keepout=False) != 0.0:
return False return False
return True return self._holes_clear(cell, net_no)
def _via_nets_at(self, cell: Cell) -> set[int]:
nets = {net for net, n in self._via_nets.get(cell, {}).items() if n > 0}
fixed = self._fixed_vias.get(cell)
if fixed is not None:
nets.add(fixed)
return nets
def _holes_clear(self, cell: Cell, net_no: int) -> bool:
"""No via centre within hole-to-hole reach, but an own-net one here."""
if self._via_nets_at(cell) - {net_no}:
return False
cx, cy = cell
return not any(self._via_nets_at((cx + dx, cy + dy)) for dx, dy in self._hole_offsets)
# --- A* search ---------------------------------------------------------- # --- A* search ----------------------------------------------------------
@@ -953,6 +990,9 @@ class GridRouter:
via_cells.append((path[-1][0], path[-1][1])) via_cells.append((path[-1][0], path[-1][1]))
conn.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells] conn.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells]
conn.via_cells = list(via_cells) conn.via_cells = list(via_cells)
for cell in conn.via_cells:
count = self._via_nets.setdefault(cell, {})
count[conn.net_no] = count.get(conn.net_no, 0) + 1
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.half = half = self._conn_half(conn) conn.half = half = self._conn_half(conn)
@@ -1027,6 +1067,10 @@ class GridRouter:
del self._halo[node] del self._halo[node]
conn.halo_nodes = [] conn.halo_nodes = []
conn.trace_cells = [] conn.trace_cells = []
for cell in conn.via_cells:
count = self._via_nets.get(cell)
if count and count.get(conn.net_no):
count[conn.net_no] -= 1
conn.via_cells = [] conn.via_cells = []
conn.wires = [] conn.wires = []
conn.vias = [] conn.vias = []
@@ -1257,11 +1301,16 @@ class GridRouter:
def _result_from(self, snapshot, unrouted: int) -> RouteResult: def _result_from(self, snapshot, unrouted: int) -> RouteResult:
result = RouteResult(self.half_width) result = RouteResult(self.half_width)
written: set[tuple[int, IntPoint]] = set()
result.unrouted = unrouted result.unrouted = unrouted
for net_no, wires, vias, ends in snapshot: for net_no, wires, vias, ends in snapshot:
for layer, points in wires: for layer, points in wires:
result.add_wire(net_no, layer, points) result.add_wire(net_no, layer, points)
for location in vias: for location in vias:
spot = (net_no, location)
if spot in written or spot in self._fixed_via_points:
continue # a via reused by another connection, or already on the board
written.add(spot)
result.add_via(net_no, location) result.add_via(net_no, location)
result.connections.append((net_no, wires, vias)) result.connections.append((net_no, wires, vias))
result.connection_ends.append(ends) result.connection_ends.append(ends)
@@ -1408,6 +1457,8 @@ def route_board(
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True, rollback: bool = True,
net_widths: dict[int, int] | None = None, net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> 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(
@@ -1428,5 +1479,7 @@ def route_board(
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, rollback=rollback,
net_widths=net_widths, net_widths=net_widths,
via_drill=via_drill,
hole_to_hole=hole_to_hole,
) )
return router.route() return router.route()
+26
View File
@@ -12,6 +12,8 @@ to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and
from __future__ import annotations from __future__ import annotations
import re
from freeroute.board import build_board from freeroute.board import build_board
from freeroute.dsn import DsnBoard, parse_dsn from freeroute.dsn import DsnBoard, parse_dsn
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
@@ -72,6 +74,20 @@ def _mm_to_board(dsn: DsnBoard, scale: int, value_mm: float | None) -> int | Non
return round(value_mm / unit_mm * scale) return round(value_mm / unit_mm * scale)
def _via_drill_board(dsn: DsnBoard, scale: int) -> int | None:
"""The via's drill in board units, from KiCad's padstack name
(``Via[0-1]_600:300_um`` is a 600 um pad with a 300 um drill); None when
the name does not say, and the pad size stands in for it."""
match = re.search(r"_([\d.]+):([\d.]+)_um", _via_padstack(dsn))
if not match:
return None
return round(float(match.group(2)) * _dsn_units_per_um(dsn) * scale)
def _dsn_units_per_um(dsn: DsnBoard) -> float:
return {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}.get(dsn.unit, 1.0)
def _via_half_board(dsn: DsnBoard, scale: int) -> int | None: def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
"""Half the via pad's size in board units, from the DSN's via padstack. """Half the via pad's size in board units, from the DSN's via padstack.
@@ -102,6 +118,7 @@ def route_dsn_board(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]: ) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
@@ -128,6 +145,8 @@ def route_dsn_board(
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, rollback=rollback,
net_widths=_net_widths_board(dsn, scale), net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
) )
return result, scale, [layer.name for layer in dsn.layers] return result, scale, [layer.name for layer in dsn.layers]
@@ -177,6 +196,7 @@ def build_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
@@ -185,6 +205,7 @@ def build_routing_result(
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed, edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback, rollback=rollback,
) )
return _to_routing_result(route, dsn, scale, layer_names) return _to_routing_result(route, dsn, scale, layer_names)
@@ -204,6 +225,7 @@ def route_dsn_board_exact(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
@@ -234,6 +256,8 @@ def route_dsn_board_exact(
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, rollback=rollback,
net_widths=_net_widths_board(dsn, scale), net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
) )
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
@@ -252,6 +276,7 @@ def build_exact_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
@@ -268,6 +293,7 @@ def build_exact_routing_result(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback, rollback=rollback,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
+36
View File
@@ -0,0 +1,36 @@
"""Vias keep hole-to-hole spacing, even within one net."""
from __future__ import annotations
import itertools
import math
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
# four GND pads 0.5 mm apart over a GND plane: each drops a via to the plane
_DSN = """(pcb holes (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
(layer B.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 19500 -500 19500 -19500 500 -19500 500 -500))
(via "Via[0-3]_500:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 9000 -10000 front 0) (place G2 9500 -10000 front 0)
(place G3 10000 -10000 front 0) (place G4 10500 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -150 -300 150 300)) (attach off))
(padstack "Via[0-3]_500:300_um" (shape (circle F.Cu 500)) (shape (circle B.Cu 500)) (attach off)))
(network (net GND (pins G1-1 G2-1 G3-1 G4-1))))"""
def test_a_nets_own_vias_keep_hole_to_hole_spacing():
dsn = parse_dsn(_DSN)
exact, scale, _ = route_dsn_board_exact(dsn, resolution=2)
assert exact.result.failed == []
vias = exact.result.vias[1]
assert len(set(vias)) == len(vias), "a via written twice"
need = (300 + 250) * scale # drill + 0.25 mm, centre to centre
for a, b in itertools.combinations(vias, 2):
assert math.hypot(a.x - b.x, a.y - b.y) >= need, f"vias at {a} and {b} too close"