Files
rpmroute/tests/board/test_build.py
rsp2k 3e4f53a469 Keepout exits and rule areas
A route may not leave a cell inside a wire keepout as a wire, nor reach
a pin there by one: it changes layer instead, and the via goes in the
pad. A pin whose every stub enters a keepout is reached by a via only.

A structure keepout named rule_area... (mckicad names KiCad rule areas
so) is checked for overlap only, as KiCad's DRC does, with the free ends
of each wire tested for their round caps.
2026-09-28 10:08:02 -06:00

291 lines
11 KiB
Python

"""DSN -> board construction invariants, validated on a real KiCad board.
There is no unit-test oracle for the board model, so these assert structural
invariants against the parsed DSN (the source of truth) and — when a JVM + JAR
are available — cross-check the board's net set against the reference router's
routed net set.
Fixture: ``tests/dsn/fixtures/kicad_routable.dsn`` (a KiCad pcbnew export).
"""
from __future__ import annotations
from pathlib import Path
import sys
import pytest
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
ROUTABLE = FIXTURES / "kicad_routable.dsn"
@pytest.fixture(scope="module")
def dsn():
return parse_dsn(ROUTABLE.read_text())
@pytest.fixture(scope="module")
def board(dsn) -> BasicBoard:
return build_board(dsn)
# --- structural counts match the DSN ----------------------------------------
def test_layer_count_matches_dsn(dsn, board):
assert board.get_layer_count() == len(dsn.layers)
assert [layer.name for layer in board.layer_structure.arr] == [
layer.name for layer in dsn.layers
]
def test_net_count_matches_dsn(dsn, board):
assert len(board.nets) == len(dsn.nets)
assert board.nets.names() == {n.name for n in dsn.nets}
def test_pin_count_matches_placement(dsn, board):
images = {img.name: img for img in dsn.images}
expected = sum(
len(images[pl.lib_name].pins)
for pl in dsn.placements
for place in pl.places
if place.x is not None and pl.lib_name in images
)
assert len(board.get_pins()) == expected
assert expected > 0
def test_obstacle_count_matches_keepouts(dsn, board):
# Board-level keepouts plus every placed instance of a footprint-level one.
# This fixture has none of the first and six plated mounting holes, whose
# copper KiCad exports as a keepout on each layer.
images = {img.name: img for img in dsn.images}
placed = 0
for placement in dsn.placements:
image = images.get(placement.lib_name)
if image is None:
continue
per_part = len(image.keepouts) + len(image.via_keepouts) + len(image.wire_keepouts)
placed += per_part * sum(p.x is not None for p in placement.places)
assert len(dsn.keepouts) == 0
assert placed == 12
assert len(board.get_obstacle_areas()) == placed
# --- per-item invariants ----------------------------------------------------
def test_every_pin_shape_contains_its_origin(board):
pins = board.get_pins()
assert pins
for pin in pins:
assert pin.shape_contains_origin(), f"pad shape missed origin for pin {pin.name}"
def test_every_pin_reports_a_net(dsn, board):
# this board's netlist connects every pad, so every pin has a net number
for pin in board.get_pins():
assert pin.net_nos, f"pin {pin.name} has no net"
for net_no in pin.net_nos:
assert board.nets.get_by_number(net_no) is not None
def test_items_report_correct_net_membership(board):
for pin in board.get_pins():
for net_no in pin.net_nos:
assert pin in board.get_connectable_items(net_no)
def test_pin_layers_are_valid(board):
n_layers = board.get_layer_count()
for pin in board.get_pins():
assert pin.layers
assert all(0 <= layer < n_layers for layer in pin.layers)
def test_bounding_box_is_non_degenerate(board):
assert not board.bounding_box.is_empty()
assert board.bounding_box.dimension() == 2
# every pin location lies within the board bounding box
for pin in board.get_pins():
assert board.bounding_box.contains(pin.location)
def test_item_types(board):
for item in board.get_items():
assert isinstance(item, (Pin, ObstacleArea))
# --- oracle cross-check ------------------------------------------------------
@pytest.mark.oracle
def test_board_net_set_covers_oracle_routed_nets(board):
from oracle import HAS_ORACLE, route_dsn, routed_net_set
if not HAS_ORACLE:
pytest.skip("FreeRouting oracle unavailable")
# Routing the full board is slow; a couple of passes is enough to route most
# nets, and a generous timeout absorbs JVM start-up variance.
ses = route_dsn(ROUTABLE, max_passes=2, timeout=420)
routed = routed_net_set(ses)
assert routed, "oracle produced no routed nets"
# every net the reference router routed exists on our constructed board
assert routed <= board.nets.names()
# --- keepouts as KiCad exports them ------------------------------------------
# Found on the TerraHawk charge-node boards (2026-09-27): a no-track ring along
# the board edge arrives as one keyhole polygon, "keep router off" rule areas
# arrive as wire_keepout, and neither may block the board's interior.
_RING_DSN = """(pcb ring (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "" (polygon F.Cu 0 0 0 40000 0 40000 -30000 0 -30000 0 0
500 -500 500 -29500 39500 -29500 39500 -500 500 -500 0 0))
(wire_keepout "pour" (polygon F.Cu 0 10000 -10000 20000 -10000 20000 -20000 10000 -20000))
(place_keepout "courtyard" (polygon F.Cu 0 30000 -10000 35000 -10000 35000 -20000 30000 -20000))
(rule (width 250) (clearance 150)))
(placement) (library) (network))"""
def _areas(dsn_text):
return build_board(parse_dsn(dsn_text)).get_obstacle_areas()
def _blocked(areas, x_um, y_um):
p = IntPoint(x_um * 10, y_um * 10)
return any(t.contains(p) for a in areas for t in a.tiles)
def test_keyhole_ring_keepout_blocks_only_the_border():
areas = _areas(_RING_DSN)
assert _blocked(areas, 200, -15000) # inside the 0.5 mm edge strip
assert not _blocked(areas, 5000, -5000) # open board interior
def test_wire_keepout_is_read_and_blocks_its_area():
dsn = parse_dsn(_RING_DSN)
assert [k.kind for k in dsn.keepouts].count("wire_keepout") == 1
assert _blocked(_areas(_RING_DSN), 15000, -15000)
def test_place_keepout_does_not_block_routing():
assert not _blocked(_areas(_RING_DSN), 32500, -15000)
def test_grid_router_leaves_the_interior_of_a_ring_keepout_open():
from freeroute.route.pipeline import route_dsn_board
ring_only = "\n".join(
line for line in _RING_DSN.splitlines() if "wire_keepout" not in line
).replace(
"(placement) (library) (network)",
"(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))\n"
" (library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))\n"
" (network (net N (pins P1-1 P2-1)))",
)
result, _, _ = route_dsn_board(parse_dsn(ring_only), max_passes=2)
assert result.routed_net_numbers
# --- pads placed as KiCad places them (2026-09-27, TerraHawk rc4) ------------
_PAD_DSN = """(pcb pads (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(via "Via[0-1]_600:300_um")
(rule (width 250) (clearance 150)))
(placement
(component R (place TOP 10000 -10000 front 0) (place BOT 20000 -10000 back 0)
(place ROT 30000 -10000 front 90)))
(library
(image R (pin rect 1 -1000 0))
(padstack rect (shape (rect F.Cu -400 -250 400 250)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network))"""
def _pins_by_ref(text):
board = build_board(parse_dsn(text))
return {p.component_ref: p for p in board.get_pins()}, board
def test_back_side_pad_moves_to_the_bottom_layer():
pins, board = _pins_by_ref(_PAD_DSN)
names = [layer.name for layer in board.layer_structure.arr]
assert [names[i] for i in pins["TOP"].layers] == ["F.Cu"]
assert [names[i] for i in pins["BOT"].layers] == ["B.Cu"]
def test_back_side_pin_offset_is_mirrored():
pins, _ = _pins_by_ref(_PAD_DSN)
assert pins["TOP"].location.x == (10000 - 1000) * 10
assert pins["BOT"].location.x == (20000 + 1000) * 10
def test_rotated_part_rotates_its_pad_outline():
pins, _ = _pins_by_ref(_PAD_DSN)
flat = pins["TOP"].shape.bounding_box()
turned = pins["ROT"].shape.bounding_box()
assert (flat.ur.x - flat.ll.x, flat.ur.y - flat.ll.y) == (8000, 5000)
assert (turned.ur.x - turned.ll.x, turned.ur.y - turned.ll.y) == (5000, 8000)
def test_oval_pad_keeps_its_width():
oval = _PAD_DSN.replace("(shape (rect F.Cu -400 -250 400 250))", "(shape (path F.Cu 500 -300 0 300 0))")
pins, _ = _pins_by_ref(oval)
box = pins["TOP"].shape.bounding_box()
assert box.ur.y - box.ll.y == 5000 # the aperture, not a zero-height line
assert box.ur.x - box.ll.x == 11000 # centreline 600 um plus the aperture
def test_via_size_comes_from_the_via_padstack():
from freeroute.route.pipeline import _via_half_board
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
# --- rule areas: overlap only ------------------------------------------------
_CHANNEL_DSN = """(pcb channel.dsn
(parser (string_quote ")) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "{name}top" (polygon signal 0 18000 0 22000 0 22000 -14750 18000 -14750))
(keepout "{name}bottom" (polygon signal 0 18000 -15250 22000 -15250 22000 -30000 18000 -30000))
(rule (width 250) (clearance 150)))
(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))
(library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))
(network (net N (pins P1-1 P2-1))))"""
def test_a_keepout_named_rule_area_takes_no_clearance():
named = _areas(_CHANNEL_DSN.format(name="rule_area_"))
plain = _areas(_CHANNEL_DSN.format(name=""))
assert named and not any(a.clearance for a in named)
assert plain and all(a.clearance for a in plain)
def _route_channel(name):
from freeroute.route.pipeline import build_exact_routing_result
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
def test_a_trace_may_run_beside_a_rule_area_closer_than_clearance():
# the 0.5 mm channel fits a 0.25 mm trace, but not with 0.15 mm each side
assert _route_channel("rule_area_").unrouted == []
assert _route_channel("").unrouted != []