Files
rpmroute/tests/route/test_real_board_geometry.py
T
rsp2k 56d576e613 Route to inner plane layers with a via, not pad to pad
On a four-layer board with a GND plane on an inner power layer, freeroute
ignored the plane (it only used zones on routing layers) and chained every
GND pad to the next with tracks across the board. Now a through-hole pad
inside a plane is joined to it already, and for a net with a plane every
other group connects straight to the plane: a route may end on any cell
inside it where a via fits (checked lazily, like other vias), and ending
there places that via. Signal nets are unaffected.

Tested on a four-layer DSN: three SMD GND pads each get a short stub and
their own via, the THT GND pad needs nothing, and no track crosses the
board. Ahead of the TerraHawk ESC, a four-layer board.
2026-09-27 14:19:13 -06:00

214 lines
9.8 KiB
Python

"""Routing behaviours found on the TerraHawk rc4 boards (2026-09-27)."""
from __future__ import annotations
import math
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
# 20 x 20 mm board whose corners are rounded with a 3 mm radius, as polylines
_R = 3000.0
_arc = [(_R - _R * math.cos(math.radians(a)), _R - _R * math.sin(math.radians(a))) for a in range(0, 91, 15)]
def _outline_coords():
pts = []
for cx, cy, sx, sy in ((0, 0, 1, 1), (20000, 0, -1, 1), (20000, -20000, -1, -1), (0, -20000, 1, -1)):
corner = [(cx + sx * x, cy - sy * y) for x, y in _arc]
pts.extend(corner if sx * sy > 0 else corner[::-1])
return " ".join(f"{x:.1f} {y:.1f}" for x, y in pts)
def _dsn(placement, network, extra=""):
return f"""(pcb t (parser) (resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 {_outline_coords()}))
(via "Via[0-1]_600:300_um") (rule (width 250) (clearance 150)) {extra})
(placement {placement})
(library (image P (pin pad 1 0 0))
(padstack pad (shape (rect F.Cu -150 -500 150 500)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network {network}))"""
def test_routes_keep_edge_clearance_at_a_rounded_corner():
# two pads near the top-left corner: the direct path cuts the corner arc
text = _dsn("(component P (place A 1800 -6000 front 0) (place B 6000 -1800 front 90))",
"(net N (pins A-1 B-1))")
dsn = parse_dsn(text)
result, scale, _ = route_dsn_board_exact(dsn, edge_clearance_mm=0.5)
assert result.routed_net_numbers
outline = build_board(dsn).outline
half = 125 * scale
for segs in result.result.wires.values():
for _layer, pts in segs:
for p in pts:
# every trace point, plus half the trace, keeps 0.5 mm to the edge
assert outline.edge_distance(p) >= half + 500 * scale - 1
@pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)])
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape):
# Five 0.3 mm pads at 0.5 mm pitch, each wired to its own pad 8 mm away.
# Between two neighbours a trace's centre has a legal band only 0.15 mm
# wide. At resolution 1 the 0.4 mm grid misses most bands; at 4 its
# 0.1 mm columns cannot, and every pin gets out, as the JAR manages.
places = " ".join(f"(place R{i} {6000 + 500 * i} -6000 front 0)" for i in range(5))
places += " " + " ".join(f"(place F{i} {6000 + 500 * i} -14000 front 0)" for i in range(5))
nets = " ".join(f"(net N{i} (pins R{i}-1 F{i}-1))" for i in range(5))
result, _, _ = route_dsn_board_exact(
parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution
)
assert len(result.routed_net_numbers) == escape
def test_resolution_one_has_no_halo_so_the_grid_is_unchanged():
from freeroute.route.grid_router import GridRouter
board = build_board(parse_dsn(_dsn("", "")))
assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == []
fine = GridRouter(board, trace_width=2500, clearance=1500, resolution=2)
assert fine.step == 2000 and (1, 0) in fine._halo_offsets and (2, 0) not in fine._halo_offsets
def test_heuristic_weight_bounds_route_length():
# weighted A* may take a longer route, but never more than W times the shortest
places = "(place A 3000 -3000 front 0) (place B 17000 -17000 front 0)"
def length(weight):
result, _, _ = route_dsn_board_exact(
parse_dsn(_dsn(f"(component P {places})", "(net N (pins A-1 B-1))")),
heuristic_weight=weight,
)
return sum(
math.hypot(q.x - p.x, q.y - p.y)
for segs in result.result.wires.values()
for _layer, pts in segs
for p, q in zip(pts, pts[1:], strict=False)
)
shortest = length(1.0)
assert shortest > 0
assert length(2.0) <= 2.0 * shortest
# --- copper zones (DSN planes) -----------------------------------------------
# rc4 power stage: pads inside "keep router off" pours connect through the pour,
# and freeroute, not knowing zones, dropped every such connection.
_ZONE = "(plane N (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000 4000 -4000))"
_NO_TRACKS = "(wire_keepout \"pour\" (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000))"
def _zone_board():
places = "(place P1 5500 -7000 front 0) (place P2 8500 -7000 front 0) (place P3 16000 -16000 front 0)"
return parse_dsn(_dsn(f"(component P {places})", "(net N (pins P1-1 P2-1 P3-1))", _ZONE + " " + _NO_TRACKS))
def test_planes_are_read_and_are_not_obstacles():
dsn = _zone_board()
assert [(p.net, p.shape.layer) for p in dsn.planes] == [("N", "F.Cu")]
board = build_board(dsn)
assert len(board.get_conduction_areas()) == 1
assert all(not a.is_obstacle() or a.name == "pour" for a in board.get_obstacle_areas())
def test_pads_in_one_zone_need_no_route_between_them():
from freeroute.route.grid_router import _connected_groups
board = build_board(_zone_board())
pins = sorted(board.get_pins(), key=lambda p: p.component_ref)
groups = _connected_groups(pins, board.get_conduction_areas())
assert [sorted(p.component_ref for p in g[0]) for g in groups] == [["P1", "P2"], ["P3"]]
def test_a_pad_reaches_a_zone_under_a_no_tracks_area_by_a_via():
dsn = _zone_board()
result, scale, names = route_dsn_board_exact(dsn)
net = next(iter(result.routed_net_numbers))
assert result.result.vias.get(net) # it had to drop into the zone
board = build_board(dsn)
pour = next(a for a in board.get_obstacle_areas() if a.name == "pour")
f_cu = names.index("F.Cu")
for layer, pts in result.result.wires[net]:
if layer == f_cu: # no track runs inside the no-tracks area
assert not any(t.contains(p) and not t.bounding_box().is_empty()
for t in pour.tiles for p in pts[1:-1])
def test_a_pour_other_nets_can_cross_is_not_trusted_to_connect():
# the same zone with no no-tracks area over it: its fill could be split by
# other nets' tracks, so its pads are joined by routing, not assumed
places = "(place P1 5500 -7000 front 0) (place P2 8500 -7000 front 0)"
dsn = parse_dsn(_dsn(f"(component P {places})", "(net N (pins P1-1 P2-1))", _ZONE))
result, _, _ = route_dsn_board_exact(dsn)
net = next(iter(result.routed_net_numbers))
assert result.result.wires.get(net) # P1 and P2 got a track between them
def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys):
# a keepout wall across the board on both layers: A and B cannot be joined
from freeroute.cli import main
wall = " ".join(
f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))"
for layer in ("F.Cu", "B.Cu")
)
dsn = tmp_path / "wall.dsn"
dsn.write_text(_dsn("(component P (place A 4000 -10000 front 0) (place B 16000 -10000 front 0))",
"(net N (pins A-1 B-1))", wall))
assert main([str(dsn), "--engine", "exact", "-do", str(tmp_path / "out.ses")]) == 0
err = capsys.readouterr().err
assert "1 connection(s) not routed" in err and "N: A-1 to B-1" in err
# --- inner plane layers (a 4-layer board with a GND plane on In1.Cu) --------
def _four_layer_dsn():
return """(pcb four (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power))
(layer In2.Cu (type power)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 30000 0 30000 -30000 0 -30000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))
(via "Via[0-3]_600:300_um") (rule (width 250) (clearance 150)))
(placement
(component S (place G1 5000 -5000 front 0) (place G2 25000 -5000 front 0)
(place G3 15000 -25000 front 0) (place A 5000 -15000 front 0) (place B 25000 -15000 front 0))
(component T (place GT 15000 -15000 front 0)))
(library
(image S (pin smd 1 0 0))
(image T (pin tht 1 0 0))
(padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack tht (shape (circle F.Cu 1000)) (shape (circle B.Cu 1000)) (attach off))
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net GND (pins G1-1 G2-1 G3-1 GT-1)) (net SIG (pins A-1 B-1))))"""
def test_smd_pads_reach_an_inner_plane_by_their_own_via():
dsn = parse_dsn(_four_layer_dsn())
result, scale, names = route_dsn_board_exact(dsn)
gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND")
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
assert len(result.result.vias.get(gnd, [])) == 3 # one per SMD pad, none for the THT pad
for _layer, pts in result.result.wires.get(gnd, []): # stubs, not tracks across the board
length = sum(abs(q.x - p.x) + abs(q.y - p.y) for p, q in zip(pts, pts[1:], strict=False))
assert length < 3000 * scale # under 3 mm: a stub to its via
assert sig in result.result.routed_net_numbers # signals still route on the outer layers
assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs)
def test_a_through_hole_pad_inside_a_plane_needs_no_route():
from freeroute.route.grid_router import _connected_groups
board = build_board(parse_dsn(_four_layer_dsn()))
plane = [z for z in board.get_conduction_areas() if z.name == "GND"]
gnd_pins = [p for p in board.get_pins() if p.component_ref in ("G1", "G2", "G3", "GT")]
groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane))
hub = next(g for g in groups if g[1])
assert [p.component_ref for p in hub[0]] == ["GT"]