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

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

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

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

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

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

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

580 lines
24 KiB
Python

#!/usr/bin/env python3
"""Import a Specctra session and install audited RF/USB copper.
Freerouting is used only for the dense non-critical interconnect. The GPS
front end and USB2 pair are removed from that result and rebuilt here with
deterministic geometry derived from the selected JLC08161H-3313 stackup.
"""
from __future__ import annotations
import argparse
import math
import shutil
from pathlib import Path
import pcbnew
import finish_signal_routes
import route_local_power
import stitch_plane_pads
ROOT = Path(__file__).resolve().parents[1]
POWER_LOCAL = ROOT / "hardware" / "board_power_local.kicad_pcb"
ROUTED = ROOT / "hardware" / "board_routed.kicad_pcb"
RF_NETS = {"RF_SMA", "RF_PRE_SAW", "RF_POST_SAW", "MAX_MIXIN_HI"}
USB_NETS = {"USB_D_P", "USB_D_N", "FTDI_D_P", "FTDI_D_N"}
CRITICAL_NETS = RF_NETS | USB_NETS
def mm(value: float) -> int:
return pcbnew.FromMM(value)
def point(x: float, y: float) -> pcbnew.VECTOR2I:
return pcbnew.VECTOR2I_MM(x, y)
def remove(board: pcbnew.BOARD, item) -> None:
"""Remove and disown a board item.
pcbnew's BOARD.Remove() does not clear the SWIG "thisown" flag, so the
Python wrapper still believes it owns the now-freed C++ object. Letting
the garbage collector finalize it later double-frees that memory and
corrupts the process (subsequent pcbnew calls start returning raw,
unusable SwigPyObject instances instead of typed wrappers). Explicitly
disowning the wrapper right after removal avoids that.
"""
board.Remove(item)
item.thisown = False
def footprint(board: pcbnew.BOARD, ref: str) -> pcbnew.FOOTPRINT:
item = board.FindFootprintByReference(ref)
if item is None:
raise RuntimeError(f"Missing footprint {ref}")
return item
def pads(board: pcbnew.BOARD, ref: str, number: str) -> list[pcbnew.PAD]:
result = [pad for pad in footprint(board, ref).Pads() if pad.GetNumber() == str(number)]
if not result:
raise RuntimeError(f"Missing pad {ref}.{number}")
return result
def pad_xy(board: pcbnew.BOARD, ref: str, number: str, index: int = 0) -> tuple[float, float]:
position = pads(board, ref, number)[index].GetPosition()
return pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
def assert_ref_nets(board: pcbnew.BOARD, ref: str, expected: set[str]) -> None:
actual = {pad.GetNetname() for pad in footprint(board, ref).Pads() if pad.GetNetname()}
if actual != expected:
raise RuntimeError(f"{ref} net mismatch: expected {sorted(expected)}, got {sorted(actual)}")
def add_segment(board: pcbnew.BOARD, net_name: str,
start: tuple[float, float], end: tuple[float, float],
width: float, layer: int = pcbnew.F_Cu, locked: bool = True) -> None:
if start == end:
return
track = pcbnew.PCB_TRACK(board)
track.SetStart(point(*start))
track.SetEnd(point(*end))
track.SetWidth(mm(width))
track.SetLayer(layer)
track.SetNet(board.FindNet(net_name))
track.SetLocked(locked)
board.Add(track)
def add_polyline(board: pcbnew.BOARD, net_name: str,
vertices: list[tuple[float, float]], width: float,
layer: int = pcbnew.F_Cu) -> None:
for start, end in zip(vertices, vertices[1:]):
add_segment(board, net_name, start, end, width, layer)
def add_via(board: pcbnew.BOARD, net_name: str, position: tuple[float, float],
diameter: float = 0.45, drill: float = 0.225) -> None:
via = pcbnew.PCB_VIA(board)
via.SetPosition(point(*position))
via.SetWidth(mm(diameter))
via.SetDrill(mm(drill))
via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
via.SetNet(board.FindNet(net_name))
via.SetLocked(True)
board.Add(via)
def remove_critical_routes(board: pcbnew.BOARD) -> int:
doomed = [item for item in board.GetTracks() if item.GetNetname() in CRITICAL_NETS]
for item in doomed:
remove(board, item)
return len(doomed)
def remove_temporary_keepouts(board: pcbnew.BOARD) -> int:
doomed = [zone for zone in board.Zones()
if zone.GetIsRuleArea() and zone.GetZoneName().startswith("TEMP_")]
for zone in doomed:
remove(board, zone)
return len(doomed)
def route_rf(board: pcbnew.BOARD) -> None:
# 0.164-mm L1 CPWG with 0.200-mm ground gap: 49.65 ohms on the exact
# JLC08161H-3313 L1/L2 dielectric construction.
for ref, expected in {
"C83": {"RF_SMA", "RF_PRE_SAW"},
"C84": {"RF_POST_SAW", "GND"},
"R39": {"RF_POST_SAW", "MAX_MIXIN_HI"},
"C85": {"MAX_MIXIN_HI", "GND"},
}.items():
assert_ref_nets(board, ref, expected)
w = 0.164
j2 = pad_xy(board, "J2", "1")
j2_inner = (j2[0] - 0.50, j2[1])
dc_block_1 = pad_xy(board, "C83", "1")
l4_2 = pad_xy(board, "L4", "2")
add_polyline(board, "RF_SMA", [j2_inner, (170.10, 42.09), l4_2, dc_block_1], w)
# D1 sits directly on the 50-ohm line: its pad 1 overlaps the run above,
# so no spur is drawn. On a 1.34-mm stub the 0.3-pF clamp presented
# -j228 ohm at L1 and cost 3.4 dB of return loss for nothing.
add_polyline(board, "RF_PRE_SAW",
[pad_xy(board, "C83", "2"), pad_xy(board, "FL1", "1")], w)
fl_out = pad_xy(board, "FL1", "4")
tune1 = pad_xy(board, "C84", "1")
series_1 = pad_xy(board, "R39", "1")
add_polyline(board, "RF_POST_SAW", [fl_out, tune1, series_1], w)
series_2 = pad_xy(board, "R39", "2")
mixin = pad_xy(board, "U21", "25")
add_polyline(board, "MAX_MIXIN_HI",
[series_2, (159.69, 41.80), (mixin[0], 41.80), mixin], w)
tune2 = pad_xy(board, "C85", "1")
add_polyline(board, "MAX_MIXIN_HI", [tune2, (tune2[0], 41.80)], w)
def route_usb(board: pcbnew.BOARD) -> None:
# Main device-side pair uses the upper receptacle contact pair. The two
# mirrored USB-C contacts join through a sub-1.5-mm L3 crossover so neither
# polarity creates a same-layer crossing at the receptacle.
w = 0.135
for ref, expected in {
"R62": {"USB_D_P", "FTDI_D_P"},
"R63": {"USB_D_N", "FTDI_D_N"},
}.items():
assert_ref_nets(board, ref, expected)
dm_main, dp_main = pad_xy(board, "J1", "B7"), pad_xy(board, "J1", "A6")
dm_mirror, dp_mirror = pad_xy(board, "J1", "A7"), pad_xy(board, "J1", "B6")
dm_join, dp_join = (28.60, 47.25), (29.20, 47.75)
dm_aux, dp_aux = (27.60, 48.25), (28.20, 48.75)
add_polyline(board, "USB_D_N", [dm_main, dm_join], w)
add_polyline(board, "USB_D_P", [dp_main, dp_join], w)
add_polyline(board, "USB_D_N", [dm_mirror, dm_aux], w)
add_polyline(board, "USB_D_P", [dp_mirror, dp_aux], w)
for net, location in (("USB_D_N", dm_join), ("USB_D_P", dp_join),
("USB_D_N", dm_aux), ("USB_D_P", dp_aux)):
add_via(board, net, location)
add_polyline(board, "USB_D_N", [dm_aux, dm_join], w, pcbnew.In2_Cu)
add_polyline(board, "USB_D_P", [dp_aux, dp_join], w, pcbnew.In2_Cu)
# Down the free channel east of J1 into the TPD2EUSB30A clamp pads. D_P
# drops at x=30.00 rather than on its own pad column so it keeps 0.2325 mm
# to the D_N clamp pad, then jogs 45 degrees onto U6.1.
dm_esd = pad_xy(board, "U6", "3")
dp_esd = pad_xy(board, "U6", "1")
add_polyline(board, "USB_D_N",
[dm_join, (29.60, 47.25), (30.50, 48.15), dm_esd], w)
add_polyline(board, "USB_D_P",
[dp_join, (29.45, 47.75), (30.00, 48.30),
(30.00, 51.775), dp_esd], w)
# Both clamp taps run as a 0.5-mm-pitch 45-degree pair to the series
# terminators. D_N leaves U6.3 straight east; D_P has to step south first,
# because U6.2 (ground) sits directly east of U6.1 on the same pad row.
dm_r = pad_xy(board, "R63", "1")
dp_r = pad_xy(board, "R62", "1")
add_polyline(board, "USB_D_N",
[dm_esd, (31.000, 51.075), (34.425, 54.50), dm_r], w)
add_polyline(board, "USB_D_P",
[dp_esd, (30.150, 52.500), (31.718, 52.500),
(34.718, 55.50), dp_r], w)
# Equal short post-termination legs into the FT4232H USB PHY.
add_polyline(board, "FTDI_D_N",
[pad_xy(board, "R63", "2"), (37.76, 54.75), pad_xy(board, "U2", "7")], w)
add_polyline(board, "FTDI_D_P",
[pad_xy(board, "R62", "2"), (37.76, 55.25), pad_xy(board, "U2", "8")], w)
def add_rf_ground(board: pcbnew.BOARD) -> None:
zone = pcbnew.ZONE(board)
zone.SetLayer(pcbnew.F_Cu)
zone.SetNet(board.FindNet("GND"))
zone.SetLocalClearance(mm(0.20))
zone.SetMinThickness(mm(0.10))
zone.SetPadConnection(pcbnew.ZONE_CONNECTION_FULL)
zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_NEVER)
zone.SetAssignedPriority(100)
outline = zone.Outline()
outline.NewOutline()
for x, y in ((152.0, 36.0), (174.5, 36.0), (174.5, 49.5), (152.0, 49.5)):
outline.Append(point(x, y))
board.Add(zone)
# J2 uses separate top- and bottom-side SMT lands with the same pad number.
# The top lands overlap the RF ground zone, but a bottom land whose centre
# is outside the outline cannot acquire a thermal from the board-wide B.Cu
# pour. Start inside each 1.5-mm-wide land and stop far enough inboard for
# the existing B.Cu pour to absorb the bond. At x=174.50 a 0.40-mm track
# retains 0.30 mm to the x=175.00 routed edge (rule minimum 0.25 mm).
for y in (37.75, 46.25):
add_segment(board, "GND", (174.50, y), (173.50, y),
0.40, pcbnew.B_Cu)
# Lambda/20 is about 9.5 mm at GPS L1 in FR-4. This 2-mm fence is much
# tighter and also gives every local shunt/EP a short plane connection.
fence = [(x, 38.7) for x in range(153, 174, 2)]
fence += [(x, 45.0) for x in range(161, 174, 2)]
existing_vias = [item for item in board.GetTracks()
if isinstance(item, pcbnew.PCB_VIA)]
# The fence goes in after the session has been imported, so by now there is
# routed copper on F.Cu and B.Cu inside this region as well. Checking only
# vias and pads let a fence via land straight on top of a signal trace.
other_tracks = [item for item in board.GetTracks()
if not isinstance(item, pcbnew.PCB_VIA)
and item.GetNetname() != "GND"]
all_pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()]
via_radius = 0.225 + 0.10
for location in fence:
candidate = point(*location)
blocked = any(
item.GetNetname() != "GND" and
math.hypot(pcbnew.ToMM(item.GetPosition().x - candidate.x),
pcbnew.ToMM(item.GetPosition().y - candidate.y)) < 0.75
for item in existing_vias
)
if not blocked:
x, y = location
for pad in all_pads:
if pad.GetNetname() == "GND":
continue
box = pad.GetBoundingBox()
if (pcbnew.ToMM(box.GetX()) - 0.35 <= x <= pcbnew.ToMM(box.GetRight()) + 0.35 and
pcbnew.ToMM(box.GetY()) - 0.35 <= y <= pcbnew.ToMM(box.GetBottom()) + 0.35):
blocked = True
break
if not blocked:
for item in other_tracks:
start, end = item.GetStart(), item.GetEnd()
reach = via_radius + pcbnew.ToMM(item.GetWidth()) / 2
if route_local_power.segment_distance(location,
(pcbnew.ToMM(start.x), pcbnew.ToMM(start.y)),
(pcbnew.ToMM(end.x), pcbnew.ToMM(end.y))) < reach:
blocked = True
break
if not blocked:
add_via(board, "GND", location, 0.45, 0.225)
NETCLASS_WIDTH = {"Default": 0.12, "BGA_ESCAPE": 0.10, "CLOCK": 0.15,
"POWER": 0.40}
MIN_TRACK_WIDTH = 0.10
MIN_CLEARANCE = 0.10
# These local rails carry bias or IC-internal supply current and may safely use
# the finisher's 0.25-mm POWER width. The 5-V input paths and FPGA rails are
# deliberately absent: they must be completed by the plane/local-power stage
# at their full width, never "made connected" with a signal-sized neck.
FINE_POWER_NETS = {
"FTDI_1V8", "FTDI_VPHY", "FTDI_VPLL",
"IMU_3V3", "IMU_VREGA2", "IMU_VREGD",
"ANT_BIAS_SEL", "ANT_BIAS_SW",
}
def normalize_imported_widths(board: pcbnew.BOARD) -> tuple[int, int]:
"""Restore the net class width on copper the session came back too thin.
Freerouting's auto-routing stage honours the DSN rule width exactly, but
its fanout stage emits three quarters of it, which lands below this board's
0.10-mm minimum track width. The affected pieces are the short stubs at
the pads, and widening one can walk it into a neighbouring pad, so each is
grown only as far as it still clears everything else - the class width
where there is room, the board minimum where there is not.
"""
obstacles = route_local_power.Obstacles(board)
doomed = []
widened = 0
for item in board.GetTracks():
if isinstance(item, pcbnew.PCB_VIA) or item.IsLocked():
continue
target = NETCLASS_WIDTH.get(item.GetNetClassName())
if target is None or pcbnew.ToMM(item.GetWidth()) >= target - 1e-6:
continue
start = (pcbnew.ToMM(item.GetStart().x), pcbnew.ToMM(item.GetStart().y))
end = (pcbnew.ToMM(item.GetEnd().x), pcbnew.ToMM(item.GetEnd().y))
for width in (target, MIN_TRACK_WIDTH):
# Judge existing copper against the rule itself, not the extra
# margin the local router leaves itself when placing new copper.
if obstacles.track_clear(item.GetNetname(), start, end,
width, item.GetLayer(),
clearance_floor=MIN_CLEARANCE):
item.SetWidth(mm(width))
widened += 1
break
else:
# Nothing legal fits here, so this piece cannot ship. Dropping it
# unravels its net back to the pads, and the completion pass
# further down rebuilds the connection at the proper width.
doomed.append(item)
for item in doomed:
remove(board, item)
return widened, len(doomed)
def remove_dangling_copper(board: pcbnew.BOARD) -> tuple[int, int]:
"""Delete copper the finished routing left hanging.
A ball whose net ended up routed entirely on F.Cu leaves its pre-routed
dogbone via connected on one layer only, and removing that via in turn
strands the stub that fed it. Both are drilled holes and copper with no
purpose, so this repeats until nothing new comes loose.
"""
vias = tracks = 0
for _round in range(8):
board.BuildConnectivity()
connectivity = board.GetConnectivity()
doomed = [item for item in board.GetTracks()
if item.GetNetname() not in CRITICAL_NETS
and connectivity.TestTrackEndpointDangling(item, False)]
if not doomed:
break
for item in doomed:
if isinstance(item, pcbnew.PCB_VIA):
vias += 1
else:
tracks += 1
remove(board, item)
board.BuildConnectivity()
return vias, tracks
def drop_plane_islands(board: pcbnew.BOARD) -> int:
"""Discard unconnected plane fragments left by the escape via anti-pads.
The generated planes keep every fragment while the board is unrouted so the
Specctra export still shows the router a plane target under each via field.
A finished board must not ship floating copper, so the final fill removes
any island the completed routing did not connect.
"""
changed = 0
for zone in board.Zones():
if zone.GetIsRuleArea() or zone.GetNetname() == "":
continue
zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS)
changed += 1
return changed
def add_named_rule_areas(board: pcbnew.BOARD) -> None:
"""Permanent named areas that only exist so custom DRC rules can name them.
The TPD2EUSB30A is a SOT-23 clamp whose ground pad sits between its two
channel pads, so USB_D_P and USB_D_N must separate for about 1.5 mm to pass
it. That is normal for this package, but it does break the 0.90-mm coupled
gap the pair holds everywhere else, so the rule is scoped to skip this one
breakout instead of being relaxed board-wide.
RF_NO_DIGITAL names the region under the GPS front end. The TEMP_ version
of it exists only while the autorouter runs, so once that is deleted
nothing would stop a later hand edit from dropping a digital trace onto L3,
L5, L6 or B.Cu directly under the RF line. A plain keepout cannot be used
here because it would also reject the ground stitching vias and the
MAX2771's own supply drops, so the area carries no constraint itself and
board.kicad_dru scopes the disallow to non-power net classes. It
deliberately stops short of L2 and L7: those must stay solid and unbroken
under the RF path, which is exactly what an unrouted plane layer gives.
"""
below = pcbnew.LSET()
for layer in (pcbnew.In2_Cu, pcbnew.In3_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu,
pcbnew.B_Cu):
below.AddLayer(layer)
top = pcbnew.LSET()
top.AddLayer(pcbnew.F_Cu)
areas = (
("USB_ESD_BREAKOUT", (29.90, 50.70, 32.10, 53.10), top),
("RF_NO_DIGITAL", (152.00, 36.00, 174.50, 43.70), below),
)
for name, bounds, layers in areas:
x0, y0, x1, y1 = bounds
area = pcbnew.ZONE(board)
area.SetZoneName(name)
area.SetIsRuleArea(True)
area.SetLayerSet(layers)
area.SetDoNotAllowTracks(False)
area.SetDoNotAllowVias(False)
area.SetDoNotAllowPads(False)
area.SetDoNotAllowFootprints(False)
area.SetDoNotAllowZoneFills(False)
outline = area.Outline()
outline.NewOutline()
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
outline.Append(point(x, y))
board.Add(area)
def add_rf_launch_relief(board: pcbnew.BOARD) -> None:
"""Void the reference planes under the SMA edge-launch pad.
J2's centre land is 1.5 mm wide because that is what the connector's
centre pin needs mechanically, but 1.5 mm of copper 0.0994 mm above a
solid L2_GND is a 10.7-ohm transmission line, not a 50-ohm one. The
1.84 mm of it that sits over the plane is 6.7 degrees long at L1 and
reflects 12 dB - by far the worst discontinuity in the RF path, and worse
than everything the 0.164-mm line downstream is carefully built to avoid.
Removing the plane under the land moves its reference down to L4_PWR
(VCC3V0_RF, an AC ground) 0.587 mm below, which raises the launch to about
37 ohms and 30 dB. L3_SIG carries no pour and no copper here, so this
only has to void L2; the area covers L3 as well so a later edit cannot
fill the gap back in. It stops at x = 172.35, where the land ends and the
0.164-mm line begins, so the line keeps its own L2 reference.
"""
relief = pcbnew.LSET()
for layer in (pcbnew.In1_Cu, pcbnew.In2_Cu): # L2_GND, L3_SIG
relief.AddLayer(layer)
area = pcbnew.ZONE(board)
area.SetZoneName("RF_LAUNCH_RELIEF")
area.SetIsRuleArea(True)
area.SetLayerSet(relief)
area.SetDoNotAllowTracks(True)
area.SetDoNotAllowVias(True)
area.SetDoNotAllowZoneFills(True)
area.SetDoNotAllowPads(False)
area.SetDoNotAllowFootprints(False)
outline = area.Outline()
outline.NewOutline()
for x, y in ((172.35, 40.95), (175.10, 40.95), (175.10, 43.05), (172.35, 43.05)):
outline.Append(point(x, y))
board.Add(area)
def routed_lengths(board: pcbnew.BOARD) -> dict[str, float]:
totals = {net: 0.0 for net in CRITICAL_NETS}
for item in board.GetTracks():
net = item.GetNetname()
if net not in totals or isinstance(item, pcbnew.PCB_VIA):
continue
start, end = item.GetStart(), item.GetEnd()
totals[net] += math.hypot(pcbnew.ToMM(end.x - start.x), pcbnew.ToMM(end.y - start.y))
return totals
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("session", type=Path, help="Freerouting .ses file")
parser.add_argument("--base", type=Path, default=POWER_LOCAL,
help="Board used to create the routed DSN")
parser.add_argument("--output", type=Path, default=ROUTED)
parser.add_argument(
"--finish-nets", default="",
help="Comma- or semicolon-separated residual nets to close with the "
"fine local maze before dangling-copper cleanup; use 'auto' to "
"consume the local router's exact unresolved-net result",
)
args = parser.parse_args()
board = pcbnew.LoadBoard(str(args.base))
if not pcbnew.ImportSpecctraSES(board, str(args.session)):
raise RuntimeError(f"Could not import {args.session}")
removed = remove_critical_routes(board)
widened, dropped = normalize_imported_widths(board)
# Keep imported dogbones and dangling track ends until after the focused
# finisher. They are useful legal anchors for the few connections the
# global router leaves open. The old ordering deleted those anchors first
# and then forced every residual route to find a brand-new via beside a
# fine-pitch pad, which is exactly where there is no room.
stranded_vias = stranded_tracks = 0
removed_keepouts = remove_temporary_keepouts(board)
route_rf(board)
route_usb(board)
add_rf_ground(board)
add_rf_launch_relief(board)
add_named_rule_areas(board)
# The reserved RF and USB corridors are gone now, so the power pads that
# sit inside them can finally reach their planes. Both passes are the same
# deterministic code used earlier in the pipeline.
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Zone fill before final power completion failed")
stitched, _unreachable = stitch_plane_pads.stitch(board)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Zone fill after final plane stitching failed")
remaining = route_local_power.complete(board, signals=True)
requested = [name.strip()
for name in args.finish_nets.replace(";", ",").split(",")
if name.strip()]
if requested == ["auto"]:
candidates = []
class_priority = {"CLOCK": 0, "BGA_ESCAPE": 1, "Default": 2}
for name in remaining:
net = board.FindNet(name)
if net is None:
continue
klass = net.GetNetClassName()
if klass in {"Default", "BGA_ESCAPE", "CLOCK"} or name in FINE_POWER_NETS:
# Route the widest/most constrained clocks before ordinary
# signals can spend their channels; local low-current rails
# are last because their endpoints are confined to one IC.
priority = class_priority.get(klass, 3)
candidates.append((priority, name))
finish_nets = [name for _priority, name in sorted(candidates)]
print("Fine-maze residuals:", ", ".join(finish_nets) or "none")
else:
finish_nets = requested
if finish_nets:
board.BuildConnectivity()
maze = finish_signal_routes.FineMaze(board)
for net_name in finish_nets:
if board.FindNet(net_name) is None:
raise RuntimeError(f"Unknown --finish-nets entry: {net_name}")
finish_signal_routes.finish_net(maze, net_name)
late_vias, late_tracks = remove_dangling_copper(board)
cleaned = drop_plane_islands(board)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Final zone fill failed")
pcbnew.SaveBoard(str(args.output), board)
for suffix in (".kicad_pro", ".kicad_dru"):
source = args.base.with_suffix(suffix)
if source.exists():
shutil.copyfile(source, args.output.with_suffix(suffix))
print(f"Removed {removed} autorouted critical-net items")
print(f"Widened {widened} imported tracks; dropped {dropped} that had no room")
print(f"Removed {stranded_vias + late_vias} unused vias and "
f"{stranded_tracks + late_tracks} stranded stubs")
print(f"Removed {removed_keepouts} temporary critical-route keepouts")
print("Late plane stitching:",
", ".join(f"{n}={k}" for n, k in sorted(stitched.items())) or "none")
print(f"Enabled island removal on {cleaned} plane zones")
print(f"Saved {args.output}")
for net, length in sorted(routed_lengths(board).items()):
print(f"{net:16s} {length:8.3f} mm")
if __name__ == "__main__":
main()