From 74d97b601c347a10d3b33731cbb3a1eb08209baf Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 7 Oct 2026 20:04:04 -0600 Subject: [PATCH] Check grid and room output on exact shapes and report shorts The grid and room engines write copper they never check. After a grid or room run the result is now put through the exact engine's geometry (its pads, fixed copper and keepouts; wires as boxes or slanted slabs; vias as their pads) and the report gives the shorts and clearance violations, with examples, instead of a blanket warning. --strict exits 3 when there are any; by default the exit status stays 0 so the JAR drop-in contract holds. The exact engine's static-copper setup moves into insert_static_copper so both use it; exact runs are byte-identical before and after. --- src/rpmroute/board/search_tree.py | 33 +++++++ src/rpmroute/cli.py | 33 +++++-- src/rpmroute/route/exact_router.py | 70 +++++++------ src/rpmroute/route/verify.py | 151 +++++++++++++++++++++++++++++ tests/route/test_verify_output.py | 42 ++++++++ tests/test_cli.py | 18 +++- 6 files changed, 307 insertions(+), 40 deletions(-) create mode 100644 src/rpmroute/route/verify.py create mode 100644 tests/route/test_verify_output.py diff --git a/src/rpmroute/board/search_tree.py b/src/rpmroute/board/search_tree.py index db48847..4529f7c 100644 --- a/src/rpmroute/board/search_tree.py +++ b/src/rpmroute/board/search_tree.py @@ -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 diff --git a/src/rpmroute/cli.py b/src/rpmroute/cli.py index 2879603..ba25b22 100644 --- a/src/rpmroute/cli.py +++ b/src/rpmroute/cli.py @@ -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 @@ -251,6 +252,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 @@ -387,23 +397,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__": diff --git a/src/rpmroute/route/exact_router.py b/src/rpmroute/route/exact_router.py index eef2f88..458ae0f 100644 --- a/src/rpmroute/route/exact_router.py +++ b/src/rpmroute/route/exact_router.py @@ -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``. diff --git a/src/rpmroute/route/verify.py b/src/rpmroute/route/verify.py new file mode 100644 index 0000000..fcfaed0 --- /dev/null +++ b/src/rpmroute/route/verify.py @@ -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) diff --git a/tests/route/test_verify_output.py b/tests/route/test_verify_output.py new file mode 100644 index 0000000..cf51af7 --- /dev/null +++ b/tests/route/test_verify_output.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index f0d129e..ef0fd61 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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):