Merge main into feat/reduce-vias

This commit is contained in:
2026-10-07 20:54:02 -06:00
6 changed files with 307 additions and 40 deletions
+33
View File
@@ -237,3 +237,36 @@ class ShapeSearchTree:
if overlaps_2d(grown, other_tile):
return shape
return None
def violation_pairs(self, clearance: int) -> list[tuple[TreeShape, TreeShape, bool]]:
"""Every different-net pair sharing a layer, at least one of them
routed, whose coppers come closer than ``clearance``: ``(routed tile,
other tile, touching)``. ``touching`` is True when the coppers
themselves overlap (a short, or copper inside a keepout); False for a
clearance violation. Each pair of tiles is reported once.
:meth:`has_violation` asks whether there is one; this lists them all,
for a report on copper that no engine has checked.
"""
pairs: list[tuple[TreeShape, TreeShape, bool]] = []
seen: set[tuple[int, int]] = set()
for shape in self.all_shapes():
if not shape.routed:
continue
mine = shape.exact if shape.exact is not None else shape.tile
grown = shape.clearance_region(clearance)
for other in self.overlapping(grown.bounding_box()):
if other is shape or other.net_no == shape.net_no:
continue
if not (shape.layers & other.layers):
continue
key = (min(id(shape), id(other)), max(id(shape), id(other)))
if key in seen:
continue
theirs = other.exact if other.exact is not None else other.tile
touching = overlaps_2d(mine, theirs)
if not touching and (not other.clearance or not overlaps_2d(grown, theirs)):
continue # a rule area only counts an overlap
seen.add(key)
pairs.append((shape, other, touching))
return pairs
+27 -6
View File
@@ -25,6 +25,7 @@ from rpmroute.route.pipeline import (
build_rooms_routing_result,
build_routing_result,
)
from rpmroute.route.verify import verify_result
from rpmroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for
@@ -287,6 +288,15 @@ def build_parser() -> argparse.ArgumentParser:
metavar="J",
help="processes for --orders (default: one per order, up to the CPU count)",
)
parser.add_argument(
"--strict",
action="store_true",
help=(
"exit with status 3 when the routed copper has shorts or clearance "
"violations (grid and room output is checked after routing; the "
"session file is still written)"
),
)
return parser
@@ -432,23 +442,34 @@ def main(argv: list[str] | None = None) -> int:
for net, a, b, zone in in_zone:
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
flawed = False
if args.engine != "exact":
# These engines route on approximations and do not check the copper
# they write against exact shapes: a board can come out with shorts
# that this report would otherwise never mention.
# they write: check it now against the exact shapes the exact engine
# uses, so shorts are reported rather than shipped.
check = verify_result(dsn, result)
flawed = not check.clean
print(
f"rpmroute: WARNING: the {args.engine} engine does not check its copper for "
"clearance or shorts; run KiCad's DRC on the result, or route with "
"--engine exact (the default)",
f"rpmroute: {args.engine} output checked on exact shapes: "
f"{check.shorts} short(s), {check.clearance} clearance violation(s)",
file=sys.stderr,
)
for kind, examples in (("short", check.short_examples), ("clearance", check.clearance_examples)):
for found in examples:
print(f" {kind}: {found}", file=sys.stderr)
if flawed:
print(
f"rpmroute: WARNING: this {args.engine} routing is not DRC-clean; fix it in "
"KiCad or route with --engine exact (the default)",
file=sys.stderr,
)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(ses_text)
else:
sys.stdout.write(ses_text)
return 0
return 3 if (flawed and args.strict) else 0
if __name__ == "__main__":
+38 -32
View File
@@ -155,38 +155,7 @@ def route_board_exact(
owner = _counter()
# static items: pad and keepout copper (routed=False)
for pin in board.get_pins():
if pin.shape is None:
continue
box = pin.shape.bounding_box()
if box.is_empty():
continue
net = pin.net_nos[0] if pin.net_nos else -1
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
# a rotated pad is checked against its outline, not its box
exact = pin.shape if _is_rotated(pin.shape) else None
tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
blocks = (
"all" if obstacle.blocks_wires and obstacle.blocks_vias
else "wires" if obstacle.blocks_wires else "vias"
)
for tile in obstacle.tiles:
box = tile.bounding_box()
if not box.is_empty():
tree.insert(
TreeShape(
next(owner), -1, frozenset({obstacle.layer}), box,
blocks=blocks, clearance=obstacle.clearance,
)
)
insert_static_copper(tree, board, signal_layers, owner)
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
@@ -325,6 +294,43 @@ def route_board_exact(
# --- 45-degree (diagonal) recovery -------------------------------------------
def insert_static_copper(tree: ShapeSearchTree, board: BasicBoard, signal_layers, owner) -> None:
"""Index the board's pads, fixed copper and keepouts (``routed=False``) on
``signal_layers``: what routed copper is checked against."""
for pin in board.get_pins():
if pin.shape is None:
continue
box = pin.shape.bounding_box()
if box.is_empty():
continue
net = pin.net_nos[0] if pin.net_nos else -1
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
# a rotated pad is checked against its outline, not its box
exact = pin.shape if _is_rotated(pin.shape) else None
tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
blocks = (
"all" if obstacle.blocks_wires and obstacle.blocks_vias
else "wires" if obstacle.blocks_wires else "vias"
)
for tile in obstacle.tiles:
box = tile.bounding_box()
if not box.is_empty():
tree.insert(
TreeShape(
next(owner), -1, frozenset({obstacle.layer}), box,
blocks=blocks, clearance=obstacle.clearance,
)
)
def _octilinear_candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]:
"""Octilinear (0/45/90/135-degree) polylines from ``a`` to ``b``.
+151
View File
@@ -0,0 +1,151 @@
"""Check routed copper that no engine has checked.
The exact engine verifies every connection against exact pad and copper
shapes before it accepts it. The grid and room engines do not: they route on
approximations, and their output can short or crowd another net with nothing
in their report to say so. :func:`verify_result` puts a finished
:class:`RoutingResult` through the same exact geometry the exact engine uses
(its pads, fixed copper and keepouts, wires as boxes or slanted slabs, vias as
their pads) and lists what it finds.
A violation is a pair of different-net coppers on a shared layer, at least one
of them routed, closer than the board's clearance rule. It is a short when the
coppers themselves overlap (or routed copper sits inside a keepout), else a
clearance violation. Pairs are counted per routed segment or via, as KiCad's
DRC counts per item pair; pads already too close to each other in the design
are not the router's and are not counted.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import itertools
from rpmroute.board import build_board
from rpmroute.board.search_tree import ShapeSearchTree, TreeShape
from rpmroute.dsn.model import DsnBoard
from rpmroute.geometry import IntBox, IntPoint, segment_box, segment_slab
from rpmroute.geometry.polyline import corner_box
from rpmroute.ses.model import RoutingResult
from .exact_router import insert_static_copper
from .pipeline import _dsn_units_per_um, _rule_clearance_dsn, _via_half_board
# examples kept per kind; the counts are always complete
MAX_EXAMPLES = 5
@dataclass
class Violation:
"""One offending pair: the routed copper's net, what it came too close
to, the layer, and roughly where (mm, DSN coordinates)."""
net: str
other: str
layer: str
x_mm: float
y_mm: float
def __str__(self) -> str:
return f"{self.net} x {self.other} on {self.layer} at ({self.x_mm:.2f}, {self.y_mm:.2f}) mm"
@dataclass
class VerifyReport:
shorts: int = 0
clearance: int = 0
short_examples: list[Violation] = field(default_factory=list)
clearance_examples: list[Violation] = field(default_factory=list)
@property
def clean(self) -> bool:
return not (self.shorts or self.clearance)
def verify_result(dsn: DsnBoard, result: RoutingResult) -> VerifyReport:
"""Check ``result``'s copper against ``dsn``'s pads, keepouts and each
other, with the board's clearance rule."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
clearance = round(_rule_clearance_dsn(dsn) * scale)
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
layer_index = {layer.name: i for i, layer in enumerate(dsn.layers)}
via_layers = frozenset(signal_layers)
net_numbers = {n.name: i + 1 for i, n in enumerate(dsn.nets)}
names = {number: name for name, number in net_numbers.items()}
tree = ShapeSearchTree()
owner = itertools.count(1)
insert_static_copper(tree, board, signal_layers, owner)
static_names: dict[int, str] = {}
for shape in tree.all_shapes():
static_names[shape.owner] = "keepout" if shape.net_no == -1 else f"pad of {names.get(shape.net_no, '?')}"
via_default = _via_half_board(dsn, scale)
for net in result.nets:
net_no = net_numbers.get(net.name)
if net_no is None:
continue
for wire in net.wires:
layer = layer_index.get(wire.layer)
if layer is None or layer not in signal_layers:
continue
half = max(round(wire.width * scale / 2), 1)
pts = [
IntPoint(round(wire.coords[i] * scale), round(wire.coords[i + 1] * scale))
for i in range(0, len(wire.coords) - 1, 2)
]
on = frozenset({layer})
for a, b in zip(pts, pts[1:], strict=False):
oid = next(owner)
if a.x == b.x or a.y == b.y:
tree.insert(TreeShape(oid, net_no, on, segment_box(a, b, half), routed=True))
else: # a 45-degree or slanted segment, on its exact copper
slab = segment_slab(a, b, half)
tree.insert(TreeShape(
oid, net_no, on, slab.bounding_box(), routed=True,
exact=slab, seg=("slab", a, b, half),
))
# the round end where it meets the next segment, with this one
tree.insert(TreeShape(oid, net_no, on, corner_box(b, half), routed=True))
for via in net.vias:
half = via_default or max(round(_wire_width(net) * scale / 2), 1)
x, y = round(via.x * scale), round(via.y * scale)
tree.insert(TreeShape(
next(owner), net_no, via_layers, IntBox(x - half, y - half, x + half, y + half), routed=True,
))
mm_per_unit = 1 / (_dsn_units_per_um(dsn) * 1000 * scale)
# one entry per pair of items, however many of their tiles meet; a short
# if any of them overlap
by_pair: dict[tuple[int, int], tuple[TreeShape, TreeShape, bool]] = {}
for shape, other, touching in tree.violation_pairs(clearance):
key = (min(shape.owner, other.owner), max(shape.owner, other.owner))
seen = by_pair.get(key)
if seen is None or (touching and not seen[2]):
by_pair[key] = (shape, other, touching)
report = VerifyReport()
for shape, other, touching in by_pair.values():
layer = min(shape.layers & other.layers)
box = shape.tile.intersection(other.tile.offset(clearance))
if box.is_empty():
box = shape.tile
what = names.get(other.net_no, "?") if other.routed else static_names.get(other.owner, "?")
found = Violation(
names.get(shape.net_no, "?"), what, dsn.layers[layer].name,
(box.ll.x + box.ur.x) / 2 * mm_per_unit, (box.ll.y + box.ur.y) / 2 * mm_per_unit,
)
if touching:
report.shorts += 1
if len(report.short_examples) < MAX_EXAMPLES:
report.short_examples.append(found)
else:
report.clearance += 1
if len(report.clearance_examples) < MAX_EXAMPLES:
report.clearance_examples.append(found)
return report
def _wire_width(net) -> float:
return max((w.width for w in net.wires), default=0.0)
+42
View File
@@ -0,0 +1,42 @@
"""verify_result: copper no engine checked is checked on exact shapes."""
from pathlib import Path
from rpmroute.dsn import parse_dsn
from rpmroute.route.verify import verify_result
from rpmroute.ses.model import RoutedWire, RoutingResult
FIXTURE = Path(__file__).parents[1] / "dsn" / "fixtures" / "crossing_2net.dsn"
# rule: 2000 um wide traces, 2000 um clearance; NET_A runs along y = -40000
A = RoutedWire(layer="Top", width=2000, coords=[2000, -40000, 198000, -40000])
def _check(b_wire):
result = RoutingResult()
result.add_wire("NET_A", A)
result.add_wire("NET_B", b_wire)
return verify_result(parse_dsn(FIXTURE.read_text()), result)
def test_a_crossing_on_one_layer_is_a_short():
report = _check(RoutedWire(layer="Top", width=2000, coords=[100000, -15000, 100000, -65000]))
assert report.shorts == 1 and report.clearance == 0
found = report.short_examples[0]
assert {found.net, found.other} == {"NET_A", "NET_B"} and found.layer == "Top"
assert abs(found.x_mm - 100.0) < 1.5 and abs(found.y_mm + 40.0) < 1.5
def test_copper_closer_than_the_rule_is_a_clearance_violation():
# edges 1500 um apart against a 2000 um rule
report = _check(RoutedWire(layer="Top", width=2000, coords=[50000, -36500, 150000, -36500]))
assert report.shorts == 0 and report.clearance == 1
def test_exactly_the_clearance_apart_is_clean():
report = _check(RoutedWire(layer="Top", width=2000, coords=[50000, -36000, 150000, -36000]))
assert report.clean
def test_a_crossing_on_another_layer_is_clean():
report = _check(RoutedWire(layer="Bottom", width=2000, coords=[100000, -15000, 100000, -65000]))
assert report.clean
+16 -2
View File
@@ -82,9 +82,23 @@ def test_cli_default_engine_is_exact(tmp_path):
@pytest.mark.parametrize("engine", ["grid", "room"])
def test_unchecked_engines_say_so(tmp_path, capsys, engine):
def test_unchecked_engines_are_checked_after_routing(tmp_path, capsys, engine):
assert main([str(FIXTURE), "--engine", engine, "-do", str(tmp_path / "out.ses")]) == 0
assert "does not check its copper" in capsys.readouterr().err
assert f"{engine} output checked on exact shapes: 0 short(s), 0 clearance" in capsys.readouterr().err
def test_strict_fails_a_run_whose_copper_is_not_clean(tmp_path, monkeypatch, capsys):
from rpmroute import cli
from rpmroute.route.verify import VerifyReport, Violation
bad = VerifyReport(shorts=1, short_examples=[Violation("A", "B", "F.Cu", 1.0, 2.0)])
monkeypatch.setattr(cli, "verify_result", lambda dsn, result: bad)
out = tmp_path / "out.ses"
assert main([str(FIXTURE), "--engine", "grid", "-do", str(out)]) == 0
assert main([str(FIXTURE), "--engine", "grid", "--strict", "-do", str(out)]) == 3
assert out.exists() # the session is still written
err = capsys.readouterr().err
assert "short: A x B on F.Cu at (1.00, 2.00) mm" in err and "not DRC-clean" in err
def test_the_exact_engine_does_not_warn(tmp_path, capsys):