#!/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.In7_Cu, pcbnew.In8_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()