Files
pcb/scripts/route_local_power.py
T
onur 791ca5c9fa Restack the generator and source board to eight layers, matching the deliverable
The routed deliverable has always been eight layers (JLC08161H-3313), but
generate_pcb.py still emitted ten and hardware/board.kicad_pcb was itself still
a ten-layer board. Source and generator agreed with each other and disagreed
with what is actually being fabricated, so running the documented regeneration
flow would have silently replaced the eight-layer design with a conflicting
ten-layer one and destroyed the routing.

generate_pcb.py now builds the real stack: SetCopperLayerCount(8), no L8_PWR /
L9_SIG, and the STACKUP string taken verbatim from board_routed.kicad_pcb - the
two are now byte-identical. Silkscreen reads JLC08161H-3313 8L instead of
JLC10161H 10L, and the title block follows.

The zone layout follows the same correction. With only one dedicated power
plane there is no third plane for the two big FPGA I/O rails, so they are
carried as islands on the routing layers that have room: VCC1V8 on L6 and
VCC3V3 on B.Cu, replacing the ten-layer arrangement of a VCC3V3 background on
L8 and a board-wide GND pour on B.Cu. The regenerated board now reproduces the
routed board's 26 copper zones exactly (the 27th, the F.Cu RF ground pour, is
added later by finalize_routing).

Also drops the In7_Cu/In8_Cu remnants from finalize_routing's RF_NO_DIGITAL
layer set and route_local_power's RF_REGION_LAYERS - those layers do not exist
on this board, so they were dead bits in a layer mask - and corrects the
JLC10161H reference in board.kicad_dru.

The placement check now reports 21 isolated_copper warnings rather than 17.
That is expected and explained in README.md: on an unrouted board the I/O-rail
islands on L6 and B.Cu have nothing joining them yet. The routed board still
reports zero.

Verified: regenerated board is 8 layers with a stackup identical to the routed
board, 312 footprints (311 + R108), TEMP keepouts at their corrected extents.
design_data assertions pass, ERC 0/0, routed board still 0 DRC violations,
0 unconnected, every net one copper group, parity unchanged at 402.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KaTmCCxCEW4awsmXRNT1d
2026-09-01 13:26:37 +03:00

939 lines
42 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.
# The F.Cu ground pour over the whole GPS front end.
RF_GROUND_REGION = (152.0, 36.0, 174.5, 49.5)
# The band the RF line actually occupies, and the only part of the front end
# that has to stay clear of digital copper on the layers below F.Cu. The
# MAX2771's own digital pins sit south of it (pin rows at y = 44.5 .. 47.4)
# and need those layers to escape the package at all.
RF_NO_DIGITAL_REGION = (152.0, 36.0, 174.5, 43.7)
# 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_NO_DIGITAL_REGION[0] - margin <= position[0] <= RF_NO_DIGITAL_REGION[2] + margin and
RF_NO_DIGITAL_REGION[1] - margin <= position[1] <= RF_NO_DIGITAL_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 _seg_aabb_distance(start: tuple[float, float], end: tuple[float, float],
x0: float, y0: float, x1: float, y1: float) -> float:
"""Exact distance from a segment to an axis-aligned box (0 if they touch)."""
for probe in (start, end):
if x0 <= probe[0] <= x1 and y0 <= probe[1] <= y1:
return 0.0
corners = ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
return min(segments_distance(start, end, corners[i], corners[(i + 1) % 4])
for i in range(4))
def pad_point_distance(shape: tuple[float, float, float, float, float],
probe: tuple[float, float]) -> float:
"""Distance from a point to a pad, as an oriented rounded rectangle.
`shape` is (cx, cy, hx, hy, r): centre, half extents and corner radius.
A circle is hx = hy = 0 with r = radius; a plain rectangle is r = 0. This
replaces the old bounding-box test, which measured a diagonal neighbour
from the box edge instead of the land itself and so rejected every legal
0.8-mm BGA dogbone site.
"""
cx, cy, hx, hy, r = shape
dx = max(abs(probe[0] - cx) - hx, 0.0)
dy = max(abs(probe[1] - cy) - hy, 0.0)
return max(math.hypot(dx, dy) - r, 0.0)
def pad_segment_distance(shape: tuple[float, float, float, float, float],
start: tuple[float, float],
end: tuple[float, float]) -> float:
"""Distance from a segment to a pad modelled as a rounded rectangle."""
cx, cy, hx, hy, r = shape
return max(_seg_aabb_distance(start, end, cx - hx, cy - hy,
cx + hx, cy + hy) - r, 0.0)
def pad_shape(pad: pcbnew.PAD) -> tuple[float, float, float, float, float]:
"""Analytic (cx, cy, hx, hy, r) for a pad, exact for every shape on this
board (circle, oval, rectangle, rounded rectangle) at 0 or 90 degrees."""
position = pad.GetPosition()
cx, cy = to_mm(position.x), to_mm(position.y)
size_x, size_y = to_mm(pad.GetSizeX()) / 2, to_mm(pad.GetSizeY()) / 2
if round(pad.GetOrientationDegrees()) % 180 == 90:
size_x, size_y = size_y, size_x
shape = pad.GetShape()
if shape == pcbnew.PAD_SHAPE_CIRCLE:
return (cx, cy, 0.0, 0.0, min(size_x, size_y))
if shape == pcbnew.PAD_SHAPE_OVAL:
radius = min(size_x, size_y)
return (cx, cy, size_x - radius, size_y - radius, radius)
if shape == pcbnew.PAD_SHAPE_ROUNDRECT:
radius = min(to_mm(pad.GetRoundRectCornerRadius()), size_x, size_y)
return (cx, cy, size_x - radius, size_y - radius, radius)
# rectangle, trapezoid, custom and anything else: the bounding box, which
# is what the model used everywhere before.
return (cx, cy, size_x, size_y, 0.0)
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,
escape_refs: frozenset[str] | None = None,
escape_clearance: float = 0.10) -> None:
# Footprints whose pads carry a DRC rule that overrides the net-class
# clearance -- on this board the "Ti375 0.8-mm BGA escape clearance"
# rule in board.kicad_dru, which allows 0.10 mm whenever one of the two
# objects belongs to U1. Opt-in: every existing caller keeps the plain
# net-class behaviour.
self.escape_refs = frozenset(escape_refs or ())
self.escape_clearance = escape_clearance
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(), pad_shape(pad),
pad.GetLayerSet(),
footprint.GetReference()), 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":
_, _, shape, layers, ref = entry
if not layers.Contains(layer):
continue
if ref in self.escape_refs:
clearance = self.escape_clearance
if pad_segment_distance(shape, start, end) < half + clearance:
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":
_, _, shape, _layers, ref = entry
if ref in self.escape_refs:
clearance = self.escape_clearance
if pad_point_distance(shape, position) < radius + clearance:
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:
# The Specctra import normally completes hundreds of signal nets. A
# full BuildConnectivity inside route_net() for every already-finished
# net made this post-pass spend several minutes proving the same fact
# twice. Build once, retain only nets that are genuinely split, and
# run the unchanged router over that exact residual set.
board.BuildConnectivity()
all_widths = signal_nets(board, signal_classes)
router.widths = {
name: width for name, width in all_widths.items()
if len(groups_for_net(board, name)) > 1
}
router.clearance = SIGNAL_CLEARANCE
router.clearances = {
name: clearance
for name, clearance in signal_clearances(board, signal_classes).items()
if name in router.widths
}
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()