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.
214 lines
9.8 KiB
Python
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"]
|