af7c43b2a8
The Ti375 fanout could not be resolved with three routing layers, so L5 stops being a dedicated 1.8-V plane and becomes a signal layer. No rail owns a whole layer any more: the supplies are split into zone islands on L4 and L6, L2 and L7 stay solid and completely unrouted, and F.Cu, L3, L5 and B.Cu are all available for signals. Routing is staged rather than handed to the autorouter wholesale. The BGA escape, the buck high-current loops, plane stitching and the local power completion all run first and are locked; Freerouting then sees only the ordinary digital classes, with everything else present as fixed copper. finalize_routing.py installs the audited RF and USB geometry and closes whatever the autorouter left. Four bugs behind the routing failures, all of them the same shape - a layer list written down instead of read from the board, which was correct while L5 was a plane and silently wrong afterwards: - export_routing_stage.py emitted via keepouts for three layers only, so every existing via was invisible to the router on L5. It drove traces straight through them: 102 shorting_items in DRC. - route_local_power.py never used L5 at all, and picked up escape vias on two layers instead of all four. - add_rf_ground() judged fence positions against vias and pads but not tracks. It runs after the session import, when that region already has routed copper, and dropped a ground via on top of a signal trace. route_local_power.py also grew a via cloud on every rail: reaching a plane counted as progress even when it merged nothing, so the same pad collected a via per pass, 0.4 mm further out each time. Progress is now measured by the group count actually falling, and groups already sitting on their plane are skipped. That removed 404 redundant vias. The MAX2771 PLL loop filter was spread across three corners of the package, which forced the charge-pump node onto an inner layer under the GPS front end. All three parts now sit in the pocket at U21's north-west corner and the net stays on F.Cu. A permanent RF_NO_DIGITAL area plus a scoped rule keeps that region free of non-power copper on L3/L5/L6/B.Cu, and the local router enforces the same restriction, since it is the one stage that runs after the temporary keepout is deleted. Also enables the solder-mask bridge check that was switched off board-wide; the board passes it with no violations. Final board: DRC clean - no shorts, clearance, disallowed-item or dangling-copper violations - with 26 unconnected endpoints left. Routing is spread 37% L3, 27% F.Cu, 26% L5, 10% B.Cu over 6548 mm and 971 vias. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
466 lines
19 KiB
Python
466 lines
19 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 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 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:
|
|
board.Remove(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:
|
|
board.Remove(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 = pad_xy(board, "D1", "1")
|
|
add_polyline(board, "RF_SMA", [d1, (d1[0], 42.09)], w)
|
|
|
|
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)
|
|
|
|
# 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
|
|
|
|
|
|
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:
|
|
board.Remove(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
|
|
board.Remove(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.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, 49.50), 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 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)
|
|
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)
|
|
stranded_vias, stranded_tracks = remove_dangling_copper(board)
|
|
removed_keepouts = remove_temporary_keepouts(board)
|
|
route_rf(board)
|
|
route_usb(board)
|
|
add_rf_ground(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")
|
|
route_local_power.complete(board, signals=True)
|
|
|
|
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()
|