95609a6b6a
The board now routes end to end. Every net carrying two or more pads is a single connected copper group (216 nets, 1175 pads); kicad-cli reports 0 DRC violations and 0 unconnected items with zones refilled. Freerouting was never the blocker. The 14 remaining opens were three separate defects: 1. Obstacles in route_local_power.py modelled every pad as its axis-aligned bounding box. A 0.8-mm BGA dogbone site sits diagonally between four lands: true clearance 0.1407 mm, but the box test measured 0.4 mm from the box edge and rejected it, so no via could be placed anywhere in the Ti375 escape field. Replaced with an exact rounded-rectangle model, validated against KiCad's own SHAPE::Collide to 0.00 um over 14850 samples. Same 0 DRC and one fewer unconnected at the route_local_power stage. 2. CLOCK's 0.15-mm clearance cannot physically escape the BGA: two adjacent 0.45-mm dogbone vias on 0.8-mm pitch leave a 0.35-mm channel and a 0.10-mm trace needs 0.40 mm. HARD_TCK and MAX_SCLK could never leave the package. New rule "Ti375 escape field copper clearance" relaxes clearance to the 0.10 mm this field already uses, only where both objects are inside U1's courtyard. The clock trunk keeps 0.15 mm outside it. 3. RF_NO_DIGITAL was a rectangle reaching 7 mm past the RF line, covering the MAX2771's whole digital pin field and forcing every U21 output onto F.Cu alone. Narrowed to the corridor generate_pcb.py already reserves as TEMP_RF_LINE (y <= 43.7), clearing RF copper by 1.1 mm. The RF return path is guaranteed by the unrouted L2/L7 ground planes, not by this area. scripts/close_remaining.py replaces the ad-hoc residual scripts: a multi-layer maze over (x, y, layer) at 0.1 mm on all five routable layers, seeded from all copper of both groups, exact clearances, 0.10-mm necking inside the escape field, and reversible rip-up that rolls back entirely if a displaced net cannot be put back. ~150 s for the whole board. Also removes the regenerable intermediate boards, one-off session logs and the superseded residual scripts, and tracks board_routed.* as the deliverable. The 402 schematic-parity warnings are pre-existing and unchanged: 199 auto-named unconnected pins, 199 footprint library-nickname mismatches, and 4 mounting holes with no symbol. None affects connectivity or fabrication. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012KaTmCCxCEW4awsmXRNT1d
1109 lines
44 KiB
Python
1109 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""Close every connection the staged flow leaves open, with one router.
|
|
|
|
The earlier residual scripts each hard-coded a shape: drop a via beside pad A,
|
|
A* on one nominated layer, drop a via beside pad B. That shape cannot escape a
|
|
0.8-mm BGA ball whose four dogbone sites are taken, and it cannot bridge a
|
|
power pour that signal traces have cut into islands. Those are exactly the
|
|
failures left on this board, so this stage replaces the shape with a real maze:
|
|
|
|
* the search runs over (x, y, layer) on all five routable layers at a 0.1-mm
|
|
pitch and changes layer through a via wherever one legally fits;
|
|
* start and goal are *every* piece of copper in the two groups being joined -
|
|
pad, track or via - so the router leaves from whichever is cheapest instead
|
|
of one nominated pad;
|
|
* clearance uses the exact pad/track/via geometry at the real net-class values,
|
|
including the board.kicad_dru rule that relaxes clearance against the Ti375
|
|
lands to 0.10 mm inside the escape field;
|
|
* copper is necked to 0.10 mm inside the U1 courtyard, which is the only width
|
|
that fits between two 0.8-mm-pitch lands, and runs at class width outside;
|
|
* cells inside a foreign non-ground pour are refused, so a new trace can never
|
|
cut a power island in half.
|
|
|
|
Everything added is locked. Run the KiCad DRC afterwards.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import heapq
|
|
import math
|
|
import os
|
|
import shutil
|
|
import time
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from matplotlib.path import Path as MplPath
|
|
|
|
import pcbnew
|
|
|
|
import route_local_power as rlp
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
ROUTABLE = ("F.Cu", "L3_SIG", "L5_SIG", "L6_SIG", "B.Cu")
|
|
STEP = 0.1
|
|
VIA_PENALTY = 1.5 # mm of path cost charged for one layer change
|
|
VIA_D, VIA_DRILL = 0.45, 0.225
|
|
HOLE_TO_HOLE = 0.25 # board design rule
|
|
EDGE_MARGIN = 0.30
|
|
|
|
ESCAPE_REFS = frozenset({"U1"})
|
|
ESCAPE_CLEARANCE = 0.10 # "Ti375 0.8-mm BGA escape clearance" rule
|
|
FIELD_WIDTH = 0.10 # only width that threads two 0.8-mm lands
|
|
|
|
# Routing widths. POWER's 0.4-mm class width is a plane-drop default, not a
|
|
# width the residual connections can carry through congested copper.
|
|
ROUTE_WIDTH = {"Default": 0.12, "BGA_ESCAPE": 0.10, "CLOCK": 0.15,
|
|
"POWER": 0.25}
|
|
|
|
# Pours that a new trace must not cross, because splitting them strands copper.
|
|
# The board-wide GND pours simply re-pour around a trace, so they are not
|
|
# blockers.
|
|
POUR_SAFE_NETS = frozenset({"GND"})
|
|
POUR_MARGIN = 0.05
|
|
|
|
# Copper the router may rip up and hand back to itself to re-route. The
|
|
# deterministic stages (power loops, RF/USB geometry, BGA dogbones) are all
|
|
# locked, so "unlocked and in a signal class" is exactly the autorouted copper.
|
|
RIPPABLE_CLASSES = frozenset({"Default", "BGA_ESCAPE", "CLOCK"})
|
|
|
|
|
|
def mm(v):
|
|
return pcbnew.ToMM(v)
|
|
|
|
|
|
def MM(v):
|
|
return pcbnew.FromMM(v)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# board model
|
|
# --------------------------------------------------------------------------
|
|
def _project_clearances(board: pcbnew.BOARD) -> dict[str, float]:
|
|
"""Net-class name -> clearance in mm, read from the sibling .kicad_pro."""
|
|
import json
|
|
path = Path(board.GetFileName()).with_suffix(".kicad_pro")
|
|
out = {"Default": 0.1}
|
|
if not path.exists():
|
|
return out
|
|
data = json.loads(path.read_text())
|
|
for entry in data.get("net_settings", {}).get("classes", []):
|
|
if "clearance" in entry:
|
|
out[entry["name"]] = float(entry["clearance"])
|
|
return out
|
|
|
|
|
|
class Model:
|
|
def __init__(self, board: pcbnew.BOARD) -> None:
|
|
self.board = board
|
|
self.layer_num = {board.GetLayerName(l): l
|
|
for l in board.GetEnabledLayers().CuStack()}
|
|
self.routable = [self.layer_num[n] for n in ROUTABLE
|
|
if n in self.layer_num]
|
|
self.layer_index = {l: i for i, l in enumerate(self.routable)}
|
|
|
|
# Net-class clearances come from the project file: the pcbnew Python
|
|
# binding hands back an opaque object for GetNetClass().
|
|
self.class_clearance = _project_clearances(board)
|
|
self.clearance: dict[str, float] = {}
|
|
self.classname: dict[str, str] = {}
|
|
for _code, info in board.GetNetsByNetcode().items():
|
|
name = info.GetNetname()
|
|
cls = info.GetNetClassName()
|
|
self.classname[name] = cls
|
|
self.clearance[name] = self.class_clearance.get(cls, 0.1)
|
|
|
|
self.pads = [] # (shape, layerset, net, ref)
|
|
self.pad_holes = [] # (pos, radius, drill, net, uuid, rippable)
|
|
self.tracks = [] # (a, b, half, layer, net, uuid, rippable)
|
|
self.vias = [] # pad holes + board vias, same shape
|
|
for fp in board.GetFootprints():
|
|
ref = fp.GetReference()
|
|
for pad in fp.Pads():
|
|
self.pads.append((rlp.pad_shape(pad), pad.GetLayerSet(),
|
|
pad.GetNetname(), ref))
|
|
drill = max(mm(pad.GetDrillSizeX()), mm(pad.GetDrillSizeY()))
|
|
if drill > 0:
|
|
p = pad.GetPosition()
|
|
self.pad_holes.append(((mm(p.x), mm(p.y)), 0.0, drill / 2,
|
|
pad.GetNetname(), None, False))
|
|
self.load_copper()
|
|
|
|
# filled pours, per copper layer, as polygons
|
|
self.fills = [] # (net, layer, MplPath, bbox)
|
|
for zone in board.Zones():
|
|
if zone.GetIsRuleArea():
|
|
continue
|
|
net = zone.GetNetname()
|
|
for layer in zone.GetLayerSet().CuStack():
|
|
polys = zone.GetFilledPolysList(layer)
|
|
if polys is None:
|
|
continue
|
|
for i in range(polys.OutlineCount()):
|
|
self.fills.append((net, layer) + _outline_path(polys.Outline(i)))
|
|
|
|
# rule areas that forbid copper outright
|
|
self.keepouts = [] # (MplPath, bbox, layerset, no_track, no_via)
|
|
self.rf_areas = [] # RF_NO_DIGITAL: blocks non-POWER track and via
|
|
for zone in board.Zones():
|
|
if not zone.GetIsRuleArea():
|
|
continue
|
|
outline = zone.Outline()
|
|
for i in range(outline.OutlineCount()):
|
|
path, bbox = _outline_path(outline.Outline(i))
|
|
entry = (path, bbox, zone.GetLayerSet())
|
|
if zone.GetZoneName() == "RF_NO_DIGITAL":
|
|
self.rf_areas.append(entry)
|
|
if zone.GetDoNotAllowTracks() or zone.GetDoNotAllowVias():
|
|
self.keepouts.append(entry + (zone.GetDoNotAllowTracks(),
|
|
zone.GetDoNotAllowVias()))
|
|
|
|
u1 = board.FindFootprintByReference("U1")
|
|
self.u1_courtyard = None
|
|
if u1 is not None:
|
|
poly = u1.GetCourtyard(pcbnew.F_Cu)
|
|
if poly and poly.OutlineCount():
|
|
self.u1_courtyard = _outline_path(poly.Outline(0))
|
|
|
|
box = self.board.GetBoardEdgesBoundingBox()
|
|
self.edge = (mm(box.GetX()) + EDGE_MARGIN, mm(box.GetY()) + EDGE_MARGIN,
|
|
mm(box.GetRight()) - EDGE_MARGIN,
|
|
mm(box.GetBottom()) - EDGE_MARGIN)
|
|
|
|
def load_copper(self) -> None:
|
|
"""(Re)read every track and via from the board.
|
|
|
|
Copper is identified by KIID, never by the address of a SWIG proxy:
|
|
the same board item can be handed out as several different Python
|
|
objects, and a proxy for a removed item may be freed at any time.
|
|
"""
|
|
self.tracks = []
|
|
vias = []
|
|
for t in self.board.GetTracks():
|
|
net = t.GetNetname()
|
|
uuid = t.m_Uuid.AsString()
|
|
rippable = (not t.IsLocked() and bool(net) and
|
|
self.classname.get(net) in RIPPABLE_CLASSES)
|
|
if t.GetClass() == "PCB_VIA":
|
|
p = t.GetPosition()
|
|
vias.append(((mm(p.x), mm(p.y)),
|
|
mm(t.GetWidth(pcbnew.F_Cu)) / 2,
|
|
mm(t.GetDrill()) / 2, net, uuid, rippable))
|
|
else:
|
|
self.tracks.append(((mm(t.GetStart().x), mm(t.GetStart().y)),
|
|
(mm(t.GetEnd().x), mm(t.GetEnd().y)),
|
|
mm(t.GetWidth()) / 2, t.GetLayer(),
|
|
net, uuid, rippable))
|
|
self.vias = self.pad_holes + vias
|
|
|
|
def route_width(self, net: str) -> float:
|
|
return ROUTE_WIDTH.get(self.classname.get(net, "Default"), 0.12)
|
|
|
|
def clearance_to(self, net: str, other: str) -> float:
|
|
return max(self.clearance.get(net, 0.1), self.clearance.get(other, 0.1))
|
|
|
|
|
|
def _outline_path(outline):
|
|
pts = [(mm(outline.CPoint(i).x), mm(outline.CPoint(i).y))
|
|
for i in range(outline.PointCount())]
|
|
arr = np.asarray(pts)
|
|
bbox = (arr[:, 0].min(), arr[:, 1].min(), arr[:, 0].max(), arr[:, 1].max())
|
|
return MplPath(arr), bbox
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# connectivity groups
|
|
# --------------------------------------------------------------------------
|
|
def net_groups(board: pcbnew.BOARD, net: str):
|
|
conn = board.GetConnectivity()
|
|
items = [p for fp in board.GetFootprints() for p in fp.Pads()
|
|
if p.GetNetname() == net]
|
|
items += [t for t in board.GetTracks() if t.GetNetname() == net]
|
|
index = {it.m_Uuid.AsString(): n for n, it in enumerate(items)}
|
|
parent = list(range(len(items)))
|
|
|
|
def find(a):
|
|
while parent[a] != a:
|
|
parent[a] = parent[parent[a]]
|
|
a = parent[a]
|
|
return a
|
|
|
|
for i, it in enumerate(items):
|
|
for other in conn.GetConnectedItems(it, 0):
|
|
j = index.get(other.m_Uuid.AsString())
|
|
if j is not None:
|
|
ra, rb = find(i), find(j)
|
|
if ra != rb:
|
|
parent[rb] = ra
|
|
|
|
out = defaultdict(list)
|
|
for i, it in enumerate(items):
|
|
out[find(i)].append(it)
|
|
return list(out.values())
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# rasterised routing field
|
|
# --------------------------------------------------------------------------
|
|
def _seg_distance(X, Y, a, b):
|
|
ax, ay = a
|
|
bx, by = b
|
|
dx, dy = bx - ax, by - ay
|
|
length2 = dx * dx + dy * dy
|
|
if length2 <= 1e-12:
|
|
return np.hypot(X - ax, Y - ay)
|
|
t = np.clip(((X - ax) * dx + (Y - ay) * dy) / length2, 0.0, 1.0)
|
|
return np.hypot(X - (ax + t * dx), Y - (ay + t * dy))
|
|
|
|
|
|
class Field:
|
|
"""Blocked bitmaps for one net over one rectangular region.
|
|
|
|
`blocked[w][layer]` is True where a trace of half-width w may not sit;
|
|
`via_blocked` is True where a through via may not sit.
|
|
"""
|
|
|
|
def __init__(self, model: Model, net: str, region, widths,
|
|
ignore_rippable: bool = False,
|
|
protected: frozenset[str] = frozenset()) -> None:
|
|
self.model = model
|
|
self.net = net
|
|
self.ignore_rippable = ignore_rippable
|
|
self.protected = protected
|
|
x0, y0, x1, y1 = region
|
|
# Snap the grid to the absolute STEP lattice. Ball centres, dogbone
|
|
# sites and the mid-lines between two 0.8-mm lands are all whole
|
|
# multiples of 0.1 mm; on an unaligned grid the legal corridor between
|
|
# two lands (0.05 mm either side of its centre) falls between samples
|
|
# and the escape field looks sealed.
|
|
x0 = math.floor(x0 / STEP) * STEP
|
|
y0 = math.floor(y0 / STEP) * STEP
|
|
self.x0, self.y0 = x0, y0
|
|
self.nx = int(math.ceil((x1 - x0) / STEP)) + 1
|
|
self.ny = int(math.ceil((y1 - y0) / STEP)) + 1
|
|
self.xs = x0 + STEP * np.arange(self.nx)
|
|
self.ys = y0 + STEP * np.arange(self.ny)
|
|
self.X, self.Y = np.meshgrid(self.xs, self.ys)
|
|
self.nl = len(model.routable)
|
|
self.widths = widths
|
|
|
|
self.blocked = {w: np.zeros((self.nl, self.ny, self.nx), bool)
|
|
for w in widths}
|
|
self.via_blocked = np.zeros((self.ny, self.nx), bool)
|
|
|
|
self.in_u1 = self._mask_path(model.u1_courtyard) if model.u1_courtyard \
|
|
else np.zeros((self.ny, self.nx), bool)
|
|
self._mark_edges()
|
|
self._mark_obstacles()
|
|
self._mark_pours()
|
|
self._mark_rule_areas()
|
|
|
|
# -- helpers ----------------------------------------------------------
|
|
def _window(self, bbox, pad):
|
|
i0 = max(int(math.floor((bbox[0] - pad - self.x0) / STEP)), 0)
|
|
j0 = max(int(math.floor((bbox[1] - pad - self.y0) / STEP)), 0)
|
|
i1 = min(int(math.ceil((bbox[2] + pad - self.x0) / STEP)) + 1, self.nx)
|
|
j1 = min(int(math.ceil((bbox[3] + pad - self.y0) / STEP)) + 1, self.ny)
|
|
if i0 >= i1 or j0 >= j1:
|
|
return None
|
|
return slice(j0, j1), slice(i0, i1)
|
|
|
|
def _mask_path(self, path_bbox):
|
|
path, bbox = path_bbox
|
|
mask = np.zeros((self.ny, self.nx), bool)
|
|
win = self._window(bbox, 0.0)
|
|
if win is None:
|
|
return mask
|
|
sy, sx = win
|
|
pts = np.column_stack([self.X[sy, sx].ravel(), self.Y[sy, sx].ravel()])
|
|
mask[sy, sx] = path.contains_points(pts).reshape(self.X[sy, sx].shape)
|
|
return mask
|
|
|
|
@staticmethod
|
|
def _dilate(mask, radius: float):
|
|
"""Grow a boolean mask by `radius` mm.
|
|
|
|
The keep-out rules are written with intersectsArea(), so a via or track
|
|
violates as soon as its *body* touches the area - testing the centre
|
|
cell alone lets copper hang over the edge.
|
|
"""
|
|
cells = int(math.ceil(radius / STEP))
|
|
if cells <= 0:
|
|
return mask
|
|
ny, nx = mask.shape
|
|
out = mask.copy()
|
|
for dj in range(-cells, cells + 1):
|
|
for di in range(-cells, cells + 1):
|
|
if (dj or di) and dj * dj + di * di <= cells * cells:
|
|
dst = (slice(max(0, -dj), ny - max(0, dj)),
|
|
slice(max(0, -di), nx - max(0, di)))
|
|
src = (slice(max(0, dj), ny - max(0, -dj)),
|
|
slice(max(0, di), nx - max(0, -di)))
|
|
out[dst] |= mask[src]
|
|
return out
|
|
|
|
def _in_field(self, points) -> bool:
|
|
"""True when every point lies inside the Ti375 escape field."""
|
|
if self.model.u1_courtyard is None:
|
|
return False
|
|
path, _bbox = self.model.u1_courtyard
|
|
return bool(path.contains_points(np.asarray(points)).all())
|
|
|
|
def _threshold(self, sy, sx, clearance: float, inside: bool):
|
|
"""Clearance owed per cell: the escape-field minimum where both this
|
|
cell and the obstacle sit inside U1's courtyard, the net-class value
|
|
everywhere else. Mirrors the "Ti375 escape field copper clearance"
|
|
rule in board.kicad_dru."""
|
|
if not inside or clearance <= ESCAPE_CLEARANCE:
|
|
return clearance
|
|
return np.where(self.in_u1[sy, sx], ESCAPE_CLEARANCE, clearance)
|
|
|
|
def _mark_edges(self):
|
|
ex0, ey0, ex1, ey1 = self.model.edge
|
|
for w in self.widths:
|
|
half = w / 2
|
|
bad = ((self.X < ex0 + half) | (self.X > ex1 - half) |
|
|
(self.Y < ey0 + half) | (self.Y > ey1 - half))
|
|
self.blocked[w][:, bad] = True
|
|
half = VIA_D / 2
|
|
self.via_blocked |= ((self.X < ex0 + half) | (self.X > ex1 - half) |
|
|
(self.Y < ey0 + half) | (self.Y > ey1 - half))
|
|
|
|
def _mark_obstacles(self):
|
|
model = self.model
|
|
net = self.net
|
|
for shape, layers, other, ref in model.pads:
|
|
if other == net:
|
|
continue
|
|
cx, cy, hx, hy, r = shape
|
|
bbox = (cx - hx - r, cy - hy - r, cx + hx + r, cy + hy + r)
|
|
win = self._window(bbox, 0.6)
|
|
if win is None:
|
|
continue
|
|
sy, sx = win
|
|
if ref in ESCAPE_REFS:
|
|
clearance = ESCAPE_CLEARANCE
|
|
else:
|
|
clearance = self._threshold(
|
|
sy, sx, model.clearance_to(net, other),
|
|
self._in_field([(cx - hx - r, cy - hy - r),
|
|
(cx + hx + r, cy + hy + r),
|
|
(cx - hx - r, cy + hy + r),
|
|
(cx + hx + r, cy - hy - r)]))
|
|
dx = np.maximum(np.abs(self.X[sy, sx] - cx) - hx, 0.0)
|
|
dy = np.maximum(np.abs(self.Y[sy, sx] - cy) - hy, 0.0)
|
|
dist = np.maximum(np.hypot(dx, dy) - r, 0.0)
|
|
for li, layer in enumerate(model.routable):
|
|
if not layers.Contains(layer):
|
|
continue
|
|
for w in self.widths:
|
|
self.blocked[w][li, sy, sx] |= dist < clearance + w / 2
|
|
self.via_blocked[sy, sx] |= dist < clearance + VIA_D / 2
|
|
|
|
for a, b, half, layer, other, _uuid, rippable in model.tracks:
|
|
if other == net or layer not in model.layer_index:
|
|
continue
|
|
if self.ignore_rippable and rippable and other not in self.protected:
|
|
continue
|
|
li = model.layer_index[layer]
|
|
bbox = (min(a[0], b[0]), min(a[1], b[1]),
|
|
max(a[0], b[0]), max(a[1], b[1]))
|
|
win = self._window(bbox, half + 0.6)
|
|
if win is None:
|
|
continue
|
|
sy, sx = win
|
|
clearance = self._threshold(sy, sx, model.clearance_to(net, other),
|
|
self._in_field([a, b]))
|
|
dist = _seg_distance(self.X[sy, sx], self.Y[sy, sx], a, b) - half
|
|
for w in self.widths:
|
|
self.blocked[w][li, sy, sx] |= dist < clearance + w / 2
|
|
self.via_blocked[sy, sx] |= dist < clearance + VIA_D / 2
|
|
|
|
for pos, radius, drill, other, _uuid, rippable in model.vias:
|
|
soft = (self.ignore_rippable and rippable and
|
|
other not in self.protected)
|
|
bbox = (pos[0] - radius, pos[1] - radius,
|
|
pos[0] + radius, pos[1] + radius)
|
|
win = self._window(bbox, max(radius, drill) + 0.6)
|
|
if win is None:
|
|
continue
|
|
sy, sx = win
|
|
centre = np.hypot(self.X[sy, sx] - pos[0], self.Y[sy, sx] - pos[1])
|
|
if not soft:
|
|
# a hole always owes hole-to-hole, even to its own net
|
|
self.via_blocked[sy, sx] |= (centre <
|
|
drill + VIA_DRILL / 2 + HOLE_TO_HOLE)
|
|
if other == net or soft:
|
|
continue
|
|
clearance = self._threshold(
|
|
sy, sx, model.clearance_to(net, other),
|
|
self._in_field([(pos[0] - radius, pos[1] - radius),
|
|
(pos[0] + radius, pos[1] + radius),
|
|
(pos[0] - radius, pos[1] + radius),
|
|
(pos[0] + radius, pos[1] - radius)]))
|
|
dist = np.maximum(centre - radius, 0.0)
|
|
for w in self.widths:
|
|
self.blocked[w][:, sy, sx] |= dist < clearance + w / 2
|
|
self.via_blocked[sy, sx] |= dist < clearance + VIA_D / 2
|
|
|
|
def _mark_pours(self):
|
|
"""Refuse to route inside a foreign pour that a trace would split."""
|
|
model = self.model
|
|
for other, layer, path, bbox in model.fills:
|
|
if other == self.net or other in POUR_SAFE_NETS:
|
|
continue
|
|
if layer not in model.layer_index:
|
|
continue
|
|
li = model.layer_index[layer]
|
|
win = self._window(bbox, POUR_MARGIN)
|
|
if win is None:
|
|
continue
|
|
sy, sx = win
|
|
pts = np.column_stack([self.X[sy, sx].ravel(),
|
|
self.Y[sy, sx].ravel()])
|
|
inside = path.contains_points(pts, radius=POUR_MARGIN)
|
|
inside = inside.reshape(self.X[sy, sx].shape)
|
|
for w in self.widths:
|
|
self.blocked[w][li, sy, sx] |= inside
|
|
|
|
def _mark_rule_areas(self):
|
|
model = self.model
|
|
is_power = model.classname.get(self.net) == "POWER"
|
|
for path, bbox, layers, no_track, no_via in model.keepouts:
|
|
mask = self._mask_path((path, bbox))
|
|
grown = {w: self._dilate(mask, w / 2) for w in self.widths}
|
|
for li, layer in enumerate(model.routable):
|
|
if no_track and layers.Contains(layer):
|
|
for w in self.widths:
|
|
self.blocked[w][li] |= grown[w]
|
|
if no_via:
|
|
self.via_blocked |= self._dilate(mask, VIA_D / 2)
|
|
if is_power:
|
|
return
|
|
# board.kicad_dru: no track and no via for non-POWER classes inside
|
|
# the GNSS front-end area, on every layer the area covers.
|
|
for path, bbox, layers in model.rf_areas:
|
|
mask = self._mask_path((path, bbox))
|
|
grown = {w: self._dilate(mask, w / 2) for w in self.widths}
|
|
covered = False
|
|
for li, layer in enumerate(model.routable):
|
|
if layers.Contains(layer):
|
|
covered = True
|
|
for w in self.widths:
|
|
self.blocked[w][li] |= grown[w]
|
|
if covered:
|
|
self.via_blocked |= self._dilate(mask, VIA_D / 2)
|
|
|
|
# -- indexing ---------------------------------------------------------
|
|
def cell(self, x, y):
|
|
i = int(round((x - self.x0) / STEP))
|
|
j = int(round((y - self.y0) / STEP))
|
|
if 0 <= i < self.nx and 0 <= j < self.ny:
|
|
return j, i
|
|
return None
|
|
|
|
def xy(self, j, i):
|
|
# plain floats: pcbnew's FromMM rejects numpy scalars
|
|
return (round(float(self.x0) + int(i) * STEP, 4),
|
|
round(float(self.y0) + int(j) * STEP, 4))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# search
|
|
# --------------------------------------------------------------------------
|
|
def seed_cells(field: Field, model: Model, items):
|
|
"""Grid cells that lie inside a group's existing copper."""
|
|
cells = set()
|
|
for it in items:
|
|
cls = it.GetClass()
|
|
if cls == "PAD":
|
|
shape = rlp.pad_shape(it)
|
|
cx, cy, hx, hy, r = shape
|
|
bbox = (cx - hx - r, cy - hy - r, cx + hx + r, cy + hy + r)
|
|
win = field._window(bbox, 0.0)
|
|
if win is None:
|
|
continue
|
|
sy, sx = win
|
|
dx = np.maximum(np.abs(field.X[sy, sx] - cx) - hx, 0.0)
|
|
dy = np.maximum(np.abs(field.Y[sy, sx] - cy) - hy, 0.0)
|
|
inside = np.maximum(np.hypot(dx, dy) - r, 0.0) <= 0.0
|
|
layers = it.GetLayerSet()
|
|
for li, layer in enumerate(model.routable):
|
|
if not layers.Contains(layer):
|
|
continue
|
|
for j, i in zip(*np.nonzero(inside)):
|
|
cells.add((li, int(j) + sy.start, int(i) + sx.start))
|
|
elif cls == "PCB_VIA":
|
|
p = it.GetPosition()
|
|
radius = mm(it.GetWidth(pcbnew.F_Cu)) / 2
|
|
pos = (mm(p.x), mm(p.y))
|
|
win = field._window((pos[0] - radius, pos[1] - radius,
|
|
pos[0] + radius, pos[1] + radius), 0.0)
|
|
if win is None:
|
|
continue
|
|
sy, sx = win
|
|
inside = np.hypot(field.X[sy, sx] - pos[0],
|
|
field.Y[sy, sx] - pos[1]) <= radius
|
|
for li in range(field.nl):
|
|
for j, i in zip(*np.nonzero(inside)):
|
|
cells.add((li, int(j) + sy.start, int(i) + sx.start))
|
|
else:
|
|
layer = it.GetLayer()
|
|
if layer not in model.layer_index:
|
|
continue
|
|
li = model.layer_index[layer]
|
|
a = (mm(it.GetStart().x), mm(it.GetStart().y))
|
|
b = (mm(it.GetEnd().x), mm(it.GetEnd().y))
|
|
half = mm(it.GetWidth()) / 2
|
|
bbox = (min(a[0], b[0]), min(a[1], b[1]),
|
|
max(a[0], b[0]), max(a[1], b[1]))
|
|
win = field._window(bbox, half)
|
|
if win is None:
|
|
continue
|
|
sy, sx = win
|
|
inside = _seg_distance(field.X[sy, sx], field.Y[sy, sx], a, b) <= half
|
|
for j, i in zip(*np.nonzero(inside)):
|
|
cells.add((li, int(j) + sy.start, int(i) + sx.start))
|
|
return cells
|
|
|
|
|
|
NEIGHBOURS = [(-1, 0), (1, 0), (0, -1), (0, 1),
|
|
(-1, -1), (-1, 1), (1, -1), (1, 1)]
|
|
|
|
|
|
def astar(field: Field, narrow_w: float, wide_w: float,
|
|
starts, goals, deadline: float):
|
|
nl, ny, nx = field.nl, field.ny, field.nx
|
|
if not starts or not goals:
|
|
return None
|
|
narrow = field.blocked[narrow_w]
|
|
wide = field.blocked[wide_w]
|
|
via_blocked = field.via_blocked
|
|
in_u1 = field.in_u1
|
|
|
|
goal_set = set(goals)
|
|
gj = np.fromiter((g[1] for g in goals), int)
|
|
gi = np.fromiter((g[2] for g in goals), int)
|
|
gy0, gy1 = gj.min(), gj.max()
|
|
gx0, gx1 = gi.min(), gi.max()
|
|
|
|
def heuristic(j, i):
|
|
dj = 0 if gy0 <= j <= gy1 else min(abs(j - gy0), abs(j - gy1))
|
|
di = 0 if gx0 <= i <= gx1 else min(abs(i - gx0), abs(i - gx1))
|
|
return math.hypot(dj, di) * STEP
|
|
|
|
INF = math.inf
|
|
best = {}
|
|
prev = {}
|
|
heap = []
|
|
for (li, j, i) in starts:
|
|
if wide[li, j, i] and narrow[li, j, i]:
|
|
continue
|
|
key = (li, j, i)
|
|
best[key] = 0.0
|
|
heapq.heappush(heap, (heuristic(j, i), 0.0, key))
|
|
|
|
diag = STEP * math.sqrt(2.0)
|
|
stop = time.time() + deadline
|
|
counter = 0
|
|
while heap:
|
|
counter += 1
|
|
if (counter & 0x3FF) == 0 and time.time() > stop:
|
|
return None
|
|
_f, cost, key = heapq.heappop(heap)
|
|
if cost > best.get(key, INF):
|
|
continue
|
|
if key in goal_set:
|
|
path = [key]
|
|
while path[-1] in prev:
|
|
path.append(prev[path[-1]])
|
|
return list(reversed(path))
|
|
li, j, i = key
|
|
here_narrow = in_u1[j, i]
|
|
for dj, di in NEIGHBOURS:
|
|
nj, ni = j + dj, i + di
|
|
if not (0 <= nj < ny and 0 <= ni < nx):
|
|
continue
|
|
use_narrow = here_narrow and in_u1[nj, ni]
|
|
grid = narrow if use_narrow else wide
|
|
# Both ends must clear at the width this edge is emitted with.
|
|
# Distance to each obstacle (rounded rect, disc, capsule) is a
|
|
# convex function, so its minimum along the segment is attained at
|
|
# an endpoint - checking the two cells is exact, not a sample.
|
|
if grid[li, nj, ni] or grid[li, j, i]:
|
|
continue
|
|
step = diag if dj and di else STEP
|
|
nkey = (li, nj, ni)
|
|
ncost = cost + step
|
|
if ncost < best.get(nkey, INF):
|
|
best[nkey] = ncost
|
|
prev[nkey] = key
|
|
heapq.heappush(heap, (ncost + heuristic(nj, ni), ncost, nkey))
|
|
if via_blocked[j, i]:
|
|
continue
|
|
for nli in range(nl):
|
|
if nli == li:
|
|
continue
|
|
grid = narrow if here_narrow else wide
|
|
if grid[nli, j, i] or grid[li, j, i]:
|
|
continue
|
|
nkey = (nli, j, i)
|
|
ncost = cost + VIA_PENALTY
|
|
if ncost < best.get(nkey, INF):
|
|
best[nkey] = ncost
|
|
prev[nkey] = key
|
|
heapq.heappush(heap, (ncost + heuristic(j, i), ncost, nkey))
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# emission
|
|
# --------------------------------------------------------------------------
|
|
def collapse(points):
|
|
if len(points) < 3:
|
|
return points
|
|
out = [points[0]]
|
|
for k in range(1, len(points) - 1):
|
|
ax, ay = out[-1]
|
|
bx, by = points[k]
|
|
cx, cy = points[k + 1]
|
|
if abs((bx - ax) * (cy - by) - (by - ay) * (cx - bx)) < 1e-9:
|
|
continue
|
|
out.append(points[k])
|
|
out.append(points[-1])
|
|
return out
|
|
|
|
|
|
def decompose(field: Field, model: Model, path, narrow_w: float,
|
|
wide_w: float):
|
|
"""Cell path -> [(layer, width, polyline)] plus via positions.
|
|
|
|
The width is decided per edge, exactly as the search decided it: the
|
|
escape-field width only where both ends of that edge are inside U1's
|
|
courtyard. Deciding it per run instead would emit class-width copper
|
|
through a corridor that was only ever checked at the narrow width.
|
|
"""
|
|
runs = []
|
|
vias = []
|
|
for u, v in zip(path, path[1:]):
|
|
if u[0] != v[0]:
|
|
vias.append((u[1], u[2]))
|
|
continue
|
|
width = (narrow_w if field.in_u1[u[1], u[2]] and field.in_u1[v[1], v[2]]
|
|
else wide_w)
|
|
if runs and runs[-1][0] == u[0] and runs[-1][1] == width \
|
|
and runs[-1][2][-1] == u:
|
|
runs[-1][2].append(v)
|
|
else:
|
|
runs.append([u[0], width, [u, v]])
|
|
out = []
|
|
for li, width, cells in runs:
|
|
pts = collapse([field.xy(j, i) for _l, j, i in cells])
|
|
out.append((model.routable[li], width, pts))
|
|
return out, [field.xy(j, i) for j, i in vias]
|
|
|
|
|
|
def emit(board, model: Model, field: Field, net: str, path,
|
|
narrow_w: float, wide_w: float):
|
|
"""Turn a cell path into locked tracks and vias. Returns (tracks, vias)."""
|
|
netinfo = board.FindNet(net)
|
|
geometry, vias = decompose(field, model, path, narrow_w, wide_w)
|
|
created = []
|
|
added_t = added_v = 0
|
|
for layer, width, pts in geometry:
|
|
for a, b in zip(pts, pts[1:]):
|
|
if abs(a[0] - b[0]) < 1e-6 and abs(a[1] - b[1]) < 1e-6:
|
|
continue
|
|
t = pcbnew.PCB_TRACK(board)
|
|
t.SetStart(pcbnew.VECTOR2I_MM(*a))
|
|
t.SetEnd(pcbnew.VECTOR2I_MM(*b))
|
|
t.SetWidth(MM(width))
|
|
t.SetLayer(layer)
|
|
t.SetNet(netinfo)
|
|
t.SetLocked(True)
|
|
board.Add(t)
|
|
t.thisown = False
|
|
model.tracks.append((a, b, width / 2, layer, net,
|
|
t.m_Uuid.AsString(), False))
|
|
created.append(t)
|
|
added_t += 1
|
|
for pos in vias:
|
|
v = pcbnew.PCB_VIA(board)
|
|
v.SetPosition(pcbnew.VECTOR2I_MM(*pos))
|
|
v.SetWidth(MM(VIA_D))
|
|
v.SetDrill(MM(VIA_DRILL))
|
|
v.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
|
|
v.SetNet(netinfo)
|
|
v.SetLocked(True)
|
|
board.Add(v)
|
|
v.thisown = False
|
|
model.vias.append((pos, VIA_D / 2, VIA_DRILL / 2, net,
|
|
v.m_Uuid.AsString(), False))
|
|
created.append(v)
|
|
added_v += 1
|
|
return added_t, added_v, created
|
|
|
|
|
|
def group_bbox(items):
|
|
xs, ys = [], []
|
|
for it in items:
|
|
box = it.GetBoundingBox()
|
|
xs += [mm(box.GetX()), mm(box.GetRight())]
|
|
ys += [mm(box.GetY()), mm(box.GetBottom())]
|
|
return min(xs), min(ys), max(xs), max(ys)
|
|
|
|
|
|
def group_centre(items):
|
|
x0, y0, x1, y1 = group_bbox(items)
|
|
return ((x0 + x1) / 2, (y0 + y1) / 2)
|
|
|
|
|
|
def conflicts(model: Model, net: str, geometry, vias, protected):
|
|
"""Rippable board items the proposed route cannot coexist with."""
|
|
victims = set()
|
|
for a, b, half, layer, other, uuid, rippable in model.tracks:
|
|
if not rippable or uuid is None or other == net or other in protected:
|
|
continue
|
|
clearance = model.clearance_to(net, other)
|
|
hit = False
|
|
for glayer, gwidth, pts in geometry:
|
|
if glayer != layer:
|
|
continue
|
|
gh = gwidth / 2
|
|
for p, q in zip(pts, pts[1:]):
|
|
if rlp.segments_distance(p, q, a, b) < gh + half + clearance:
|
|
hit = True
|
|
break
|
|
if hit:
|
|
break
|
|
if not hit:
|
|
for v in vias:
|
|
if rlp.segment_distance(v, a, b) < VIA_D / 2 + half + clearance:
|
|
hit = True
|
|
break
|
|
if hit:
|
|
victims.add(uuid)
|
|
for pos, radius, drill, other, uuid, rippable in model.vias:
|
|
if not rippable or uuid is None or other == net or other in protected:
|
|
continue
|
|
clearance = model.clearance_to(net, other)
|
|
hit = False
|
|
for _glayer, gwidth, pts in geometry:
|
|
gh = gwidth / 2
|
|
for p, q in zip(pts, pts[1:]):
|
|
if rlp.segment_distance(pos, p, q) < gh + radius + clearance:
|
|
hit = True
|
|
break
|
|
if hit:
|
|
break
|
|
if not hit:
|
|
for v in vias:
|
|
gap = math.hypot(v[0] - pos[0], v[1] - pos[1])
|
|
if gap < VIA_D / 2 + radius + clearance or \
|
|
gap < VIA_DRILL / 2 + drill + HOLE_TO_HOLE:
|
|
hit = True
|
|
break
|
|
if hit:
|
|
victims.add(uuid)
|
|
return victims
|
|
|
|
|
|
def rip(board, model: Model, victims: set):
|
|
"""Delete the named copper, returning recipes that can rebuild it.
|
|
|
|
Rip-up is only ever worth doing if the board ends up better for it, so
|
|
every removal is reversible: if the nets we displaced cannot be put back,
|
|
the whole attempt is rolled back and the board is left exactly as it was.
|
|
"""
|
|
doomed = [t for t in board.GetTracks() if t.m_Uuid.AsString() in victims]
|
|
recipes = []
|
|
for item in doomed:
|
|
if item.GetClass() == "PCB_VIA":
|
|
p = item.GetPosition()
|
|
recipes.append(("via", (mm(p.x), mm(p.y)),
|
|
mm(item.GetWidth(pcbnew.F_Cu)), mm(item.GetDrill()),
|
|
item.GetNetname(), item.IsLocked(), None))
|
|
else:
|
|
recipes.append(("track",
|
|
(mm(item.GetStart().x), mm(item.GetStart().y)),
|
|
(mm(item.GetEnd().x), mm(item.GetEnd().y)),
|
|
mm(item.GetWidth()), item.GetNetname(),
|
|
item.IsLocked(), item.GetLayer()))
|
|
board.Remove(item)
|
|
model.load_copper()
|
|
return recipes
|
|
|
|
|
|
def restore(board, model: Model, recipes) -> None:
|
|
"""Rebuild copper removed by rip()."""
|
|
for recipe in recipes:
|
|
if recipe[0] == "via":
|
|
_kind, pos, width, drill, net, locked, _layer = recipe
|
|
item = pcbnew.PCB_VIA(board)
|
|
item.SetPosition(pcbnew.VECTOR2I_MM(*pos))
|
|
item.SetWidth(MM(width))
|
|
item.SetDrill(MM(drill))
|
|
item.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
|
|
else:
|
|
_kind, start, end, width, net, locked, layer = recipe
|
|
item = pcbnew.PCB_TRACK(board)
|
|
item.SetStart(pcbnew.VECTOR2I_MM(*start))
|
|
item.SetEnd(pcbnew.VECTOR2I_MM(*end))
|
|
item.SetWidth(MM(width))
|
|
item.SetLayer(layer)
|
|
item.SetNet(board.FindNet(net))
|
|
item.SetLocked(locked)
|
|
board.Add(item)
|
|
item.thisown = False
|
|
model.load_copper()
|
|
|
|
|
|
def undo(board, model: Model, items) -> None:
|
|
"""Remove copper this run added."""
|
|
for item in items:
|
|
board.Remove(item)
|
|
model.load_copper()
|
|
|
|
|
|
def _attempt(board, model, net, source, target, region, widths,
|
|
narrow_w, wide_w, deadline, ignore_rippable, protected):
|
|
field = Field(model, net, region, widths,
|
|
ignore_rippable=ignore_rippable, protected=protected)
|
|
starts = seed_cells(field, model, source)
|
|
goals = seed_cells(field, model, target)
|
|
path = astar(field, narrow_w, wide_w, starts, goals, deadline)
|
|
return field, path
|
|
|
|
|
|
def _regions(source, target, margins):
|
|
sb, tb = group_bbox(source), group_bbox(target)
|
|
base = (min(sb[0], tb[0]), min(sb[1], tb[1]),
|
|
max(sb[2], tb[2]), max(sb[3], tb[3]))
|
|
return [(base[0] - m, base[1] - m, base[2] + m, base[3] + m)
|
|
for m in margins]
|
|
|
|
|
|
def route_once(board, model: Model, net: str, source, target, protected,
|
|
margins, deadline, ignore_rippable=False):
|
|
"""One connection attempt. Returns (field, path, region) or None."""
|
|
width = model.route_width(net)
|
|
widths = sorted({FIELD_WIDTH, width})
|
|
for region in _regions(source, target, margins):
|
|
field, path = _attempt(board, model, net, source, target, region,
|
|
widths, widths[0], widths[-1], deadline,
|
|
ignore_rippable, protected)
|
|
if path:
|
|
return field, path, region, widths
|
|
return None
|
|
|
|
|
|
def close_net(board, model: Model, net: str, protected, margins, deadline,
|
|
depth=0, max_depth=0):
|
|
"""Join every open group of `net`. Returns (ok, created, tracks, vias)."""
|
|
created, tracks, vias = [], 0, 0
|
|
while True:
|
|
board.BuildConnectivity()
|
|
groups = net_groups(board, net)
|
|
if len(groups) < 2:
|
|
return True, created, tracks, vias
|
|
groups.sort(key=len, reverse=True)
|
|
found = route_once(board, model, net, groups[-1], groups[0],
|
|
protected, margins, deadline)
|
|
if found is None:
|
|
if depth < max_depth:
|
|
ok, extra, t, v, _r = ripup_transaction(
|
|
board, model, net, groups[-1], groups[0], protected,
|
|
margins, deadline, depth + 1, max_depth)
|
|
if ok:
|
|
created += extra
|
|
tracks += t
|
|
vias += v
|
|
continue
|
|
return False, created, tracks, vias
|
|
field, path, _region, widths = found
|
|
t, v, items = emit(board, model, field, net, path,
|
|
widths[0], widths[-1])
|
|
created += items
|
|
tracks += t
|
|
vias += v
|
|
|
|
|
|
def ripup_transaction(board, model: Model, net: str, source, target,
|
|
protected, margins, deadline, depth, max_depth):
|
|
"""Route `net` through autorouted copper, then put the displaced nets back.
|
|
|
|
Either the whole thing succeeds or the board is restored exactly as it
|
|
was, so a rip-up can never leave the routing worse than it found it.
|
|
Returns (ok, created, tracks, vias, ripped).
|
|
"""
|
|
found = route_once(board, model, net, source, target, protected,
|
|
margins, deadline, ignore_rippable=True)
|
|
if found is None:
|
|
return False, [], 0, 0, 0
|
|
field, path, _region, widths = found
|
|
geometry, vias_xy = decompose(field, model, path, widths[0], widths[-1])
|
|
victims = conflicts(model, net, geometry, vias_xy, protected)
|
|
if not victims:
|
|
return False, [], 0, 0, 0
|
|
victim_nets = {t[4] for t in model.tracks if t[5] in victims}
|
|
victim_nets |= {v[3] for v in model.vias if v[4] in victims}
|
|
victim_nets.discard(net)
|
|
|
|
recipes = rip(board, model, victims)
|
|
found = route_once(board, model, net, source, target, protected,
|
|
margins, deadline)
|
|
if found is None:
|
|
restore(board, model, recipes)
|
|
return False, [], 0, 0, 0
|
|
field, path, _region, widths = found
|
|
tracks, vias, created = emit(board, model, field, net, path,
|
|
widths[0], widths[-1])
|
|
|
|
guard = frozenset(set(protected) | {net})
|
|
for victim in sorted(victim_nets):
|
|
ok, extra, vt, vv = close_net(board, model, victim, guard,
|
|
margins, deadline, depth, max_depth)
|
|
created += extra
|
|
tracks += vt
|
|
vias += vv
|
|
if not ok:
|
|
undo(board, model, created)
|
|
restore(board, model, recipes)
|
|
return False, [], 0, 0, 0
|
|
return True, created, tracks, vias, len(recipes)
|
|
|
|
|
|
def connect(board, model: Model, net: str, source, target, protected,
|
|
margins=(4.0, 9.0, 18.0), deadline=45.0, allow_ripup=True,
|
|
max_depth=1):
|
|
found = route_once(board, model, net, source, target, protected,
|
|
margins, deadline)
|
|
if found is not None:
|
|
field, path, _region, widths = found
|
|
t, v, _items = emit(board, model, field, net, path,
|
|
widths[0], widths[-1])
|
|
return f"OK {t} tracks {v} vias 0 ripped"
|
|
if not allow_ripup:
|
|
return "FAILED"
|
|
ok, _created, t, v, ripped = ripup_transaction(
|
|
board, model, net, source, target, protected, margins, deadline,
|
|
0, max_depth)
|
|
if ok:
|
|
return f"OK {t} tracks {v} vias {ripped} ripped"
|
|
return "FAILED"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
def open_nets(board):
|
|
out = []
|
|
for _code, info in board.GetNetsByNetcode().items():
|
|
name = info.GetNetname()
|
|
if not name:
|
|
continue
|
|
groups = net_groups(board, name)
|
|
if len(groups) > 1:
|
|
out.append((name, groups))
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("board", type=Path)
|
|
ap.add_argument("--out", type=Path)
|
|
ap.add_argument("--net", action="append",
|
|
help="restrict to these nets (repeatable)")
|
|
ap.add_argument("--skip", action="append", default=[],
|
|
help="leave these nets alone (repeatable)")
|
|
ap.add_argument("--deadline", type=float, default=45.0,
|
|
help="seconds per connection attempt")
|
|
ap.add_argument("--passes", type=int, default=6)
|
|
ap.add_argument("--no-ripup", action="store_true",
|
|
help="never rip up autorouted copper")
|
|
ap.add_argument("--ripup-depth", type=int, default=1,
|
|
help="how many levels of nested rip-up to allow")
|
|
args = ap.parse_args()
|
|
out = args.out or args.board
|
|
|
|
board = pcbnew.LoadBoard(str(args.board))
|
|
pcbnew.ZONE_FILLER(board).Fill(board.Zones())
|
|
board.BuildConnectivity()
|
|
model = Model(board)
|
|
|
|
started = time.time()
|
|
total_t = total_v = total_r = 0
|
|
# Nets this run has already settled. They are never ripped up again, so
|
|
# two nets cannot take turns evicting each other.
|
|
protected: set[str] = set()
|
|
for attempt in range(args.passes):
|
|
# Refill first. These pours have island removal enabled, so a fill
|
|
# can drop a fragment that was the only thing holding two pieces of a
|
|
# rail together; groups must be read from the board as it will
|
|
# actually be manufactured, not from a stale fill.
|
|
pcbnew.ZONE_FILLER(board).Fill(board.Zones())
|
|
board.BuildConnectivity()
|
|
pending = [name for name, _groups in open_nets(board)]
|
|
if args.net:
|
|
pending = [n for n in pending if n in set(args.net)]
|
|
pending = [n for n in pending if n not in set(args.skip)]
|
|
if not pending:
|
|
break
|
|
print(f"\n--- pass {attempt + 1}: {len(pending)} nets with open groups ---")
|
|
for net in pending:
|
|
# Groups are re-derived before every single connection. Ripping
|
|
# up copper deletes board items, and any Python proxy captured
|
|
# before that points at freed memory.
|
|
while True:
|
|
board.BuildConnectivity()
|
|
groups = net_groups(board, net)
|
|
if len(groups) < 2:
|
|
protected.add(net)
|
|
break
|
|
groups.sort(key=len, reverse=True)
|
|
extra, main_group = groups[-1], groups[0]
|
|
centre = group_centre(extra)
|
|
msg = connect(board, model, net, extra, main_group, protected,
|
|
deadline=args.deadline,
|
|
allow_ripup=not args.no_ripup,
|
|
max_depth=args.ripup_depth)
|
|
print(f" {net:16} {len(extra):3d} item(s) at "
|
|
f"({centre[0]:6.1f},{centre[1]:6.1f}) -> {msg}")
|
|
if not msg.startswith("OK"):
|
|
break
|
|
parts = msg.split()
|
|
total_t += int(parts[1])
|
|
total_v += int(parts[3])
|
|
total_r += int(parts[5])
|
|
|
|
# Rip-up leaves tails behind when a net comes back on a different path.
|
|
# Fill first: a track that lands on a plane is only "connected" once the
|
|
# pour around it has been recomputed, and the dangling test would
|
|
# otherwise delete perfectly good copper.
|
|
pcbnew.ZONE_FILLER(board).Fill(board.Zones())
|
|
board.BuildConnectivity()
|
|
import finalize_routing
|
|
dead_vias, dead_tracks = finalize_routing.remove_dangling_copper(board)
|
|
if dead_vias or dead_tracks:
|
|
print(f"removed {dead_tracks} dangling stubs and {dead_vias} "
|
|
f"one-layer vias")
|
|
|
|
pcbnew.ZONE_FILLER(board).Fill(board.Zones())
|
|
board.BuildConnectivity()
|
|
remaining = board.GetConnectivity().GetUnconnectedCount(True)
|
|
print(f"\nadded {total_t} tracks and {total_v} vias, ripped {total_r} "
|
|
f"items, in {time.time() - started:.0f}s")
|
|
print("unconnected endpoints:", remaining)
|
|
|
|
pcbnew.SaveBoard(str(out), board)
|
|
for suffix in (".kicad_pro", ".kicad_dru"):
|
|
src = str(args.board).rsplit(".", 1)[0] + suffix
|
|
dst = str(out).rsplit(".", 1)[0] + suffix
|
|
if os.path.exists(src) and src != dst:
|
|
shutil.copyfile(src, dst)
|
|
print("saved", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|