Measured against KiCad's own pads on the TerraHawk rc4 power stage: - Back-side parts kept their pads on F.Cu (32 pads). Mirror the copper layer for a back-side part. - Pad outlines were copied unrotated (29 pads on rotated parts). Place the outline through the same chain as the pin: pin rotation, back-side mirror, part rotation. Oval pads, a path with a width, came out as a zero-height sliver; pad them out by the aperture. - Multi-shape padstacks used the first shape; use the largest. - Footprint-level keepouts were parsed but never placed. KiCad exports a mounting hole's plated ring that way, so the router could not see it. - Vias were sized like a trace (0.25 mm square for a 0.6 mm via), and every remaining short and clearance error was a via. Read the size from the DSN's via padstack and use it in all three engines; the grid engine now reserves the cells a via's copper reaches. With these the exact and room engines route that board with no shorts, clearance or other DRC errors. Pins now carry their component reference.
199 lines
6.9 KiB
Python
199 lines
6.9 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
|