Hole spacing to a fixed via measured from where it is, with its own drill

A fixed via sits anywhere, not on a cell centre, and may have a bigger
drill than the router's via; checking hole spacing between cell centres
with one drill let the flight controller's routed 0.2 mm via come 0.19
mm from a hand-placed 0.25 mm one. The board now carries each fixed
via's drill (from KiCad's padstack name), and cells are checked against
fixed vias exactly.
This commit is contained in:
2026-09-29 02:34:17 -06:00
parent f56bb10996
commit 6e852a929b
4 changed files with 51 additions and 3 deletions
+10 -1
View File
@@ -23,6 +23,7 @@ the containment invariant, and pads are centre-symmetric in practice).
from __future__ import annotations from __future__ import annotations
import math import math
import re
from freeroute.dsn.model import DsnBoard from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
@@ -38,6 +39,9 @@ from .transform import CoordinateTransform
__all__ = ["build_board"] __all__ = ["build_board"]
# DSN units per micrometre, for drills KiCad gives in um in via padstack names
_DSN_PER_UM = {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}
def build_board(dsn: DsnBoard) -> BasicBoard: def build_board(dsn: DsnBoard) -> BasicBoard:
"""Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`.""" """Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`."""
@@ -503,10 +507,15 @@ def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransf
location = transform.dsn_to_board_point(via.x, via.y) location = transform.dsn_to_board_point(via.x, via.y)
pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform) pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform)
box = pad.bounding_box() box = pad.bounding_box()
drill = re.search(r"_([\d.]+):([\d.]+)_um", via.padstack)
drill_board = (
round(transform.dsn_to_board(float(drill.group(2)) * _DSN_PER_UM.get(dsn.unit, 1.0)))
if drill else 0
)
board.insert( board.insert(
Via( Via(
net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack, net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack,
location=location, layers=sorted(layers or signal_layers), drill=drill_board, location=location, layers=sorted(layers or signal_layers),
shape=IntBox(box.ll.x + location.x, box.ll.y + location.y, shape=IntBox(box.ll.x + location.x, box.ll.y + location.y,
box.ur.x + location.x, box.ur.y + location.y), box.ur.x + location.x, box.ur.y + location.y),
) )
+2
View File
@@ -117,6 +117,8 @@ class Via(Item):
#: layer indices the via's pads occupy, and the largest pad as a box #: layer indices the via's pads occupy, and the largest pad as a box
layers: list[int] = field(default_factory=list) layers: list[int] = field(default_factory=list)
shape: object = None shape: object = None
#: drill diameter in board units, when the padstack name gives it
drill: int = 0
@dataclass @dataclass
+11
View File
@@ -268,6 +268,8 @@ class GridRouter:
self._via_nets: dict[Cell, dict[int, int]] = {} # routed via centres self._via_nets: dict[Cell, dict[int, int]] = {} # routed via centres
self._via_centres: list[IntPoint] | None = None # cache for _hole_clear_at self._via_centres: list[IntPoint] | None = None # cache for _hole_clear_at
self._fixed_vias: dict[Cell, int] = {} self._fixed_vias: dict[Cell, int] = {}
# fixed vias sit anywhere, not on cell centres: exact hole checks
self._fixed_holes: list[tuple[IntPoint, int]] = []
self._fixed_via_points: set[tuple[int, IntPoint]] = set() self._fixed_via_points: set[tuple[int, IntPoint]] = set()
if layers is None: if layers is None:
@@ -301,6 +303,9 @@ class GridRouter:
if isinstance(item, Via) and item.fixed and item.net_nos: 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_vias[self._cell_of(item.location)] = item.net_nos[0]
self._fixed_via_points.add((item.net_nos[0], item.location)) self._fixed_via_points.add((item.net_nos[0], item.location))
# its own drill, from KiCad's padstack name (Via[0-3]_500:250_um)
reach = self._via_drill // 2 + (item.drill or self._via_drill) // 2 + self._hole_gap
self._fixed_holes.append((item.location, reach))
# Through-hole pads have holes too, and a route changes layer at one # 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. # 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. # The DSN has no drill for a pad; its smaller half size stands in.
@@ -545,6 +550,12 @@ class GridRouter:
and no through-hole pad's hole within reach at all.""" and no through-hole pad's hole within reach at all."""
if cell in self._tht_block: if cell in self._tht_block:
return False return False
if self._fixed_holes:
here = self._cell_center(cell)
for loc, reach in self._fixed_holes:
d = math.hypot(loc.x - here.x, loc.y - here.y)
if d < reach and d > self.step: # beyond its own cell: too close
return False
if self._via_nets_at(cell) - {net_no}: if self._via_nets_at(cell) - {net_no}:
return False return False
cx, cy = cell cx, cy = cell
+28 -2
View File
@@ -56,7 +56,7 @@ def test_connections_sharing_a_plane_via_write_it_once():
assert len(set(vias)) == len(vias), "a shared via written more than once" assert len(set(vias)) == len(vias), "a shared via written more than once"
def _router(dsn_text): def _router(dsn_text, via_drill_um=300):
from freeroute.board import build_board from freeroute.board import build_board
from freeroute.route.grid_router import GridRouter from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
@@ -66,7 +66,7 @@ def _router(dsn_text):
return GridRouter( return GridRouter(
build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s), build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s), resolution=2, orthogonal=True, clearance=round(_rule_clearance_dsn(dsn) * s), resolution=2, orthogonal=True,
via_drill=300 * s, hole_to_hole=250 * s, via_drill=via_drill_um * s, hole_to_hole=250 * s,
), s ), s
@@ -98,3 +98,29 @@ def test_a_via_in_a_pad_keeps_hole_spacing_from_a_fixed_via():
router, s = _router(text) router, s = _router(text)
assert not router._hole_clear_at(IntPoint(3400 * s, -3000 * s)) # 0.4 mm < 0.55 mm 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)) assert router._hole_clear_at(IntPoint(3600 * s, -3000 * s))
def test_hole_spacing_from_a_fixed_via_is_measured_from_where_it_really_is():
# a fixed via off the grid with its own 0.25 mm drill, and the router's
# via a smaller 0.2 mm one (the flight controller's case): a new via needs
# its centre 0.1 + 0.125 + 0.25 = 0.475 mm away, measured exactly, where
# one rule for all vias gave 0.2 + 0.25 = 0.45 from the cell centres
text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)) (net GND))\n"
" (wiring (via \"Via[0-1]_500:250_um\" 3030 -3030 (net GND) (type route))))")
text = text.replace(
'(attach off)))\n (network',
'(attach off))\n (padstack "Via[0-1]_500:250_um" (shape (circle F.Cu 500)) '
'(shape (circle B.Cu 500)) (attach off)))\n (network',
)
router, s = _router(text, via_drill_um=200)
via = next(v for v in router.board.get_items() if getattr(v, "drill", 0))
assert via.drill == 250 * s
loc = via.location
for cell in [(x, y) for x in range(router.cols) for y in range(router.rows)]:
c = router._cell_center(cell)
d = math.hypot(c.x - loc.x, c.y - loc.y)
if router.step * 1.5 < d < 470 * s:
assert not router._holes_clear(cell, 1), f"{d / s:.0f} um from the fixed via"
elif 560 * s < d < 700 * s and cell not in router._tht_block:
assert router._holes_clear(cell, 1)