Files
pcb/scripts/route_local_power.py
T
onur af7c43b2a8 Restack to four signal layers and route the board
The Ti375 fanout could not be resolved with three routing layers, so L5
stops being a dedicated 1.8-V plane and becomes a signal layer.  No rail
owns a whole layer any more: the supplies are split into zone islands on
L4 and L6, L2 and L7 stay solid and completely unrouted, and F.Cu, L3, L5
and B.Cu are all available for signals.

Routing is staged rather than handed to the autorouter wholesale.  The
BGA escape, the buck high-current loops, plane stitching and the local
power completion all run first and are locked; Freerouting then sees only
the ordinary digital classes, with everything else present as fixed
copper.  finalize_routing.py installs the audited RF and USB geometry and
closes whatever the autorouter left.

Four bugs behind the routing failures, all of them the same shape - a
layer list written down instead of read from the board, which was correct
while L5 was a plane and silently wrong afterwards:

- export_routing_stage.py emitted via keepouts for three layers only, so
  every existing via was invisible to the router on L5.  It drove traces
  straight through them: 102 shorting_items in DRC.
- route_local_power.py never used L5 at all, and picked up escape vias on
  two layers instead of all four.
- add_rf_ground() judged fence positions against vias and pads but not
  tracks.  It runs after the session import, when that region already has
  routed copper, and dropped a ground via on top of a signal trace.

route_local_power.py also grew a via cloud on every rail: reaching a
plane counted as progress even when it merged nothing, so the same pad
collected a via per pass, 0.4 mm further out each time.  Progress is now
measured by the group count actually falling, and groups already sitting
on their plane are skipped.  That removed 404 redundant vias.

The MAX2771 PLL loop filter was spread across three corners of the
package, which forced the charge-pump node onto an inner layer under the
GPS front end.  All three parts now sit in the pocket at U21's north-west
corner and the net stays on F.Cu.  A permanent RF_NO_DIGITAL area plus a
scoped rule keeps that region free of non-power copper on L3/L5/L6/B.Cu,
and the local router enforces the same restriction, since it is the one
stage that runs after the temporary keepout is deleted.

Also enables the solder-mask bridge check that was switched off
board-wide; the board passes it with no violations.

Final board: DRC clean - no shorts, clearance, disallowed-item or
dangling-copper violations - with 26 unconnected endpoints left.  Routing
is spread 37% L3, 27% F.Cu, 26% L5, 10% B.Cu over 6548 mm and 971 vias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 01:41:43 +03:00

859 lines
38 KiB
Python

