Compare commits

...
Author SHA1 Message Date
rsp2k c6297656a6 Let pins on turned fine-pitch pads leave along the pad's axis
A QFN placed at 45 degrees had no exit on the grid: every horizontal or
vertical step from a pin passes its neighbours inside clearance. A pin whose
pad is turned more than ten degrees off the grid axes now leaves along the
pad's own axis, away from the part, until an L from there to a grid cell
clears every other net's pad, then searches as usual. The slanted leg is
checked and stored as an exact slab (segment_slab), not its bounding box,
in the grid stub checks and in the exact engine.

Where pins leave side by side, a cell names only its first owner, so stubs
are also indexed per cell for every connection near them; the exact
stub-vs-stub check, trace entry and rip-up all consult that index. A pin
with an axis exit and no clear cell reports no way out instead of falling
back to a cell on another net's route. diagnose.py replays with the exits.
2026-10-03 18:49:36 -06:00
rsp2k 4933b6f992 Merge feat/negotiated-congestion: --negotiate N, priced sharing and history before routing 2026-10-03 16:14:36 -06:00
rsp2k 6a560b9506 diagnose.py: --negotiate, as the router 2026-10-03 15:34:37 -06:00
rsp2k 09c58ea630 --negotiate: PathFinder rounds on the empty board before routing
Every connection is routed N times on the empty board, where routes may
share cells at a price that doubles each round for every other net on a
cell, and a cell that stays shared gains history. The real routing then
pays that history on free cells, so the first routes stop taking the space
later routes (a fine-pitch part's escapes) have no way round. Off by
default.

The old history term never steered: it only accrued at rip-up fights,
after the first routes had claimed the contested space, and routed
connections are never revisited in later passes.
2026-10-03 14:45:59 -06:00
rsp2k 7c076d9995 Merge fix/minisumo: shared-via owners for search and rip-up, round vias against the outline 2026-10-03 11:31:01 -06:00
rsp2k 8f006fec25 Rip every user of a shared via, and judge vias round at the outline
Two connections of a net can share a via. The grid kept one owner per via
cell next to the claim list, and the search and rip-up read only that owner:
a route of another net through the via ripped the first user and left the
second's via under the new trace. The exact check then threw one of them
out. The search and rip-up now read the claim list, and the single owner is
gone.

The exact check also tested a via against the outline as its square box,
whose corner reaches past a 45-degree chamfer where the round via does not.
The grid keeps the via's centre its radius plus the edge clearance from the
outline, as KiCad measures it; the exact check now does the same.

kitspace_minisumo_v3 seed 4: 4 rejected connections to 0, 11 failed to 7.
2026-10-03 11:03:38 -06:00
rsp2k ad10113b8b Merge fix/round-pads: route past round pads by their outline, not their bounding square 2026-10-02 04:51:51 -06:00
rsp2k dc11b4aa14 Route past round pads by their outline, not their bounding square
A circle pad was its bounding box in both the grid and the exact check, so a
4 mm mounting hole's square corners refused legal routes 0.8 mm clear of the
real copper. It is now a 16-sided polygon whose sides touch the circle, so
the router is never closer than KiCad measures. diagnose.py takes --seed.
2026-10-02 04:14:39 -06:00
rsp2k de00937084 Merge fix/roomba: grid and exact check agree on rotated pads, shared vias and wide nets 2026-09-30 14:02:54 -06:00
rsp2k f79134fee6 Make the grid and the exact check agree on rotated pads, shared vias and wide nets
Rotated oval pads were built from padded corners that are not in outline
order, so they fell back to their bounding box; neighbouring boxes of a
rotated socket overlap, and the grid's own-pad exemption opened the
overlap to the pin's net, which the exact check then refused. Pads are now
the convex hull of their corners, the exact tree checks a rotated pad
against that outline, and the grid blocks cells against the same outline.

A via reused by a second connection of its net left that connection with
no claim on the via's footprint, so ripping the first owner freed cells
around a via still on the board. Footprint cells now keep every claimant.

A net class wider than the default was searched as a default trace: its
spread was empty below half a cell, halos were circular while the exact
check compares boxes, and a rip-up route only counted what its centre
crossed. The spread now covers the extra width in whole cells, halos are
square, and rip-up victims include what the spread crosses.

bench/diagnose.py now routes with net-class widths and hole spacing, as
the CLI does.
2026-09-30 12:08:25 -06:00
rsp2k ef8398c9b2 Merge fix/ttn-9: straight pin exits, rip-up can reach a pin's way out, exact checks between stubs 2026-09-30 11:11:39 -06:00
rsp2k 8a33dc4576 bench/drc_check.sh: route a KiCad board and judge it with DRC at every severity
Copies the board (stripping its tracks unless --keep), exports the DSN
through mckicad, routes, applies the session and runs DRC with zones
refilled, counting hole problems whatever their severity. FREEROUTE and
MCK choose the builds under test.
2026-09-30 10:54:12 -06:00
rsp2k 8b3ebc53cf Leave fine-pitch pins straight and let rip-up reach a pin's way out
A 1.0 mm trace from a 1.27 mm pitch pin (the RN2483 row on the TTN
Enschede mote) has to stay within 0.06 mm of the pin's centreline until
it is past the neighbouring pads. No grid column is that close, and the
router could not get there:

- A stub ran as one slanted segment to its cell centre. The exact check
  sees that as its bounding box, which lands on the next pad. Orthogonal
  stubs now leave straight along the pin's axis and jog to the cell
  only at the end.
- End nodes were chosen as in free space even in the rip-up search, so
  once another net crossed a pin's only exit, the pin had no end left
  and rip-up could never take the exit back. In rip-up mode those cells
  and stubs are now enterable at the rip penalty, and routes on a stub
  are ripped along with the ones the path crosses.
- Two neighbouring pins' stubs each claim the cells a centred trace
  would crowd, so their claims overlapped well before their copper
  did. Stub against stub is now checked on the copper.
- Escapes also look one column either side, so two pins leaving the
  same way can jog apart.

TTN Enschede mote, exact engine with --shove: 12 unrouted before, 0
after; KiCad DRC on the result has no clearance violations and no
unconnected items. The 0.5 mm pitch row test at resolution 1 now
routes all 5 pins (it pinned 2).
2026-09-30 09:49:05 -06:00
rsp2k 7a42230f36 Merge fix/fixed-via-holes: exact hole spacing to fixed vias, vias on one route kept apart 2026-09-29 09:13:47 -06:00
17 changed files with 1324 additions and 113 deletions
+16 -2
View File
@@ -32,8 +32,10 @@ from freeroute.route.exact_router import route_board_exact
from freeroute.route.grid_router import GridRouter, _ends_of
from freeroute.route.pipeline import (
_mm_to_board,
_net_widths_board,
_rule_clearance_dsn,
_rule_width_dsn,
_via_drill_board,
_via_half_board,
)
@@ -183,7 +185,8 @@ def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
def diagnose(
dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False,
access_cost: float = 0.0,
access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, seed: int | None = None,
negotiate: int = 0,
) -> dict:
dsn = parse_dsn(dsn_text)
names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
@@ -199,6 +202,12 @@ def diagnose(
resolution=resolution,
shove=shove,
access_cost=access_cost,
seed=seed,
negotiate=negotiate,
# the product's configuration: net-class widths and hole spacing
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
)
routed = seen["router"]
best = routed._diag_best
@@ -206,6 +215,8 @@ def diagnose(
# the board as the best pass left it
state = _fresh(seen)
# pins on turned pads leave along their pad's axis: replay with the same exits
state._axis_exits = dict(routed._axis_exits)
conns = state._conns
for idx, (path, a_end, b_end) in best.items():
conns[idx].a_end, conns[idx].b_end = a_end, b_end
@@ -270,11 +281,14 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--grid-resolution", type=int, default=2, metavar="K")
ap.add_argument("--shove", action="store_true", help="as mckicad routes")
ap.add_argument("--access-cost", type=float, default=0.0)
ap.add_argument("--seed", type=int, help="route in this shuffled order, as bench.py does")
ap.add_argument("--negotiate", type=int, default=0, metavar="N", help="as the router's --negotiate")
ap.add_argument("--hole-to-hole", type=float, default=0.25, metavar="MM")
ap.add_argument("--json", type=Path, help="also write the full table here")
args = ap.parse_args(argv)
report = diagnose(args.dsn.read_text(), args.edge_clearance, args.grid_resolution, args.shove,
args.access_cost)
args.access_cost, args.hole_to_hole, args.seed, args.negotiate)
for row in report["failures"]:
extra = ""
for w in row.get("walled", []):
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Route one KiCad board with freeroute and judge the result with KiCad's DRC.
#
# bench/drc_check.sh [--keep] <board.kicad_pcb> <out-dir> [freeroute args...]
#
# Copies the board (and its .kicad_pro) into <out-dir>, strips its tracks and
# vias unless --keep is given (then they reach the router as fixed wiring),
# exports the DSN through mckicad, routes, applies the session, and runs DRC
# with zones refilled at every severity. Prints one line: time, copper added,
# shorts, opens, hole problems (whatever severity the board gives them) and
# the other errors. The source board is never touched.
#
# FREEROUTE (default: this checkout's .venv/bin/freeroute) and MCK (default
# ~/claude/mckicad) pick the builds to judge.
set -euo pipefail
keep=0
if [[ "${1:-}" == "--keep" ]]; then keep=1; shift; fi
src="$1"; out="$2"; shift 2
here="$(cd "$(dirname "$0")/.." && pwd)"
FREEROUTE="${FREEROUTE:-$here/.venv/bin/freeroute}"
MCK="${MCK:-$HOME/claude/mckicad}"
rm -rf "$out"; mkdir -p "$out"
cp "${src%.kicad_pcb}.kicad_pro" "$out/board.kicad_pro" 2>/dev/null || true
if [[ $keep == 1 ]]; then
cp "$src" "$out/board.kicad_pcb"
else
(cd "$MCK" && uv run --no-sync python - "$src" "$out/board.kicad_pcb" <<'EOF'
import sys
from mckicad.utils.sexp_tree import parse
text = open(sys.argv[1]).read()
spans = sorted((c.span for c in parse(text).children if c.tag in ("segment", "arc", "via")), reverse=True)
for a, b in spans:
text = text[:a] + text[b:]
open(sys.argv[2], "w").write(text)
EOF
)
fi
(cd "$MCK" && uv run --no-sync python -c "
from mckicad.utils.freerouting import FreeRoutingEngine
assert FreeRoutingEngine().export_dsn_from_kicad('$out/board.kicad_pcb', '$out/board.dsn', {})") \
>/dev/null 2>&1 || { echo "$out: DSN export failed"; exit 1; }
start=$(date +%s.%N)
"$FREEROUTE" "$out/board.dsn" -do "$out/board.ses" "$@" >"$out/route.log" 2>&1 || true
secs=$(python3 -c "print(round($(date +%s.%N)-$start,1))")
[[ -s "$out/board.ses" ]] || { echo "$out: no session ($(tail -1 "$out/route.log"))"; exit 1; }
(cd "$MCK" && uv run --no-sync python -c "
from mckicad.utils.ses_apply import apply_ses_to_board as a
r = a('$out/board.kicad_pcb', '$out/board.ses', '$out/board.kicad_pcb')
print(r['segments_added'], r['vias_added'])" 2>/dev/null) >"$out/apply.txt"
kicad-cli pcb drc --refill-zones --format json --severity-all \
-o "$out/drc.json" "$out/board.kicad_pcb" >/dev/null 2>&1 || true
python3 - "$out" "$secs" <<'EOF'
import collections, json, sys
out, secs = sys.argv[1:]
seg, via = open(f"{out}/apply.txt").read().split()
d = json.load(open(f"{out}/drc.json"))
errors = collections.Counter(v["type"] for v in d["violations"] if v.get("severity") == "error")
other = collections.Counter(v["type"] for v in d["violations"] if v.get("severity") != "error")
holes = {k: errors.pop(k, 0) + other.get(k, 0) for k in ("holes_co_located", "hole_to_hole")}
print(f"{out}: {secs}s {seg} seg {via} via shorts {errors.pop('shorting_items', 0)} "
f"opens {len(d.get('unconnected_items', []))} holes {holes} errors {dict(errors)}")
EOF
+60 -9
View File
@@ -12,9 +12,10 @@ model. It mirrors what FreeRouting's ``Structure.create_board`` / ``Library`` /
* padstacks x placement -> :class:`Pin` items
* keepouts -> :class:`ObstacleArea` items
Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad its
bounding box (documented approximation pending an exact circle tile); a convex
polygon pad an exact :class:`Simplex`. Every pad is placed centred on its pin
Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad a
16-sided :class:`Simplex` drawn just outside it (its corners stand out by 2% of
the radius, where the bounding box's stood out by 41%); a convex polygon pad an
exact :class:`Simplex`. Every pad is placed centred on its pin
location, so it contains that location by construction. Component rotation is
applied to the pin position; the pad tile itself is not rotated (irrelevant to
the containment invariant, and pads are centre-symmetric in practice).
@@ -249,7 +250,7 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor
r = round(transform.dsn_to_board(shape.diameter / 2))
cx = round(transform.dsn_to_board(centre.x))
cy = round(transform.dsn_to_board(centre.y))
return IntBox(cx - r, cy - r, cx + r, cy + r)
return _circle_tile(cx, cy, r)
if isinstance(shape, Rectangle):
x0, y0, x1, y1 = shape.coords[:4]
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
@@ -268,15 +269,65 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor
for x, y in corners:
p = _to_board_offset(place, x, y, dsn_pin.rotation)
pts.append(IntPoint(round(transform.dsn_to_board(p.x)), round(transform.dsn_to_board(p.y))))
if len(pts) >= 3:
simplex = Simplex.from_corners(pts)
if not simplex.is_empty():
return simplex
# An oval's padded corners are not in outline order, and a mirrored
# part reverses the winding: take the convex hull, then either winding.
hull = _convex_hull(pts)
if len(hull) >= 3:
for corners in (hull, hull[::-1]):
simplex = Simplex.from_corners(corners)
if not simplex.is_empty():
return simplex
xs = [p.x for p in pts] or [0]
ys = [p.y for p in pts] or [0]
return IntBox(min(xs), min(ys), max(xs), max(ys))
_CIRCLE_SIDES = 16
def _circle_tile(cx: int, cy: int, r: int):
"""A polygon around the circle, every side touching it. A square around a
big round pad (a mounting hole) puts phantom copper in its corners that
blocks legal routes past it; this stays outside the circle, so the router
is still never closer than KiCad measures."""
if r < 8:
return IntBox(cx - r, cy - r, cx + r, cy + r)
reach = r / math.cos(math.pi / _CIRCLE_SIDES)
corners = []
for k in range(_CIRCLE_SIDES):
a = (k + 0.5) * 2 * math.pi / _CIRCLE_SIDES
dx, dy = reach * math.cos(a), reach * math.sin(a)
# round away from the centre so the polygon never cuts into the circle
corners.append(IntPoint(cx + math.copysign(math.ceil(abs(dx)), dx),
cy + math.copysign(math.ceil(abs(dy)), dy)))
corners = [IntPoint(int(p.x), int(p.y)) for p in corners]
for winding in (corners, corners[::-1]):
simplex = Simplex.from_corners(winding)
if not simplex.is_empty():
return simplex
return IntBox(cx - r, cy - r, cx + r, cy + r)
def _convex_hull(pts: list[IntPoint]) -> list[IntPoint]:
"""The convex hull of ``pts`` counter-clockwise, collinear points dropped."""
ps = sorted({(p.x, p.y) for p in pts})
if len(ps) < 3:
return [IntPoint(x, y) for x, y in ps]
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
def half(points):
out: list[tuple[int, int]] = []
for p in points:
while len(out) >= 2 and cross(out[-2], out[-1], p) <= 0:
out.pop()
out.append(p)
return out[:-1]
return [IntPoint(x, y) for x, y in half(ps) + half(ps[::-1])]
def _pad_tile(shape: Shape, transform: CoordinateTransform):
"""A board-relative pad tile from a DSN padstack shape (centred near origin)."""
if isinstance(shape, Rectangle):
@@ -286,7 +337,7 @@ def _pad_tile(shape: Shape, transform: CoordinateTransform):
r = round(transform.dsn_to_board(shape.diameter / 2))
cx = round(transform.dsn_to_board(shape.center_x))
cy = round(transform.dsn_to_board(shape.center_y))
return IntBox(cx - r, cy - r, cx + r, cy + r) # bounding-box approximation
return _circle_tile(cx, cy, r)
if isinstance(shape, (Polygon, Path)):
coords = shape.coords
pts = [
+25 -2
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
from dataclasses import dataclass
from freeroute.geometry import IntBox, Simplex, overlaps_2d, segment_octagon
from freeroute.geometry import IntBox, IntPoint, Simplex, overlaps_2d, segment_octagon, segment_slab
__all__ = ["TreeShape", "ShapeSearchTree"]
@@ -63,7 +63,9 @@ class TreeShape:
segment swept by ``half_width + clearance`` (exact integer superset)."""
if self.seg is None:
return self.tile.offset(clearance)
a, b, half_width = self.seg
a, b, half_width = self.seg[-3:]
if self.seg[0] == "slab": # a slanted pin exit, at any angle
return segment_slab(a, b, half_width + clearance)
return segment_octagon(a, b, half_width + clearance)
@@ -151,6 +153,25 @@ class ShapeSearchTree:
return True
return False
def segment_conflict(
self, a: IntPoint, b: IntPoint, half_width: int, net_no: int, layer: int,
clearance: int, kind: str = "wire",
) -> bool:
""":meth:`clearance_conflict` for a slanted segment, checked on its
exact swept copper instead of its bounding box (which would reach the
neighbouring pads of a pin leaving along its turned pad's axis)."""
copper = segment_slab(a, b, half_width)
grown = segment_slab(a, b, half_width + clearance)
for shape in self.overlapping(grown.bounding_box()):
if shape.net_no == net_no or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue
other = shape.exact if shape.exact is not None else shape.tile
if overlaps_2d(copper if not shape.clearance else grown, other):
return True
return False
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
for shape in self.overlapping(box):
@@ -188,6 +209,8 @@ class ShapeSearchTree:
overlap). Static pad-vs-pad spacing in the source design is not counted.
"""
for shape in self.all_shapes():
if not shape.routed:
continue # a pair with routed copper is checked from that side
expanded = shape.tile.offset(clearance)
grown = None # exact clearance region, built lazily only for diagonals
for other in self.overlapping(expanded):
+14 -2
View File
@@ -30,8 +30,8 @@ from freeroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}),
"room": frozenset({"shove", "pack"}),
}
@@ -46,6 +46,7 @@ _FLAG_NAMES = {
"orders": "--orders",
"promote": "--promote-failures",
"access": "--access-cost",
"negotiate": "--negotiate",
"rollback": "--no-rollback",
}
@@ -167,6 +168,14 @@ def build_parser() -> argparse.ArgumentParser:
help="grid and exact engines: extra cost for a route to pass where it would close "
"another pin's way out while that pin still has connections to route (default 0: off)",
)
parser.add_argument(
"--negotiate",
type=int,
metavar="N",
help="grid and exact engines: before routing, N rounds of negotiation on the empty "
"board, where routes may share space at a price that rises where they compete; "
"the contested cells then cost extra in the real routing (default 0: off)",
)
parser.add_argument(
"--no-rollback",
dest="rollback",
@@ -212,6 +221,7 @@ def main(argv: list[str] | None = None) -> int:
("orders", args.orders is not None),
("promote", args.promote_failures is not None),
("access", args.access_cost is not None),
("negotiate", args.negotiate is not None),
("rollback", not args.rollback),
)
if asked
@@ -247,6 +257,7 @@ def main(argv: list[str] | None = None) -> int:
"resolution": 2 if args.grid_resolution is None else args.grid_resolution,
"heuristic_weight": args.heuristic_weight,
"access_cost": args.access_cost or 0.0,
"negotiate": args.negotiate or 0,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback,
}
@@ -258,6 +269,7 @@ def main(argv: list[str] | None = None) -> int:
"edge_clearance_mm": args.edge_clearance,
"resolution": 1 if args.grid_resolution is None else args.grid_resolution,
"access_cost": args.access_cost or 0.0,
"negotiate": args.negotiate or 0,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback,
}
+2 -1
View File
@@ -21,7 +21,7 @@ from .direction import Direction, IntDirection
from .float_point import FloatPoint
from .limits import CRIT_DOUBLE, CRIT_INT
from .line import Line
from .octagon import ceil_sqrt2, overlaps_2d, segment_octagon
from .octagon import ceil_sqrt2, overlaps_2d, segment_octagon, segment_slab
from .point import IntPoint, Point, RationalPoint, point, rational_point
from .polygon import Polygon
from .polygon_shape import PolygonShape
@@ -59,5 +59,6 @@ __all__ = [
"shove_segment",
"ceil_sqrt2",
"segment_octagon",
"segment_slab",
"overlaps_2d",
]
+41 -1
View File
@@ -29,7 +29,7 @@ from .line import Line
from .point import IntPoint
from .simplex import Simplex
__all__ = ["ceil_sqrt2", "segment_octagon", "overlaps_2d"]
__all__ = ["ceil_sqrt2", "segment_octagon", "segment_slab", "overlaps_2d"]
def ceil_sqrt2(radius: int) -> int:
@@ -82,6 +82,46 @@ def segment_octagon(a: IntPoint, b: IntPoint, radius: int) -> Simplex:
return Simplex.get_instance(lines)
def segment_slab(a: IntPoint, b: IntPoint, radius: int) -> Simplex:
"""Convex cover of segment ``a``-``b`` swept by ``radius``, at any angle.
The rectangle along the segment, ``radius`` either side, with flush ends
like :func:`polyline.segment_box`: the ends of a trace leg sit on a pad or
on the square corner tile of the next leg, which cover them. Corners are
rounded away from the middle and the radius gets one unit more, so the
side bounds never under-report, and it stays tight for a slanted segment
off the 45-degree lines (a pin leaving along its pad's axis on a part
turned 30 degrees).
"""
dx, dy = b.x - a.x, b.y - a.y
length = math.hypot(dx, dy)
r = max(radius, 0) + 1
if length == 0:
return Simplex.from_corners([
IntPoint(a.x - r, a.y - r), IntPoint(a.x + r, a.y - r),
IntPoint(a.x + r, a.y + r), IntPoint(a.x - r, a.y + r),
])
ux, uy = dx / length, dy / length
nx, ny = -uy, ux
mx, my = (a.x + b.x) / 2, (a.y + b.y) / 2
def out(x: float, y: float) -> IntPoint:
return IntPoint(math.ceil(x) if x >= mx else math.floor(x),
math.ceil(y) if y >= my else math.floor(y))
corners = [
out(a.x + nx * r, a.y + ny * r),
out(b.x + nx * r, b.y + ny * r),
out(b.x - nx * r, b.y - ny * r),
out(a.x - nx * r, a.y - ny * r),
]
for winding in (corners, corners[::-1]):
slab = Simplex.from_corners(winding)
if not slab.is_empty():
return slab
return Simplex(())
def overlaps_2d(shape_a, shape_b) -> bool:
"""True if the two convex tiles share 2-D area (a real clearance overlap).
+62 -9
View File
@@ -38,8 +38,10 @@ from freeroute.geometry import (
IntPoint,
Polyline,
PolylineShape,
Simplex,
segment_box,
segment_octagon,
segment_slab,
shove_segment,
)
from freeroute.geometry.polyline import corner_box
@@ -98,6 +100,7 @@ def route_board_exact(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
@@ -133,6 +136,7 @@ def route_board_exact(
seed=seed,
route_first=route_first,
access_cost=access_cost,
negotiate=negotiate,
rollback=rollback,
net_widths=net_widths,
via_drill=via_drill,
@@ -154,7 +158,9 @@ def route_board_exact(
net = pin.net_nos[0] if pin.net_nos else -1
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box))
# a rotated pad is checked against its outline, not its box
exact = pin.shape if _is_rotated(pin.shape) else None
tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
@@ -195,16 +201,23 @@ def route_board_exact(
zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0]
):
wire_half = net_half.get(net_no, half_width)
boxes = _copper_boxes(wires, vias, wire_half, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], wire_half, via_layers, via_half))
pieces = _copper_pieces(wires, vias, wire_half, via_layers, via_half)
boxes = [(box, box_layers) for box, box_layers, _ in pieces]
wire_count = len(_copper_pieces(wires, [], wire_half, via_layers, via_half))
if any(
tree.clearance_conflict(
box, net_no, layer, clearance, "wire" if i < wire_count else "via"
(
tree.segment_conflict(*seg, wire_half, net_no, layer, clearance)
if seg is not None
else tree.clearance_conflict(
box, net_no, layer, clearance, "wire" if i < wire_count else "via"
)
)
for i, (box, box_layers) in enumerate(boxes)
for i, (box, box_layers, seg) in enumerate(pieces)
for layer in box_layers
) or _end_in_rule_area(tree, wires, wire_half) or (
outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)
outline is not None and not _inside_outline(
outline, boxes[:wire_count], vias, (via_half or wire_half) + edge, edge
)
):
rejected.append((net_no, *ends))
continue # exact-clearance conflict -> drop (may be recovered by shove)
@@ -218,9 +231,15 @@ def route_board_exact(
v for v in dict.fromkeys(vias)
if v not in acc.vias and not _on_fixed_via(net_no, v, fixed_vias, via_half)
)
for box, box_layers in boxes:
for box, box_layers, seg in pieces:
oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
if seg is None:
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
else: # a slanted pin exit: its exact copper, rebuilt for the DRC check
tree.insert(TreeShape(
oid, net_no, box_layers, box, routed=True,
exact=segment_slab(*seg, wire_half), seg=("slab", *seg, wire_half),
))
acc.owners.append(oid)
accepted_before_recovery = set(accepted)
@@ -655,6 +674,27 @@ def _on_fixed_via(net_no, point, fixed_vias, via_half) -> bool:
)
def _copper_pieces(wires, vias, half_width, via_layers, via_half=None):
"""Like :func:`_copper_boxes`, but a slanted wire segment (a pin leaving
along a turned pad's axis) comes with its end points, so it can be checked
on its exact copper: ``(box, layers, (a, b) or None)``."""
via_half = via_half or half_width
pieces: list[tuple[IntBox, frozenset[int], tuple | None]] = []
for layer, points in wires:
on = frozenset({layer})
for a, b in zip(points, points[1:], strict=False):
if a.x == b.x or a.y == b.y:
pieces.append((segment_box(a, b, half_width), on, None))
else:
pieces.append((segment_slab(a, b, half_width).bounding_box(), on, (a, b)))
for c in points[1:-1]:
pieces.append((corner_box(c, half_width), on, None))
for loc in vias:
vbox = IntBox(loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half)
pieces.append((vbox, via_layers, None))
return pieces
def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = []
@@ -667,6 +707,19 @@ def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
return boxes
def _inside_outline(outline, wire_boxes, vias, via_reach, edge) -> bool:
"""Copper inside the board, ``edge`` from its outline. A via is round, as
the grid and KiCad take it: its square box would reach past a chamfered
corner where the via itself does not."""
return all(outline.box_clear(b, edge) for b, _ in wire_boxes) and all(
outline.point_clear(v, via_reach) for v in vias
)
def _is_rotated(shape) -> bool:
return isinstance(shape, Simplex) and not shape.is_int_box()
def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]:
return [
pin
+499 -81
View File
@@ -27,13 +27,15 @@ DRC-clean geometry, and blind/buried via spans.
from __future__ import annotations
import heapq
import itertools
import math
import random
from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry.polyline import corner_box
from freeroute.geometry import IntBox, IntPoint, Simplex
from freeroute.geometry.octagon import overlaps_2d, segment_slab
from freeroute.geometry.polyline import corner_box, segment_box
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -123,6 +125,8 @@ class _Connection:
"path",
"narrow",
"half",
"stubs",
"stub_near",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -138,6 +142,10 @@ class _Connection:
self.via_cells: list[Cell] = []
self.via_halo: list[Cell] = []
self.halo_nodes: list[Node] = []
# each end's stub: its layer, its copper tiles, and the nodes it claims
self.stubs: list[tuple[int, list[IntBox], set[Node]]] = []
# every node its stubs come near, claimed or not (see GridRouter._stub_near)
self.stub_near: list[Node] = []
# the pins and zones each end may use, and which pin the route ended
# on (None when it ended on a zone)
self.a_group: tuple[list[Pin], list] = ([a], [])
@@ -175,6 +183,7 @@ class GridRouter:
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
@@ -186,6 +195,15 @@ class GridRouter:
# and the clearance around both) cost other nets this much extra, so
# they route round a fine-pitch pin's way out instead of sealing it.
self.access_cost = max(float(access_cost), 0.0)
# Negotiated congestion (PathFinder): before the real routing, every
# connection is routed this many times on the empty board, where routes
# may overlap at a price that grows where they compete. The cells that
# stay contested keep a history cost the real routing pays, so the first
# routes leave room around the places later routes have no way round.
self.negotiate = max(int(negotiate), 0)
self._history: dict[Node, float] = {}
self._present: dict[Node, dict[int, int]] | None = None
self._present_cost = 0.0
self._doorways: dict[Node, list[tuple[int, int]]] = {}
self._pending: dict[int, int] = {}
# Rip-up as a transaction: the connections a rip-up route crosses are
@@ -203,6 +221,12 @@ class GridRouter:
self._escape_cells = 12
self._crowded_cache: dict = {}
self._trapped_cache: dict = {}
# pin location -> where a pin on a turned fine-pitch pad leaves its
# pin row along the pad's axis (None: it leaves like any other pin)
self._axis_exits: dict[IntPoint, IntPoint | None] = {}
# and the other nets' pads near each such pin, its stubs' obstacles
self._axis_pads: dict[IntPoint, list] = {}
self._part_centres: dict[int, tuple[float, float] | None] = {}
self.heuristic_weight = max(float(heuristic_weight), 1.0)
self.trace_width = max(trace_width, 1)
self.clearance = max(clearance, 0)
@@ -229,7 +253,7 @@ class GridRouter:
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step < need
if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need
]
# A via's copper is wider than a trace. Another net's trace needs its
# centre via_half + clearance + half_width from the via's centre, so a
@@ -294,7 +318,16 @@ class GridRouter:
self._zone_cells: dict[int, list[Node]] = {}
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
self._via_owner: dict[Cell, int] = {}
# Node -> connections whose stub comes near it. A cell has one owner,
# and a stub claims only cells nobody owns yet, so where several pins
# leave side by side (a QFN turned 45 degrees: neighbours 0.35 mm apart
# on each axis) the cell two stubs share names only the first, and the
# exact check between the later stubs would never run.
self._stub_near: dict[Node, set[int]] = {}
# every connection whose via covers a cell, in claim order: a via two
# connections share still blocks after the first of them is ripped,
# and a route through it must rip all of them
self._via_claims: dict[Cell, list[int]] = {}
# trace halos: node -> indices of the connections claiming it
self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles()
@@ -375,6 +408,19 @@ class GridRouter:
shape.contains(IntPoint(c.x + dx, c.y + dy)) for dx in (-h, h) for dy in (-h, h)
)
def _pad_cells(self, shape, box: IntBox, margin: int):
"""Cells where a trace square ``margin`` wide around the centre overlaps
the pad: its box when it is one, else its exact (rotated) outline,
which is what the exact engine checks against."""
cells = self._cells_in_box(box, margin)
if not isinstance(shape, Simplex) or shape.is_int_box():
yield from cells
return
for cell in cells:
c = self._cell_center(cell)
if overlaps_2d(shape, IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin)):
yield cell
def _rasterize_obstacles(self) -> None:
margin = self.clearance + self.half_width
outline = self.board.outline
@@ -400,7 +446,7 @@ class GridRouter:
continue
net = item.net_nos[0] if item.net_nos else 0
pin_layers = [layer for layer in item.layers if layer in self.layers]
for cell in self._cells_in_box(box, margin):
for cell in self._pad_cells(item.shape, box, margin):
for layer in pin_layers:
self._pad_block.setdefault((*cell, layer), set()).add(net)
# Copper of the pin's own net: a trace inside it adds nothing,
@@ -499,14 +545,19 @@ class GridRouter:
if owner is not None and conns[owner].net_no != net_no:
blocking = owner
else:
via_owner = self._via_owner.get(cell)
if via_owner is not None and conns[via_owner].net_no != net_no:
blocking = via_owner
blocking = self._via_blocker(cell, net_no, conns)
if blocking is None:
for i in self._halo.get(node, ()):
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None:
# another net's pin stub comes too close, though the cell it once
# claimed may since have been freed by a rip of its first owner
for i in self._stub_near.get(node, ()):
if conns[i].net_no != net_no:
blocking = i
break
if blocking is None:
return 0.0
if not allow_ripup:
@@ -516,6 +567,17 @@ class GridRouter:
return None
return self.rip_penalty * (1 + rip_count)
def _via_others(self, cell: Cell, net_no: int, conns: list[_Connection]) -> list[int]:
"""Connections of other nets whose via covers ``cell``: all of them,
since two connections of a net can share one via."""
return [i for i in self._via_claims.get(cell, ()) if conns[i].net_no != net_no]
def _via_blocker(self, cell: Cell, net_no: int, conns: list[_Connection]) -> int | None:
"""The other net's via connection on ``cell`` hardest to rip (the
penalty and the rip cap go by it), or None."""
others = self._via_others(cell, net_no, conns)
return max(others, key=lambda i: conns[i].rip_count) if others else None
def _via_cells(self, cell: Cell) -> list[Cell]:
"""The cell a via sits in plus the neighbours its copper reaches."""
r = self.via_reach
@@ -589,18 +651,19 @@ class GridRouter:
return self.half_width if conn.narrow else self._net_half.get(conn.net_no, self.half_width)
def _spread_for(self, half: int) -> list[tuple[int, int]]:
"""Cells a trace of ``half`` also needs free: those whose centre is
within its extra half width (plus half a cell, for the grid)."""
"""Cells a trace of ``half`` also needs free: every cell out to its
extra half width, rounded up to whole cells. Blocks, halos and via
footprints are sized for a default-width trace, and copper is checked
as boxes, so anything within that square could be too close."""
extra = half - self.half_width
if extra <= 0:
return []
reach = math.ceil(extra / self.step) + 1
limit = extra + self.step / 2
reach = math.ceil(extra / self.step)
return [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step <= limit
if dx or dy
]
def _halo_for(self, half: int) -> list[tuple[int, int]]:
@@ -613,7 +676,7 @@ class GridRouter:
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step < need
if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need
]
return offsets
@@ -634,8 +697,8 @@ class GridRouter:
if not conn.b_group[0] and any(z.layer in self._power_layers for z in conn.b_group[1]):
# the plane is everywhere: aim at where the route starts instead
goal_cell = self._group_target(conn.a_group)
starts = self._group_ends(conn.a_group, conn.net_no, conns)
goals = self._group_ends(conn.b_group, conn.net_no, conns)
starts = self._group_ends(conn.a_group, conn.net_no, conns, allow_ripup)
goals = self._group_ends(conn.b_group, conn.net_no, conns, allow_ripup)
if not starts or not goals:
return None
# A start that is already an end joins at once, except that ending on
@@ -656,10 +719,19 @@ class GridRouter:
came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked
# a pin end costs its stub; a zone or plane end is entered normally
goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if isinstance(p, Pin)}
# (and in rip-up mode the rip penalty of whatever lies on it)
goal_stub = {
n: self._stub_cost(p, n) + self._end_penalty(p, n, net_no, allow_ripup, conns)
for n, p in goals.items()
if isinstance(p, Pin)
}
for s in sorted(starts):
pin = starts[s]
g_score[s] = self._stub_cost(pin, s) if isinstance(pin, Pin) else 0.0
g_score[s] = (
self._stub_cost(pin, s) + self._end_penalty(pin, s, net_no, allow_ripup, conns)
if isinstance(pin, Pin)
else 0.0
)
heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s))
# Via footprints are checked lazily: a layer change goes on the heap as
@@ -727,6 +799,8 @@ class GridRouter:
continue
if self._doorways:
enter += self._access(nxt, net_no)
if self._history or self._present is not None:
enter += self._congestion(nxt, net_no)
step = 1.0 if dx == 0 or dy == 0 else math.sqrt(2)
self._relax(
current, nxt, base + step + enter, goal_cell, g_score, came_from, open_heap
@@ -739,9 +813,12 @@ class GridRouter:
if layer == cl:
continue
nxt = (cx, cy, layer)
if g_via < g_score.get(nxt, math.inf):
g_layer = g_via
if self._history or self._present is not None:
g_layer += self._congestion(nxt, net_no)
if g_layer < g_score.get(nxt, math.inf):
heapq.heappush(
open_heap, (g_via + self._h(nxt, goal_cell), nxt, g_via, current)
open_heap, (g_layer + self._h(nxt, goal_cell), nxt, g_layer, current)
)
return None
@@ -790,36 +867,176 @@ class GridRouter:
math.hypot(loc.x - point.x, loc.y - point.y) < reach for loc, reach in self._tht_holes
)
def _stub_points(self, pin_location: IntPoint, cell: Cell) -> list[IntPoint]:
"""The stub's corners, from the pin to its cell centre.
The direct segment is fine for 45-degree routing. Orthogonal routing
leaves the pin straight along the axis it is further off the cell on
and jogs across to the centre at the end: an L of two flush boxes,
which is what the exact check sees. A single slanted segment is
checked as its bounding box there, and a 1 mm trace to a column
0.1 mm off a 1.27 mm pitch pin's centreline then lands inside the
clearance of the pad next door, though the straight exit clears it.
"""
end = self._cell_center(cell)
head = [pin_location]
start = pin_location
exit_point = self._axis_exits.get(pin_location)
if exit_point is not None:
# a pin on a turned fine-pitch pad first leaves along the pad's
# axis, past the pin row, then takes the usual L to its cell
head = [pin_location, exit_point]
start = exit_point
dx, dy = end.x - start.x, end.y - start.y
if not self._orthogonal or dx == 0 or dy == 0:
tail = [end]
elif abs(dx) >= abs(dy):
tail = [IntPoint(end.x, start.y), end]
else:
tail = [IntPoint(start.x, end.y), end]
points = []
for point in head + tail:
if not points or points[-1] != point:
points.append(point)
return points if len(points) > 1 else [pin_location, end]
def _leg_shapes(self, points: list[IntPoint], half: int, grow: int = 0) -> list:
"""A stub's copper, grown by ``grow``: a box per straight leg and per
inner corner, and an exact slab for a slanted leg (a pad-axis exit),
whose bounding box would reach the neighbouring pads."""
shapes: list = []
for a, b in zip(points, points[1:], strict=False):
if a == b:
continue
if a.x == b.x or a.y == b.y:
box = segment_box(a, b, half)
shapes.append(box.offset(grow) if grow else box)
else:
shapes.append(segment_slab(a, b, half + grow))
for c in points[1:-1]:
box = corner_box(c, half)
shapes.append(box.offset(grow) if grow else box)
return shapes
def _axis_exit(self, pin: Pin) -> IntPoint | None:
"""Where a pin on a turned fine-pitch pad clears its pin row.
A QFN placed at 45 degrees has no straight exit on the grid: every
horizontal or vertical step from a pin passes its neighbours closer
than the clearance. The way out is along the pad's own axis, away
from the part, until an L from there to a grid cell clears every
other net's pad (checked on exact shapes, as the exact engine will).
Any angle off the grid axes counts, not just 45 degrees.
"""
loc = pin.location
if loc in self._axis_exits:
return self._axis_exits[loc]
self._axis_exits[loc] = None
shape = pin.shape
if not isinstance(shape, Simplex) or shape.is_int_box():
return None
corners = [shape.corner_approx(i) for i in range(shape.border_line_count())]
edges = [(b.x - a.x, b.y - a.y) for a, b in zip(corners, corners[1:] + corners[:1], strict=False)]
lengths = [math.hypot(dx, dy) for dx, dy in edges]
if len(edges) < 3 or max(lengths) < 1.25 * min(lengths):
return None # no long axis to leave along
dx, dy = edges[lengths.index(max(lengths))]
ux, uy = dx / max(lengths), dy / max(lengths)
if min(abs(ux), abs(uy)) < 0.17:
return None # within ten degrees of a grid axis: the usual exits work
centre = self._part_centre(pin)
if centre is None:
return None
if (loc.x - centre[0]) * ux + (loc.y - centre[1]) * uy < 0:
ux, uy = -ux, -uy
reach = max((c.x - loc.x) * ux + (c.y - loc.y) * uy for c in corners)
h, cl = self.half_width, self.clearance
span = reach + h + cl + 16 * self.step
others = [
p.shape for p in self.board.get_pins()
if p.shape is not None and p is not pin and not set(p.net_nos) & set(pin.net_nos)
and set(p.layers) & set(pin.layers)
and abs(p.location.x - loc.x) < 2 * span and abs(p.location.y - loc.y) < 2 * span
]
self._axis_pads[loc] = others
for j in range(16):
d = reach + h + cl + j * self.step / 2
exit_point = IntPoint(round(loc.x + ux * d), round(loc.y + uy * d))
out = segment_slab(loc, exit_point, h + cl)
if any(overlaps_2d(out, o) for o in others):
continue
ex, ey = self._cell_of(exit_point)
self._axis_exits[loc] = exit_point
for ddx, ddy in itertools.product((0, -1, 1), repeat=2):
cell = (ex + ddx, ey + ddy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
legs = self._leg_shapes(self._stub_points(loc, cell)[1:], h, cl)
if not any(overlaps_2d(g, o) for g in legs for o in others):
return exit_point
self._axis_exits[loc] = None
return None
def _exit_clips_a_pad(self, pin: Pin, cell: Cell) -> bool:
"""Whether the stub from an axis exit to ``cell`` comes closer than the
clearance to another net's pad (exactly, as the exact engine checks):
the exit was chosen with one clear cell, not every cell around it."""
legs = self._leg_shapes(
self._stub_points(pin.location, cell)[1:], self._cur_half, self.clearance
)
return any(overlaps_2d(g, pad) for g in legs for pad in self._axis_pads.get(pin.location, ()))
def _part_centre(self, pin: Pin) -> tuple[float, float] | None:
key = pin.component_no
if key not in self._part_centres:
mates = [p.location for p in self.board.get_pins() if p.component_no == key] if key else []
self._part_centres[key] = (
(sum(m.x for m in mates) / len(mates), sum(m.y for m in mates) / len(mates))
if len(mates) > 2 else None
)
return self._part_centres[key]
def _stub_cells(self, pin_location: IntPoint, cell: Cell, half: int | None = None) -> list[Cell]:
"""Cells where another net's trace would come too close to the stub."""
from freeroute.board.outline import _point_segment
end = self._cell_center(cell)
points = self._stub_points(pin_location, cell)
reach = (half or self._cur_half) + self.half_width + self.clearance
box = IntBox(
min(pin_location.x, end.x), min(pin_location.y, end.y),
max(pin_location.x, end.x), max(pin_location.y, end.y),
min(p.x for p in points), min(p.y for p in points),
max(p.x for p in points), max(p.y for p in points),
)
return [
c
for c in self._cells_in_box(box, reach)
if _point_segment(self._cell_center(c), pin_location, end) < reach
]
legs = list(zip(points, points[1:], strict=False))
joints = points[1:] # each carries a square corner tile
def near(c) -> bool:
p = self._cell_center(c)
if min(_point_segment(p, a, b) for a, b in legs) < reach:
return True
return any(max(abs(p.x - j.x), abs(p.y - j.y)) < reach for j in joints)
return [c for c in self._cells_in_box(box, reach) if near(c)]
def _stub_clear(
self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, keepouts_only: bool = False
self,
pin: Pin,
cell: Cell,
layer: int,
net_no: int,
conns,
keepouts_only: bool = False,
allow_ripup: bool = False,
) -> bool:
keepouts = self._keepout_boxes.get(layer)
if keepouts:
# the stub is off the grid, so check it exactly, as the exact
# engine will: its copper grown by clearance must miss every keepout
from freeroute.geometry.polyline import Polyline, PolylineShape
end = self._cell_center(cell)
if end != pin.location:
for tile in PolylineShape(Polyline([pin.location, end]), self._cur_half).tiles():
grown = tile.offset(self.clearance)
if any(k.overlaps(grown if needs else tile) for k, needs in keepouts):
points = self._stub_points(pin.location, cell)
if points[-1] != pin.location:
legs = self._leg_shapes(points, self._cur_half)
grown_legs = self._leg_shapes(points, self._cur_half, self.clearance)
for tile, grown in zip(legs, grown_legs, strict=True):
if any(overlaps_2d(k, grown if needs else tile) for k, needs in keepouts):
return False
# the track's round end at the pin, for a rule area
cap = corner_box(pin.location, self._cur_half)
@@ -827,14 +1044,69 @@ class GridRouter:
return False
if keepouts_only:
return True
for c in self._stub_cells(pin.location, cell):
owner = self._cell_owner.get((*c, layer))
if owner is not None and conns[owner].net_no != net_no:
return False
via_owner = self._via_owner.get(c)
if via_owner is not None and conns[via_owner].net_no != net_no:
return False
return True
owners = self._stub_owners(pin, cell, layer, net_no, conns)
# in rip-up mode a route on the stub is one more to rip, unless it may not be
return not owners or (
allow_ripup and all(conns[i].rip_count < self.rip_cap for i in owners)
)
def _stub_tiles(self, pin_location: IntPoint, cell: Cell, half: int, grow: int = 0) -> list:
"""The stub's copper (grown by ``grow``): its legs, and a square at each
corner and at the cell end, where the route carries on."""
points = self._stub_points(pin_location, cell)
tiles: list = [
corner_box(point, half).offset(grow) if grow else corner_box(point, half)
for point in points[1:]
]
if len(points) > 1 and points[-1] != pin_location:
tiles += self._leg_shapes(points, half, grow)
return tiles
def _stub_owners(
self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, half: int | None = None
) -> set[int]:
"""Other nets' routes whose copper the stub from ``pin`` to ``cell`` would touch."""
owners: set[int] = set()
grown = None
for c in self._stub_cells(pin.location, cell, half):
node = (*c, layer)
owner = self._cell_owner.get(node)
if owner is not None and conns[owner].net_no != net_no and owner not in owners:
# Both stubs are off the grid, and each claims the cells a
# trace centred there would crowd, so two stubs side by side
# (neighbouring pins leaving straight) overlap in claims well
# before their copper comes near. Check those exactly.
other = [tiles for lay, tiles, nodes in conns[owner].stubs if node in nodes]
if other:
if grown is None:
grown = self._stub_tiles(
pin.location, cell, half or self._cur_half, self.clearance
)
if not any(overlaps_2d(g, t) for tiles in other for t in tiles for g in grown):
continue
owners.add(owner)
for idx in self._stub_near.get(node, ()):
if idx in owners or conns[idx].net_no == net_no:
continue
theirs = [tiles for lay, tiles, _ in conns[idx].stubs if lay == layer]
if theirs:
if grown is None:
grown = self._stub_tiles(
pin.location, cell, half or self._cur_half, self.clearance
)
if any(overlaps_2d(g, t) for tiles in theirs for t in tiles for g in grown):
owners.add(idx)
owners.update(self._via_others(c, net_no, conns))
return owners
def _end_penalty(self, pin: Pin, node: Node, net_no: int, allow_ripup: bool, conns) -> float:
"""Rip-up cost of starting or ending on ``node``: the routes on its
cell and on the stub to it, each at the penalty entering it would cost."""
if not allow_ripup:
return 0.0
owners = self._stub_owners(pin, node[:2], node[2], net_no, conns)
owners |= self._crossed_owners([node], net_no, conns)
return sum(self.rip_penalty * (1 + conns[i].rip_count) for i in owners)
def _zone_nodes(self, zone) -> list[Node]:
"""Cells of a zone where a via of this net fits inside its copper; for a
@@ -882,11 +1154,13 @@ class GridRouter:
return zone.name or "zone"
return ""
def _group_ends(self, group, net_no: int, conns) -> dict[Node, Pin | None]:
def _group_ends(
self, group, net_no: int, conns, allow_ripup: bool = False
) -> dict[Node, Pin | None]:
"""End nodes of a group, each mapped to its pin (None for a zone cell)."""
ends: dict[Node, Pin | None] = {}
for pin in group[0]:
for node in sorted(self._end_nodes(pin, net_no, conns)):
for node in sorted(self._end_nodes(pin, net_no, conns, allow_ripup)):
ends.setdefault(node, pin)
for zone in group[1]:
plane = zone.layer in self._power_layers
@@ -902,34 +1176,54 @@ class GridRouter:
return self._cell_of(IntPoint((box.ll.x + box.ur.x) // 2, (box.ll.y + box.ur.y) // 2))
def _stub_cost(self, pin: Pin, node: Node) -> float:
c = self._cell_center(node[:2])
dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y)
if self._orthogonal: # measured the way the route itself is allowed to move
return (dx + dy) / self.step
return math.hypot(dx, dy) / self.step
if self._axis_exits.get(pin.location) is None:
c = self._cell_center(node[:2])
dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y)
if self._orthogonal: # measured the way the route itself is allowed to move
return (dx + dy) / self.step
return math.hypot(dx, dy) / self.step
points = self._stub_points(pin.location, node[:2])
return sum(
math.hypot(b.x - a.x, b.y - a.y) for a, b in zip(points, points[1:], strict=False)
) / self.step
def _end_nodes(self, pin: Pin, net_no: int, conns) -> set[Node]:
def _end_nodes(self, pin: Pin, net_no: int, conns, allow_ripup: bool = False) -> set[Node]:
"""Nodes a route may start or end on for ``pin``: the nearest cell and
its neighbours, where the cell is enterable and the stub to it is clear.
A crowded fine-pitch pin gets more than one way out."""
A crowded fine-pitch pin gets more than one way out. In rip-up mode a
cell or stub another route is on counts as enterable, as it does
everywhere else, or a pin whose only way out another net has taken
could never have it back."""
layers = [layer for layer in pin.layers if layer in self.layers]
cx, cy = self._cell_of(pin.location)
exit_point = None
if self._orthogonal and self._crowded(pin, net_no):
exit_point = self._axis_exit(pin)
cx, cy = self._cell_of(exit_point if exit_point is not None else pin.location)
nodes: set[Node] = set()
for dx, dy in ((0, 0), *self._neighbours):
cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
if exit_point is not None and self._exit_clips_a_pad(pin, cell):
continue
for layer in layers:
if (dx or dy) and self._enter_cost(cell, layer, net_no, False, conns) is None:
# the pin's own cell is in its pad; an exit cell is not
checked = dx or dy or exit_point is not None
if checked and self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None:
continue
if self._stub_clear(pin, cell, layer, net_no, conns):
if self._stub_clear(pin, cell, layer, net_no, conns, allow_ripup=allow_ripup):
nodes.add((*cell, layer))
# Also offer exact escape stubs: a fine-pitch pin's nearby cells can be
# a pocket inside its own pad that leads nowhere (the grid columns miss
# the narrow legal band past the pad tip). The stub's length is its
# cost, so it is only taken where a short step would not do.
if self._orthogonal and self._crowded(pin, net_no) and self._trapped(pin, net_no, nodes):
nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy))
nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy), allow_ripup)
if not nodes and exit_point is not None:
# every cell past the pin row is taken: report no way out, so the
# search goes on to rip-up, rather than fall back to a cell out in
# the open on top of another net's route
return nodes
if not nodes: # nothing clear: fall back to the nearest cell, as before
nodes = {(cx, cy, layer) for layer in layers}
if self._keepout_boxes and not any(
@@ -995,14 +1289,15 @@ class GridRouter:
self._trapped_cache[key] = trapped
return trapped
def _escape_nodes(self, pin: Pin, net_no: int, conns, layers, origin: Cell) -> set[Node]:
def _escape_nodes(
self, pin: Pin, net_no: int, conns, layers, origin: Cell, allow_ripup: bool = False
) -> set[Node]:
"""For a pin walled in on the grid (a fine-pitch pin whose legal band
the grid columns miss): look along each axis for the first open cell
whose straight stub from the pin clears every other net's pad by the
full margin, measured exactly rather than on the grid."""
from freeroute.board.outline import _segment_box
whose stub from the pin clears every other net's pad by the full
clearance, checked on the stub's copper tiles as the exact engine
will rather than on the grid."""
margin = self.half_width + self.clearance
reach = self._escape_cells
near = [
(p.shape.bounding_box(), set(p.layers))
@@ -1014,24 +1309,30 @@ class GridRouter:
]
cx, cy = origin
nodes: set[Node] = set()
for dx, dy in _NEIGHBOURS[:4]:
# the pin's own column or row and the one either side: two pins side
# by side can then jog apart at the end of their straight exits
for (dx, dy), side in itertools.product(_NEIGHBOURS[:4], (0, -1, 1)):
sx, sy = (side, 0) if dx == 0 else (0, side)
nodes_before = len(nodes)
for k in range(2, reach + 1):
cell = (cx + dx * k, cy + dy * k)
cell = (cx + dx * k + sx, cy + dy * k + sy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
break
end = self._cell_center(cell)
grown = self._leg_shapes(
self._stub_points(pin.location, cell), self._cur_half, self.clearance
)
for layer in layers:
if self._enter_cost(cell, layer, net_no, False, conns) is None:
if self._enter_cost(cell, layer, net_no, allow_ripup, conns) is None:
continue
if any(
layer in box_layers and _segment_box(pin.location, end, box) < margin
layer in box_layers and any(overlaps_2d(g, box) for g in grown)
for box, box_layers in near
):
continue
if self._stub_clear(pin, cell, layer, net_no, conns):
if self._stub_clear(pin, cell, layer, net_no, conns, allow_ripup=allow_ripup):
nodes.add((*cell, layer))
if any(n[:2] == cell for n in nodes):
break # the first open cell along this axis is enough
if len(nodes) > nodes_before:
break # the first open cell along this line is enough
return nodes
def _relax(self, current, nxt, tentative, goal_cell, g_score, came_from, open_heap):
@@ -1081,19 +1382,27 @@ class GridRouter:
conn.via_halo = []
for cell in conn.via_cells:
for c in self._via_cells(cell):
if c not in self._via_owner:
self._via_owner[c] = conn.index
claims = self._via_claims.setdefault(c, [])
if conn.index not in claims:
claims.append(conn.index)
conn.via_halo.append(c)
ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else ()
conn.stubs = []
self._forget_stubs(conn)
for pin, (layer, cells), at in ends:
if not isinstance(pin, Pin):
continue # ended on a zone or a plane: no stub
cell = cells[at]
claimed: set[Node] = set()
for c in self._stub_cells(pin.location, cell, half):
node = (*c, layer)
self._stub_near.setdefault(node, set()).add(conn.index)
conn.stub_near.append(node)
if node not in self._cell_owner:
self._cell_owner[node] = conn.index
conn.trace_cells.append((c, layer))
claimed.add(node)
conn.stubs.append((layer, self._stub_tiles(pin.location, cell, half), claimed))
# A pin inside a wire keepout can only leave by a via. Its stub to the
# cell centre would be track inside the keepout, so the via goes on
# the pin itself (a via in its own pad) and the stub is left out.
@@ -1111,9 +1420,9 @@ class GridRouter:
continue
points = [self._cell_center(c) for c in cells]
if idx == 0 and isinstance(conn.a_end, Pin):
points.insert(0, conn.a_end.location)
points[0:0] = self._stub_points(conn.a_end.location, cells[0])[:-1]
if idx == len(wire_segs) - 1 and isinstance(conn.b_end, Pin):
points.append(conn.b_end.location)
points.extend(self._stub_points(conn.b_end.location, cells[-1])[-2::-1])
points = _simplify(points)
if len(points) >= 2:
conn.wires.append((layer, points))
@@ -1124,13 +1433,25 @@ class GridRouter:
conn.routed = True
self._settle(conn, -1)
def _forget_stubs(self, conn: _Connection) -> None:
for node in conn.stub_near:
near = self._stub_near.get(node)
if near is not None:
near.discard(conn.index)
if not near:
del self._stub_near[node]
conn.stub_near = []
def _rip(self, conn: _Connection) -> None:
for cell, layer in conn.trace_cells:
if self._cell_owner.get((*cell, layer)) == conn.index:
del self._cell_owner[(*cell, layer)]
for cell in conn.via_halo:
if self._via_owner.get(cell) == conn.index:
del self._via_owner[cell]
claims = self._via_claims.get(cell)
if claims is not None and conn.index in claims:
claims.remove(conn.index)
if claims is not None and not claims:
del self._via_claims[cell]
conn.via_halo = []
for node in conn.halo_nodes:
claims = self._halo.get(node)
@@ -1140,6 +1461,8 @@ class GridRouter:
del self._halo[node]
conn.halo_nodes = []
conn.trace_cells = []
conn.stubs = []
self._forget_stubs(conn)
for cell in conn.via_cells:
count = self._via_nets.get(cell)
if count and count.get(conn.net_no):
@@ -1153,18 +1476,32 @@ class GridRouter:
conn.rip_count += 1
self._settle(conn, +1)
def _crossed_owners(self, path: list[Node], net_no: int, conns: list[_Connection]) -> set[int]:
def _crossed_owners(
self, path: list[Node], net_no: int, conns: list[_Connection], spread=None
) -> set[int]:
"""Connections whose copper or clearance ``path`` crosses; a trace
wider than the default crosses them on its ``spread`` cells too."""
spread = self._spread if spread is None else spread
if spread:
path = list(
dict.fromkeys(
(x + dx, y + dy, layer)
for x, y, layer in path
for dx, dy in ((0, 0), *spread)
)
)
owners: set[int] = set()
for cx, cy, cl in path:
owner = self._cell_owner.get((cx, cy, cl))
if owner is not None and conns[owner].net_no != net_no:
owners.add(owner)
via_owner = self._via_owner.get((cx, cy))
if via_owner is not None and conns[via_owner].net_no != net_no:
owners.add(via_owner)
owners.update(self._via_others((cx, cy), net_no, conns))
for i in self._halo.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
for i in self._stub_near.get((cx, cy, cl), ()):
if conns[i].net_no != net_no:
owners.add(i)
return owners
# --- pin access ---------------------------------------------------------
@@ -1211,6 +1548,74 @@ class GridRouter:
return self.access_cost
return 0.0
# --- negotiated congestion --------------------------------------------
def _congestion(self, node: Node, net_no: int) -> float:
"""History of a contested cell, plus while negotiating the price of
sharing it with the other nets on it now."""
cost = self._history.get(node, 0.0)
if self._present is not None:
users = self._present.get(node)
if users:
others = len(users) - (1 if net_no in users else 0)
cost += self._present_cost * others
return cost
def _footprint(self, path: list[Node]) -> set[Node]:
"""The nodes a route claims: its cells and their clearance halo on
its layer, and every via's footprint on all layers."""
wire_segs, via_cells = _split_path(path)
halo = [(0, 0), *self._halo_offsets]
nodes: set[Node] = set()
for layer, cells in wire_segs:
for cx, cy in cells:
for dx, dy in halo:
nodes.add((cx + dx, cy + dy, layer))
for cell in via_cells:
for c in self._via_cells(cell):
for layer in self.layers:
nodes.add((*c, layer))
return nodes
def _negotiate(self, order: list[_Connection], conns: list[_Connection]) -> None:
"""Route every connection on the empty board ``self.negotiate`` times,
letting routes share cells at a price: the number of other nets on a
cell times a factor that doubles every round, plus the cell's history,
which grows by one for every extra net each round it stays shared.
Only the history is kept; the board is left empty."""
present: dict[Node, dict[int, int]] = {}
claimed: dict[int, set[Node]] = {}
ends = [(c, c.a_end, c.b_end) for c in conns]
self._present, self._present_cost = present, 0.5
try:
for _ in range(self.negotiate):
for conn in order:
for node in claimed.pop(conn.index, ()):
users = present[node]
users[conn.net_no] -= 1
if not users[conn.net_no]:
del users[conn.net_no]
path = self._search(conn, allow_ripup=False, conns=conns)
if path is None:
continue
nodes = self._footprint(path)
claimed[conn.index] = nodes
for node in nodes:
users = present.setdefault(node, {})
users[conn.net_no] = users.get(conn.net_no, 0) + 1
shared = 0
for node, users in present.items():
if len(users) > 1:
shared += 1
self._history[node] = self._history.get(node, 0.0) + len(users) - 1
if not shared:
break
self._present_cost *= 2
finally:
self._present = None
for conn, a_end, b_end in ends: # the search picks ends; undo that
conn.a_end, conn.b_end = a_end, b_end
# --- passes -------------------------------------------------------------
def route(self) -> RouteResult:
@@ -1234,6 +1639,8 @@ class GridRouter:
if self.route_first:
# stable: the promoted keep their order among themselves, as do the rest
order.sort(key=lambda c: (c.net_no, *_ends_of(c)) not in self.route_first)
if self.negotiate:
self._negotiate(order, conns)
for _ in range(passes):
changed = False
for conn in order:
@@ -1272,7 +1679,16 @@ class GridRouter:
path = self._search(conn, allow_ripup=True, conns=conns)
if path is None:
return False
victims = [conns[i] for i in sorted(self._crossed_owners(path, conn.net_no, conns))]
crossed = self._crossed_owners(
path, conn.net_no, conns, self._spread_for(self._conn_half(conn))
)
# and the routes on the stubs from the pins to the path's ends
for end, node in ((conn.a_end, path[0]), (conn.b_end, path[-1])):
if isinstance(end, Pin):
crossed |= self._stub_owners(
end, node[:2], node[2], conn.net_no, conns, self._conn_half(conn)
)
victims = [conns[i] for i in sorted(crossed)]
if self.rollback:
return self._ripup_transaction(conn, path, victims, conns)
for victim in victims:
@@ -1621,6 +2037,7 @@ def route_board(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
@@ -1643,6 +2060,7 @@ def route_board(
seed=seed,
route_first=route_first,
access_cost=access_cost,
negotiate=negotiate,
rollback=rollback,
net_widths=net_widths,
via_drill=via_drill,
+8
View File
@@ -118,6 +118,7 @@ def route_dsn_board(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]:
@@ -143,6 +144,7 @@ def route_dsn_board(
seed=seed,
route_first=route_first,
access_cost=access_cost,
negotiate=negotiate,
rollback=rollback,
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
@@ -196,6 +198,7 @@ def build_routing_result(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> RoutingResult:
@@ -205,6 +208,7 @@ def build_routing_result(
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first,
access_cost=access_cost,
negotiate=negotiate,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback,
)
@@ -225,6 +229,7 @@ def route_dsn_board_exact(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]:
@@ -254,6 +259,7 @@ def route_dsn_board_exact(
seed=seed,
route_first=route_first,
access_cost=access_cost,
negotiate=negotiate,
rollback=rollback,
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
@@ -276,6 +282,7 @@ def build_exact_routing_result(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
negotiate: int = 0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> RoutingResult:
@@ -293,6 +300,7 @@ def build_exact_routing_result(
seed=seed,
route_first=route_first,
access_cost=access_cost,
negotiate=negotiate,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback,
)
+113
View File
@@ -0,0 +1,113 @@
"""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 freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint, overlaps_2d, segment_slab
from freeroute.route.grid_router import GridRouter
from freeroute.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 freeroute.geometry import IntBox
assert not overlaps_2d(slab, IntBox(8000, 0, 8100, 100))
assert overlaps_2d(slab, IntBox(4900, 4900, 5100, 5100))
+284
View File
@@ -0,0 +1,284 @@
"""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 freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntBox, Simplex
from freeroute.route.grid_router import GridRouter
from freeroute.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 freeroute.board.outline import BoardOutline
from freeroute.geometry import IntBox, IntPoint
from freeroute.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)
+65
View File
@@ -0,0 +1,65 @@
"""--negotiate: PathFinder-style rounds on the empty board before routing."""
from __future__ import annotations
from pathlib import Path
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
def _router(name, negotiate):
dsn = parse_dsn((FIXTURES / name).read_text())
s = dsn.resolution.value
return GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
negotiate=negotiate,
)
def test_negotiation_leaves_the_board_empty_and_the_ends_as_they_were():
router = _router("true_density.dsn", 3)
conns = router._conns = router._build_connections()
ends = [(c.a_end, c.b_end) for c in conns]
router._negotiate(list(conns), conns)
assert router._history # a dense board has contested cells
assert not router._cell_owner and not router._halo and not router._via_claims
assert not any(c.routed for c in conns)
assert [(c.a_end, c.b_end) for c in conns] == ends
assert router._present is None
def test_sharing_is_priced_only_for_other_nets():
router = _router("true_density.dsn", 1)
node = (3, 4, 0)
router._history[node] = 2.0
router._present, router._present_cost = {node: {7: 1, 9: 2}}, 0.5
assert router._congestion(node, 7) == pytest.approx(2.5) # one other net
assert router._congestion(node, 5) == pytest.approx(3.0) # two other nets
router._present = None
assert router._congestion(node, 5) == pytest.approx(2.0) # history only, after
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_negotiated_routing_keeps_the_occupancy_honest(name):
"""Only routed connections hold cells afterwards, as without negotiation."""
router = _router(name, 3)
router.route()
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for claims in router._halo.values() for i in claims)
def test_option_reaches_the_router_and_is_off_by_default():
text = (FIXTURES / "kicad_routable.dsn").read_text()
plain, _, _ = route_dsn_board(parse_dsn(text))
again, _, _ = route_dsn_board(parse_dsn(text), negotiate=0)
assert plain.unrouted == again.unrouted
+8 -5
View File
@@ -51,19 +51,22 @@ def test_routes_keep_edge_clearance_at_a_rounded_corner():
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):
@pytest.mark.parametrize("resolution", [1, 4])
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution):
# 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.
# wide. At resolution 4 the 0.1 mm columns cannot miss it. At resolution
# 1 the 0.4 mm grid misses most bands, but a stub leaves its pin straight
# along the band and jogs to the column only past the pads, so every pin
# gets out there too, as the JAR manages (only 2 did before).
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
assert len(result.routed_net_numbers) == 5
assert result.drc_clean
def test_resolution_one_has_no_halo_so_the_grid_is_unchanged():
+1 -1
View File
@@ -31,7 +31,7 @@ def test_occupancy_only_holds_routed_connections(name):
router, _ = _routed_router(name)
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for i in router._via_owner.values())
assert all(conns[i].routed for claims in router._via_claims.values() for i in claims)
assert all(conns[i].routed for claims in router._halo.values() for i in claims)
+58
View File
@@ -0,0 +1,58 @@
"""Pins of a 1.27 mm pitch row routed at 1.0 mm (the RN2483 module on the
TTN Enschede mote). A trace centre has to stay within 0.06 mm of a pin's
centreline to pass its neighbours, which no grid column hits, so a route
leaves each pin straight along that line and only jogs to its column once
past the pads. FreeRouting routes this row in full the same way."""
from __future__ import annotations
import pytest
from freeroute.board import build_board
from freeroute.board.items import Pin
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import route_dsn_board_exact
def _row(x0: int, pins: int = 6, skew: int = 3000) -> str:
places = " ".join(f"(place R{i} {x0 + 1270 * i} -5000 front 0)" for i in range(pins))
places += " " + " ".join(
f"(place T{i} {x0 + 1270 * i + skew} -15000 front 0)" for i in range(pins)
)
nets = " ".join(f"(net N{i} (pins R{i}-1 T{i}-1))" for i in range(pins))
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 0 0 30000 0 30000 -20000 0 -20000 0 0))
(via "Via[0-1]_600:400_um") (rule (width 1000) (clearance 200)))
(placement (component P {places}))
(library (image P (pin pad 1 0 0))
(padstack pad (shape (rect F.Cu -508 -762 508 762)) (attach off))
(padstack "Via[0-1]_600:400_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600))
(attach off)))
(network {nets}))"""
@pytest.mark.parametrize("x0", [5000, 5150, 5300])
def test_every_pin_of_a_row_leaves_straight_and_routes(x0):
# three offsets of the row against the grid; before, 2 or 3 of 6 routed
result, _, _ = route_dsn_board_exact(parse_dsn(_row(x0)), resolution=2)
assert len(result.routed_net_numbers) == 6
assert result.drc_clean
def test_a_walled_in_pin_may_escape_to_the_column_either_side():
# two pins side by side leaving the same way can then jog apart
board = build_board(parse_dsn(_row(5000)))
router = GridRouter(board, trace_width=10000, clearance=2000, orthogonal=True, resolution=2)
router._conns = router._build_connections()
pin = next(
p for p in board.get_items()
if isinstance(p, Pin) and p.location == IntPoint(50000 + 2 * 12700, -50000)
)
net = pin.net_nos[0]
cx, cy = router._cell_of(pin.location)
nodes = router._escape_nodes(pin, net, router._conns, [0], (cx, cy))
columns = {x for x, y, _ in nodes if y != cy}
assert {cx - 1, cx, cx + 1} <= columns
+1
View File
@@ -158,6 +158,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"],
"negotiate": ["--negotiate", "2"],
"rollback": ["--no-rollback"],
}
for engine, options in ENGINE_OPTIONS.items():