diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 9eed329..07163c6 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -175,6 +175,14 @@ def build_parser() -> argparse.ArgumentParser: "connections a rip-up displaces are rerouted at once and the rip-up is undone " "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( "--jobs", 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, "heuristic_weight": args.heuristic_weight, "access_cost": args.access_cost or 0.0, + "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, } elif args.engine == "grid": @@ -249,6 +258,7 @@ def main(argv: list[str] | None = None) -> int: "edge_clearance_mm": args.edge_clearance, "resolution": 1 if args.grid_resolution is None else args.grid_resolution, "access_cost": args.access_cost or 0.0, + "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, } if args.engine == "room": diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 3238280..dc6b8f9 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -100,6 +100,8 @@ def route_board_exact( access_cost: float = 0.0, rollback: bool = True, net_widths: dict[int, int] | None = None, + via_drill: int | None = None, + hole_to_hole: int = 0, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -133,6 +135,8 @@ def route_board_exact( access_cost=access_cost, rollback=rollback, net_widths=net_widths, + via_drill=via_drill, + hole_to_hole=hole_to_hole, ) edge = 0 if edge_clearance is None else edge_clearance outline = board.outline diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 9785677..4a49bc5 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -31,7 +31,7 @@ import math import random 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.polyline import corner_box @@ -177,6 +177,8 @@ class GridRouter: access_cost: float = 0.0, rollback: bool = True, net_widths: dict[int, int] | None = None, + via_drill: int | None = None, + hole_to_hole: int = 0, ) -> None: self.board = board # 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._cur_half = self.half_width 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: 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._rasterize_obstacles() 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 ------------------------------------------------------- @@ -491,7 +514,21 @@ class GridRouter: return False if self._enter_cost_base(c, layer, net_no, False, conns, wire_keepout=False) != 0.0: 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 ---------------------------------------------------------- @@ -953,6 +990,9 @@ class GridRouter: 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.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: self._cell_owner[(*cell, layer)] = conn.index conn.half = half = self._conn_half(conn) @@ -1027,6 +1067,10 @@ class GridRouter: del self._halo[node] conn.halo_nodes = [] 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.wires = [] conn.vias = [] @@ -1257,11 +1301,16 @@ class GridRouter: def _result_from(self, snapshot, unrouted: int) -> RouteResult: result = RouteResult(self.half_width) + written: set[tuple[int, IntPoint]] = set() result.unrouted = unrouted for net_no, wires, vias, ends in snapshot: for layer, points in wires: result.add_wire(net_no, layer, points) 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.connections.append((net_no, wires, vias)) result.connection_ends.append(ends) @@ -1408,6 +1457,8 @@ def route_board( access_cost: float = 0.0, rollback: bool = True, net_widths: dict[int, int] | None = None, + via_drill: int | None = None, + hole_to_hole: int = 0, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -1428,5 +1479,7 @@ def route_board( access_cost=access_cost, rollback=rollback, net_widths=net_widths, + via_drill=via_drill, + hole_to_hole=hole_to_hole, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 2311bd2..d5b770a 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -12,6 +12,8 @@ to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and from __future__ import annotations +import re + from freeroute.board import build_board from freeroute.dsn import DsnBoard, parse_dsn 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) +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: """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, route_first: frozenset | None = None, access_cost: float = 0.0, + hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> tuple[RouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, @@ -128,6 +145,8 @@ def route_dsn_board( access_cost=access_cost, rollback=rollback, 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] @@ -177,6 +196,7 @@ def build_routing_result( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> 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, route_first=route_first, access_cost=access_cost, + hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, ) return _to_routing_result(route, dsn, scale, layer_names) @@ -204,6 +225,7 @@ def route_dsn_board_exact( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> tuple[ExactRouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, @@ -234,6 +256,8 @@ def route_dsn_board_exact( access_cost=access_cost, rollback=rollback, 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] @@ -252,6 +276,7 @@ def build_exact_routing_result( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" @@ -268,6 +293,7 @@ def build_exact_routing_result( seed=seed, route_first=route_first, access_cost=access_cost, + hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/tests/route/test_via_spacing.py b/tests/route/test_via_spacing.py new file mode 100644 index 0000000..2d42638 --- /dev/null +++ b/tests/route/test_via_spacing.py @@ -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"