Model pads and vias as KiCad places them

Measured against KiCad's own pads on the TerraHawk rc4 power stage:

- Back-side parts kept their pads on F.Cu (32 pads). Mirror the copper
  layer for a back-side part.
- Pad outlines were copied unrotated (29 pads on rotated parts). Place the
  outline through the same chain as the pin: pin rotation, back-side
  mirror, part rotation. Oval pads, a path with a width, came out as a
  zero-height sliver; pad them out by the aperture.
- Multi-shape padstacks used the first shape; use the largest.
- Footprint-level keepouts were parsed but never placed. KiCad exports a
  mounting hole's plated ring that way, so the router could not see it.
- Vias were sized like a trace (0.25 mm square for a 0.6 mm via), and every
  remaining short and clearance error was a via. Read the size from the
  DSN's via padstack and use it in all three engines; the grid engine now
  reserves the cells a via's copper reaches.

With these the exact and room engines route that board with no shorts,
clearance or other DRC errors. Pins now carry their component reference.
This commit is contained in:
2026-09-27 06:40:34 -06:00
parent 3ad5c983f5
commit f1df60fc31
7 changed files with 245 additions and 34 deletions
+7 -5
View File
@@ -90,6 +90,7 @@ def route_board_exact(
diagonal: bool = False,
max_shove_depth: int = 5,
shove_cap: int = 3,
via_half: int | None = None,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry.
@@ -102,6 +103,7 @@ def route_board_exact(
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
]
half_width = max(trace_width // 2, 1)
via_half = via_half or half_width
step = trace_width + clearance
result = route_board(
@@ -113,6 +115,7 @@ def route_board_exact(
max_passes=max_passes,
rip_up=rip_up,
orthogonal=True,
via_half=via_half,
)
tree = ShapeSearchTree()
@@ -141,7 +144,7 @@ def route_board_exact(
accepted: dict[int, _Accepted] = {}
for net_no in sorted(set(result.wires) | set(result.vias)):
boxes = _net_boxes(result, net_no, half_width, via_layers)
boxes = _net_boxes(result, net_no, half_width, via_layers, via_half)
if any(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
@@ -543,15 +546,14 @@ def _rollback(shoved, accepted, tree, owner) -> int: # noqa: ARG001
# --- helpers -----------------------------------------------------------------
def _net_boxes(result, net_no, half_width, via_layers):
def _net_boxes(result, net_no, half_width, via_layers, via_half=None):
via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = []
for layer, points in result.wires.get(net_no, []):
for box in PolylineShape(Polyline(points), half_width).tiles():
boxes.append((box, frozenset({layer})))
for loc in result.vias.get(net_no, []):
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
)
vbox = IntBox(loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half)
boxes.append((vbox, via_layers))
return boxes
+32 -4
View File
@@ -98,6 +98,7 @@ class _Connection:
"vias",
"trace_cells",
"via_cells",
"via_halo",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -111,6 +112,7 @@ class _Connection:
self.vias: list[IntPoint] = []
self.trace_cells: list[tuple[Cell, int]] = []
self.via_cells: list[Cell] = []
self.via_halo: list[Cell] = []
class GridRouter:
@@ -129,6 +131,7 @@ class GridRouter:
rip_cap: int = 4,
rip_penalty: float = 30.0,
orthogonal: bool = False,
via_half: int | None = None,
) -> None:
self.board = board
self.trace_width = max(trace_width, 1)
@@ -144,6 +147,12 @@ class GridRouter:
self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS
# cell size must fit a trace plus its clearance to a neighbouring trace
self.step = max(self.trace_width + self.clearance, 1)
# A via's copper is wider than a trace. Another net's trace needs its
# centre via_half + clearance + half_width from the via's centre, so a
# via also claims every cell within via_reach of its own.
self.via_half = via_half or self.half_width
need = self.via_half + self.clearance + self.half_width
self.via_reach = max(0, math.ceil(need / self.step) - 1)
if layers is None:
layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal]
@@ -243,10 +252,22 @@ class GridRouter:
return None
return self.rip_penalty * (1 + rip_count)
def _via_cells(self, cell: Cell) -> list[Cell]:
"""The cell a via sits in plus the neighbours its copper reaches."""
r = self.via_reach
cx, cy = cell
return [
(x, y)
for x in range(max(cx - r, 0), min(cx + r, self.cols - 1) + 1)
for y in range(max(cy - r, 0), min(cy + r, self.rows - 1) + 1)
]
def _via_placeable(self, cell: Cell, net_no: int, conns: list[_Connection]) -> bool:
"""A through via needs every signal layer strictly free of other nets."""
"""A through via needs its whole footprint free of other nets on every layer."""
return all(
self._enter_cost(cell, layer, net_no, False, conns) == 0.0 for layer in self.layers
self._enter_cost(c, layer, net_no, False, conns) == 0.0
for c in self._via_cells(cell)
for layer in self.layers
)
# --- A* search ----------------------------------------------------------
@@ -321,8 +342,12 @@ class GridRouter:
conn.via_cells = list(via_cells)
for cell, layer in conn.trace_cells:
self._cell_owner[(*cell, layer)] = conn.index
conn.via_halo = []
for cell in conn.via_cells:
self._via_owner[cell] = conn.index
for c in self._via_cells(cell):
if c not in self._via_owner:
self._via_owner[c] = conn.index
conn.via_halo.append(c)
conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs):
points = [self._cell_center(c) for c in cells]
@@ -340,9 +365,10 @@ class GridRouter:
for cell, layer in conn.trace_cells:
if self._cell_owner.get((*cell, layer)) == conn.index:
del self._cell_owner[(*cell, layer)]
for cell in conn.via_cells:
for cell in conn.via_halo:
if self._via_owner.get(cell) == conn.index:
del self._via_owner[cell]
conn.via_halo = []
conn.trace_cells = []
conn.via_cells = []
conn.wires = []
@@ -493,6 +519,7 @@ def route_board(
rip_up: bool = True,
max_passes: int = 10,
orthogonal: bool = False,
via_half: int | None = None,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
@@ -504,5 +531,6 @@ def route_board(
rip_up=rip_up,
max_passes=max_passes,
orthogonal=orthogonal,
via_half=via_half,
)
return router.route()
+22
View File
@@ -45,6 +45,25 @@ def _via_padstack(dsn: DsnBoard) -> str:
return dsn.via_padstack_names[0] if dsn.via_padstack_names else _DEFAULT_VIA
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.
None when the DSN names no via padstack with a shape, so the engines fall
back to their old estimate.
"""
padstack = dsn.padstack(_via_padstack(dsn))
if padstack is None or not padstack.shapes:
return None
half = 0.0
for shape in padstack.shapes:
if hasattr(shape, "diameter"):
half = max(half, shape.diameter / 2)
elif len(getattr(shape, "coords", [])) >= 4:
xs, ys = shape.coords[0::2], shape.coords[1::2]
half = max(half, (max(xs) - min(xs)) / 2, (max(ys) - min(ys)) / 2)
return round(half * scale) or None
def route_dsn_board(
dsn: DsnBoard,
*,
@@ -62,6 +81,7 @@ def route_dsn_board(
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
result = route_board(
board,
via_half=_via_half_board(dsn, scale),
trace_width=width_board,
clearance=clearance_board,
layers=layers,
@@ -130,6 +150,7 @@ def route_dsn_board_exact(
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
exact = route_board_exact(
board,
via_half=_via_half_board(dsn, scale),
trace_width=width_board,
clearance=clearance_board,
layers=layers,
@@ -175,6 +196,7 @@ def route_dsn_board_rooms(
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
rooms = route_board_rooms(
board,
via_half=_via_half_board(dsn, scale),
trace_width=width_board,
clearance=clearance_board,
layers=layers,
+25 -13
View File
@@ -471,6 +471,7 @@ def route_board_rooms(
pack: bool = False,
max_shove_depth: int = 5,
shove_cap: int = 3,
via_half: int | None = None,
) -> ExactRouteResult:
"""Route ``board`` with the continuous expansion-room track.
@@ -494,6 +495,9 @@ def route_board_rooms(
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
]
half_width = max(trace_width // 2, 1)
# half the via pad's size: a via is not a trace, and sizing it like one
# let routes pass within a via's real copper
via_half = via_half or half_width
margin = clearance + half_width
outline = board.bounding_box
@@ -537,7 +541,7 @@ def route_board_rooms(
via_layers,
accepted,
shove,
)
via_half=via_half)
for net_no in sorted(_routable_nets(board, signal_layers)):
if net_no in packed:
continue
@@ -562,7 +566,7 @@ def route_board_rooms(
accepted,
max_shove_depth,
shove_cap,
)
via_half=via_half)
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean, shoves=shoves)
@@ -587,6 +591,7 @@ def _route_connection(
accepted=None,
max_shove_depth=5,
shove_cap=3,
via_half=None,
):
rooms_by_layer = {
layer: _free_rooms(tree, outline, layer, net_no, margin, half_width)
@@ -607,7 +612,7 @@ def _route_connection(
if realized is not None:
wires, vias = realized
boxes = _collect_boxes(wires, vias, half_width, via_layers, outline)
boxes = _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=via_half)
if boxes is not None and not any(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
@@ -615,7 +620,7 @@ def _route_connection(
):
_commit(
net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted
)
, via_half=via_half)
return 0
# the net would be dropped: try a transactional shove-in-rooms recovery
@@ -634,11 +639,12 @@ def _route_connection(
clearance,
max_shove_depth,
shove_cap,
)
via_half=via_half)
return 0
def _collect_boxes(wires, vias, half_width, via_layers, outline):
def _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=None):
via_half = via_half or half_width
"""The ``(box, layers)`` copper tiles of a realized route, or ``None`` if any
tile leaves the board outline (the same early-out as the old inline check)."""
boxes: list[tuple[IntBox, frozenset[int]]] = []
@@ -649,13 +655,16 @@ def _collect_boxes(wires, vias, half_width, via_layers, outline):
boxes.append((box, frozenset({layer})))
for loc in vias:
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
)
boxes.append((vbox, via_layers))
return boxes
def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted):
def _commit(
net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted, via_half=None
):
via_half = via_half or half_width
"""Insert a verified route's copper and record it in the routed output.
With ``shove=False`` this is the foundation router's exact commit sequence.
@@ -668,7 +677,7 @@ def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, sh
tree.insert(TreeShape(next(owner), net_no, frozenset({layer}), box, routed=True))
for loc in vias:
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
)
tree.insert(TreeShape(next(owner), net_no, via_layers, vbox, routed=True))
for layer, corners in wires:
@@ -693,7 +702,7 @@ def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, sh
acc.wire_owners.append(w_owners)
for loc in vias:
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
)
oid = next(owner)
tree.insert(TreeShape(oid, net_no, via_layers, vbox, routed=True))
@@ -854,6 +863,7 @@ def _pack_channels(
via_layers,
accepted,
shove,
via_half=None,
) -> set[int]:
"""Coordinated multi-trace channel packing (opt-in pre-pass).
@@ -922,7 +932,7 @@ def _pack_channels(
spacing_min,
via_layers,
outline,
)
via_half=via_half)
if placed is None:
continue # could not pack every member cleanly -> leave to greedy
for net_no, wires in placed:
@@ -937,7 +947,7 @@ def _pack_channels(
via_layers,
shove,
accepted,
)
via_half=via_half)
routed.add(net_no)
return routed
@@ -958,6 +968,7 @@ def _pack_group(
spacing_min,
via_layers,
outline,
via_half=None,
):
"""Assign parallel lanes + staggered fan-in trunks to every net crossing one
gap, and return ``[(net_no, wires), ...]`` if ALL of them realize exactly
@@ -1014,7 +1025,7 @@ def _pack_group(
if len(corners) < 2:
return None
wires = [(layer, corners)]
boxes = _collect_boxes(wires, [], half_width, via_layers, outline)
boxes = _collect_boxes(wires, [], half_width, via_layers, outline, via_half=via_half)
if boxes is None:
return None
if any(
@@ -1049,6 +1060,7 @@ def _shove_recover_room(
clearance,
max_shove_depth,
shove_cap,
via_half=None,
) -> int:
"""Recover a dropped net by nudging the committed trace(s) that block it.