Compare commits

...
Author SHA1 Message Date
rsp2k 83ad0f8ef7 Keep a route's own vias apart while it is searched
Committed vias were checked for hole spacing, but a route's own vias are
not committed until it is done, so a route hopping layers (more of them
on 6 layers) could put two of its vias 0.41 mm apart, as th-eda's
6-layer flight controller showed. A via is now also checked against
layer changes earlier on its own route.
2026-09-29 03:10:52 -06:00
rsp2k 6e852a929b 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.
2026-09-29 02:34:17 -06:00
rsp2k f56bb10996 Test: the session never repeats wiring already on the board 2026-09-29 01:17:59 -06:00
rsp2k 8f656006a6 Merge fix/hole-spacing-pads: hole spacing from through-hole pads and fixed vias 2026-09-29 01:16:59 -06:00
rsp2k 6cc2406583 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.
2026-09-29 01:13:16 -06:00
rsp2k 30297e94dc Merge feat/net-widths: fixed wiring, plane via checks, class widths, via spacing, fan-out connectivity 2026-09-29 00:26:02 -06:00
6 changed files with 195 additions and 4 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 -1
View File
@@ -215,7 +215,8 @@ def route_board_exact(
# connections of one net that end on the same plane cell share its # 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 # via: write it once, and never on top of a via already on the board
acc.vias.extend( 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: for box, box_layers in boxes:
oid = next(owner) oid = next(owner)
@@ -645,6 +646,15 @@ def _end_in_rule_area(tree, wires, half_width) -> bool:
return False 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): def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = [] boxes: list[tuple[IntBox, frozenset[int]]] = []
+77 -2
View File
@@ -257,6 +257,7 @@ class GridRouter:
# an own-net via at the very same cell, which it simply reuses. # an own-net via at the very same cell, which it simply reuses.
drill = via_drill if via_drill else 2 * self.via_half drill = via_drill if via_drill else 2 * self.via_half
need = drill + max(hole_to_hole, 0) 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) reach = math.ceil(need / self.step)
self._hole_offsets = [ self._hole_offsets = [
(dx, dy) (dx, dy)
@@ -265,7 +266,10 @@ class GridRouter:
if (dx or dy) and math.hypot(dx, dy) * self.step < need 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_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_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:
@@ -299,6 +303,24 @@ 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
# 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 ------------------------------------------------------- # --- grid helpers -------------------------------------------------------
@@ -516,6 +538,26 @@ class GridRouter:
return False return False
return self._holes_clear(cell, net_no) return self._holes_clear(cell, net_no)
def _near_path_via(self, node: Node, cell: Cell, came_from: dict) -> bool:
"""A layer change earlier on the route that reaches ``node`` sits
within hole spacing of ``cell`` (and is not the same through via).
Committed vias are checked elsewhere; a route's own are not yet
committed while it is searched. Looks back far enough to cover the
hole spacing twice over, not along the whole route."""
limit = 4 * (math.ceil(self._hole_need / self.step) + 1)
need = self._hole_need / self.step
steps = 0
while node in came_from and steps < limit:
prev = came_from[node]
changes_layer = prev[:2] == node[:2] and prev[2] != node[2]
if changes_layer and node[:2] != cell and (
math.hypot(node[0] - cell[0], node[1] - cell[1]) < need
):
return True
node = prev
steps += 1
return False
def _via_nets_at(self, cell: Cell) -> set[int]: def _via_nets_at(self, cell: Cell) -> set[int]:
nets = {net for net, n in self._via_nets.get(cell, {}).items() if n > 0} nets = {net for net, n in self._via_nets.get(cell, {}).items() if n > 0}
fixed = self._fixed_vias.get(cell) fixed = self._fixed_vias.get(cell)
@@ -524,7 +566,16 @@ class GridRouter:
return nets return nets
def _holes_clear(self, cell: Cell, net_no: int) -> bool: 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._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
@@ -633,6 +684,8 @@ class GridRouter:
pin = ends.get(node) pin = ends.get(node)
if ok and isinstance(pin, Pin) and self._walled(node): if ok and isinstance(pin, Pin) and self._walled(node):
ok = self._via_fits_at(pin.location, net_no) ok = self._via_fits_at(pin.location, net_no)
if ok and self._near_path_via(source, cell, came_from):
ok = False # too close to a via earlier on this same route
if ok: if ok:
self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap) self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap)
continue continue
@@ -644,6 +697,8 @@ class GridRouter:
ok = via_ok.get(cell) ok = via_ok.get(cell)
if ok is None: if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns) ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
if ok and self._near_path_via(current, cell, came_from):
ok = False
else: else:
ok = True ok = True
if ok: if ok:
@@ -717,7 +772,23 @@ class GridRouter:
] ]
copper += [(box, net) for net, _, box in self.board.get_fixed_copper()] copper += [(box, net) for net, _, box in self.board.get_fixed_copper()]
self._static_copper = 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]: 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.""" """Cells where another net's trace would come too close to the stub."""
@@ -993,6 +1064,8 @@ class GridRouter:
for cell in conn.via_cells: for cell in conn.via_cells:
count = self._via_nets.setdefault(cell, {}) count = self._via_nets.setdefault(cell, {})
count[conn.net_no] = count.get(conn.net_no, 0) + 1 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: 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)
@@ -1071,6 +1144,8 @@ class GridRouter:
count = self._via_nets.get(cell) count = self._via_nets.get(cell)
if count and count.get(conn.net_no): if count and count.get(conn.net_no):
count[conn.net_no] -= 1 count[conn.net_no] -= 1
if conn.via_cells:
self._via_centres = None
conn.via_cells = [] conn.via_cells = []
conn.wires = [] conn.wires = []
conn.vias = [] conn.vias = []
+15
View File
@@ -91,3 +91,18 @@ def test_a_fanned_out_signal_is_routed_from_its_via():
assert sig in exact.result.routed_net_numbers assert sig in exact.result.routed_net_numbers
assert not exact.result.vias.get(sig), "the fan-out via is already there" assert not exact.result.vias.get(sig), "the fan-out via is already there"
assert {names[layer] for layer, _ in exact.result.wires[sig]} == {"B.Cu"} assert {names[layer] for layer, _ in exact.result.wires[sig]} == {"B.Cu"}
def test_the_session_never_repeats_copper_already_on_the_board():
# the JAR writes the DSN's wiring back into its session, and an applier
# then adds it a second time on top of itself; freeroute writes only
# what it routed
from freeroute.route.pipeline import build_exact_routing_result
fixed_vias = {(5000.0, -11500.0), (10000.0, -11500.0)}
fixed_paths = {(5000.0, -10000.0, 5000.0, -11500.0), (10000.0, -10000.0, 10000.0, -11500.0),
(2000.0, -2000.0, 8000.0, -2000.0)}
result = build_exact_routing_result(parse_dsn(_FANOUT), resolution=2)
for net in result.nets:
assert not {(v.x, v.y) for v in net.vias} & fixed_vias
assert not {tuple(w.coords) for w in net.wires} & fixed_paths
+80
View File
@@ -54,3 +54,83 @@ def test_connections_sharing_a_plane_via_write_it_once():
vias = exact.result.vias[1] vias = exact.result.vias[1]
assert len(vias) < 8, "no via is shared, so this board no longer tests sharing" 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" assert len(set(vias)) == len(vias), "a shared via written more than once"
def _router(dsn_text, via_drill_um=300):
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=via_drill_um * 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))
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)
def test_a_route_keeps_its_own_vias_apart():
# a route that changed layer at (10, 10) may not change layer again one
# cell away; the same cell is the same through via, and far is fine
router, _s = _router(_THT)
came_from = {(10, 10, 1): (10, 10, 0), (11, 10, 1): (10, 10, 1), (12, 10, 1): (11, 10, 1)}
assert router._near_path_via((12, 10, 1), (11, 10), came_from)
assert not router._near_path_via((12, 10, 1), (10, 10), came_from)
assert not router._near_path_via((12, 10, 1), (40, 10), came_from)