From 6cc24065836e7e831b51e6c068ab62498547ff5e Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 29 Sep 2026 01:13:16 -0600 Subject: [PATCH] Hole spacing also from through-hole pads and fixed vias A route could drop a via next to (or onto) a through-hole pad of its own net, where it changes layer for free, and a via reusing a fixed via's cell came out a little off the fixed via's centre, beside it. No via now goes within hole spacing of a through-hole pad of any net; a via in a pad is checked exactly against every via and hole; and a routed via on a fixed via of its net is not written. charge-sense: 4 hole_to_hole to 0; flight controller: 2 to 0, unconnected unchanged. --- src/freeroute/route/exact_router.py | 12 +++++++- src/freeroute/route/grid_router.py | 44 +++++++++++++++++++++++++++-- tests/route/test_via_spacing.py | 44 +++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index ae1c954..112079b 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -215,7 +215,8 @@ def route_board_exact( # connections of one net that end on the same plane cell share its # via: write it once, and never on top of a via already on the board acc.vias.extend( - v for v in dict.fromkeys(vias) if v not in acc.vias and (net_no, v) not in fixed_vias + v for v in dict.fromkeys(vias) + if v not in acc.vias and not _on_fixed_via(net_no, v, fixed_vias, via_half) ) for box, box_layers in boxes: oid = next(owner) @@ -645,6 +646,15 @@ def _end_in_rule_area(tree, wires, half_width) -> bool: return False +def _on_fixed_via(net_no, point, fixed_vias, via_half) -> bool: + """A routed via that reuses a via already on the board: the grid put it + on the fixed via's cell, a little off its centre, and it is not written.""" + return any( + net == net_no and abs(loc.x - point.x) <= via_half and abs(loc.y - point.y) <= via_half + for net, loc in fixed_vias + ) + + def _copper_boxes(wires, vias, half_width, via_layers, via_half=None): via_half = via_half or half_width boxes: list[tuple[IntBox, frozenset[int]]] = [] diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 8f71ba7..65eded6 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -257,6 +257,7 @@ class GridRouter: # 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) + self._via_drill, self._hole_gap, self._hole_need = drill, max(hole_to_hole, 0), need reach = math.ceil(need / self.step) self._hole_offsets = [ (dx, dy) @@ -265,6 +266,7 @@ class GridRouter: if (dx or dy) and math.hypot(dx, dy) * self.step < need ] self._via_nets: dict[Cell, dict[int, int]] = {} # routed via centres + self._via_centres: list[IntPoint] | None = None # cache for _hole_clear_at self._fixed_vias: dict[Cell, int] = {} self._fixed_via_points: set[tuple[int, IntPoint]] = set() @@ -299,6 +301,21 @@ class GridRouter: 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)) + # Through-hole pads have holes too, and a route changes layer at one + # for free, so no via (of any net) goes within hole spacing of one. + # The DSN has no drill for a pad; its smaller half size stands in. + self._tht_holes: list[tuple[IntPoint, int]] = [] + self._tht_block: set[Cell] = set() + for pin in board.get_pins(): + if pin.shape is None or sum(1 for layer in pin.layers if layer in self.layers) < 2: + continue + box = pin.shape.bounding_box() + radius = min(box.ur.x - box.ll.x, box.ur.y - box.ll.y) // 2 + reach = radius + self._via_drill // 2 + self._hole_gap + self._tht_holes.append((pin.location, reach)) + ring = IntBox(pin.location.x, pin.location.y, pin.location.x, pin.location.y) + for cell in self._cells_in_box(ring, reach): + self._tht_block.add(cell) # --- grid helpers ------------------------------------------------------- @@ -524,7 +541,10 @@ class GridRouter: 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.""" + """No via centre within hole-to-hole reach, but an own-net one here, + and no through-hole pad's hole within reach at all.""" + if cell in self._tht_block: + return False if self._via_nets_at(cell) - {net_no}: return False cx, cy = cell @@ -717,7 +737,23 @@ class GridRouter: ] copper += [(box, net) for net, _, box in self.board.get_fixed_copper()] self._static_copper = copper - return not any(net != net_no and box.overlaps(grown) for box, net in self._static_copper) + if any(net != net_no and box.overlaps(grown) for box, net in self._static_copper): + return False + return self._hole_clear_at(point) + + def _hole_clear_at(self, point: IntPoint) -> bool: + """Hole spacing measured exactly from ``point`` to every via and + through-hole pad (a via moved into a pad is not on its cell's centre).""" + need = self._hole_need + if self._via_centres is None: # rebuilt only after vias change + self._via_centres = [loc for _net, loc in self._fixed_via_points] + [ + self._cell_center(c) for c, count in self._via_nets.items() if any(count.values()) + ] + if any(math.hypot(c.x - point.x, c.y - point.y) < need for c in self._via_centres): + return False + return not any( + math.hypot(loc.x - point.x, loc.y - point.y) < reach for loc, reach in self._tht_holes + ) 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.""" @@ -993,6 +1029,8 @@ class GridRouter: for cell in conn.via_cells: count = self._via_nets.setdefault(cell, {}) count[conn.net_no] = count.get(conn.net_no, 0) + 1 + if conn.via_cells: + self._via_centres = None for cell, layer in conn.trace_cells: self._cell_owner[(*cell, layer)] = conn.index conn.half = half = self._conn_half(conn) @@ -1071,6 +1109,8 @@ class GridRouter: count = self._via_nets.get(cell) if count and count.get(conn.net_no): count[conn.net_no] -= 1 + if conn.via_cells: + self._via_centres = None conn.via_cells = [] conn.wires = [] conn.vias = [] diff --git a/tests/route/test_via_spacing.py b/tests/route/test_via_spacing.py index f591307..c26c154 100644 --- a/tests/route/test_via_spacing.py +++ b/tests/route/test_via_spacing.py @@ -54,3 +54,47 @@ def test_connections_sharing_a_plane_via_write_it_once(): vias = exact.result.vias[1] assert len(vias) < 8, "no via is shared, so this board no longer tests sharing" assert len(set(vias)) == len(vias), "a shared via written more than once" + + +def _router(dsn_text): + from freeroute.board import build_board + from freeroute.route.grid_router import GridRouter + from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn + + dsn = parse_dsn(dsn_text) + s = dsn.resolution.value + return GridRouter( + build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), resolution=2, orthogonal=True, + via_drill=300 * s, hole_to_hole=250 * s, + ), s + + +_THT = """(pcb tht (parser) (resolution um 10) (unit um) + (structure (layer F.Cu (type signal)) (layer B.Cu (type signal)) + (boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0)) + (via "Via[0-1]_500:300_um") (rule (width 150) (clearance 150))) + (placement (component T (place J1 10000 -10000 front 0))) + (library (image T (pin tht 1 0 0)) + (padstack tht (shape (circle F.Cu 1700)) (shape (circle B.Cu 1700)) (attach off)) + (padstack "Via[0-1]_500:300_um" (shape (circle F.Cu 500)) (shape (circle B.Cu 500)) (attach off))) + (network (net SIG (pins J1-1))))""" + + +def test_no_via_goes_near_a_through_hole_pads_hole_even_of_its_own_net(): + router, s = _router(_THT) + pin = router._cell_of(router.board.get_pins()[0].location) + assert not router._holes_clear(pin, 1) # own net, right on the pad + far = (pin[0] + 40, pin[1]) + assert router._holes_clear(far, 1) + + +def test_a_via_in_a_pad_keeps_hole_spacing_from_a_fixed_via(): + from freeroute.geometry import IntPoint + + text = _THT.replace("(network (net SIG (pins J1-1))))", + "(network (net SIG (pins J1-1)))\n" + " (wiring (via \"Via[0-1]_500:300_um\" 3000 -3000 (net SIG) (type route))))") + router, s = _router(text) + assert not router._hole_clear_at(IntPoint(3400 * s, -3000 * s)) # 0.4 mm < 0.55 mm + assert router._hole_clear_at(IntPoint(3600 * s, -3000 * s))