791ca5c9fa
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
939 lines
42 KiB
Python
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()
|