Merge feat/verify-output: check grid and room output on exact shapes, --strict
This commit is contained in:
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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``.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user