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.
114 lines
4.6 KiB
Python
114 lines
4.6 KiB
Python
"""Pins of a fine-pitch part turned off the grid axes leave along their pads.
|
|
|
|
A QFN at 45 degrees has no straight exit: every horizontal or vertical step
|
|
from a pin passes its neighbours closer than the clearance (0.35 mm away on
|
|
each axis at 0.5 mm pitch). The grid leaves along the pad's own axis first,
|
|
past the pin row, and the exact engine checks that slanted leg on its exact
|
|
copper. Found on th-eda's lift ESC, where a QFN-24 at 45 degrees left 15
|
|
connections unroutable on an empty board.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from rpmroute.board import build_board
|
|
from rpmroute.dsn import parse_dsn
|
|
from rpmroute.geometry import IntPoint, overlaps_2d, segment_slab
|
|
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,
|
|
)
|
|
|
|
|
|
def _qfn_side_dsn(rotation: float) -> str:
|
|
"""Two rows of five 0.25 x 0.8 mm pads at 0.5 mm pitch (one side of a
|
|
QFN and its opposite side), turned by ``rotation``; each pin of the top
|
|
row is its own net, routed to a pad 4.5 mm out, the pads fanned 1.5 mm
|
|
apart."""
|
|
cx, cy = 100000, -100000
|
|
t = math.radians(rotation)
|
|
out = (-math.sin(t), math.cos(t)) # the pads' outward axis after turning
|
|
along = (math.cos(t), math.sin(t)) # the pin row
|
|
pins, targets, nets = [], [], []
|
|
for i in range(5):
|
|
x = -1000 + 500 * i
|
|
pins += [f"(pin P {i + 1} {x} 1000)", f"(pin P {i + 6} {x} -1000)"]
|
|
px = cx + x * math.cos(t) - 1000 * math.sin(t)
|
|
py = cy + x * math.sin(t) + 1000 * math.cos(t)
|
|
spread = 1500 * (i - 2) - x # fan the far pads out to 1.5 mm apart
|
|
tx = px + 4500 * out[0] + spread * along[0]
|
|
ty = py + 4500 * out[1] + spread * along[1]
|
|
targets.append(f"(place T{i + 1} {round(tx)} {round(ty)} front 0)")
|
|
nets.append(f"(net N{i} (pins U1-{i + 1} T{i + 1}-1))")
|
|
return f"""(pcb qfn (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal)) (layer B.Cu (type signal))
|
|
(boundary (path pcb 0 115000 -115000 85000 -115000 85000 -85000 115000 -85000
|
|
115000 -115000))
|
|
(via "Via[0-1]_450:200_um")
|
|
(rule (width 150) (clearance 150)))
|
|
(placement
|
|
(component Q (place U1 {cx} {cy} front {rotation}))
|
|
(component T {" ".join(targets)}))
|
|
(library
|
|
(image Q {" ".join(pins)})
|
|
(image T (pin t 1 0 0))
|
|
(padstack P (shape (rect F.Cu -125 -400 125 400)) (attach off))
|
|
(padstack t (shape (rect F.Cu -300 -300 300 300)) (attach off))
|
|
(padstack "Via[0-1]_450:200_um" (shape (circle F.Cu 450)) (shape (circle B.Cu 450))
|
|
(attach off)))
|
|
(network {" ".join(nets)}))"""
|
|
|
|
|
|
@pytest.mark.parametrize("rotation", [45, 30, -45])
|
|
def test_a_turned_fine_pitch_part_gets_its_pins_out(rotation):
|
|
result = build_exact_routing_result(parse_dsn(_qfn_side_dsn(rotation)), resolution=2, shove=True)
|
|
assert result.unrouted == []
|
|
|
|
|
|
def test_the_exit_runs_along_the_pad_axis_and_clears_the_neighbours():
|
|
dsn = parse_dsn(_qfn_side_dsn(45))
|
|
s = dsn.resolution.value
|
|
board = build_board(dsn)
|
|
router = GridRouter(
|
|
board,
|
|
trace_width=round(_rule_width_dsn(dsn) * s),
|
|
clearance=round(_rule_clearance_dsn(dsn) * s),
|
|
via_half=_via_half_board(dsn, s),
|
|
orthogonal=True,
|
|
)
|
|
pins = {p.name: p for p in board.get_pins() if p.component_ref == "U1"}
|
|
pin = pins["3"]
|
|
exit_point = router._axis_exit(pin)
|
|
assert exit_point is not None
|
|
dx, dy = exit_point.x - pin.location.x, exit_point.y - pin.location.y
|
|
assert abs(abs(dx) - abs(dy)) <= 2 # along the 45-degree axis
|
|
leg = segment_slab(pin.location, exit_point, router.half_width + router.clearance)
|
|
for name in ("2", "4"):
|
|
assert not overlaps_2d(leg, pins[name].shape)
|
|
|
|
|
|
def test_a_pad_on_the_grid_axes_keeps_the_usual_exits():
|
|
dsn = parse_dsn(_qfn_side_dsn(0))
|
|
s = dsn.resolution.value
|
|
board = build_board(dsn)
|
|
router = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s),
|
|
clearance=round(_rule_clearance_dsn(dsn) * s), via_half=_via_half_board(dsn, s))
|
|
pin = next(p for p in board.get_pins() if p.component_ref == "U1" and p.name == "3")
|
|
assert router._axis_exit(pin) is None
|
|
|
|
|
|
def test_a_slanted_slab_is_tight_where_a_bounding_box_is_not():
|
|
slab = segment_slab(IntPoint(0, 0), IntPoint(10000, 10000), 750)
|
|
# a point beside the diagonal but inside its bounding box
|
|
from rpmroute.geometry import IntBox
|
|
|
|
assert not overlaps_2d(slab, IntBox(8000, 0, 8100, 100))
|
|
assert overlaps_2d(slab, IntBox(4900, 4900, 5100, 5100))
|