Compare commits

...
Author SHA1 Message Date
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
rsp2k 83ad0f8ef7 Keep a route's own vias apart while it is searched
Committed vias were checked for hole spacing, but a route's own vias are
not committed until it is done, so a route hopping layers (more of them
on 6 layers) could put two of its vias 0.41 mm apart, as th-eda's
6-layer flight controller showed. A via is now also checked against
layer changes earlier on its own route.
2026-09-29 03:10:52 -06:00
rsp2k 6e852a929b Hole spacing to a fixed via measured from where it is, with its own drill
A fixed via sits anywhere, not on a cell centre, and may have a bigger
drill than the router's via; checking hole spacing between cell centres
with one drill let the flight controller's routed 0.2 mm via come 0.19
mm from a hand-placed 0.25 mm one. The board now carries each fixed
via's drill (from KiCad's padstack name), and cells are checked against
fixed vias exactly.
2026-09-29 02:34:17 -06:00
rsp2k f56bb10996 Test: the session never repeats wiring already on the board 2026-09-29 01:17:59 -06:00
rsp2k 8f656006a6 Merge fix/hole-spacing-pads: hole spacing from through-hole pads and fixed vias 2026-09-29 01:16:59 -06:00
rsp2k 6cc2406583 Hole spacing also from through-hole pads and fixed vias
A route could drop a via next to (or onto) a through-hole pad of its own
net, where it changes layer for free, and a via reusing a fixed via's
cell came out a little off the fixed via's centre, beside it. No via now
goes within hole spacing of a through-hole pad of any net; a via in a pad
is checked exactly against every via and hole; and a routed via on a
fixed via of its net is not written. charge-sense: 4 hole_to_hole to 0;
flight controller: 2 to 0, unconnected unchanged.
2026-09-29 01:13:16 -06:00
rsp2k 30297e94dc Merge feat/net-widths: fixed wiring, plane via checks, class widths, via spacing, fan-out connectivity 2026-09-29 00:26:02 -06:00
rsp2k 45b3753cff Count copper already on the board as connections already made
Fixed tracks and vias only kept other nets away, so a pin fanned out to
its plane was routed to the plane again, beside its own fan-out. Pins,
zones and planes that fixed copper of their net touches now form one
connected group: a fanned-out supply pin needs nothing more, a fanned-out
signal routes from its via, and copper touching no pin or zone adds
nothing to route.
2026-09-29 00:16:56 -06:00
rsp2k 7d8e5d5c10 bench/diagnose.py: tell fixed copper, pads and keepouts apart as walls 2026-09-29 00:14:40 -06:00
rsp2k 8152619541 Write each plane via once in the exact engine's output too
The exact engine rebuilds its output from each connection's own vias,
so connections sharing a plane via each wrote a copy: th-eda's flight
controller had 71 spots carrying 2 to 4 vias of one net. Vias are now
written once per net and spot, and never on a via already on the board.
2026-09-28 23:56:23 -06:00
rsp2k 714fa873ae Keep hole-to-hole spacing between every two vias, one net's included
Every pin of a supply drops its own via to the plane, and nothing kept
two of them apart: the flight controller came back with 199 co-located
holes and 109 hole-to-hole errors, nearly all VDD_3V3 and GND. A via now
keeps its drill plus --hole-to-hole (default 0.25 mm, KiCad's) from
every via centre, fixed ones included, except an own-net via at the same
cell, which it reuses; such a via is written once, and none is written
on top of a via already on the board.
2026-09-28 21:52:52 -06:00
rsp2k 4a844e8cd7 Route wider net classes at their width, falling back where they don't fit
The grid is laid out for the default width; a wider trace also needs the
cells its extra half width reaches to be free, and claims a wider halo.
A connection that does not fit at its class width (typically at a
fine-pitch pin) is routed at the default instead and reported. A net is
written at its class width when all of its connections went in at it,
and the exact check verifies it at that width; nets that shove or the
diagonal pass reshaped go out at the default.
2026-09-28 21:16:40 -06:00
16 changed files with 1371 additions and 83 deletions
+41 -4
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,
)
@@ -106,6 +108,15 @@ def _capture_conflicts() -> list:
return log
def _wall_name(wall, names) -> str:
if isinstance(wall, str):
if wall.startswith("fixed copper of net "):
net = int(wall.rsplit(" ", 1)[1])
return f"fixed copper of {names.get(net, net)}"
return wall
return names.get(wall, "pads/keepouts")
def _gap(a, b) -> int:
dx = max(b.ll.x - a.ur.x, a.ll.x - b.ur.x, 0)
dy = max(b.ll.y - a.ur.y, a.ll.y - b.ur.y, 0)
@@ -118,6 +129,25 @@ def _fresh(seen) -> GridRouter:
return router
def _static_wall(router: GridRouter, node) -> str:
"""What static copper closes a node: a keepout, copper already on the
board (a fixed via or track, by net), or a pad."""
if node in router._keepout:
return "keepout"
fixed = getattr(router, "_diag_fixed", None)
if fixed is None:
fixed = router._diag_fixed = {}
margin = router.clearance + router.half_width
for net, layers, box in router.board.get_fixed_copper():
for cell in router._cells_in_box(box, margin):
for layer in layers:
fixed[(*cell, layer)] = net
net = fixed.get(node)
if net is not None:
return f"fixed copper of net {net}"
return "pads"
def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
"""How many nodes a pin can reach through free cells (up to FLOOD_LIMIT),
and which other nets' copper bounds the region."""
@@ -146,7 +176,7 @@ def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
for owner in owners:
walls[conns[owner].net_no] += 1
if not owners:
walls[0] += 1 # a pad or keepout
walls[_static_wall(router, n)] += 1
continue
seen.add(n)
frontier.append(n)
@@ -155,7 +185,7 @@ 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,
) -> dict:
dsn = parse_dsn(dsn_text)
names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
@@ -171,6 +201,10 @@ def diagnose(
resolution=resolution,
shove=shove,
access_cost=access_cost,
# 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
@@ -224,7 +258,9 @@ def diagnose(
if reach < FLOOD_LIMIT:
walled.append({
"pin": end, "reach": reach,
"walls": [names.get(n, "pads/keepouts") for n, _ in walls.most_common(4)],
"walls": [
_wall_name(n, names) for n, _ in walls.most_common(4)
],
})
row["kind"] = "walled-in" if walled else "congestion"
if walled:
@@ -240,11 +276,12 @@ 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("--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)
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
+38 -5
View File
@@ -23,6 +23,7 @@ the containment invariant, and pads are centre-symmetric in practice).
from __future__ import annotations
import math
import re
from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
@@ -38,6 +39,9 @@ from .transform import CoordinateTransform
__all__ = ["build_board"]
# DSN units per micrometre, for drills KiCad gives in um in via padstack names
_DSN_PER_UM = {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}
def build_board(dsn: DsnBoard) -> BasicBoard:
"""Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`."""
@@ -264,15 +268,39 @@ 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))
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):
@@ -503,10 +531,15 @@ def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransf
location = transform.dsn_to_board_point(via.x, via.y)
pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform)
box = pad.bounding_box()
drill = re.search(r"_([\d.]+):([\d.]+)_um", via.padstack)
drill_board = (
round(transform.dsn_to_board(float(drill.group(2)) * _DSN_PER_UM.get(dsn.unit, 1.0)))
if drill else 0
)
board.insert(
Via(
net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack,
location=location, layers=sorted(layers or signal_layers),
drill=drill_board, location=location, layers=sorted(layers or signal_layers),
shape=IntBox(box.ll.x + location.x, box.ll.y + location.y,
box.ur.x + location.x, box.ur.y + location.y),
)
+2
View File
@@ -117,6 +117,8 @@ class Via(Item):
#: layer indices the via's pads occupy, and the largest pad as a box
layers: list[int] = field(default_factory=list)
shape: object = None
#: drill diameter in board units, when the padstack name gives it
drill: int = 0
@dataclass
+2
View File
@@ -188,6 +188,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):
+18
View File
@@ -175,6 +175,14 @@ def build_parser() -> argparse.ArgumentParser:
"connections a rip-up displaces are rerouted at once and the rip-up is undone "
"if fewer connections end up routed",
)
parser.add_argument(
"--hole-to-hole",
type=float,
default=0.25,
metavar="MM",
help="grid and exact engines: least distance between the edges of any two via "
"holes, of one net or two (KiCad's min_hole_to_hole; default 0.25)",
)
parser.add_argument(
"--jobs",
type=int,
@@ -239,6 +247,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,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback,
}
elif args.engine == "grid":
@@ -249,6 +258,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,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback,
}
if args.engine == "room":
@@ -287,6 +297,14 @@ def main(argv: list[str] | None = None) -> int:
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
for net, a, b, _zone in missing:
print(f" {net}: {a} to {b}", file=sys.stderr)
if result.narrowed:
print(
f"freeroute: {len(result.narrowed)} connection(s) of a wider net class only fitted "
"at the default width; their nets are written at the default width:",
file=sys.stderr,
)
for net, a, b in result.narrowed:
print(f" {net}: {a} to {b}", file=sys.stderr)
if in_zone:
print(
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
+53 -6
View File
@@ -31,13 +31,14 @@ from dataclasses import dataclass, field
import math
from freeroute.board.board import BasicBoard
from freeroute.board.items import Pin
from freeroute.board.items import Pin, Via
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import (
IntBox,
IntPoint,
Polyline,
PolylineShape,
Simplex,
segment_box,
segment_octagon,
shove_segment,
@@ -99,6 +100,9 @@ def route_board_exact(
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry.
@@ -131,6 +135,9 @@ def route_board_exact(
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
net_widths=net_widths,
via_drill=via_drill,
hole_to_hole=hole_to_hole,
)
edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline
@@ -148,7 +155,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():
@@ -172,23 +181,32 @@ def route_board_exact(
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
fixed_vias = {
(item.net_nos[0], item.location)
for item in board.get_items()
if isinstance(item, Via) and item.fixed and item.net_nos
}
# Verify connection by connection, so one conflicting segment of a
# 25-pin supply net costs that one connection, not the whole net.
rejected: list[tuple[int, str, str]] = []
ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections)
# each net is checked at the width it will be written at: its class's
# when all of its connections went in at that, else the default
net_half = dict(result.net_half)
for (net_no, wires, vias), ends in sorted(
zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0]
):
boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half))
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))
if any(
tree.clearance_conflict(
box, net_no, layer, clearance, "wire" if i < wire_count else "via"
)
for i, (box, box_layers) in enumerate(boxes)
for layer in box_layers
) or _end_in_rule_area(tree, wires, half_width) or (
) 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)
):
rejected.append((net_no, *ends))
@@ -197,7 +215,12 @@ def route_board_exact(
if acc is None:
acc = accepted[net_no] = _Accepted(wires=[], vias=[])
acc.wires.extend(wires)
acc.vias.extend(vias)
# connections of one net that end on the same plane cell share its
# via: write it once, and never on top of a via already on the board
acc.vias.extend(
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:
oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
@@ -247,6 +270,17 @@ def route_board_exact(
for net_no, acc in accepted.items():
clean.wires[net_no] = acc.wires
clean.vias[net_no] = acc.vias
# shove and the diagonal pass lay copper at the default width: nets they
# recovered, and nets whose traces shove pushed aside
reshaped = (set(accepted) - accepted_before_recovery) | {
net for net, acc in accepted.items() if acc.shove_count
}
if diagonal:
reshaped = set(accepted)
clean.net_half = {
net: half for net, half in net_half.items() if net in accepted and net not in reshaped
}
clean.narrowed = list(result.narrowed)
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean, shoves=shoves)
@@ -615,6 +649,15 @@ def _end_in_rule_area(tree, wires, half_width) -> bool:
return False
def _on_fixed_via(net_no, point, fixed_vias, via_half) -> bool:
"""A routed via that reuses a via already on the board: the grid put it
on the fixed via's cell, a little off its centre, and it is not written."""
return any(
net == net_no and abs(loc.x - point.x) <= via_half and abs(loc.y - point.y) <= via_half
for net, loc in fixed_vias
)
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]]] = []
@@ -627,6 +670,10 @@ def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
return boxes
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
+564 -62
View File
@@ -27,12 +27,14 @@ 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
from freeroute.geometry import IntBox, IntPoint
from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from freeroute.geometry import IntBox, IntPoint, Simplex
from freeroute.geometry.octagon import overlaps_2d
from freeroute.geometry.polyline import corner_box
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -61,7 +63,10 @@ class RouteResult:
``routed_net_numbers`` are the nets that got any wire or via.
"""
__slots__ = ("wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed")
__slots__ = (
"wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed",
"net_half", "narrowed",
)
def __init__(self, half_width: int) -> None:
self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {}
@@ -76,6 +81,10 @@ class RouteResult:
self.connection_ends: list[tuple[str, str, str]] = []
#: connections left unrouted: (net_no, one end, the other end)
self.failed: list[tuple[int, str, str]] = []
#: half width of each net routed wider than the default (its class)
self.net_half: dict[int, int] = {}
#: connections of a wider class that only fitted at the default width
self.narrowed: list[tuple[int, str, str]] = []
def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None:
self.wires.setdefault(net_no, []).append((layer, path))
@@ -114,6 +123,9 @@ class _Connection:
"a_end",
"b_end",
"path",
"narrow",
"half",
"stubs",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -129,6 +141,8 @@ 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]]] = []
# 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], [])
@@ -137,6 +151,10 @@ class _Connection:
self.b_end: Pin | None = b
# the node path last committed, so a rip-up can be undone
self.path: list[Node] | None = None
# a connection of a wider class that did not fit at that width is
# routed at the default width instead; the half width it went in at
self.narrow = False
self.half = 0
class GridRouter:
@@ -163,6 +181,9 @@ class GridRouter:
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> None:
self.board = board
# Pin access: while a pin still has connections to route, the cells
@@ -213,7 +234,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
@@ -223,6 +244,38 @@ class GridRouter:
self.edge_clearance = 0 if edge_clearance is None else edge_clearance
need = self.via_half + self.clearance + self.half_width
self.via_reach = max(0, math.ceil(need / self.step) - 1)
# Net classes wider than the default: the grid is laid out for the
# default width, so a wider trace must also find the cells around its
# own free, out to its extra half width, and claims a wider halo.
self._net_half = {
net: max(width // 2, 1)
for net, width in (net_widths or {}).items()
if width // 2 > self.half_width
}
self._spread: list[tuple[int, int]] = [] # the current search's
self._cur_half = self.half_width
self._halo_by_half: dict[int, list[tuple[int, int]]] = {self.half_width: self._halo_offsets}
# Hole-to-hole spacing holds between any two vias, of one net or two.
# Other nets' vias are kept further apart already; a net's own vias
# (every pin of a supply dropping to its plane) are not, so a via
# must keep its drill plus the spacing from every via centre, except
# an own-net via at the very same cell, which it simply reuses.
drill = via_drill if via_drill else 2 * self.via_half
need = drill + max(hole_to_hole, 0)
self._via_drill, self._hole_gap, self._hole_need = drill, max(hole_to_hole, 0), need
reach = math.ceil(need / self.step)
self._hole_offsets = [
(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
]
self._via_nets: dict[Cell, dict[int, int]] = {} # routed via centres
self._via_centres: list[IntPoint] | None = None # cache for _hole_clear_at
self._fixed_vias: dict[Cell, int] = {}
# fixed vias sit anywhere, not on cell centres: exact hole checks
self._fixed_holes: list[tuple[IntPoint, int]] = []
self._fixed_via_points: set[tuple[int, IntPoint]] = set()
if layers is None:
layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal]
@@ -247,10 +300,35 @@ class GridRouter:
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
self._via_owner: dict[Cell, 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
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()
self._rasterize_fixed_copper()
for item in board.get_items():
if isinstance(item, Via) and item.fixed and item.net_nos:
self._fixed_vias[self._cell_of(item.location)] = item.net_nos[0]
self._fixed_via_points.add((item.net_nos[0], item.location))
# its own drill, from KiCad's padstack name (Via[0-3]_500:250_um)
reach = self._via_drill // 2 + (item.drill or self._via_drill) // 2 + self._hole_gap
self._fixed_holes.append((item.location, reach))
# Through-hole pads have holes too, and a route changes layer at one
# for free, so no via (of any net) goes within hole spacing of one.
# The DSN has no drill for a pad; its smaller half size stands in.
self._tht_holes: list[tuple[IntPoint, int]] = []
self._tht_block: set[Cell] = set()
for pin in board.get_pins():
if pin.shape is None or sum(1 for layer in pin.layers if layer in self.layers) < 2:
continue
box = pin.shape.bounding_box()
radius = min(box.ur.x - box.ll.x, box.ur.y - box.ll.y) // 2
reach = radius + self._via_drill // 2 + self._hole_gap
self._tht_holes.append((pin.location, reach))
ring = IntBox(pin.location.x, pin.location.y, pin.location.x, pin.location.y)
for cell in self._cells_in_box(ring, reach):
self._tht_block.add(cell)
# --- grid helpers -------------------------------------------------------
@@ -305,6 +383,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
@@ -330,7 +421,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,
@@ -386,6 +477,30 @@ class GridRouter:
allow_ripup: bool,
conns: list[_Connection],
wire_keepout: bool = True,
) -> float | None:
""":meth:`_enter_cost_base`, and for a trace wider than the default
the same for every cell its extra width reaches (the worst of them)."""
cost = self._enter_cost_base(cell, layer, net_no, allow_ripup, conns, wire_keepout)
if cost is None or not self._spread:
return cost
for dx, dy in self._spread:
c = (cell[0] + dx, cell[1] + dy)
if not (0 <= c[0] < self.cols and 0 <= c[1] < self.rows):
return None
more = self._enter_cost_base(c, layer, net_no, allow_ripup, conns, wire_keepout)
if more is None:
return None
cost = max(cost, more)
return cost
def _enter_cost_base(
self,
cell: Cell,
layer: int,
net_no: int,
allow_ripup: bool,
conns: list[_Connection],
wire_keepout: bool = True,
) -> float | None:
"""Cost to enter ``(cell, layer)`` for ``net_no``; ``None`` if blocked.
@@ -440,21 +555,109 @@ class GridRouter:
for layer in self.layers:
if (*c, layer) in self._via_keepout:
return False
if self._enter_cost(c, layer, net_no, False, conns, wire_keepout=False) != 0.0:
if self._enter_cost_base(c, layer, net_no, False, conns, wire_keepout=False) != 0.0:
return False
return True
return self._holes_clear(cell, net_no)
def _near_path_via(self, node: Node, cell: Cell, came_from: dict) -> bool:
"""A layer change earlier on the route that reaches ``node`` sits
within hole spacing of ``cell`` (and is not the same through via).
Committed vias are checked elsewhere; a route's own are not yet
committed while it is searched. Looks back far enough to cover the
hole spacing twice over, not along the whole route."""
limit = 4 * (math.ceil(self._hole_need / self.step) + 1)
need = self._hole_need / self.step
steps = 0
while node in came_from and steps < limit:
prev = came_from[node]
changes_layer = prev[:2] == node[:2] and prev[2] != node[2]
if changes_layer and node[:2] != cell and (
math.hypot(node[0] - cell[0], node[1] - cell[1]) < need
):
return True
node = prev
steps += 1
return False
def _via_nets_at(self, cell: Cell) -> set[int]:
nets = {net for net, n in self._via_nets.get(cell, {}).items() if n > 0}
fixed = self._fixed_vias.get(cell)
if fixed is not None:
nets.add(fixed)
return nets
def _holes_clear(self, cell: Cell, net_no: int) -> bool:
"""No via centre within hole-to-hole reach, but an own-net one here,
and no through-hole pad's hole within reach at all."""
if cell in self._tht_block:
return False
if self._fixed_holes:
here = self._cell_center(cell)
for loc, reach in self._fixed_holes:
d = math.hypot(loc.x - here.x, loc.y - here.y)
if d < reach and d > self.step: # beyond its own cell: too close
return False
if self._via_nets_at(cell) - {net_no}:
return False
cx, cy = cell
return not any(self._via_nets_at((cx + dx, cy + dy)) for dx, dy in self._hole_offsets)
# --- A* search ----------------------------------------------------------
def _conn_half(self, conn: _Connection) -> int:
"""The half width a connection is routed at: its class's, unless it
has fallen back to the default."""
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: 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)
return [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if dx or dy
]
def _halo_for(self, half: int) -> list[tuple[int, int]]:
"""Cells a committed trace of ``half`` claims from default-width traces."""
offsets = self._halo_by_half.get(half)
if offsets is None:
need = half + self.clearance + self.half_width
reach = math.ceil(need / self.step)
offsets = self._halo_by_half[half] = [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need
]
return offsets
def _search(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection]
) -> list[Node] | None:
half = self._conn_half(conn)
self._cur_half, self._spread = half, self._spread_for(half)
try:
return self._search_at_width(conn, allow_ripup, conns)
finally:
self._cur_half, self._spread = self.half_width, []
def _search_at_width(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection]
) -> list[Node] | None:
goal_cell = self._group_target(conn.b_group)
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
@@ -475,10 +678,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
@@ -503,6 +715,8 @@ class GridRouter:
pin = ends.get(node)
if ok and isinstance(pin, Pin) and self._walled(node):
ok = self._via_fits_at(pin.location, net_no)
if ok and self._near_path_via(source, cell, came_from):
ok = False # too close to a via earlier on this same route
if ok:
self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap)
continue
@@ -514,6 +728,8 @@ class GridRouter:
ok = via_ok.get(cell)
if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
if ok and self._near_path_via(current, cell, came_from):
ok = False
else:
ok = True
if ok:
@@ -587,26 +803,70 @@ class GridRouter:
]
copper += [(box, net) for net, _, box in self.board.get_fixed_copper()]
self._static_copper = copper
return not any(net != net_no and box.overlaps(grown) for box, net in self._static_copper)
if any(net != net_no and box.overlaps(grown) for box, net in self._static_copper):
return False
return self._hole_clear_at(point)
def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> list[Cell]:
def _hole_clear_at(self, point: IntPoint) -> bool:
"""Hole spacing measured exactly from ``point`` to every via and
through-hole pad (a via moved into a pad is not on its cell's centre)."""
need = self._hole_need
if self._via_centres is None: # rebuilt only after vias change
self._via_centres = [loc for _net, loc in self._fixed_via_points] + [
self._cell_center(c) for c, count in self._via_nets.items() if any(count.values())
]
if any(math.hypot(c.x - point.x, c.y - point.y) < need for c in self._via_centres):
return False
return not any(
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)
dx, dy = end.x - pin_location.x, end.y - pin_location.y
if not self._orthogonal or dx == 0 or dy == 0:
return [pin_location, end]
if abs(dx) >= abs(dy):
return [pin_location, IntPoint(end.x, pin_location.y), end]
return [pin_location, IntPoint(pin_location.x, end.y), end]
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)
reach = 2 * self.half_width + self.clearance
points = self._stub_points(pin_location, cell)
end = points[-1]
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),
)
legs = list(zip(points, points[1:], strict=False))
return [
c
for c in self._cells_in_box(box, reach)
if _point_segment(self._cell_center(c), pin_location, end) < reach
if min(_point_segment(self._cell_center(c), a, b) for a, b in legs) < reach
]
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:
@@ -614,26 +874,72 @@ class GridRouter:
# 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.half_width).tiles():
points = self._stub_points(pin.location, cell)
if points[-1] != pin.location:
for tile in PolylineShape(Polyline(points), self._cur_half).tiles():
grown = tile.offset(self.clearance)
if any(k.overlaps(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.half_width)
cap = corner_box(pin.location, self._cur_half)
if any(not needs and k.overlaps(cap) for k, needs in keepouts):
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
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) -> list[IntBox]:
"""The stub's copper: 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 = [corner_box(point, half) for point in points[1:]]
if len(points) > 1 and points[-1] != pin_location:
from freeroute.geometry.polyline import Polyline, PolylineShape
tiles += PolylineShape(Polyline(points), half).tiles()
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 = [
t.offset(self.clearance)
for t in self._stub_tiles(pin.location, cell, half or self._cur_half)
]
if not any(g.overlaps(t) for tiles in other for t in tiles for g in grown):
continue
owners.add(owner)
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.add(via_owner)
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
@@ -681,11 +987,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
@@ -707,10 +1015,13 @@ class GridRouter:
return (dx + dy) / self.step
return math.hypot(dx, dy) / 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)
nodes: set[Node] = set()
@@ -719,16 +1030,16 @@ class GridRouter:
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
for layer in layers:
if (dx or dy) and self._enter_cost(cell, layer, net_no, False, conns) is None:
if (dx or dy) 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: # 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(
@@ -794,14 +1105,16 @@ 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."""
from freeroute.geometry.polyline import Polyline, PolylineShape
margin = self.half_width + self.clearance
reach = self._escape_cells
near = [
(p.shape.bounding_box(), set(p.layers))
@@ -813,24 +1126,33 @@ 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 = [
tile.offset(self.clearance)
for tile in PolylineShape(
Polyline(self._stub_points(pin.location, cell)), self._cur_half
).tiles()
]
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(g.overlaps(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):
@@ -860,11 +1182,18 @@ class GridRouter:
via_cells.append((path[-1][0], path[-1][1]))
conn.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells]
conn.via_cells = list(via_cells)
for cell in conn.via_cells:
count = self._via_nets.setdefault(cell, {})
count[conn.net_no] = count.get(conn.net_no, 0) + 1
if conn.via_cells:
self._via_centres = None
for cell, layer in conn.trace_cells:
self._cell_owner[(*cell, layer)] = conn.index
conn.half = half = self._conn_half(conn)
halo = self._halo_for(half)
conn.halo_nodes = []
for (cx, cy), layer in conn.trace_cells:
for dx, dy in self._halo_offsets:
for dx, dy in halo:
x, y = cx + dx, cy + dy
if 0 <= x < self.cols and 0 <= y < self.rows:
node = (x, y, layer)
@@ -873,19 +1202,25 @@ 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)
self._via_owner.setdefault(c, conn.index)
ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else ()
conn.stubs = []
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]
for c in self._stub_cells(pin.location, cell):
claimed: set[Node] = set()
for c in self._stub_cells(pin.location, cell, half):
node = (*c, layer)
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.
@@ -903,9 +1238,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))
@@ -921,8 +1256,16 @@ class GridRouter:
if self._cell_owner.get((*cell, layer)) == conn.index:
del self._cell_owner[(*cell, layer)]
for cell in conn.via_halo:
claims = self._via_claims.get(cell)
if claims is not None and conn.index in claims:
claims.remove(conn.index)
if self._via_owner.get(cell) == conn.index:
del self._via_owner[cell]
if claims:
self._via_owner[cell] = claims[0]
else:
del self._via_owner[cell]
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)
@@ -932,6 +1275,13 @@ class GridRouter:
del self._halo[node]
conn.halo_nodes = []
conn.trace_cells = []
conn.stubs = []
for cell in conn.via_cells:
count = self._via_nets.get(cell)
if count and count.get(conn.net_no):
count[conn.net_no] -= 1
if conn.via_cells:
self._via_centres = None
conn.via_cells = []
conn.wires = []
conn.vias = []
@@ -939,7 +1289,20 @@ 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))
@@ -1012,6 +1375,7 @@ class GridRouter:
best_count = -1
best_snapshot: list[tuple[int, list, list]] | None = None
best_unrouted = len(conns)
best_widths: tuple[dict[int, int], list] = ({}, [])
# conns[i].index == i is what rip-up relies on, so shuffle a view
order = list(conns)
if self.seed is not None:
@@ -1031,13 +1395,25 @@ class GridRouter:
best_count = routed_count
best_unrouted = len(conns) - routed_count
best_snapshot = self._snapshot(conns)
best_widths = self._net_widths(conns)
if routed_count == len(conns):
break
if not changed:
break
return self._result_from(best_snapshot or [], best_unrouted)
result = self._result_from(best_snapshot or [], best_unrouted)
result.net_half, result.narrowed = best_widths
return result
def _try_route(self, conn: _Connection, conns: list[_Connection]) -> bool:
if self._route_once(conn, conns):
return True
if conn.narrow or conn.net_no not in self._net_half:
return False
# a wider class that does not fit: route it at the default width
conn.narrow = True
return self._route_once(conn, conns)
def _route_once(self, conn: _Connection, conns: list[_Connection]) -> bool:
path = self._search(conn, allow_ripup=False, conns=conns)
if path is None:
if not self.rip_up:
@@ -1045,7 +1421,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:
@@ -1101,7 +1486,9 @@ class GridRouter:
# deterministic: fewer-pin nets first, then by net number
for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)):
zones = zones_by_net.get(net_no, []) + planes_by_net.get(net_no, [])
groups = _connected_groups(pins_by_net.get(net_no, []), zones, plane_ids)
groups = _connected_groups(
pins_by_net.get(net_no, []), zones, plane_ids, self._fixed_pieces(net_no)
)
hubs = [g for g in groups if any(id(z) in plane_ids for z in g[1])]
if hubs:
# a plane net: every other group goes straight to the plane
@@ -1115,6 +1502,28 @@ class GridRouter:
conns.append(conn)
return conns
def _fixed_pieces(self, net_no: int) -> list[_FixedPiece]:
"""The net's tracks and vias already on the board, as connectivity."""
cache = getattr(self, "_fixed_cache", None)
if cache is None:
from freeroute.board.items import Trace
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box
cache = self._fixed_cache = {}
for item in self.board.get_items():
if not item.fixed or not item.net_nos:
continue
if isinstance(item, Via) and item.shape is not None:
piece = _FixedPiece(item.layers, [item.shape.bounding_box()], via=item)
elif isinstance(item, Trace) and not item.is_empty():
boxes = list(PolylineShape(Polyline(item.corners), item.half_width).tiles())
boxes += [corner_box(e, item.half_width) for e in (item.corners[0], item.corners[-1])]
piece = _FixedPiece([item.layer], boxes)
else:
continue
cache.setdefault(item.net_nos[0], []).append(piece)
return cache.get(net_no, [])
def _pins_by_net(self) -> dict[int, list[Pin]]:
by_net: dict[int, list[Pin]] = {}
for pin in self.board.get_pins():
@@ -1124,6 +1533,21 @@ class GridRouter:
by_net.setdefault(net_no, []).append(pin)
return by_net
def _net_widths(self, conns: list[_Connection]) -> tuple[dict[int, int], list]:
"""Half width of each wider-class net whose routed connections all went
in at it (a net is written at one width), and the connections that
had to fall back to the default."""
halves: dict[int, int] = {}
narrowed = []
for c in conns:
if not c.routed:
continue
halves[c.net_no] = min(halves.get(c.net_no, c.half), c.half)
if c.narrow:
narrowed.append((c.net_no, *_ends_of(c)))
wide = {net: half for net, half in halves.items() if half > self.half_width}
return wide, narrowed
def _snapshot(self, conns: list[_Connection]) -> list[tuple]:
return [
(c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias),
@@ -1134,11 +1558,16 @@ class GridRouter:
def _result_from(self, snapshot, unrouted: int) -> RouteResult:
result = RouteResult(self.half_width)
written: set[tuple[int, IntPoint]] = set()
result.unrouted = unrouted
for net_no, wires, vias, ends in snapshot:
for layer, points in wires:
result.add_wire(net_no, layer, points)
for location in vias:
spot = (net_no, location)
if spot in written or spot in self._fixed_via_points:
continue # a via reused by another connection, or already on the board
written.add(spot)
result.add_via(net_no, location)
result.connections.append((net_no, wires, vias))
result.connection_ends.append(ends)
@@ -1189,13 +1618,49 @@ def _pin_touches_zone(pin: Pin, zone, plane: bool = False) -> bool:
return any(zone.polygon.contains(c) for c in corners)
def _touches(a: IntBox, b: IntBox) -> bool:
return not (a.ur.x < b.ll.x or b.ur.x < a.ll.x or a.ur.y < b.ll.y or b.ur.y < a.ll.y)
def _fixed_joins_zone(piece, zone, plane: bool) -> bool:
"""A through via inside a plane joins it; copper inside a zone on its
own layer joins that zone."""
if zone.polygon is None:
return False
if piece.via is not None:
if plane or zone.layer in piece.layers:
return zone.polygon.contains(piece.via.location)
return False
return zone.layer in piece.layers and any(
zone.polygon.contains(IntPoint((b.ll.x + b.ur.x) // 2, (b.ll.y + b.ur.y) // 2))
for b in piece.boxes
)
class _FixedPiece:
"""One track or via already on the board, as connectivity."""
__slots__ = ("layers", "boxes", "via")
def __init__(self, layers, boxes, via=None) -> None:
self.layers = frozenset(layers)
self.boxes = boxes
self.via = via # the Via item, for a via
def _connected_groups(
pins: list[Pin], zones: list, planes: frozenset = frozenset()
pins: list[Pin], zones: list, planes: frozenset = frozenset(), fixed: list | None = None
) -> list[tuple[list[Pin], list]]:
"""Pins and zones of one net that copper already joins: a pad in a zone
is connected to it, and two zones sharing a pad are one. Only the groups
need routing between them. Pin groups come first, in pin order."""
parent = list(range(len(pins) + len(zones)))
is connected to it, two zones sharing a pad are one, and copper already
on the board (tracks and vias, ``fixed``) joins whatever it touches. Only
the groups need routing between them. Pin groups come first, in pin
order. A group's fixed vias join its pins as extra route ends, so a
fanned-out pin is routed from its via; copper that touches no pin and no
zone adds nothing to route."""
fixed = fixed or []
n_pins, n_zones = len(pins), len(zones)
parent = list(range(n_pins + n_zones + len(fixed)))
def find(i: int) -> int:
while parent[i] != i:
@@ -1207,6 +1672,24 @@ def _connected_groups(
for i, pin in enumerate(pins):
if _pin_touches_zone(pin, zone, id(zone) in planes):
parent[find(i)] = find(len(pins) + j)
base = n_pins + n_zones
for k, piece in enumerate(fixed):
me = base + k
for i, pin in enumerate(pins):
if pin.shape is None or not piece.layers & set(pin.layers):
continue
pad = pin.shape.bounding_box()
if any(_touches(pad, b) for b in piece.boxes):
parent[find(i)] = find(me)
for j, zone in enumerate(zones):
if _fixed_joins_zone(piece, zone, id(zone) in planes):
parent[find(n_pins + j)] = find(me)
for k2 in range(k):
other = fixed[k2]
if piece.layers & other.layers and any(
_touches(a, b) for a in piece.boxes for b in other.boxes
):
parent[find(base + k2)] = find(me)
groups: dict[int, tuple[list[Pin], list]] = {}
order: list[int] = []
for i, pin in enumerate(pins):
@@ -1221,9 +1704,22 @@ def _connected_groups(
groups[root] = ([], [])
order.append(root)
groups[root][1].append(zone)
for k, piece in enumerate(fixed):
root = find(base + k)
if piece.via is not None and root in groups:
groups[root][0].append(_via_end(piece.via))
return [groups[r] for r in order]
def _via_end(via) -> Pin:
"""A fixed via as a place a route may start or end (not a pad to route)."""
return Pin(
net_nos=list(via.net_nos), fixed=True, name="via", component_ref="",
padstack_name=via.padstack_name, location=via.location,
layers=list(via.layers), shape=via.shape,
)
def _split_path(path: list[Node]) -> tuple[list[tuple[int, list[Cell]]], list[Cell]]:
"""Split a node path into per-layer wire segments and via cells."""
wires: list[tuple[int, list[Cell]]] = []
@@ -1284,6 +1780,9 @@ def route_board(
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
@@ -1303,5 +1802,8 @@ def route_board(
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
net_widths=net_widths,
via_drill=via_drill,
hole_to_hole=hole_to_hole,
)
return router.route()
+54 -1
View File
@@ -12,6 +12,8 @@ to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and
from __future__ import annotations
import re
from freeroute.board import build_board
from freeroute.dsn import DsnBoard, parse_dsn
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
@@ -34,6 +36,25 @@ def _rule_width_dsn(dsn: DsnBoard) -> float:
return rules[0].value if rules else _DEFAULT_WIDTH_DSN
def _net_widths_board(dsn: DsnBoard, scale: int) -> dict[int, int]:
"""Track width of every net whose class asks for more than the default,
in board units, by net number (build_board numbers nets in DSN order)."""
default = _rule_width_dsn(dsn)
by_name: dict[str, float] = {}
for net_class in dsn.net_classes:
if not net_class.width_rules:
continue
width = net_class.width_rules[0].value
if width > default:
for name in net_class.net_names:
by_name[name] = max(by_name.get(name, 0.0), width)
return {
i + 1: round(by_name[net.name] * scale)
for i, net in enumerate(dsn.nets)
if net.name in by_name
}
def _rule_clearance_dsn(dsn: DsnBoard) -> float:
for rule in dsn.structure_rules.clearance_rules:
if not rule.class_pairs: # the layer-wide default clearance
@@ -53,6 +74,20 @@ def _mm_to_board(dsn: DsnBoard, scale: int, value_mm: float | None) -> int | Non
return round(value_mm / unit_mm * scale)
def _via_drill_board(dsn: DsnBoard, scale: int) -> int | None:
"""The via's drill in board units, from KiCad's padstack name
(``Via[0-1]_600:300_um`` is a 600 um pad with a 300 um drill); None when
the name does not say, and the pad size stands in for it."""
match = re.search(r"_([\d.]+):([\d.]+)_um", _via_padstack(dsn))
if not match:
return None
return round(float(match.group(2)) * _dsn_units_per_um(dsn) * scale)
def _dsn_units_per_um(dsn: DsnBoard) -> float:
return {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}.get(dsn.unit, 1.0)
def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
"""Half the via pad's size in board units, from the DSN's via padstack.
@@ -83,6 +118,7 @@ def route_dsn_board(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
@@ -108,6 +144,9 @@ def route_dsn_board(
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
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,
)
return result, scale, [layer.name for layer in dsn.layers]
@@ -122,6 +161,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
result = RoutingResult()
for net_no, segments in route.wires.items():
name = numbers_to_names.get(net_no, str(net_no))
# its class's width when every connection of the net went in at it
half = getattr(route, "net_half", {}).get(net_no)
net_width = 2 * half / scale if half else width_dsn
for layer_index, path in segments:
layer_name = layer_names[layer_index] if layer_index < len(layer_names) else "F.Cu"
coords: list[float] = []
@@ -129,7 +171,7 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
coords.append(point.x / scale)
coords.append(point.y / scale)
if len(coords) >= 4:
result.add_wire(name, RoutedWire(layer=layer_name, width=width_dsn, coords=coords))
result.add_wire(name, RoutedWire(layer=layer_name, width=net_width, coords=coords))
for net_no, locations in route.vias.items():
name = numbers_to_names.get(net_no, str(net_no))
for loc in locations:
@@ -137,6 +179,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
result.unrouted = [
(numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", [])
]
result.narrowed = [
(numbers_to_names.get(n, str(n)), a, b) for n, a, b in getattr(route, "narrowed", [])
]
return result
@@ -151,6 +196,7 @@ def build_routing_result(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
@@ -159,6 +205,7 @@ def build_routing_result(
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first,
access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback,
)
return _to_routing_result(route, dsn, scale, layer_names)
@@ -178,6 +225,7 @@ def route_dsn_board_exact(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
@@ -207,6 +255,9 @@ def route_dsn_board_exact(
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
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,
)
return exact, scale, [layer.name for layer in dsn.layers]
@@ -225,6 +276,7 @@ def build_exact_routing_result(
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
@@ -241,6 +293,7 @@ def build_exact_routing_result(
seed=seed,
route_first=route_first,
access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback,
)
return _to_routing_result(exact.result, dsn, scale, layer_names)
+3
View File
@@ -60,6 +60,9 @@ class RoutingResult:
#: connections the router could not make: (net name, one end, other end,
#: the zone both ends sit in or "") -- a zone's fill may join the latter
unrouted: list[tuple[str, str, str, str]] = field(default_factory=list)
#: connections of a wider net class that only fitted at the default width
#: (net name, one end, other end); their net is written at the default
narrowed: list[tuple[str, str, str]] = field(default_factory=list)
def net(self, name: str, *, create: bool = False) -> RoutedNet | None:
"""Return the :class:`RoutedNet` with ``name`` (creating it if asked)."""
+58
View File
@@ -48,3 +48,61 @@ def test_other_nets_route_around_a_fixed_track():
continue
for p, q in zip(pts, pts[1:], strict=False):
assert not (min(p.x, q.x) < wall < max(p.x, q.x)), "crossed the GND track on F.Cu"
# --- fixed wiring as connectivity ------------------------------------------
_FANOUT = """(pcb fanout (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
(layer B.Cu (type signal))
(boundary (path pcb 0 0 0 30000 0 30000 -20000 0 -20000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -19500 500 -19500 500 -500))
(via "Via[0-3]_600:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 5000 -10000 front 0) (place A 10000 -10000 front 0)
(place B 25000 -11500 back 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net GND (pins G1-1)) (net SIG (pins A-1 B-1)))
(wiring
(wire (path F.Cu 150 5000 -10000 5000 -11500) (net GND) (type route))
(via "Via[0-3]_600:300_um" 5000 -11500 (net GND) (type route))
(wire (path F.Cu 150 10000 -10000 10000 -11500) (net SIG) (type route))
(via "Via[0-3]_600:300_um" 10000 -11500 (net SIG) (type route))
(wire (path F.Cu 300 2000 -2000 8000 -2000) (net GND) (type route))))"""
def test_a_pad_fanned_out_to_its_plane_needs_no_route():
dsn = parse_dsn(_FANOUT)
exact, _scale, _names = route_dsn_board_exact(dsn, resolution=2)
gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND")
assert exact.result.failed == []
assert not exact.result.vias.get(gnd) and not exact.result.wires.get(gnd)
def test_a_fanned_out_signal_is_routed_from_its_via():
# B is on the bottom face, level with A's fan-out via: starting from that
# via on B.Cu is the cheapest way, and needs no via of its own
dsn = parse_dsn(_FANOUT)
exact, scale, names = route_dsn_board_exact(dsn, resolution=2)
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
assert sig in exact.result.routed_net_numbers
assert not exact.result.vias.get(sig), "the fan-out via is already there"
assert {names[layer] for layer, _ in exact.result.wires[sig]} == {"B.Cu"}
def test_the_session_never_repeats_copper_already_on_the_board():
# the JAR writes the DSN's wiring back into its session, and an applier
# then adds it a second time on top of itself; freeroute writes only
# what it routed
from freeroute.route.pipeline import build_exact_routing_result
fixed_vias = {(5000.0, -11500.0), (10000.0, -11500.0)}
fixed_paths = {(5000.0, -10000.0, 5000.0, -11500.0), (10000.0, -10000.0, 10000.0, -11500.0),
(2000.0, -2000.0, 8000.0, -2000.0)}
result = build_exact_routing_result(parse_dsn(_FANOUT), resolution=2)
for net in result.nets:
assert not {(v.x, v.y) for v in net.vias} & fixed_vias
assert not {tuple(w.coords) for w in net.wires} & fixed_paths
+227
View File
@@ -0,0 +1,227 @@
"""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_owner.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)
# --- 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
+42
View File
@@ -0,0 +1,42 @@
"""Net classes wider than the default are routed at their width where it fits."""
from __future__ import annotations
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import build_exact_routing_result
_BOARD = """(pcb widths (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))
{keepouts}
(rule (width 150) (clearance 150)))
(placement (component S (place P1 2000 -5000 front 0) (place P2 18000 -5000 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))
(class Power PWR (rule (width 400) (clearance 150)))))"""
# two keepouts leave a 0.65 mm channel: room for 0.15 mm (0.45), not for 0.4 mm (0.7)
_CHANNEL = (
'(keepout "" (polygon F.Cu 0 9000 0 11000 0 11000 -4675 9000 -4675))\n'
' (keepout "" (polygon F.Cu 0 9000 -5325 11000 -5325 11000 -10000 9000 -10000))'
)
def _route(keepouts=""):
return build_exact_routing_result(parse_dsn(_BOARD.format(keepouts=keepouts)), resolution=2)
def test_a_wider_class_is_routed_and_written_at_its_width():
result = _route()
assert result.unrouted == [] and result.narrowed == []
widths = {w.width for net in result.nets for w in net.wires}
assert widths == {400.0}
def test_a_wider_class_that_does_not_fit_falls_back_to_the_default():
result = _route(_CHANNEL)
assert result.unrouted == []
assert [net for net, _a, _b in result.narrowed] == ["PWR"]
widths = {w.width for net in result.nets for w in net.wires}
assert widths == {150.0}
+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():
+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
+136
View File
@@ -0,0 +1,136 @@
"""Vias keep hole-to-hole spacing, even within one net."""
from __future__ import annotations
import itertools
import math
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
# four GND pads 0.5 mm apart over a GND plane: each drops a via to the plane
_DSN = """(pcb holes (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
(layer B.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 19500 -500 19500 -19500 500 -19500 500 -500))
(via "Via[0-3]_500:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 9000 -10000 front 0) (place G2 9500 -10000 front 0)
(place G3 10000 -10000 front 0) (place G4 10500 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -150 -300 150 300)) (attach off))
(padstack "Via[0-3]_500:300_um" (shape (circle F.Cu 500)) (shape (circle B.Cu 500)) (attach off)))
(network (net GND (pins G1-1 G2-1 G3-1 G4-1))))"""
def test_a_nets_own_vias_keep_hole_to_hole_spacing():
dsn = parse_dsn(_DSN)
exact, scale, _ = route_dsn_board_exact(dsn, resolution=2)
assert exact.result.failed == []
vias = exact.result.vias[1]
assert len(set(vias)) == len(vias), "a via written twice"
need = (300 + 250) * scale # drill + 0.25 mm, centre to centre
for a, b in itertools.combinations(vias, 2):
assert math.hypot(a.x - b.x, a.y - b.y) >= need, f"vias at {a} and {b} too close"
def test_connections_sharing_a_plane_via_write_it_once():
# eight GND pads 0.4 mm apart: the hole spacing makes several of them
# end on the same plane via, which must come out once, not once each
pads = " ".join(f"(place G{i} {9000 + 400 * i} -10000 front 0)" for i in range(8))
pins = " ".join(f"G{i}-1" for i in range(8))
dsn = parse_dsn(
_DSN.replace(
"(place G1 9000 -10000 front 0) (place G2 9500 -10000 front 0)\n"
" (place G3 10000 -10000 front 0) (place G4 10500 -10000 front 0)",
pads,
)
.replace("(pins G1-1 G2-1 G3-1 G4-1)", f"(pins {pins})")
.replace("(rect F.Cu -150 -300 150 300)", "(rect F.Cu -125 -300 125 300)")
)
exact, _scale, _ = route_dsn_board_exact(dsn, resolution=2)
vias = exact.result.vias[1]
assert len(vias) < 8, "no via is shared, so this board no longer tests sharing"
assert len(set(vias)) == len(vias), "a shared via written more than once"
def _router(dsn_text, via_drill_um=300):
from freeroute.board import build_board
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
dsn = parse_dsn(dsn_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), resolution=2, orthogonal=True,
via_drill=via_drill_um * s, hole_to_hole=250 * s,
), s
_THT = """(pcb tht (parser) (resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(via "Via[0-1]_500:300_um") (rule (width 150) (clearance 150)))
(placement (component T (place J1 10000 -10000 front 0)))
(library (image T (pin tht 1 0 0))
(padstack tht (shape (circle F.Cu 1700)) (shape (circle B.Cu 1700)) (attach off))
(padstack "Via[0-1]_500:300_um" (shape (circle F.Cu 500)) (shape (circle B.Cu 500)) (attach off)))
(network (net SIG (pins J1-1))))"""
def test_no_via_goes_near_a_through_hole_pads_hole_even_of_its_own_net():
router, s = _router(_THT)
pin = router._cell_of(router.board.get_pins()[0].location)
assert not router._holes_clear(pin, 1) # own net, right on the pad
far = (pin[0] + 40, pin[1])
assert router._holes_clear(far, 1)
def test_a_via_in_a_pad_keeps_hole_spacing_from_a_fixed_via():
from freeroute.geometry import IntPoint
text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)))\n"
" (wiring (via \"Via[0-1]_500:300_um\" 3000 -3000 (net SIG) (type route))))")
router, s = _router(text)
assert not router._hole_clear_at(IntPoint(3400 * s, -3000 * s)) # 0.4 mm < 0.55 mm
assert router._hole_clear_at(IntPoint(3600 * s, -3000 * s))
def test_hole_spacing_from_a_fixed_via_is_measured_from_where_it_really_is():
# a fixed via off the grid with its own 0.25 mm drill, and the router's
# via a smaller 0.2 mm one (the flight controller's case): a new via needs
# its centre 0.1 + 0.125 + 0.25 = 0.475 mm away, measured exactly, where
# one rule for all vias gave 0.2 + 0.25 = 0.45 from the cell centres
text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)) (net GND))\n"
" (wiring (via \"Via[0-1]_500:250_um\" 3030 -3030 (net GND) (type route))))")
text = text.replace(
'(attach off)))\n (network',
'(attach off))\n (padstack "Via[0-1]_500:250_um" (shape (circle F.Cu 500)) '
'(shape (circle B.Cu 500)) (attach off)))\n (network',
)
router, s = _router(text, via_drill_um=200)
via = next(v for v in router.board.get_items() if getattr(v, "drill", 0))
assert via.drill == 250 * s
loc = via.location
for cell in [(x, y) for x in range(router.cols) for y in range(router.rows)]:
c = router._cell_center(cell)
d = math.hypot(c.x - loc.x, c.y - loc.y)
if router.step * 1.5 < d < 470 * s:
assert not router._holes_clear(cell, 1), f"{d / s:.0f} um from the fixed via"
elif 560 * s < d < 700 * s and cell not in router._tht_block:
assert router._holes_clear(cell, 1)
def test_a_route_keeps_its_own_vias_apart():
# a route that changed layer at (10, 10) may not change layer again one
# cell away; the same cell is the same through via, and far is fine
router, _s = _router(_THT)
came_from = {(10, 10, 1): (10, 10, 0), (11, 10, 1): (10, 10, 1), (12, 10, 1): (11, 10, 1)}
assert router._near_path_via((12, 10, 1), (11, 10), came_from)
assert not router._near_path_via((12, 10, 1), (10, 10), came_from)
assert not router._near_path_via((12, 10, 1), (40, 10), came_from)