#!/usr/bin/env python3
"""Finish the power distribution that planes and plane stitching cannot reach.
Three kinds of connection are left after `stitch_plane_pads.py`:
* pads whose rail has a plane, but not directly underneath them (a regulator
output capacitor just outside its island, a PMOD header on the board edge);
* rails that are too small to deserve a plane at all (`FTDI_1V8`,
`IMU_VREGD`, the antenna-bias nets), which are nevertheless excluded from
the Specctra export because their netclass is POWER;
* the local input/output loops around each LDO, where two different rails are
interleaved inside a couple of millimetres and no split plane can serve both.
None of these belong in the autorouter: it cannot see planes at all, so handing
it `GND` or `VCC3V3` would make it wire hundreds of pads together that the
plane already joins. This stage works from the real connectivity graph
instead. For every group of a rail that is still separate it either drops a
via onto the rail's own plane, or routes to the nearest other group with
45-degree geometry.
It runs in two phases. The first allows only plane drops and short direct
paths, for every rail; the second lets whatever is left take a detour through
an intermediate waypoint. Without that split the first rail in the list
spends the open space on long routes that a later rail needed for a short one.
Everything installed here is locked, and the same code runs again from
finalize_routing.py once the RF and USB corridors are released.
"""
from __future__ import annotations
import argparse
import math
import shutil
from collections import defaultdict
from pathlib import Path
import pcbnew
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT = ROOT / "hardware" / "board_plane_stitched.kicad_pcb"
DEFAULT_OUTPUT = ROOT / "hardware" / "board_power_local.kicad_pcb"
# Rails handled here. Signal nets, the deterministic converter switch nodes
# and the critical RF/USB nets are deliberately absent.
PLANE_NETS = (
"GND", "SYS5", "VCC0V95", "VCC1V8", "VCC3V3", "VCC3V3_AUX",
"VCC3V0_CLK", "VCC3V0_DIG", "VCC3V0_RF", "VCC3V3_ANT",
)
TRACE_NETS = (
"VBUS_RAW", "USB5_PROT", "EXT5_RAW", "EXT5_PROT", "USB_SHIELD",
"FTDI_1V8", "FTDI_VPHY", "FTDI_VPLL",
"IMU_3V3", "IMU_VREGA2", "IMU_VREGD",
"ANT_BIAS_SEL", "ANT_BIAS_SW",
)
TARGET_NETS = PLANE_NETS + TRACE_NETS
WIDE_NETS = {"GND", "SYS5", "VCC0V95", "VBUS_RAW", "USB5_PROT",
"EXT5_RAW", "EXT5_PROT"}
VIA_DIAMETER = 0.45
VIA_DRILL = 0.225
CLEARANCE = 0.13
# The rule value plus a rounding margin, used when completing signal nets
# through the Ti375 escape field where nothing wider will fit.
SIGNAL_CLEARANCE = 0.105
HOLE_TO_HOLE = 0.30
EDGE_CLEARANCE = 0.30
EDGE = (20.6, 20.6, 174.4, 109.4)
# Nets whose netclass demands more than the plain copper clearance. The local
# power router must respect the controlled-impedance corridors even though it
# never routes them itself.
STRICT_CLEARANCE = {
"RF_SMA": 0.20, "RF_PRE_SAW": 0.20, "RF_POST_SAW": 0.20, "MAX_MIXIN_HI": 0.20,
"USB_D_P": 0.15, "USB_D_N": 0.15, "FTDI_D_P": 0.15, "FTDI_D_N": 0.15,
}
# The edge-launch SMA lands sit on the board outline by construction; their
# ground return is built by finalize_routing.py's RF ground zone and via fence.
SKIP_REFS = {"J2"}
# Every ground land under the GPS front end is connected by that same RF ground
# zone and its via fence. Ordinary dogbones here would only add long stubs
# alongside the controlled-impedance line.
RF_GROUND_REGION = (152.0, 36.0, 174.5, 49.5)
# Nothing but ground and the MAX2771's own supplies may run under the GPS front
# end on a layer other than F.Cu. This router is the one stage that runs after
# finalize_routing.py has deleted the temporary keepout, so it has to carry the
# restriction itself; without it the deterministic completion is free to drop a
# charge-pump or control net onto L3 directly beneath the 50-ohm line.
RF_REGION_LAYERS = (pcbnew.In2_Cu, pcbnew.In3_Cu, pcbnew.In4_Cu,
pcbnew.In5_Cu, pcbnew.B_Cu)
def rf_region_forbids(net: str, layer: int | None) -> bool:
"""True when this net may not occupy the RF region on this layer.
`layer` of None means a through via, which is present on every layer and so
is judged as if it were on the inner ones.
"""
if net in TARGET_NETS:
return False
if layer is not None and layer not in RF_REGION_LAYERS:
return False
return True
def in_rf_region(position: tuple[float, float], margin: float = 0.0) -> bool:
return (RF_GROUND_REGION[0] - margin <= position[0] <= RF_GROUND_REGION[2] + margin and
RF_GROUND_REGION[1] - margin <= position[1] <= RF_GROUND_REGION[3] + margin)
MAX_PLANE_REACH = 7.0
# A dogbone via never sits further than this from the pad it escapes.
ESCAPE_VIA_REACH = 0.8
MAX_JOIN_LENGTH = 80.0
def mm(value: float) -> int:
return pcbnew.FromMM(value)
def to_mm(value: int) -> float:
return pcbnew.ToMM(value)
def point(xy: tuple[float, float]) -> pcbnew.VECTOR2I:
return pcbnew.VECTOR2I(mm(xy[0]), mm(xy[1]))
# Widths used when this stage finishes leftover signal nets. They match the
# KiCad net classes so the copper is indistinguishable from the autorouted
# result; the controlled-impedance classes are absent because finalize_routing
# builds those by hand.
NETCLASS_WIDTH = {"Default": 0.12, "BGA_ESCAPE": 0.10, "CLOCK": 0.15}
# Per-class routing clearance. CLOCK keeps its wider figure; the others drop
# to the rule value so they still fit the Ti375 escape channels.
NETCLASS_CLEARANCE = {"Default": 0.105, "BGA_ESCAPE": 0.105, "CLOCK": 0.155}
SKIP_CLASSES = frozenset({"RF50_CPWG", "USB90_DIFF"})
def net_width(net: str) -> float:
return 0.40 if net in WIDE_NETS else 0.25
def segment_distance(p: tuple[float, float], a: tuple[float, float],
b: tuple[float, float]) -> float:
dx, dy = b[0] - a[0], b[1] - a[1]
length = dx * dx + dy * dy
if length == 0.0:
return math.hypot(p[0] - a[0], p[1] - a[1])
t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length))
return math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy))
def segments_distance(a0: tuple[float, float], a1: tuple[float, float],
b0: tuple[float, float], b1: tuple[float, float]) -> float:
"""Minimum distance between two segments, including the crossing case."""
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
d1, d2 = cross(a0, a1, b0), cross(a0, a1, b1)
d3, d4 = cross(b0, b1, a0), cross(b0, b1, a1)
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
return 0.0
return min(segment_distance(b0, a0, a1), segment_distance(b1, a0, a1),
segment_distance(a0, b0, b1), segment_distance(a1, b0, b1))
class Obstacles:
"""Other-net copper, indexed on a coarse grid for quick local lookups."""
CELL = 5.0
def __init__(self, board: pcbnew.BOARD) -> None:
self.board = board
self.cells: dict[tuple[int, int], list] = defaultdict(list)
self.holes: list[tuple[tuple[float, float], float]] = []
for footprint in board.GetFootprints():
for pad in footprint.Pads():
box = pad.GetBoundingBox()
bounds = (to_mm(box.GetX()), to_mm(box.GetY()),
to_mm(box.GetRight()), to_mm(box.GetBottom()))
self._insert(("pad", pad.GetNetname(), bounds,
pad.GetLayerSet()), bounds)
drill = max(to_mm(pad.GetDrillSizeX()), to_mm(pad.GetDrillSizeY()))
if drill > 0:
position = pad.GetPosition()
self.holes.append(((to_mm(position.x), to_mm(position.y)),
drill / 2))
for item in board.GetTracks():
if isinstance(item, pcbnew.PCB_VIA):
position = (to_mm(item.GetPosition().x), to_mm(item.GetPosition().y))
radius = to_mm(item.GetWidth(pcbnew.F_Cu)) / 2
self.add_via(item.GetNetname(), position, radius,
to_mm(item.GetDrill()) / 2)
else:
start = (to_mm(item.GetStart().x), to_mm(item.GetStart().y))
end = (to_mm(item.GetEnd().x), to_mm(item.GetEnd().y))
self.add_track(item.GetNetname(), start, end,
to_mm(item.GetWidth()), item.GetLayer())
# Only areas that actually forbid copper block routing. Rule areas
# that exist purely so a custom DRC rule can name a region (see
# finalize_routing.add_named_rule_areas) must not stop anything.
self.track_areas = [zone for zone in board.Zones()
if zone.GetIsRuleArea() and zone.GetDoNotAllowTracks()]
self.via_areas = [zone for zone in board.Zones()
if zone.GetIsRuleArea() and zone.GetDoNotAllowVias()]
def _keys(self, bounds: tuple[float, float, float, float]):
x0 = int(math.floor(bounds[0] / self.CELL))
y0 = int(math.floor(bounds[1] / self.CELL))
x1 = int(math.floor(bounds[2] / self.CELL))
y1 = int(math.floor(bounds[3] / self.CELL))
for x in range(x0 - 1, x1 + 2):
for y in range(y0 - 1, y1 + 2):
yield x, y
def _insert(self, entry, bounds) -> None:
for key in self._keys(bounds):
self.cells[key].append(entry)
def add_via(self, net: str, position: tuple[float, float], radius: float,
drill_radius: float = VIA_DRILL / 2) -> None:
bounds = (position[0] - radius, position[1] - radius,
position[0] + radius, position[1] + radius)
self._insert(("via", net, position, radius), bounds)
self.holes.append((position, drill_radius))
def add_track(self, net: str, start: tuple[float, float],
end: tuple[float, float], width: float, layer: int) -> None:
bounds = (min(start[0], end[0]) - width, min(start[1], end[1]) - width,
max(start[0], end[0]) + width, max(start[1], end[1]) + width)
self._insert(("track", net, start, end, width / 2, layer), bounds)
def _nearby(self, bounds):
seen = set()
for key in self._keys(bounds):
for entry in self.cells.get(key, ()):
if id(entry) in seen:
continue
seen.add(id(entry))
yield entry
def rule_area_blocks(self, position: tuple[float, float], layer: int | None,
radius: float = 0.0) -> bool:
"""Test a disc, not a point: copper close to a rule area still violates it.
A layer of None means a through via, which is checked against the areas
that forbid vias on any layer.
"""
probes = [position]
for step in range(8):
angle = math.radians(45 * step)
probes.append((position[0] + radius * math.cos(angle),
position[1] + radius * math.sin(angle)))
areas = self.via_areas if layer is None else self.track_areas
for zone in areas:
if layer is not None and not zone.GetLayerSet().Contains(layer):
continue
outline = zone.Outline()
if any(outline.Contains(point(probe)) for probe in probes):
return True
return False
def hole_clear(self, position: tuple[float, float],
drill_radius: float = VIA_DRILL / 2) -> bool:
for other, other_radius in self.holes:
gap = math.hypot(position[0] - other[0], position[1] - other[1])
if gap < drill_radius + other_radius + HOLE_TO_HOLE:
return False
return True
def track_clear(self, net: str, start: tuple[float, float],
end: tuple[float, float], width: float, layer: int,
clearance_floor: float | None = None) -> bool:
"""`clearance_floor` overrides the routing margin with the bare rule
value, for callers that are checking existing copper rather than
placing new copper with room to spare."""
floor = CLEARANCE if clearance_floor is None else clearance_floor
half = width / 2
margin_to_edge = half + EDGE_CLEARANCE
for probe in (start, end):
if not (EDGE[0] + margin_to_edge <= probe[0] <= EDGE[2] - margin_to_edge and
EDGE[1] + margin_to_edge <= probe[1] <= EDGE[3] - margin_to_edge):
return False
forbidden = rf_region_forbids(net, layer)
for fraction in range(0, 11):
sample = (start[0] + (end[0] - start[0]) * fraction / 10,
start[1] + (end[1] - start[1]) * fraction / 10)
if self.rule_area_blocks(sample, layer, half + CLEARANCE):
return False
if forbidden and in_rf_region(sample, half + CLEARANCE):
return False
bounds = (min(start[0], end[0]) - 2, min(start[1], end[1]) - 2,
max(start[0], end[0]) + 2, max(start[1], end[1]) + 2)
for entry in self._nearby(bounds):
if entry[1] == net:
continue
clearance = max(floor, STRICT_CLEARANCE.get(entry[1], 0.0))
if entry[0] == "pad":
_, _, box, layers = entry
if not layers.Contains(layer):
continue
margin = half + clearance
corners = ((box[0], box[1]), (box[2], box[1]),
(box[2], box[3]), (box[0], box[3]))
inflated = (box[0] - margin, box[1] - margin,
box[2] + margin, box[3] + margin)
for probe in (start, end):
if (inflated[0] <= probe[0] <= inflated[2] and
inflated[1] <= probe[1] <= inflated[3]):
return False
for index in range(4):
if segments_distance(start, end, corners[index],
corners[(index + 1) % 4]) < margin:
return False
elif entry[0] == "via":
_, _, position, radius = entry
if segment_distance(position, start, end) < half + radius + clearance:
return False
else:
_, _, other_start, other_end, other_half, other_layer = entry
if other_layer != layer:
continue
if segments_distance(start, end, other_start,
other_end) < half + other_half + clearance:
return False
return True
def via_clear(self, net: str, position: tuple[float, float],
clearance_floor: float | None = None) -> bool:
floor = CLEARANCE if clearance_floor is None else clearance_floor
radius = VIA_DIAMETER / 2
if not (EDGE[0] + radius <= position[0] <= EDGE[2] - radius and
EDGE[1] + radius <= position[1] <= EDGE[3] - radius):
return False
# A through via is present on every layer, so any rule area blocks it.
if self.rule_area_blocks(position, None, radius + CLEARANCE):
return False
if rf_region_forbids(net, None) and in_rf_region(position, radius + CLEARANCE):
return False
if not self.hole_clear(position):
return False
bounds = (position[0] - 2, position[1] - 2, position[0] + 2, position[1] + 2)
for entry in self._nearby(bounds):
if entry[1] == net:
continue
clearance = max(floor, STRICT_CLEARANCE.get(entry[1], 0.0))
if entry[0] == "pad":
_, _, box, _layers = entry
margin = radius + clearance
if (box[0] - margin <= position[0] <= box[2] + margin and
box[1] - margin <= position[1] <= box[3] + margin):
return False
elif entry[0] == "via":
_, _, other, other_radius = entry
if math.hypot(position[0] - other[0],
position[1] - other[1]) < radius + other_radius + clearance:
return False
else:
_, _, start, end, half, _layer = entry
if segment_distance(position, start, end) < radius + half + clearance:
return False
return True
def diagonal_paths(a: tuple[float, float],
b: tuple[float, float]) -> list[list[tuple[float, float]]]:
"""Two-segment 45-degree routes between two points, shortest first."""
dx, dy = b[0] - a[0], b[1] - a[1]
if abs(dx) < 1e-6 or abs(dy) < 1e-6 or abs(abs(dx) - abs(dy)) < 1e-6:
return [[a, b]]
sx = math.copysign(1.0, dx)
sy = math.copysign(1.0, dy)
run = abs(abs(dx) - abs(dy))
if abs(dx) > abs(dy):
diagonal_first = [a, (a[0] + sx * abs(dy), a[1] + dy), b]
straight_first = [a, (a[0] + sx * run, a[1]), b]
else:
diagonal_first = [a, (a[0] + dx, a[1] + sy * abs(dx)), b]
straight_first = [a, (a[0], a[1] + sy * run), b]
return [diagonal_first, straight_first,
[a, (a[0], b[1]), b], [a, (b[0], a[1]), b]]
def detour_paths(a: tuple[float, float], b: tuple[float, float],
step: float = 2.5, margin: float = 12.0,
limit: int = 96) -> list[list[tuple[float, float]]]:
"""Routes that reach b through one intermediate point, shortest first.
Two segments only ever get around an obstacle by luck. Sampling a waypoint
grid over the span between the two ends, and joining a-to-waypoint and
waypoint-to-b with the same 45-degree geometry, is enough to get a supply
trace past the components between an LDO and the far end of its rail.
"""
x0, x1 = sorted((a[0], b[0]))
y0, y1 = sorted((a[1], b[1]))
candidates = []
steps_x = int((x1 - x0 + 2 * margin) / step) + 1
steps_y = int((y1 - y0 + 2 * margin) / step) + 1
for ix in range(steps_x):
for iy in range(steps_y):
way = (round(x0 - margin + ix * step, 3),
round(y0 - margin + iy * step, 3))
if not (EDGE[0] <= way[0] <= EDGE[2] and EDGE[1] <= way[1] <= EDGE[3]):
continue
length = (math.hypot(way[0] - a[0], way[1] - a[1]) +
math.hypot(b[0] - way[0], b[1] - way[1]))
candidates.append((length, way))
candidates.sort()
routes = []
for _length, way in candidates[:limit]:
first = diagonal_paths(a, way)[0]
second = diagonal_paths(way, b)[0]
routes.append(first + second[1:])
return routes
class LocalRouter:
def __init__(self, board: pcbnew.BOARD) -> None:
self.board = board
self.obstacles = Obstacles(board)
self.installed = 0
self.vias = 0
self.widths: dict[str, float] = {}
# Power rails are routed with a margin over the rule. Signal
# completion has to drop to the rule itself: a 0.12-mm trace needs
# 0.385 mm of the 0.400-mm channel between two Ti375 escape vias, and
# at 0.13 mm it would need 0.415 mm and never fit.
self.clearance = CLEARANCE
self.clearances: dict[str, float] = {}
# Taken from the board, not written down here: the stackup gained a
# fourth signal layer when L5 stopped being a power plane, and a fixed
# list would have gone on routing everything through the old three.
self.routing_layers = [layer for layer in board.GetEnabledLayers().CuStack()
if board.GetLayerType(layer) == pcbnew.LT_SIGNAL]
def width_for(self, net: str) -> float:
return self.widths.get(net, net_width(net))
def clearance_for(self, net: str) -> float:
return self.clearances.get(net, self.clearance)
def path_clear(self, net: str, vertices: list[tuple[float, float]],
width: float, layer: int) -> bool:
for start, end in zip(vertices, vertices[1:]):
if start == end:
continue
if not self.obstacles.track_clear(net, start, end, width, layer,
clearance_floor=self.clearance_for(net)):
return False
return True
def first_clear(self, net: str, a: tuple[float, float], b: tuple[float, float],
width: float, layer: int,
detour: bool = True) -> list[tuple[float, float]] | None:
"""The shortest clear route from a to b on one layer, or None."""
for vertices in diagonal_paths(a, b):
if self.path_clear(net, vertices, width, layer):
return vertices
if not detour:
return None
for vertices in detour_paths(a, b):
if self.path_clear(net, vertices, width, layer):
return vertices
return None
def commit(self, net: str, vertices: list[tuple[float, float]],
width: float, layer: int) -> None:
for start, end in zip(vertices, vertices[1:]):
if start == end:
continue
track = pcbnew.PCB_TRACK(self.board)
track.SetStart(point(start))
track.SetEnd(point(end))
track.SetWidth(mm(width))
track.SetLayer(layer)
track.SetNet(self.board.FindNet(net))
track.SetLocked(True)
self.board.Add(track)
self.obstacles.add_track(net, start, end, width, layer)
self.installed += 1
def commit_via(self, net: str, position: tuple[float, float]) -> None:
via = pcbnew.PCB_VIA(self.board)
via.SetPosition(point(position))
via.SetWidth(mm(VIA_DIAMETER))
via.SetDrill(mm(VIA_DRILL))
via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
via.SetNet(self.board.FindNet(net))
via.SetLocked(True)
self.board.Add(via)
self.obstacles.add_via(net, position, VIA_DIAMETER / 2)
self.vias += 1
def connect(self, net: str, a: tuple[float, float], a_layer: int,
b: tuple[float, float], b_layer: int,
detour: bool = True) -> bool:
"""Route a to b, changing layer through a via when that is what fits."""
width = self.width_for(net)
if a_layer == b_layer:
vertices = self.first_clear(net, a, b, width, a_layer, detour)
if vertices is not None:
self.commit(net, vertices, width, a_layer)
return True
for layer in self.routing_layers:
if layer == a_layer and layer == b_layer:
continue
for via_a, via_b in self.via_options(net, a, a_layer, b, b_layer, layer):
plan = []
if via_a is not None:
if not self.path_clear(net, [a, via_a], width, a_layer):
continue
plan.append(("track", [a, via_a], a_layer))
plan.append(("via", via_a))
start = via_a
else:
start = a
end = via_b if via_b is not None else b
middle = self.first_clear(net, start, end, width, layer, detour)
if middle is None:
continue
plan.append(("track", middle, layer))
if via_b is not None:
if not self.path_clear(net, [via_b, b], width, b_layer):
continue
plan.append(("via", via_b))
plan.append(("track", [via_b, b], b_layer))
for entry in plan:
if entry[0] == "via":
self.commit_via(net, entry[1])
else:
self.commit(net, entry[1], width, entry[2])
return True
return False
def via_options(self, net: str, a: tuple[float, float], a_layer: int,
b: tuple[float, float], b_layer: int, layer: int):
"""Offer via drop points just outside each endpoint's own pad."""
escapes_a = [None] if a_layer == layer else self.escape_points(net, a, b)
escapes_b = [None] if b_layer == layer else self.escape_points(net, b, a)
for via_a in escapes_a[:4]:
for via_b in escapes_b[:4]:
yield via_a, via_b
def escape_points(self, net: str, origin: tuple[float, float],
towards: tuple[float, float]) -> list[tuple[float, float]]:
heading = math.atan2(towards[1] - origin[1], towards[0] - origin[0])
options = []
for radius in (0.65, 0.95, 1.35):
for step in range(8):
angle = math.radians(45 * step)
candidate = (round(origin[0] + radius * math.cos(angle), 3),
round(origin[1] + radius * math.sin(angle), 3))
if not self.obstacles.via_clear(net, candidate, self.clearance_for(net)):
continue
score = -math.cos(angle - heading) * 10 + radius
options.append((score, candidate))
options.sort()
return [candidate for _score, candidate in options]
def reach_plane(self, net: str, zones: list[pcbnew.ZONE],
terminals: list[tuple[tuple[float, float], int]]) -> bool:
"""Drop a via from one of this group's pads onto the rail's own plane."""
width = self.width_for(net)
best = None
for origin, layer in terminals:
for radius in [x * 0.4 for x in range(2, int(MAX_PLANE_REACH / 0.4) + 1)]:
for step in range(8):
angle = math.radians(45 * step)
candidate = (round(origin[0] + radius * math.cos(angle), 3),
round(origin[1] + radius * math.sin(angle), 3))
probe = point(candidate)
if not any(zone.HitTestFilledArea(zone.GetLayer(), probe)
for zone in zones):
continue
if not self.obstacles.via_clear(net, candidate, self.clearance_for(net)):
continue
if not self.path_clear(net, [origin, candidate], width, layer):
continue
if best is None or radius < best[0]:
best = (radius, origin, candidate, layer)
break
if best is not None and best[0] <= radius:
break
if best is None:
return False
_radius, origin, candidate, layer = best
self.commit(net, [origin, candidate], width, layer)
self.commit_via(net, candidate)
return True
def pad_layer(pad: pcbnew.PAD) -> int:
layers = pad.GetLayerSet().Seq()
return layers[0] if layers else pcbnew.F_Cu
def groups_for_net(board: pcbnew.BOARD, net: str) -> list[list[pcbnew.PAD]]:
connectivity = board.GetConnectivity()
pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()
if pad.GetNetname() == net]
seen: set[str] = set()
groups: list[list[pcbnew.PAD]] = []
for pad in pads:
key = pad.m_Uuid.AsString()
if key in seen:
continue
members = [item for item in connectivity.GetConnectedItems(pad, 0)
if item.GetClass() == "PAD"] or [pad]
for item in members:
seen.add(item.m_Uuid.AsString())
seen.add(key)
groups.append([item.Cast() if hasattr(item, "Cast") else item
for item in members])
return groups
def escape_via_map(board: pcbnew.BOARD) -> dict[str, list[tuple[float, float]]]:
result: dict[str, list[tuple[float, float]]] = defaultdict(list)
for item in board.GetTracks():
if isinstance(item, pcbnew.PCB_VIA):
position = item.GetPosition()
result[item.GetNetname()].append(
(round(to_mm(position.x), 4), round(to_mm(position.y), 4)))
return result
def terminals_of(group: list[pcbnew.PAD], net: str,
vias: dict[str, list[tuple[float, float]]] | None = None,
inner_layers: tuple[int, ...] = (pcbnew.B_Cu,),
) -> list[tuple[tuple[float, float], int]]:
"""Points this group can be routed from, with the layer each one sits on.
A pad that already has a dogbone via contributes that via as well. Under
the Ti375 the escape vias are the only usable terminals: an isolated bank
supply ball has no plane left around its via (every neighbouring ball's
anti-pad eats it), and F.Cu there is solid escape fan-out, so the one route
that exists is a short hop to the neighbouring same-net via on B.Cu.
`inner_layers` is the set of routing layers such a via can be picked up on;
it comes from the board so a stackup change cannot silently strand one.
"""
result = []
for pad in group:
parent = pad.GetParentFootprint()
if parent is not None and parent.GetReference() in SKIP_REFS:
continue
position = pad.GetPosition()
xy = (round(to_mm(position.x), 4), round(to_mm(position.y), 4))
if net == "GND" and (RF_GROUND_REGION[0] <= xy[0] <= RF_GROUND_REGION[2] and
RF_GROUND_REGION[1] <= xy[1] <= RF_GROUND_REGION[3]):
continue
result.append((xy, pad_layer(pad)))
for via in (vias or {}).get(net, ()):
if math.hypot(via[0] - xy[0], via[1] - xy[1]) <= ESCAPE_VIA_REACH:
for layer in inner_layers:
result.append((via, layer))
return result
def on_plane(board: pcbnew.BOARD, group: list[pcbnew.PAD],
zones: list[pcbnew.ZONE]) -> bool:
"""True when this group already sits on one of the rail's own zones.
Without this test a group that reached its plane but did not thereby merge
with any other group - because no other group is on that island - reports
progress on every pass. route_net then keeps calling reach_plane, which
keeps finding the next free spot one step further out, and the rail ends up
with a cloud of dozens of redundant vias around a single pad.
"""
if not zones:
return False
wanted = {zone.m_Uuid.AsString() for zone in zones}
connectivity = board.GetConnectivity()
for pad in group:
for item in connectivity.GetConnectedItems(pad, 0):
if item.GetClass() == "ZONE" and item.m_Uuid.AsString() in wanted:
return True
return False
def route_net(router: LocalRouter, board: pcbnew.BOARD, net: str,
zones: list[pcbnew.ZONE], detour: bool = True) -> tuple[int, int]:
"""Return the number of groups joined and the number left unresolved."""
joined = 0
stalled = 0
previous = None
for _pass in range(60):
board.BuildConnectivity()
groups = groups_for_net(board, net)
if len(groups) <= 1:
break
# A pass that claims progress without reducing the group count has not
# actually connected anything new. Allow a couple of those - a plane
# drop can legitimately need one - then stop rather than pile on copper.
if previous is not None and len(groups) >= previous:
stalled += 1
if stalled >= 2:
break
else:
stalled = 0
previous = len(groups)
vias = escape_via_map(board)
inner = tuple(layer for layer in router.routing_layers
if layer != pcbnew.F_Cu)
terminals = [terminals_of(group, net, vias, inner) for group in groups]
sizes = [len(group) for group in groups]
main = sizes.index(max(sizes))
progress = False
for index, group_terminals in enumerate(terminals):
if index == main:
continue
if (zones and not on_plane(board, groups[index], zones)
and router.reach_plane(net, zones, group_terminals)):
joined += 1
progress = True
break
candidates = []
for other, other_terminals in enumerate(terminals):
if other == index:
continue
for a, a_layer in group_terminals:
for b, b_layer in other_terminals:
distance = math.hypot(a[0] - b[0], a[1] - b[1])
if distance <= MAX_JOIN_LENGTH:
candidates.append((distance, a, a_layer, b, b_layer))
candidates.sort(key=lambda entry: entry[0])
for _distance, a, a_layer, b, b_layer in candidates[:24]:
if router.connect(net, a, a_layer, b, b_layer, detour):
joined += 1
progress = True
break
if progress:
break
if not progress:
break
board.BuildConnectivity()
return joined, len(groups_for_net(board, net)) - 1
def signal_nets(board: pcbnew.BOARD,
classes: frozenset[str] | None = None) -> dict[str, float]:
"""Routable signal nets and the track width their net class asks for."""
result: dict[str, float] = {}
for footprint in board.GetFootprints():
for pad in footprint.Pads():
name = pad.GetNetname()
if not name or name in TARGET_NETS or name in result:
continue
klass = pad.GetNetClassName()
if klass in SKIP_CLASSES or (classes and klass not in classes):
continue
width = NETCLASS_WIDTH.get(klass)
if width is not None:
result[name] = width
return result
def signal_clearances(board: pcbnew.BOARD,
classes: frozenset[str] | None = None) -> dict[str, float]:
result: dict[str, float] = {}
for footprint in board.GetFootprints():
for pad in footprint.Pads():
name = pad.GetNetname()
if not name or name in result:
continue
klass = pad.GetNetClassName()
if classes and klass not in classes:
continue
value = NETCLASS_CLEARANCE.get(klass)
if value is not None:
result[name] = value
return result
def complete(board: pcbnew.BOARD, verbose: bool = True,
signals: bool = False,
signal_classes: frozenset[str] | None = None) -> dict[str, int]:
"""Run the local power completion on an already-filled board.
finalize_routing.py calls this a second time once the temporary RF and USB
rule areas are gone, which is the only moment the pads inside those
corridors can be reached. With `signals` it then makes the same two passes
over ordinary signal nets, to close the connections the autorouter gave up
on; those use their net class width and are locked like everything else.
"""
router = LocalRouter(board)
zones_by_net: dict[str, list[pcbnew.ZONE]] = defaultdict(list)
for zone in board.Zones():
if not zone.GetIsRuleArea():
zones_by_net[zone.GetNetname()].append(zone)
# Two phases. Everything that can be closed with a plane drop or a short
# direct path is done first, for every rail; only then are the leftovers
# allowed to take a detour. Otherwise the first rail in the list spends
# the free space on long routes that a later rail needed for a short one.
remaining: dict[str, int] = {}
for detour in (False, True):
remaining = {}
for net in TARGET_NETS:
if board.FindNet(net) is None:
continue
joined, left = route_net(router, board, net,
zones_by_net.get(net, []), detour)
if verbose and detour and (joined or left):
print(f"{net:14s} {left} groups still separate")
if left:
remaining[net] = left
if signals:
router.widths = signal_nets(board, signal_classes)
router.clearance = SIGNAL_CLEARANCE
router.clearances = signal_clearances(board, signal_classes)
closed = 0
for detour in (False, True):
for net in sorted(router.widths):
joined, left = route_net(router, board, net, [], detour)
closed += joined
if left:
remaining[net] = left
else:
remaining.pop(net, None)
if verbose:
print(f"Signal completion joined {closed} groups")
if verbose:
print(f"Installed {router.installed} tracks and {router.vias} vias")
print("Unresolved:",
", ".join(f"{n}={k}" for n, k in sorted(remaining.items())) or "none")
return remaining
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--clocks", action="store_true",
help="Also route the CLOCK class here, before the "
"autorouter runs, so those nets are fixed copper "
"by the time it starts.")
args = parser.parse_args()
board = pcbnew.LoadBoard(str(args.input))
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Could not fill planes before local power routing")
complete(board, signals=args.clocks,
signal_classes=frozenset({"CLOCK"}) if args.clocks else None)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Could not refill planes after local power routing")
pcbnew.SaveBoard(str(args.output), board)
for suffix in (".kicad_pro", ".kicad_dru"):
source = args.input.with_suffix(suffix)
if source.exists():
shutil.copyfile(source, args.output.with_suffix(suffix))
print(f"Saved {args.output}")
if __name__ == "__main__":
main()