The router began as a port of FreeRouting and has grown its own engine (negotiated congestion, tree nets, exact checks, axis escapes), and 'freeroute' was one letter from the project it no longer follows. The package, module and command are now rpmroute; the freeroute command stays as an alias, and the RPMROUTE / RPMROUTE_ORACLE_JAVA variables fall back to the old FREEROUTE names. Version 2026.10.07. Routing is unchanged: a PCBench board routes to a byte-identical session either way.
285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""The grid must not believe copper legal that the exact check then refuses.
|
|
|
|
Each case is one way the two used to disagree on roomba-ESP12E: a rotated oval
|
|
pad modelled as an inflated box (and opened to its own net), a via shared by
|
|
two connections of a net losing its footprint when one of them is ripped, and
|
|
a wider net class searched as if it were the default width.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from rpmroute.board import build_board
|
|
from rpmroute.dsn import parse_dsn
|
|
from rpmroute.geometry import IntBox, Simplex
|
|
from rpmroute.route.grid_router import GridRouter
|
|
from rpmroute.route.pipeline import (
|
|
_rule_clearance_dsn,
|
|
_rule_width_dsn,
|
|
_via_half_board,
|
|
build_exact_routing_result,
|
|
)
|
|
|
|
# --- rotated oval pads -------------------------------------------------------
|
|
|
|
|
|
def _socket_dsn(rotation: float, layout: str) -> str:
|
|
"""A 1x4 2.54 mm socket strip (rect pin 1, oval pins 2-4) at ``rotation``,
|
|
each pin its own net, routed to a small SMD pad 0.9 mm away."""
|
|
cx, cy = 100000, -100000
|
|
t = math.radians(rotation)
|
|
ax = (math.cos(t), math.sin(t))
|
|
perp = (-math.sin(t), math.cos(t))
|
|
places = []
|
|
for i in range(4):
|
|
px, py = cx + 2540 * i * ax[0], cy + 2540 * i * ax[1]
|
|
side = 1 if i % 2 == 0 else -1
|
|
if layout == "y":
|
|
tx, ty = px, py + side * 9000
|
|
else:
|
|
tx, ty = px + side * 8000 * perp[0], py + side * 8000 * perp[1]
|
|
places.append(f"(place T{i + 1} {round(tx)} {round(ty)} front 0)")
|
|
nets = " ".join(f"(net N{i} (pins P3-{i + 1} T{i + 1}-1))" for i in range(4))
|
|
return f"""(pcb socket (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal)) (layer B.Cu (type signal))
|
|
(boundary (path pcb 0 125000 -115000 85000 -115000 85000 -75000 125000 -75000
|
|
125000 -115000))
|
|
(via "Via[0-1]_800:400_um")
|
|
(rule (width 300) (clearance 250)))
|
|
(placement
|
|
(component S (place P3 {cx} {cy} front {rotation}))
|
|
(component T {" ".join(places)}))
|
|
(library
|
|
(image S (pin R 1 0 0) (pin O 2 2540 0) (pin O 3 5080 0) (pin O 4 7620 0))
|
|
(image T (pin t 1 0 0))
|
|
(padstack O (shape (path F.Cu 1727.2 0 -152.4 0 152.4))
|
|
(shape (path B.Cu 1727.2 0 -152.4 0 152.4)) (attach off))
|
|
(padstack R (shape (rect F.Cu -863.6 -1016 863.6 1016))
|
|
(shape (rect B.Cu -863.6 -1016 863.6 1016)) (attach off))
|
|
(padstack t (shape (rect F.Cu -500 -500 500 500)) (attach off))
|
|
(padstack "Via[0-1]_800:400_um" (shape (circle F.Cu 800)) (shape (circle B.Cu 800))
|
|
(attach off)))
|
|
(network {nets}
|
|
(class c N0 N1 N2 N3 (circuit (use_via "Via[0-1]_800:400_um"))
|
|
(rule (width 300) (clearance 250)))))"""
|
|
|
|
|
|
def test_a_rotated_oval_pad_is_its_outline_not_its_bounding_box():
|
|
board = build_board(parse_dsn(_socket_dsn(60, "y")))
|
|
oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2")
|
|
assert isinstance(oval.shape, Simplex) and not oval.shape.is_int_box()
|
|
box = oval.shape.bounding_box()
|
|
assert oval.shape.contains(oval.location)
|
|
assert not oval.shape.contains(box.ll) and not oval.shape.contains(box.ur)
|
|
|
|
|
|
@pytest.mark.parametrize("layout", ["y", "perp"])
|
|
def test_routes_leaving_a_rotated_socket_pass_the_exact_check(layout):
|
|
result = build_exact_routing_result(
|
|
parse_dsn(_socket_dsn(60, layout)), resolution=2, shove=True
|
|
)
|
|
assert result.unrouted == []
|
|
|
|
|
|
# --- a via two connections share --------------------------------------------
|
|
|
|
_SHARED_VIA = """(pcb shared (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer Top (type signal)) (layer Bottom (type signal))
|
|
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
|
|
(via "Via[0-1]_800:400_um")
|
|
(rule (width 300) (clearance 250)))
|
|
(library
|
|
(padstack p (shape (rect Top -400 -400 400 400)) (attach off))
|
|
(padstack "Via[0-1]_800:400_um" (shape (circle Top 800)) (shape (circle Bottom 800))
|
|
(attach off))
|
|
(image PAD (pin p 1 0 0)))
|
|
(placement (component PAD
|
|
(place G1 3000 -5000 front 0) (place G2 3000 -15000 front 0)
|
|
(place G3 17000 -10000 front 0) (place S1 3000 -10000 front 0)
|
|
(place S2 17000 -3000 front 0)))
|
|
(network (net GND (pins G1-1 G2-1 G3-1)) (net SIG (pins S1-1 S2-1))))"""
|
|
|
|
|
|
def _line(a, b, layer):
|
|
(x, y), (x1, y1) = a, b
|
|
out = [(x, y, layer)]
|
|
while x != x1:
|
|
x += 1 if x1 > x else -1
|
|
out.append((x, y, layer))
|
|
while y != y1:
|
|
y += 1 if y1 > y else -1
|
|
out.append((x, y, layer))
|
|
return out
|
|
|
|
|
|
def test_a_shared_via_keeps_its_footprint_when_one_user_is_ripped():
|
|
dsn = parse_dsn(_SHARED_VIA)
|
|
s = dsn.resolution.value
|
|
router = GridRouter(
|
|
build_board(dsn),
|
|
trace_width=round(_rule_width_dsn(dsn) * s),
|
|
clearance=round(_rule_clearance_dsn(dsn) * s),
|
|
via_half=_via_half_board(dsn, s),
|
|
)
|
|
conns = router._conns = router._build_connections()
|
|
gnd = [c for c in conns if len({c.a.component_ref, c.b.component_ref} & {"G1", "G2", "G3"})]
|
|
sig = next(c for c in conns if c not in gnd)
|
|
first, second = gnd
|
|
g3 = router._cell_of(next(p for p in router.board.get_pins() if p.component_ref == "G3").location)
|
|
via = (g3[0] - 10, g3[1])
|
|
|
|
def through_via(conn):
|
|
a, b = router._cell_of(conn.a.location), router._cell_of(conn.b.location)
|
|
return _line(a, via, 0) + _line(via, b, 1)
|
|
|
|
router._commit(first, through_via(first))
|
|
router._commit(second, through_via(second))
|
|
footprint = [c for c in router._via_cells(via) if c != via]
|
|
router._rip(first)
|
|
|
|
assert second.routed and via in second.via_cells
|
|
assert all(router._via_claims.get(c) == [second.index] for c in footprint)
|
|
# another net still keeps its distance from the via that is left
|
|
assert all(router._enter_cost_base(c, 0, sig.net_no, False, conns) is None for c in footprint)
|
|
|
|
|
|
def test_a_route_through_a_shared_via_rips_every_user():
|
|
"""Two connections of a net share a via. Another net routed through it
|
|
in rip-up mode has to rip both, or the one left keeps its via under the
|
|
new trace and the exact check throws one of them out."""
|
|
dsn = parse_dsn(_SHARED_VIA)
|
|
s = dsn.resolution.value
|
|
router = GridRouter(
|
|
build_board(dsn),
|
|
trace_width=round(_rule_width_dsn(dsn) * s),
|
|
clearance=round(_rule_clearance_dsn(dsn) * s),
|
|
via_half=_via_half_board(dsn, s),
|
|
)
|
|
conns = router._conns = router._build_connections()
|
|
gnd = [c for c in conns if len({c.a.component_ref, c.b.component_ref} & {"G1", "G2", "G3"})]
|
|
sig = next(c for c in conns if c not in gnd)
|
|
first, second = gnd
|
|
g3 = router._cell_of(next(p for p in router.board.get_pins() if p.component_ref == "G3").location)
|
|
via = (g3[0] - 10, g3[1])
|
|
|
|
def through_via(conn):
|
|
a, b = router._cell_of(conn.a.location), router._cell_of(conn.b.location)
|
|
return _line(a, via, 0) + _line(via, b, 1)
|
|
|
|
router._commit(first, through_via(first))
|
|
router._commit(second, through_via(second))
|
|
# the via's copper alone: footprint cells no trace or trace halo covers
|
|
crossing = [
|
|
node
|
|
for c in router._via_cells(via)
|
|
for node in ((*c, 0), (*c, 1))
|
|
if node not in router._cell_owner and node not in router._halo
|
|
]
|
|
assert crossing
|
|
|
|
assert router._crossed_owners(crossing, sig.net_no, conns) == {first.index, second.index}
|
|
|
|
|
|
# --- a wider net class --------------------------------------------------------
|
|
|
|
_WIDE = """(pcb wide (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal))
|
|
(boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0))
|
|
(rule (width 300) (clearance 250)))
|
|
(placement (component S (place P1 2000 -5000 front 0) (place P2 18000 -5000 front 0)
|
|
(place Q1 10000 {ylo} front 0) (place Q2 10000 {yhi} front 0)
|
|
(place Q3 10000 -1000 front 0) (place Q4 10000 -9000 front 0)))
|
|
(library (image S (pin pad 1 0 0)) (padstack pad (shape (rect F.Cu -600 -600 600 600)) (attach off)))
|
|
(network (net PWR (pins P1-1 P2-1)) (net SIG (pins Q1-1 Q3-1)) (net SIG2 (pins Q2-1 Q4-1))
|
|
(class Power PWR (rule (width 500) (clearance 250)))))"""
|
|
|
|
|
|
@pytest.mark.parametrize("resolution", [1, 2])
|
|
@pytest.mark.parametrize("gap", [900, 1000, 1075])
|
|
def test_a_wide_net_past_pads_it_does_not_fit_between_is_not_refused(gap, resolution):
|
|
# 0.3 mm fits the gap between the two pads, 0.5 mm does not: the grid
|
|
# must see that, rather than lay it wide and have the exact check drop it
|
|
ylo, yhi = -5000 - gap // 2 - 600, -5000 + gap // 2 + 600
|
|
result = build_exact_routing_result(
|
|
parse_dsn(_WIDE.format(ylo=ylo, yhi=yhi)), resolution=resolution
|
|
)
|
|
assert result.unrouted == []
|
|
|
|
|
|
def _wide_router():
|
|
dsn = parse_dsn(_WIDE.format(ylo=-6050, yhi=-3950))
|
|
s = dsn.resolution.value
|
|
pwr = next(i for i, net in enumerate(dsn.nets, 1) if net.name == "PWR")
|
|
return GridRouter(
|
|
build_board(dsn),
|
|
trace_width=round(_rule_width_dsn(dsn) * s),
|
|
clearance=round(_rule_clearance_dsn(dsn) * s),
|
|
resolution=2,
|
|
net_widths={pwr: 5000},
|
|
), pwr
|
|
|
|
|
|
def test_a_wide_trace_keeps_clear_of_a_default_one_at_the_grid_diagonal():
|
|
router, _pwr = _wide_router()
|
|
# 0.5 mm against 0.3 mm needs 0.25 + 0.25 + 0.15 = 0.65 mm between centres,
|
|
# on either axis, since the exact check compares boxes
|
|
need = 2500 + router.clearance + router.half_width
|
|
for dx, dy in [(2, 2), (2, 1), (1, 2)]:
|
|
assert max(dx, dy) * router.step < need
|
|
assert (dx, dy) in router._halo_for(2500)
|
|
|
|
|
|
def test_a_wide_rip_up_route_counts_what_its_extra_width_crosses():
|
|
router, pwr_net = _wide_router()
|
|
conns = router._conns = router._build_connections()
|
|
pwr = next(c for c in conns if c.net_no == pwr_net)
|
|
other = next(c for c in conns if c.net_no != pwr_net)
|
|
# a default-width trace two cells from the wide route's path
|
|
x, y = 20, 5
|
|
router._commit(other, _line((x - 3, y + 2), (x + 3, y + 2), 0))
|
|
path = _line((x - 3, y), (x + 3, y), 0)
|
|
spread = router._spread_for(2500)
|
|
assert other.index not in router._crossed_owners(path, pwr.net_no, conns)
|
|
assert other.index in router._crossed_owners(path, pwr.net_no, conns, spread)
|
|
|
|
|
|
def test_a_pad_cell_check_uses_the_rotated_outline():
|
|
board = build_board(parse_dsn(_socket_dsn(60, "y")))
|
|
router = GridRouter(board, trace_width=3000, clearance=2500, resolution=2)
|
|
oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2")
|
|
box = oval.shape.bounding_box()
|
|
margin = router.clearance + router.half_width
|
|
exact = set(router._pad_cells(oval.shape, box, margin))
|
|
boxed = set(router._cells_in_box(box, margin))
|
|
assert exact < boxed
|
|
for cell in boxed - exact:
|
|
c = router._cell_center(cell)
|
|
square = IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin)
|
|
assert oval.shape.intersection(square).dimension() != 2
|
|
|
|
|
|
def test_a_via_by_a_chamfered_corner_is_judged_round():
|
|
"""The grid keeps a via's centre its radius plus the edge clearance from
|
|
the outline. The exact check used the via's square, whose corner reaches
|
|
past a 45-degree chamfer where the round via does not."""
|
|
from rpmroute.board.outline import BoardOutline
|
|
from rpmroute.geometry import IntBox, IntPoint
|
|
from rpmroute.route.exact_router import _inside_outline
|
|
|
|
# a 10 mm square with its lower-left corner cut at 45 degrees
|
|
outline = BoardOutline([IntPoint(*p) for p in
|
|
[(30, 0), (100, 0), (100, 100), (0, 100), (0, 30)]])
|
|
half, edge = 4, 1
|
|
via = IntPoint(19, 20) # its centre 6.4 from the chamfer x + y = 30
|
|
square = IntBox(via.x - half, via.y - half, via.x + half, via.y + half)
|
|
assert not outline.box_clear(square, edge) # the square's corner: 0.7 away
|
|
assert _inside_outline(outline, [], [via], half + edge, edge)
|
|
# 4.2 from the chamfer: the round via itself is too close
|
|
assert not _inside_outline(outline, [], [IntPoint(18, 18)], half + edge, edge)
|