af7c43b2a8
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>
859 lines
38 KiB
Python
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()
|