Compare commits

..
Author SHA1 Message Date
rsp2k de9fb51bb3 --fanout: leave out pins a same-net pour on their layer already joins 2026-09-29 10:40:46 -06:00
rsp2k 74360f2f57 --fanout: only packages of --fanout-min-pins or more; supply-only mode; vias for signals that change layer 2026-09-29 10:20:11 -06:00
rsp2k 21c80d30f2 Tests: fan-out stubs go outward past their pads; a plane pin gets its via 2026-09-29 09:53:05 -06:00
rsp2k 2d686178af --fanout: commit a way out for every fine-pitch pin before routing (work in progress) 2026-09-29 09:52:35 -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
rsp2k 783dd8f0d1 Test: a pad inside a via keepout reaches the plane past it 2026-09-28 21:09:43 -06:00
rsp2k 8a82143517 Check plane and in-pad vias where they will actually go
A start that was already a plane end joined the plane at once, with no
check that its via fits: under a sensor's via keepout the via went in
the pad and the exact check refused it. A via leaving or reaching a
walled-in pin goes in that pin's pad, but was only checked at the grid
cell's centre; it is now checked exactly at the pin against via
keepouts and other nets' pads and fixed copper.
2026-09-28 21:08:51 -06:00
rsp2k 8a50bde531 Read the DSN's wiring as fixed copper of its nets
Tracks and vias already on the board were ignored, so other nets could
be routed straight through them (the flight controller's 82 GND
stitching vias and its guard tracks). They are now copper of their net:
other nets keep a pad's distance from them in the grid, the exact check
and the room engine, and they add no connections to route.
2026-09-28 21:06:35 -06:00
rsp2k 0f6a2a0812 Merge fix/plane-goal-stub: routes ending on a plane no longer crash 2026-09-28 19:55:07 -06:00
rsp2k 245cd91c0e Don't cost a pin stub for a route that ends on a plane
A plane end is marked with its zone, not a pin, so a route from a pad
outside the plane's outline crashed looking for the pin's location
(the flight-controller board's first placement).
2026-09-28 19:55:07 -06:00
rsp2k 019b043cd5 Merge feat/ripup-rollback: undo rip-ups that lose connections (on by default) 2026-09-28 16:17:30 -06:00
rsp2k a2e108bc51 Rollback on by default; --no-rollback keeps every rip-up
Over 8 net orders it left 25 to 30 fewer connections unrouted on the
dense sense boards and 16 to 18 fewer on two PCBench boards, and was
never clearly worse. KiCad DRC on the sense harness: 41 opens to 28.
2026-09-28 16:17:28 -06:00
rsp2k 3462089e09 --rollback: undo a rip-up that leaves fewer connections routed
The connections a rip-up route crosses are rerouted at once in free
space. If that leaves fewer connections routed than before, the new
route comes out and every displaced connection goes back on its old
path, with its rip count as it was. Even trades are kept.
2026-09-28 14:54:29 -06:00
rsp2k 35891c02ff Merge feat/rule-area-keepouts: keepout exits, via-only walled pins, rule-area support 2026-09-28 10:32:44 -06:00
rsp2k 3e4f53a469 Keepout exits and rule areas
A route may not leave a cell inside a wire keepout as a wire, nor reach
a pin there by one: it changes layer instead, and the via goes in the
pad. A pin whose every stub enters a keepout is reached by a via only.

A structure keepout named rule_area... (mckicad names KiCad rule areas
so) is checked for overlap only, as KiCad's DRC does, with the free ends
of each wire tested for their round caps.
2026-09-28 10:08:02 -06:00
rsp2k bb75dc5067 DSN reader: only the declared string_quote character opens a string
KiCad declares " and writes a keycap part number as a bare ', which the
tokenizer took for an unterminated string (PCBench f.60_keyboard).
2026-09-28 09:41:27 -06:00
rsp2k 9de8329027 Merge feat/pin-access: vias in pads inside keepouts, exact stub checks, --access-cost 2026-09-28 09:38:18 -06:00
rsp2k f1d0f8df70 Pin access: --access-cost, vias in pads inside wire keepouts, exact stub checks
--access-cost C makes a route pay C to pass where it would close another
pin's way out (its end cells, the via it may need there, and the
clearance around both) while that pin still has connections to route.

A pin inside a wire keepout can only leave by a via; its stub to the
cell centre was track inside the keepout, which the exact check refused.
The via now goes on the pin itself. Pin stubs are also checked exactly
against wire keepouts, as the exact engine will check them.

bench/diagnose.py reports what each refused connection collided with.
2026-09-28 09:15:11 -06:00
rsp2k c44ff272ae bench/diagnose.py: sort an exact-engine run's failures by cause
rejected by the exact check, unroutable even alone, walled in (a pin's
free region is closed off by other copper, with the nets that close it),
or congestion. The board state is the best pass's, rebuilt by replaying
its paths.
2026-09-28 00:30:05 -06:00
rsp2k 05f31fc828 Merge feat/promote-failures: reroute with failed connections first; --orders 2026-09-27 22:53:47 -06:00
25 changed files with 2143 additions and 36 deletions
+2 -2
View File
@@ -24,9 +24,11 @@ off. Exits non-zero if any run crashed or timed out.
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import math
import os
from pathlib import Path
import re
import shlex
import subprocess
@@ -34,8 +36,6 @@ import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
NOT_ROUTED = re.compile(r"freeroute: (\d+) connection\(s\) not routed")
TO_ZONE = re.compile(r"freeroute: (\d+) more connection\(s\) left to a zone fill")
+296
View File
@@ -0,0 +1,296 @@
"""Why did each connection fail? Sort the failures of one exact-engine run.
uv run python bench/diagnose.py board.dsn --edge-clearance 0.5 [--json out.json]
Every connection the exact engine leaves unrouted lands in one bucket:
rejected the grid routed it, the exact geometry check refused its copper
alone it cannot be routed even on a board with no other routes: the
pads, keepouts or the grid itself are the limit, not congestion
walled-in in the routed board one of its pins has no way out: a flood from
the pin's end cells through free cells stops short (pin access)
congestion both pins can get out, but no free path joins them
The board state is the one the result came from (the best pass), rebuilt by
replaying that pass's paths. Diagnostic only: it reaches into the router.
"""
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import sys
from freeroute.board import build_board
from freeroute.board.search_tree import ShapeSearchTree
from freeroute.dsn import parse_dsn
from freeroute.geometry.octagon import overlaps_2d
from freeroute.route import grid_router
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,
_rule_clearance_dsn,
_rule_width_dsn,
_via_half_board,
)
# a pin whose free neighbourhood holds fewer nodes than this is walled in
FLOOD_LIMIT = 200
def _capture_router():
"""Patch GridRouter so the instance route_board_exact builds, and the paths
of its best pass, can be read back afterwards."""
seen: dict = {}
original_commit = GridRouter._commit
original_snapshot = GridRouter._snapshot
def commit(self, conn, path):
original_commit(self, conn, path)
self._diag_paths[conn.index] = (list(path), conn.a_end, conn.b_end)
def snapshot(self, conns):
self._diag_best = {c.index: self._diag_paths[c.index] for c in conns if c.routed}
seen["router"] = self
return original_snapshot(self, conns)
def init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
self._diag_paths = {}
self._diag_best = {}
seen["kwargs"] = kwargs
seen["args"] = args
original_init = GridRouter.__init__
GridRouter.__init__ = init
GridRouter._commit = commit
GridRouter._snapshot = snapshot
return seen
def _capture_conflicts() -> list:
"""Record each exact-check refusal: the copper box, its kind, and the
shape it came too close to. The check stops at a connection's first
conflict, so refusals and rejected connections line up in order."""
log: list = []
original = ShapeSearchTree.clearance_conflict
def conflict(self, box, net_no, layer, clearance, kind="wire"):
hit = original(self, box, net_no, layer, clearance, kind)
if hit:
# the shape that tripped the check, found the way the check does
other = None
expanded = box.offset(clearance)
for s in self.overlapping(expanded):
if s.net_no == net_no or not s.on_layer(layer):
continue
if s.blocks != "all" and s.blocks != kind + "s":
continue
if not s.clearance:
if s.tile.overlaps(box):
other = s
break
continue
if (s.exact is not None and overlaps_2d(s.exact, expanded)) or (
s.exact is None and s.tile.overlaps(expanded)
):
other = s
break
log.append((net_no, kind, layer, box, clearance, other))
return hit
ShapeSearchTree.clearance_conflict = conflict
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)
return max(dx, dy)
def _fresh(seen) -> GridRouter:
router = grid_router.GridRouter(*seen["args"], **seen["kwargs"])
router._conns = router._build_connections()
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."""
walls: Counter = Counter()
# the search starts on every end node unconditionally (it may leave one
# inside a wire keepout by a via), so the flood does too
start = list(router._end_nodes(pin, net_no, conns))
seen = set(start)
frontier = list(start)
while frontier and len(seen) < FLOOD_LIMIT:
x, y, layer = frontier.pop()
steps = [(x + dx, y + dy, layer) for dx, dy in router._neighbours]
if router._via_placeable((x, y), net_no, conns):
steps += [(x, y, other) for other in router.layers if other != layer]
elif len(seen) == 1:
# the pin's own exit is a via that other copper leaves no room for
via_nodes = [(*c, lay) for c in router._via_cells((x, y)) for lay in router.layers]
for owner in router._crossed_owners(via_nodes, net_no, conns):
walls[conns[owner].net_no] += 1
for n in steps:
if n in seen or not (0 <= n[0] < router.cols and 0 <= n[1] < router.rows):
continue
if router._enter_cost(n[:2], n[2], net_no, False, conns) is None:
# trace, via footprint or clearance halo, as rip-up sees them
owners = router._crossed_owners([n], net_no, conns)
for owner in owners:
walls[conns[owner].net_no] += 1
if not owners:
walls[_static_wall(router, n)] += 1
continue
seen.add(n)
frontier.append(n)
return len(seen), walls
def diagnose(
dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False,
access_cost: float = 0.0,
) -> dict:
dsn = parse_dsn(dsn_text)
names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
scale = max(dsn.resolution.value, 1)
seen = _capture_router()
conflicts = _capture_conflicts()
exact = route_board_exact(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * scale),
clearance=round(_rule_clearance_dsn(dsn) * scale),
via_half=_via_half_board(dsn, scale),
edge_clearance=_mm_to_board(dsn, scale, edge_clearance_mm),
resolution=resolution,
shove=shove,
access_cost=access_cost,
)
routed = seen["router"]
best = routed._diag_best
grid_failed = {(c.net_no, *_ends_of(c)) for c in routed._conns if c.index not in best}
# the board as the best pass left it
state = _fresh(seen)
conns = state._conns
for idx, (path, a_end, b_end) in best.items():
conns[idx].a_end, conns[idx].b_end = a_end, b_end
state._commit(conns[idx], path)
empty = _fresh(seen)
# refusals in the order the check made them, one per rejected connection
per_net: dict[int, list] = {}
for net_no, kind, layer, box, clearance, other in conflicts:
if other is None:
what = "?"
elif other.net_no == -1:
what = "keepout" if other.clearance else "rule area"
else:
what = ("trace/via of " if other.routed else "pad of ") + names.get(other.net_no, "?")
need = clearance if other is None or other.clearance else 0
short = (need - _gap(box, other.tile)) / scale / 1000 if other is not None else 0
per_net.setdefault(net_no, []).append(
{"copper": kind, "layer": layer, "against": what, "short_mm": round(short, 4)}
)
by_key = {(c.net_no, *_ends_of(c)): c for c in conns}
rows = []
for failure in exact.result.failed:
net_no, a, b = failure[0], failure[1], failure[2]
zone = failure[3] if len(failure) > 3 else ""
if zone:
continue # left to a zone fill, not a failure
key = (net_no, a, b)
row = {"net": names.get(net_no, str(net_no)), "a": a, "b": b}
conn = by_key.get(key)
if key not in grid_failed:
row["kind"] = "rejected"
if per_net.get(net_no):
row["conflict"] = per_net[net_no].pop(0)
elif conn is None:
row["kind"] = "unknown"
elif empty._search(empty._conns[conn.index], allow_ripup=False, conns=empty._conns) is None:
row["kind"] = "alone"
else:
walled = []
for end, group in ((a, conn.a_group), (b, conn.b_group)):
for pin in group[0][:1]: # a group's first pin is its route end
reach, walls = _flood(state, pin, net_no, conns)
if reach < FLOOD_LIMIT:
walled.append({
"pin": end, "reach": reach,
"walls": [
_wall_name(n, names) for n, _ in walls.most_common(4)
],
})
row["kind"] = "walled-in" if walled else "congestion"
if walled:
row["walled"] = walled
rows.append(row)
return {"failures": rows, "counts": dict(Counter(r["kind"] for r in rows))}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("dsn", type=Path)
ap.add_argument("--edge-clearance", type=float, metavar="MM")
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("--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)
for row in report["failures"]:
extra = ""
for w in row.get("walled", []):
extra += f" [{w['pin']} reaches {w['reach']} cells, walled by {', '.join(w['walls'])}]"
if "conflict" in row:
c = row["conflict"]
extra += (f" [{c['copper']} on layer {c['layer']} {c['short_mm']} mm too close"
f" to {c['against']}]")
print(f"{row['kind']:11} {row['net']}: {row['a']} to {row['b']}{extra}")
total = sum(report["counts"].values())
print(f"\n{total} failed: " + ", ".join(f"{k} {v}" for k, v in sorted(report["counts"].items())),
file=sys.stderr)
if args.json:
args.json.write_text(json.dumps(report, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+21
View File
@@ -70,6 +70,27 @@ class BasicBoard:
def get_traces(self) -> list[Trace]:
return [it for it in self._items if isinstance(it, Trace)]
def get_fixed_copper(self) -> list[tuple[int, frozenset[int], IntBox]]:
"""Copper already routed on the board (the DSN's ``wiring``), as
``(net, layers, box)``. A track is its segments, its corners and the
round caps at its ends (as squares); a via is its largest pad."""
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box
out: list[tuple[int, frozenset[int], IntBox]] = []
for item in self._items:
if not item.fixed or not item.net_nos:
continue
net = item.net_nos[0]
if isinstance(item, Trace) and not item.is_empty():
layers = frozenset({item.layer})
for box in PolylineShape(Polyline(item.corners), item.half_width).tiles():
out.append((net, layers, box))
for end in (item.corners[0], item.corners[-1]):
out.append((net, layers, corner_box(end, item.half_width)))
elif isinstance(item, Via) and item.shape is not None:
out.append((net, frozenset(item.layers), item.shape.bounding_box()))
return out
def get_vias(self) -> list[Via]:
return [it for it in self._items if isinstance(it, Via)]
+62 -1
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
@@ -31,13 +32,16 @@ from freeroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard
from .clearance import ClearanceMatrix
from .items import ConductionArea, ObstacleArea, Pin
from .items import ConductionArea, ObstacleArea, Pin, Trace, Via
from .layer import Layer, LayerStructure
from .net import Nets
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`."""
@@ -56,6 +60,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
_insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets)
_insert_wiring(dsn, board, transform, nets)
return board
@@ -333,10 +338,20 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
tiles=tiles,
blocks_wires=keepout.kind != "via_keepout",
blocks_vias=keepout.kind != "wire_keepout",
clearance=not is_rule_area(keepout.area.name),
)
)
#: a structure keepout named with this prefix is a KiCad rule area (mckicad
#: names them on export); it is checked for overlap only
RULE_AREA_PREFIX = "rule_area"
def is_rule_area(name: str | None) -> bool:
return bool(name) and name.startswith(RULE_AREA_PREFIX)
def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list:
"""Convex board tiles covering a keepout border shape.
@@ -461,6 +476,52 @@ def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> l
return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()]
def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None:
"""Tracks and vias already on the board, as fixed copper of their nets."""
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
numbers = {net.name: net.net_number for net in nets}
for wire in dsn.wires:
if wire.net not in numbers or wire.layer not in layer_no:
continue
c = wire.coords
corners = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
if len(corners) < 2:
continue
board.insert(
Trace(
net_nos=[numbers[wire.net]], fixed=True, layer=layer_no[wire.layer],
half_width=max(round(transform.dsn_to_board(wire.width / 2)), 1), corners=corners,
)
)
for via in dsn.vias:
padstack = dsn.padstack(via.padstack)
if via.net not in numbers or padstack is None or not padstack.shapes:
continue
layers: set[int] = set()
for shape in padstack.shapes:
if shape.layer in ("signal", "pcb"):
layers.update(signal_layers)
elif shape.layer in layer_no:
layers.add(layer_no[shape.layer])
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,
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),
)
)
def _insert_zones(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None:
"""Each DSN plane as a ConductionArea of its net on its layer."""
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
+14
View File
@@ -76,6 +76,11 @@ class ObstacleArea(Item):
#: tracks" rule area, where vias are allowed), via_keepout only vias
blocks_wires: bool = True
blocks_vias: bool = True
#: False for a KiCad rule area: copper may not overlap it but may touch
#: it, since KiCad's DRC applies no clearance to a rule area. Every other
#: keepout may stand for copper (a net tie, a track kept as it is), so it
#: keeps the clearance unless named as a rule area.
clearance: bool = True
def is_obstacle(self) -> bool:
return True
@@ -109,6 +114,13 @@ class Via(Item):
padstack_name: str = ""
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
#: 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
#: placed by the router's fan-out, not on the board yet: written out
generated: bool = False
@dataclass
@@ -122,6 +134,8 @@ class Trace(Item):
layer: int = 0
half_width: int = 0
corners: list[IntPoint] = field(default_factory=list)
#: placed by the router's fan-out, not on the board yet: written out
generated: bool = False
def is_empty(self) -> bool:
return len(self.corners) < 2
+26
View File
@@ -50,6 +50,8 @@ class TreeShape:
#: which copper a keepout tile stops: "all", "wires" (a wire_keepout,
#: vias allowed) or "vias" (a via_keepout)
blocks: str = "all"
#: False for a rule area: copper may touch it, only overlap counts
clearance: bool = True
def on_layer(self, layer: int) -> bool:
return layer in self.layers
@@ -129,6 +131,8 @@ class ShapeSearchTree:
Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles
exactly ``clearance`` apart (touching after expansion) are allowed.
A rule area (``clearance=False``) only has to be clear of the copper
itself.
"""
expanded = box.offset(clearance)
for shape in self.overlapping(expanded):
@@ -136,6 +140,10 @@ class ShapeSearchTree:
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue # a wire-only keepout does not stop a via, and so on
if not shape.clearance:
if shape.tile.overlaps(box):
return True
continue
if shape.exact is not None:
if overlaps_2d(shape.exact, expanded):
return True
@@ -143,6 +151,17 @@ class ShapeSearchTree:
return True
return False
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
for shape in self.overlapping(box):
if shape.clearance or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue
if shape.tile.overlaps(box):
return True
return False
def clearance_conflict_shape(
self, region, net_no: int, layer: int
) -> bool:
@@ -178,6 +197,13 @@ class ShapeSearchTree:
continue # static input pair (e.g. pad vs pad) — not a routing DRC
if not (shape.layers & other.layers):
continue
if not (shape.clearance and other.clearance):
# a rule area: only an overlap is a violation
mine = shape.exact if shape.exact is not None else shape.tile
theirs = other.exact if other.exact is not None else other.tile
if overlaps_2d(mine, theirs):
return shape
continue
if shape.exact is None and other.exact is None:
if other.tile.overlaps(expanded): # byte-identical box path
return shape
+68 -2
View File
@@ -30,8 +30,11 @@ from freeroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote"}),
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback", "fanout"}),
"exact": frozenset(
{"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access",
"rollback", "fanout"}
),
"room": frozenset({"shove", "pack"}),
}
@@ -45,6 +48,9 @@ _FLAG_NAMES = {
"seed": "--seed",
"orders": "--orders",
"promote": "--promote-failures",
"access": "--access-cost",
"rollback": "--no-rollback",
"fanout": "--fanout",
}
@@ -158,6 +164,45 @@ def build_parser() -> argparse.ArgumentParser:
help="grid and exact engines: up to R rounds, each routing the connections that "
"failed so far first; keeps the best round",
)
parser.add_argument(
"--access-cost",
type=float,
metavar="C",
help="grid and exact engines: extra cost for a route to pass where it would close "
"another pin's way out while that pin still has connections to route (default 0: off)",
)
parser.add_argument(
"--no-rollback",
dest="rollback",
action="store_false",
help="grid and exact engines: keep every rip-up, as before. By default the "
"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(
"--fanout",
nargs="?",
const="all",
choices=["all", "supply"],
help="grid and exact engines: before routing, give every fine-pitch pin a stub "
"outward past its pad row (and a via to its plane, for a plane net), committed "
"as copper so later routes cannot wall it in; --fanout supply: plane pins only",
)
parser.add_argument(
"--fanout-min-pins",
type=int,
default=32,
metavar="N",
help="with --fanout: fan out only packages with at least N fine-pitch pins (default 32)",
)
parser.add_argument(
"--jobs",
type=int,
@@ -186,6 +231,9 @@ def main(argv: list[str] | None = None) -> int:
("seed", args.seed is not None),
("orders", args.orders is not None),
("promote", args.promote_failures is not None),
("access", args.access_cost is not None),
("rollback", not args.rollback),
("fanout", bool(args.fanout)),
)
if asked
]
@@ -219,6 +267,11 @@ def main(argv: list[str] | None = None) -> int:
"edge_clearance_mm": args.edge_clearance,
"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,
"fanout": args.fanout or False,
"fanout_min_pins": args.fanout_min_pins,
"rollback": args.rollback,
}
elif args.engine == "grid":
options = {
@@ -227,6 +280,11 @@ def main(argv: list[str] | None = None) -> int:
"max_passes": max_passes,
"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,
"fanout": args.fanout or False,
"fanout_min_pins": args.fanout_min_pins,
"rollback": args.rollback,
}
if args.engine == "room":
result = build_rooms_routing_result(
@@ -264,6 +322,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:",
+25
View File
@@ -27,6 +27,8 @@ __all__ = [
"NetPin",
"Net",
"NetClass",
"WiringVia",
"WiringWire",
"Keepout",
"DsnBoard",
]
@@ -186,6 +188,26 @@ class Keepout:
kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout
@dataclass
class WiringWire:
"""A track already on the board: ``(wire (path LAYER WIDTH x y ...) (net N))``."""
net: str
layer: str
width: float
coords: list[float] = field(default_factory=list)
@dataclass
class WiringVia:
"""A via already on the board: ``(via PADSTACK x y (net N))``."""
net: str
padstack: str
x: float
y: float
@dataclass
class DsnBoard:
"""The fully parsed DSN file."""
@@ -197,6 +219,9 @@ class DsnBoard:
layers: list[Layer] = field(default_factory=list)
#: copper zones, as planes of a net
planes: list[Plane] = field(default_factory=list)
#: copper already routed (the ``wiring`` scope), kept fixed
wires: list[WiringWire] = field(default_factory=list)
vias: list[WiringVia] = field(default_factory=list)
#: bounding-box boundary shape (layer ``pcb``), if present
boundary: Shape | None = None
#: outline shapes (layer ``signal``)
+54 -2
View File
@@ -9,6 +9,8 @@ tolerant ``skip_scope`` behaviour.
from __future__ import annotations
import re
from .model import (
ClearanceRule,
ComponentPlace,
@@ -25,6 +27,8 @@ from .model import (
Pin,
Resolution,
WidthRule,
WiringVia,
WiringWire,
)
from .sexp import SExp, parse
from .shapes import Shape, read_area, read_shape
@@ -481,12 +485,26 @@ def _read_network(node: SExp, board: DsnBoard) -> None:
# --- top level ---------------------------------------------------------------
_STRING_QUOTE = re.compile(r"\(\s*string_quote\s+(\S)\s*\)")
def _declared_quote(text: str) -> str:
"""The quote character the file declares in ``(string_quote X)``.
Only that character opens a string. Without a declaration both kinds are
accepted, as before: KiCad declares ``"`` and then writes a bare ``'``
where a value is a single quote mark, e.g. a keycap part number.
"""
found = _STRING_QUOTE.search(text[:4096])
return found.group(1) if found else "\"'"
def parse_dsn(text: str) -> DsnBoard:
"""Parse DSN ``text`` into a :class:`DsnBoard`.
Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``.
"""
top = parse(text)
top = parse(text, _declared_quote(text))
if top.head != "pcb":
raise DsnParseError(
f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'"
@@ -515,11 +533,45 @@ def parse_dsn(text: str) -> DsnBoard:
_read_placement(it, board)
elif head == "network":
_read_network(it, board)
# `wiring` and unknown scopes are intentionally ignored for now.
elif head == "wiring":
_read_wiring(it, board)
# unknown scopes are ignored
return board
def _read_wiring(node: SExp, board: DsnBoard) -> None:
"""Tracks and vias already on the board. They stay where they are: the
router treats them as copper of their net that other nets keep clear of."""
for it in node.items:
if not isinstance(it, SExp):
continue
net_scope = it.child("net")
net = _first_value(net_scope) if net_scope is not None else None
if not net:
continue # netless copper would need a keepout, not a wire
if it.head == "wire":
path = it.child("path") or it.child("polyline_path")
vals = [v.text for v in path.values()] if path is not None else []
if len(vals) < 6:
continue
try:
width = float(vals[1])
coords = [float(v) for v in vals[2:]]
except ValueError:
continue
board.wires.append(WiringWire(net=net, layer=vals[0], width=width, coords=coords))
elif it.head == "via":
vals = [v.text for v in it.values()]
if len(vals) < 3:
continue
try:
x, y = float(vals[1]), float(vals[2])
except ValueError:
continue
board.vias.append(WiringVia(net=net, padstack=vals[0], x=x, y=y))
def _read_plane(node: SExp):
"""``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`."""
from .model import Plane
+71 -6
View File
@@ -31,7 +31,7 @@ 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,
@@ -42,6 +42,7 @@ from freeroute.geometry import (
segment_octagon,
shove_segment,
)
from freeroute.geometry.polyline import corner_box
from .grid_router import RouteResult, route_board
@@ -96,6 +97,11 @@ def route_board_exact(
heuristic_weight: float = 1.0,
seed: int | None = None,
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.
@@ -126,6 +132,11 @@ def route_board_exact(
heuristic_weight=heuristic_weight,
seed=seed,
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
@@ -144,6 +155,10 @@ def route_board_exact(
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))
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():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
@@ -155,35 +170,54 @@ def route_board_exact(
box = tile.bounding_box()
if not box.is_empty():
tree.insert(
TreeShape(next(owner), -1, frozenset({obstacle.layer}), box, blocks=blocks)
TreeShape(
next(owner), -1, frozenset({obstacle.layer}), box,
blocks=blocks, clearance=obstacle.clearance,
)
)
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 (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)):
) 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))
continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = accepted.get(net_no)
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))
@@ -233,6 +267,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)
@@ -590,6 +635,26 @@ def _net_boxes(result, net_no, half_width, via_layers, via_half=None):
)
def _end_in_rule_area(tree, wires, half_width) -> bool:
"""A wire's tiles stop at its end points, but a track ends in a round cap
half its width beyond them. Against other copper the pad or via there
covers it; a rule area has no clearance to absorb it, so check it."""
for layer, points in wires:
for end in (points[0], points[-1]):
if tree.rule_area_overlap(corner_box(end, half_width), layer):
return True
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]]] = []
+229
View File
@@ -0,0 +1,229 @@
"""Fan out fine-pitch pins before routing: commit each pin's way out as copper.
On a dense board the pins of a fine-pitch package get walled in: their own
neighbours' pads on either side, and other nets' copper that got there first
across the only way out. Reserving the way out did not help (the reserved
cells still had to be routed, and lost to congestion there); committing it
does, which is what a person does by hand.
Each fine-pitch pin of a perimeter package (QFP, QFN, LGA: pads on the edge,
none inside) gets a straight stub outward, perpendicular to its side of the
package, just past its pad row. A pin whose net has an inner plane also gets
a via at the stub's end, staggered between neighbouring pins so their holes
keep their spacing, and so reaches the plane with nothing left to route.
Every stub and via is checked exactly against pads, keepouts, the board edge,
copper already on the board and earlier fan-out, and left out where it would
not fit. The result goes on the board as fixed copper of its net (marked
``generated``), so the router starts from the stub's end or the via, and the
session writes it out with the routes.
"""
from __future__ import annotations
from dataclasses import dataclass
import math
from freeroute.board.board import BasicBoard
from freeroute.board.items import Pin, Trace, Via
from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box
# a package is fanned out when it has this many pins and they are this close
_MIN_PINS = 8
_FINE_PITCH_FACTOR = 1.2 # neighbour centres within this many (trace + 2 clearance)
@dataclass
class FanoutRules:
trace_width: int
clearance: int
via_half: int
via_drill: int
hole_to_hole: int
edge_clearance: int
signal_layers: list[int]
via_padstack: str
#: smallest package fanned out (a big dense part is where pins get walled in)
min_pins: int = 32
#: fan out plane pins only (a stub and a via each), as a person would first
supply_only: bool = False
def plan_fanout(board: BasicBoard, rules: FanoutRules) -> list:
"""The stubs and vias to put on the board before routing."""
half = max(rules.trace_width // 2, 1)
# a net may have several plane zones (a main plane and an island)
planes: dict[int, list] = {}
for z in board.get_conduction_areas():
if z.net_nos and not board.layer_structure.arr[z.layer].is_signal:
planes.setdefault(z.net_nos[0], []).append(z)
# copper pours on the routing layers, by net: a pad inside one is joined
# by the fill, and a stub there would take the room its thermal spokes need
pours: dict[int, list] = {}
for z in board.get_conduction_areas():
if z.net_nos and board.layer_structure.arr[z.layer].is_signal:
pours.setdefault(z.net_nos[0], []).append(z)
placed = _Placed(board, rules)
by_net: dict[int, list[Pin]] = {}
for pin in board.get_pins():
for n in pin.net_nos:
by_net.setdefault(n, []).append(pin)
items: list = []
pitch = 2 * rules.via_half + rules.hole_to_hole
for pins in _packages(board, rules).values():
centre = IntPoint(
sum(p.location.x for p in pins) // len(pins), sum(p.location.y for p in pins) // len(pins)
)
for k, pin in enumerate(sorted(pins, key=lambda p: (p.location.x, p.location.y))):
net = pin.net_nos[0] if pin.net_nos else None
if net is None:
continue
layer = next(layer for layer in pin.layers if layer in rules.signal_layers)
if any(z.layer == layer and z.polygon is not None and z.polygon.contains(pin.location)
for z in pours.get(net, [])):
continue # the pour joins it
direction = _outward(pin.location, centre)
reach = _pad_reach(pin, direction) + rules.clearance + half + rules.trace_width
end = _step(pin.location, direction, reach)
plane = planes.get(net)
partners = [q for q in by_net.get(net, []) if q is not pin]
# a signal whose other pins are all on other layers has to change
# layer somewhere: do it at the stub's end, away from the pads
elsewhere = bool(partners) and all(layer not in q.layers for q in partners)
if plane is None and (rules.supply_only or not elsewhere):
if not rules.supply_only and placed.stub_fits(pin.location, end, layer, net, half):
items.append(placed.add_stub(pin.location, end, layer, net, half))
continue
# stagger the via of every other pin along the side outward
options = [reach + rules.via_half + rules.clearance + (k % 2) * pitch,
reach + rules.via_half + rules.clearance + ((k + 1) % 2) * pitch]
for d in options:
at = _step(pin.location, direction, d)
if plane is not None and not any(
z.polygon is not None and z.polygon.contains(at) for z in plane
):
continue
if placed.stub_fits(pin.location, at, layer, net, half) and placed.via_fits(at, net):
items.append(placed.add_stub(pin.location, at, layer, net, half))
items.append(placed.add_via(at, net))
break
return items
def _packages(board: BasicBoard, rules: FanoutRules) -> dict[str, list[Pin]]:
"""Fine-pitch perimeter packages: many single-layer pins, close together."""
by_ref: dict[str, list[Pin]] = {}
for pin in board.get_pins():
if pin.shape is None or not pin.component_ref:
continue
on = [layer for layer in pin.layers if layer in rules.signal_layers]
if len(on) != 1:
continue # through-hole pins change layer for free
by_ref.setdefault(pin.component_ref, []).append(pin)
fine = _FINE_PITCH_FACTOR * (rules.trace_width + 2 * rules.clearance)
out = {}
for ref, pins in by_ref.items():
if len(pins) < max(rules.min_pins, _MIN_PINS):
continue
close = sum(
1 for p in pins
if min(math.hypot(p.location.x - q.location.x, p.location.y - q.location.y)
for q in pins if q is not p) < fine * 2
)
if close >= len(pins) // 2:
out[ref] = pins
return out
def _outward(point: IntPoint, centre: IntPoint) -> tuple[int, int]:
"""Away from the package centre, along the nearer axis (its side's normal)."""
dx, dy = point.x - centre.x, point.y - centre.y
if abs(dx) >= abs(dy):
return (1 if dx >= 0 else -1, 0)
return (0, 1 if dy >= 0 else -1)
def _pad_reach(pin: Pin, direction: tuple[int, int]) -> int:
"""How far the pad reaches from its centre in ``direction``."""
box = pin.shape.bounding_box()
if direction[0] > 0:
return box.ur.x - pin.location.x
if direction[0] < 0:
return pin.location.x - box.ll.x
if direction[1] > 0:
return box.ur.y - pin.location.y
return pin.location.y - box.ll.y
def _step(point: IntPoint, direction: tuple[int, int], distance: float) -> IntPoint:
return IntPoint(point.x + round(direction[0] * distance), point.y + round(direction[1] * distance))
class _Placed:
"""Copper a fan-out stub or via has to clear, growing as fan-out is placed."""
def __init__(self, board: BasicBoard, rules: FanoutRules) -> None:
self.board, self.rules = board, rules
self.copper: list[tuple[IntBox, frozenset[int], int]] = [] # box, layers, net
for pin in board.get_pins():
if pin.shape is not None:
net = pin.net_nos[0] if pin.net_nos else -1
self.copper.append((pin.shape.bounding_box(), frozenset(pin.layers), net))
for net, layers, box in board.get_fixed_copper():
self.copper.append((box, layers, net))
self.keepouts = [
(tile.bounding_box(), o.layer, o.blocks_wires, o.blocks_vias, o.clearance)
for o in board.get_obstacle_areas() for tile in o.tiles
]
self.holes: list[tuple[IntPoint, int]] = [] # centre, radius
for item in board.get_items():
if isinstance(item, Via) and item.fixed:
self.holes.append((item.location, (item.drill or rules.via_drill) // 2))
for pin in board.get_pins():
if pin.shape is not None and sum(1 for layer in pin.layers if layer in rules.signal_layers) > 1:
b = pin.shape.bounding_box()
self.holes.append((pin.location, min(b.ur.x - b.ll.x, b.ur.y - b.ll.y) // 2))
def _clear(self, box: IntBox, layers: frozenset[int], net: int, kind: str) -> bool:
grown = box.offset(self.rules.clearance)
for other, other_layers, other_net in self.copper:
if other_net != net and layers & other_layers and other.overlaps(grown):
return False
for kbox, klayer, wires, vias, needs in self.keepouts:
if klayer not in layers or not (wires if kind == "wire" else vias):
continue
if kbox.overlaps(grown if needs else box):
return False
outline = self.board.outline
return outline is None or outline.box_clear(box, self.rules.edge_clearance)
def stub_fits(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> bool:
boxes = list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)]
return all(self._clear(box, frozenset({layer}), net, "wire") for box in boxes)
def via_fits(self, at: IntPoint, net: int) -> bool:
h = self.rules.via_half
box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h)
if not self._clear(box, frozenset(self.rules.signal_layers), net, "via"):
return False
mine = self.rules.via_drill // 2
return all(
math.hypot(c.x - at.x, c.y - at.y) >= mine + r + self.rules.hole_to_hole
for c, r in self.holes
)
def add_stub(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> Trace:
for box in list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)]:
self.copper.append((box, frozenset({layer}), net))
return Trace(net_nos=[net], fixed=True, layer=layer, half_width=half, corners=[a, b],
generated=True)
def add_via(self, at: IntPoint, net: int) -> Via:
h = self.rules.via_half
box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h)
layers = list(self.rules.signal_layers)
self.copper.append((box, frozenset(layers), net))
self.holes.append((at, self.rules.via_drill // 2))
return Via(net_nos=[net], fixed=True, padstack_name=self.rules.via_padstack,
location=at, layers=layers, shape=box, drill=self.rules.via_drill,
generated=True)
+603 -22
View File
@@ -31,8 +31,9 @@ import math
import random
from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin
from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via
from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry.polyline import corner_box
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -60,7 +61,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]]]] = {}
@@ -75,6 +79,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))
@@ -112,6 +120,9 @@ class _Connection:
"b_group",
"a_end",
"b_end",
"path",
"narrow",
"half",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -133,6 +144,12 @@ class _Connection:
self.b_group: tuple[list[Pin], list] = ([b], [])
self.a_end: Pin | None = a
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:
@@ -157,8 +174,26 @@ class GridRouter:
heuristic_weight: float = 1.0,
seed: int | None = None,
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
# its route would leave by (its end cells, the via it may need there,
# and the clearance around both) cost other nets this much extra, so
# they route round a fine-pitch pin's way out instead of sealing it.
self.access_cost = max(float(access_cost), 0.0)
self._doorways: dict[Node, list[tuple[int, int]]] = {}
self._pending: dict[int, int] = {}
# Rip-up as a transaction: the connections a rip-up route crosses are
# rerouted at once, and if fewer connections are routed afterwards
# than before, everything goes back as it was. On by default: over
# 8 net orders it left 25 to 30 fewer connections unrouted on dense
# boards and was never clearly worse.
self.rollback = bool(rollback)
# None keeps the deterministic order; a seed shuffles which connection
# routes first, to measure how much a result owes to the order
self.seed = seed
@@ -204,6 +239,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]
@@ -231,6 +298,29 @@ class GridRouter:
# 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 -------------------------------------------------------
@@ -289,6 +379,13 @@ class GridRouter:
margin = self.clearance + self.half_width
outline = self.board.outline
self._via_edge_block: set[Cell] = set()
# wire keepouts as boxes, to check a pin stub the way the exact engine will
self._keepout_boxes: dict[int, list[tuple[IntBox, bool]]] = {}
# via keepouts as boxes, and other copper a via in a pad must clear
self._via_keepout_boxes: list[tuple[IntBox, bool]] = []
self._static_copper: list[tuple[IntBox, int]] | None = None
# pin end nodes no wire can reach without entering a keepout
self._via_only: set[Node] = set()
if outline is not None:
# cells too close to the real outline (rounded corners included):
# for a trace on every layer, and separately for a via's wider copper
@@ -325,11 +422,29 @@ class GridRouter:
if box.is_empty():
continue
# the same distance the exact engine verifies against
for cell in self._cells_in_box(box, margin):
if item.blocks_wires:
self._keepout_boxes.setdefault(item.layer, []).append(
(box, item.clearance)
)
# a rule area takes no clearance: a trace may touch it
reach = margin if item.clearance else self.half_width
for cell in self._cells_in_box(box, reach):
if item.blocks_wires:
self._keepout.add((*cell, item.layer))
if item.blocks_vias:
self._via_keepout.add((*cell, item.layer))
if item.blocks_vias:
self._via_keepout_boxes.append((box, item.clearance))
def _rasterize_fixed_copper(self) -> None:
"""Tracks and vias already on the board: copper of their net, which
other nets keep the same distance from as from a pad."""
margin = self.clearance + self.half_width
for net, layers, box in self.board.get_fixed_copper():
on = [layer for layer in layers if layer in self.layers]
for cell in self._cells_in_box(box, margin):
for layer in on:
self._pad_block.setdefault((*cell, layer), set()).add(net)
# --- occupancy cost -----------------------------------------------------
@@ -341,6 +456,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.
@@ -395,14 +534,101 @@ 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: those whose centre is
within its extra half width (plus half a cell, for the grid)."""
extra = half - self.half_width
if extra <= 0:
return []
reach = math.ceil(extra / self.step) + 1
limit = extra + self.step / 2
return [
(dx, dy)
for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1)
if (dx or dy) and math.hypot(dx, dy) * self.step <= limit
]
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 math.hypot(dx, 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]):
@@ -412,7 +638,14 @@ class GridRouter:
goals = self._group_ends(conn.b_group, conn.net_no, conns)
if not starts or not goals:
return None
common = sorted(set(starts) & set(goals))
# A start that is already an end joins at once, except that ending on
# a plane there needs a via: one that cannot be placed (a via keepout,
# another net's pad) is no end, and the search goes on from there.
common = [
n for n in sorted(set(starts) & set(goals))
if not isinstance(goals[n], ConductionArea)
or self._via_placeable((n[0], n[1]), conn.net_no, conns)
]
if common:
conn.a_end, conn.b_end = starts[common[0]], goals[common[0]]
return [common[0]]
@@ -422,10 +655,11 @@ class GridRouter:
g_score: dict[Node, float] = {}
came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked
goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if p is not None}
# 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)}
for s in sorted(starts):
pin = starts[s]
g_score[s] = self._stub_cost(pin, s) if pin is not None else 0.0
g_score[s] = self._stub_cost(pin, s) 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
@@ -444,6 +678,14 @@ class GridRouter:
ok = via_ok.get(cell)
if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns)
# a via leaving or reaching a walled-in pin goes in its pad
# (see _commit), so it has to fit there exactly
for node, ends in ((source, starts), (target, goals)):
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
@@ -455,6 +697,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:
@@ -463,23 +707,34 @@ class GridRouter:
return path
cx, cy, cl = current
base = g_score[current]
for dx, dy in self._neighbours:
# A cell inside a wire keepout (a pin there, or where a via
# landed) may only be left by another via: a wire from it would
# start inside the keepout.
blocked = current in self._keepout or current in self._via_only
neighbours = () if blocked else self._neighbours
for dx, dy in neighbours:
cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
nxt = (*cell, cl)
if nxt in goal_stub: # a pin end; a zone end is entered normally
if nxt in self._keepout or nxt in self._via_only:
continue # a pin inside a keepout is reached by a via only
enter = goal_stub[nxt]
else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
if enter is None:
continue
if self._doorways:
enter += self._access(nxt, net_no)
step = 1.0 if dx == 0 or dy == 0 else math.sqrt(2)
self._relax(
current, nxt, base + step + enter, goal_cell, g_score, came_from, open_heap
)
if len(self.layers) > 1:
g_via = base + self.via_cost
if self._doorways:
g_via += self._access(current, net_no)
for layer in self.layers:
if layer == cl:
continue
@@ -496,12 +751,51 @@ class GridRouter:
# copper too, or a stub ends up closer than clearance to a trace in the
# cell beside it.
def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> list[Cell]:
def _walled(self, node: Node) -> bool:
"""A pin end no wire may leave or reach: only a via in its pad will do."""
return node in self._keepout or node in self._via_only
def _via_fits_at(self, point: IntPoint, net_no: int) -> bool:
"""A via centred exactly on ``point`` clears every via keepout and all
other nets' pads and fixed copper (what a via in a pad must meet)."""
via = IntBox(point.x - self.via_half, point.y - self.via_half,
point.x + self.via_half, point.y + self.via_half)
grown = via.offset(self.clearance)
for box, needs in self._via_keepout_boxes:
if box.overlaps(grown if needs else via):
return False
if self._static_copper is None:
copper = [
(p.shape.bounding_box(), p.net_nos[0] if p.net_nos else -1)
for p in self.board.get_pins()
if p.shape is not None and any(layer in self.layers for layer in p.layers)
]
copper += [(box, net) for net, _, box in self.board.get_fixed_copper()]
self._static_copper = 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 _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_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
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),
@@ -512,7 +806,27 @@ class GridRouter:
if _point_segment(self._cell_center(c), pin_location, end) < reach
]
def _stub_clear(self, pin: Pin, cell: Cell, layer: int, net_no: int, conns) -> bool:
def _stub_clear(
self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, keepouts_only: bool = False
) -> bool:
keepouts = self._keepout_boxes.get(layer)
if keepouts:
# the stub is off the grid, so check it exactly, as the exact
# engine will: its copper grown by clearance must miss every keepout
from freeroute.geometry.polyline import Polyline, PolylineShape
end = self._cell_center(cell)
if end != pin.location:
for tile in PolylineShape(Polyline([pin.location, end]), self._cur_half).tiles():
grown = tile.offset(self.clearance)
if any(k.overlaps(grown if needs else tile) for k, needs in keepouts):
return False
# the track's round end at the pin, for a rule area
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:
@@ -618,6 +932,13 @@ class GridRouter:
nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy))
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(
self._stub_clear(pin, (cx, cy), layer, net_no, conns, keepouts_only=True)
for layer in layers
):
# no wire can reach the pin without entering a keepout: the
# route has to arrive by a via, which goes in the pad
self._via_only.update(nodes)
return nodes
def _crowded(self, pin: Pin, net_no: int) -> bool:
@@ -731,6 +1052,7 @@ class GridRouter:
# --- commit / rip -------------------------------------------------------
def _commit(self, conn: _Connection, path: list[Node]) -> None:
conn.path = list(path)
wire_segs, via_cells = _split_path(path)
via_cells = list(via_cells)
if isinstance(conn.a_end, ConductionArea):
@@ -739,11 +1061,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)
@@ -760,13 +1089,26 @@ class GridRouter:
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):
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))
# 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.
in_pad: dict[int, IntPoint] = {}
if len(wire_segs) > 1:
for idx, pin in ((0, conn.a_end), (len(wire_segs) - 1, conn.b_end)):
layer, cells = wire_segs[idx]
node = (*cells[0], layer)
walled = node in self._keepout or node in self._via_only
if isinstance(pin, Pin) and len(cells) == 1 and walled:
in_pad[idx] = pin.location
conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs):
if idx in in_pad:
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)
@@ -776,7 +1118,11 @@ class GridRouter:
if len(points) >= 2:
conn.wires.append((layer, points))
conn.vias = [self._cell_center(c) for c in conn.via_cells]
for idx, location in in_pad.items():
# via k joins wire segments k and k + 1
conn.vias[0 if idx == 0 else idx - 1] = location
conn.routed = True
self._settle(conn, -1)
def _rip(self, conn: _Connection) -> None:
for cell, layer in conn.trace_cells:
@@ -794,11 +1140,18 @@ class GridRouter:
del self._halo[node]
conn.halo_nodes = []
conn.trace_cells = []
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 = []
conn.routed = False
conn.rip_count += 1
self._settle(conn, +1)
def _crossed_owners(self, path: list[Node], net_no: int, conns: list[_Connection]) -> set[int]:
owners: set[int] = set()
@@ -814,6 +1167,50 @@ class GridRouter:
owners.add(i)
return owners
# --- pin access ---------------------------------------------------------
@staticmethod
def _group_pins(conn: _Connection):
return [*conn.a_group[0], *conn.b_group[0]]
def _build_doorways(self, conns: list[_Connection]) -> None:
"""Map every cell that would close a pin's way out to that pin."""
spread = [(0, 0), *self._halo_offsets]
done: set[int] = set()
for conn in conns:
for pin in self._group_pins(conn):
self._pending[id(pin)] = self._pending.get(id(pin), 0) + 1
if id(pin) in done:
continue
done.add(id(pin))
claim = (id(pin), conn.net_no)
exits: set[Node] = set()
for x, y, layer in self._end_nodes(pin, conn.net_no, conns):
exits.add((x, y, layer))
for c in self._via_cells((x, y)):
exits.update((*c, other) for other in self.layers)
cells: set[Node] = set()
for x, y, layer in exits:
for dx, dy in spread:
if 0 <= x + dx < self.cols and 0 <= y + dy < self.rows:
cells.add((x + dx, y + dy, layer))
for node in cells:
self._doorways.setdefault(node, []).append(claim)
def _settle(self, conn: _Connection, delta: int) -> None:
if not self._pending:
return
for pin in self._group_pins(conn):
if id(pin) in self._pending:
self._pending[id(pin)] += delta
def _access(self, node: Node, net_no: int) -> float:
"""Extra cost for ``net_no`` to use a cell another pin still needs."""
for pin_id, owner_net in self._doorways.get(node, ()):
if owner_net != net_no and self._pending.get(pin_id, 0) > 0:
return self.access_cost
return 0.0
# --- passes -------------------------------------------------------------
def route(self) -> RouteResult:
@@ -822,11 +1219,14 @@ class GridRouter:
self._conns = conns
if not conns:
return RouteResult(self.half_width)
if self.access_cost:
self._build_doorways(conns)
passes = self.max_passes if self.rip_up else 1
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:
@@ -846,13 +1246,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:
@@ -860,11 +1272,41 @@ class GridRouter:
path = self._search(conn, allow_ripup=True, conns=conns)
if path is None:
return False
for idx in sorted(self._crossed_owners(path, conn.net_no, conns)):
self._rip(conns[idx])
victims = [conns[i] for i in sorted(self._crossed_owners(path, conn.net_no, conns))]
if self.rollback:
return self._ripup_transaction(conn, path, victims, conns)
for victim in victims:
self._rip(victim)
self._commit(conn, path)
return True
def _ripup_transaction(self, conn, path, victims, conns) -> bool:
"""Route ``conn`` through ``victims``, reroute them at once in free
space, and undo it all if fewer connections end up routed."""
saved = [(v, v.path, v.a_end, v.b_end, v.rip_count) for v in victims]
own_rips = conn.rip_count
for victim in victims:
self._rip(victim)
self._commit(conn, path)
rerouted = 0
for victim in victims:
again = self._search(victim, allow_ripup=False, conns=conns)
if again is not None:
self._commit(victim, again)
rerouted += 1
if 1 + rerouted >= len(victims):
return True # a gain, or an even trade that may pay off later
for victim in victims:
if victim.routed:
self._rip(victim)
self._rip(conn)
conn.rip_count = own_rips
for victim, old_path, a_end, b_end, rips in saved:
victim.a_end, victim.b_end = a_end, b_end
self._commit(victim, old_path)
victim.rip_count = rips
return False
def _build_connections(self) -> list[_Connection]:
pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {}
@@ -886,7 +1328,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
@@ -900,6 +1344,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, trace=item)
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():
@@ -909,6 +1375,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),
@@ -919,11 +1400,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)
@@ -974,13 +1460,50 @@ 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", "trace")
def __init__(self, layers, boxes, via=None, trace=None) -> None:
self.layers = frozenset(layers)
self.boxes = boxes
self.via = via # the Via item, for a via
self.trace = trace # the Trace item, for a track
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:
@@ -992,6 +1515,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):
@@ -1006,9 +1547,39 @@ def _connected_groups(
groups[root] = ([], [])
order.append(root)
groups[root][1].append(zone)
for k, piece in enumerate(fixed):
root = find(base + k)
if root not in groups:
continue
if piece.via is not None:
groups[root][0].append(_via_end(piece.via))
elif piece.trace is not None:
# a track's ends are places a route may start: a fan-out stub's
# far end is outside the pad row the pin sat walled in by
for end in (piece.trace.corners[0], piece.trace.corners[-1]):
groups[root][0].append(_trace_end(piece.trace, end))
return [groups[r] for r in order]
def _trace_end(trace, end: IntPoint) -> Pin:
"""An end of a fixed track as a place a route may start or end."""
from freeroute.geometry.polyline import corner_box
return Pin(
net_nos=list(trace.net_nos), fixed=True, name="track", component_ref="",
location=end, layers=[trace.layer], shape=corner_box(end, trace.half_width),
)
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]]] = []
@@ -1067,6 +1638,11 @@ def route_board(
heuristic_weight: float = 1.0,
seed: int | None = None,
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(
@@ -1084,5 +1660,10 @@ def route_board(
heuristic_weight=heuristic_weight,
seed=seed,
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()
+126 -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,56 @@ 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 _place_fanout(
board, dsn, scale, layers, width, clearance, edge, hole_to_hole_mm, mode=True, min_pins=32
) -> None:
"""Put the fan-out's stubs and vias on the board, before routing."""
from .fanout import FanoutRules, plan_fanout
via_half = _via_half_board(dsn, scale) or max(width // 2, 1)
rules = FanoutRules(
trace_width=width,
clearance=clearance,
via_half=via_half,
via_drill=_via_drill_board(dsn, scale) or 2 * via_half,
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
edge_clearance=edge or 0,
signal_layers=layers or [i for i, layer in enumerate(dsn.layers) if layer.is_signal],
via_padstack=_via_padstack(dsn),
min_pins=min_pins,
supply_only=mode == "supply",
)
for item in plan_fanout(board, rules):
board.insert(item)
def _add_generated(result, board) -> None:
"""The fan-out's copper goes into the session with the routes."""
from freeroute.board.items import Trace, Via
for item in board.get_items():
if not getattr(item, "generated", False) or not item.net_nos:
continue
if isinstance(item, Trace):
result.add_wire(item.net_nos[0], item.layer, list(item.corners))
elif isinstance(item, Via):
result.add_via(item.net_nos[0], item.location)
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.
@@ -82,6 +153,11 @@ def route_dsn_board(
max_passes: int = 10,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
fanout: bool | str = False,
fanout_min_pins: int = 32,
rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer
@@ -92,6 +168,9 @@ def route_dsn_board(
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
if fanout:
_place_fanout(board, dsn, scale, layers, width_board, clearance_board,
edge_clearance_board, hole_to_hole_mm, fanout, fanout_min_pins)
result = route_board(
board,
resolution=resolution,
@@ -104,7 +183,13 @@ def route_dsn_board(
max_passes=max_passes,
seed=seed,
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,
)
_add_generated(result, board)
return result, scale, [layer.name for layer in dsn.layers]
@@ -118,6 +203,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] = []
@@ -125,7 +213,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:
@@ -133,6 +221,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
@@ -146,12 +237,22 @@ def build_routing_result(
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
fanout: bool | str = False,
fanout_min_pins: int = 32,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
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,
fanout=fanout,
fanout_min_pins=fanout_min_pins,
rollback=rollback,
)
return _to_routing_result(route, dsn, scale, layer_names)
@@ -169,6 +270,11 @@ def route_dsn_board_exact(
diagonal: bool = False,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
fanout: bool | str = False,
fanout_min_pins: int = 32,
rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names.
@@ -180,6 +286,9 @@ def route_dsn_board_exact(
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
if fanout:
_place_fanout(board, dsn, scale, layers, width_board, clearance_board,
edge_clearance_board, hole_to_hole_mm, fanout, fanout_min_pins)
exact = route_board_exact(
board,
resolution=resolution,
@@ -195,7 +304,13 @@ def route_dsn_board_exact(
diagonal=diagonal,
seed=seed,
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,
)
_add_generated(exact.result, board)
return exact, scale, [layer.name for layer in dsn.layers]
@@ -212,6 +327,11 @@ def build_exact_routing_result(
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
fanout: bool | str = False,
fanout_min_pins: int = 32,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(
@@ -226,6 +346,11 @@ def build_exact_routing_result(
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
fanout=fanout,
fanout_min_pins=fanout_min_pins,
rollback=rollback,
)
return _to_routing_result(exact.result, dsn, scale, layer_names)
+4
View File
@@ -515,6 +515,10 @@ def route_board_rooms(
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))
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():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
+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)."""
+34
View File
@@ -254,3 +254,37 @@ def test_via_size_comes_from_the_via_padstack():
from freeroute.route.pipeline import _via_half_board
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
# --- rule areas: overlap only ------------------------------------------------
_CHANNEL_DSN = """(pcb channel.dsn
(parser (string_quote ")) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "{name}top" (polygon signal 0 18000 0 22000 0 22000 -14750 18000 -14750))
(keepout "{name}bottom" (polygon signal 0 18000 -15250 22000 -15250 22000 -30000 18000 -30000))
(rule (width 250) (clearance 150)))
(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))
(library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))
(network (net N (pins P1-1 P2-1))))"""
def test_a_keepout_named_rule_area_takes_no_clearance():
named = _areas(_CHANNEL_DSN.format(name="rule_area_"))
plain = _areas(_CHANNEL_DSN.format(name=""))
assert named and not any(a.clearance for a in named)
assert plain and all(a.clearance for a in plain)
def _route_channel(name):
from freeroute.route.pipeline import build_exact_routing_result
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
def test_a_trace_may_run_beside_a_rule_area_closer_than_clearance():
# the 0.5 mm channel fits a 0.25 mm trace, but not with 0.15 mm each side
assert _route_channel("rule_area_").unrouted == []
assert _route_channel("").unrouted != []
+34
View File
@@ -62,3 +62,37 @@ def test_has_violation_ignores_static_pad_pairs():
# a routed trace of net 3 within clearance of pad net 1 -> violation
tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True))
assert tree.has_violation(clearance=20) is not None
def test_a_rule_area_conflicts_only_on_overlap():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
near = IntBox(0, 0, 95, 100) # 5 short of the area, well inside clearance 50
over = IntBox(0, 0, 105, 100)
assert not tree.clearance_conflict(near, 7, 0, 50)
assert tree.clearance_conflict(over, 7, 0, 50)
def test_an_ordinary_keepout_still_needs_clearance():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100)))
assert tree.clearance_conflict(IntBox(0, 0, 95, 100), 7, 0, 50)
def test_the_routed_board_check_treats_a_rule_area_the_same_way():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, 7, frozenset({0}), IntBox(0, 0, 95, 100), routed=True))
assert tree.has_violation(50) is None
tree.insert(TreeShape(3, 7, frozenset({0}), IntBox(150, 0, 160, 100), routed=True))
assert tree.has_violation(50) is not None
def test_rule_area_overlap_sees_only_rule_areas():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, -1, frozenset({0}), IntBox(300, 0, 400, 100)))
assert tree.rule_area_overlap(IntBox(90, 40, 110, 60), 0)
assert not tree.rule_area_overlap(IntBox(290, 40, 310, 60), 0)
assert not tree.rule_area_overlap(IntBox(90, 40, 110, 60), 1)
+23
View File
@@ -156,3 +156,26 @@ def test_unplaced_component_has_no_coords():
place = board.placements[0].places[0]
assert place.name == "U9"
assert place.x is None and place.y is None
# --- string_quote ----------------------------------------------------------
_QUOTED = """(pcb q.dsn
(parser (string_quote {q}) (space_in_quoted_tokens on))
(resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (boundary (path pcb 0 0 0 1000 0 1000 1000 0 1000 0 0)))
(placement (component "SW1" (place SW1 100 100 front 0 (PN {pn}))))
(library)
(network)
)"""
def test_a_bare_apostrophe_is_an_atom_when_the_file_quotes_with_double_quotes():
# KiCad declares " and writes a keycap's part number as a bare '
board = parse_dsn(_QUOTED.format(q='"', pn="'"))
assert board.parser.string_quote == '"'
def test_the_declared_quote_character_is_the_one_that_opens_strings():
board = parse_dsn(_QUOTED.format(q="'", pn="'10k 1%'").replace('"SW1"', "'SW1'"))
assert board.parser.string_quote == "'"
+78
View File
@@ -0,0 +1,78 @@
"""--fanout: fine-pitch pins get their way out committed before routing."""
from __future__ import annotations
from freeroute.board import build_board
from freeroute.board.items import Trace, Via
from freeroute.dsn import parse_dsn
from freeroute.route.fanout import FanoutRules, plan_fanout
from freeroute.route.pipeline import route_dsn_board_exact
# a 12-pad package, 3 pads a side at 0.5 mm pitch, over a GND plane on In1;
# pad 1 (left side, top) is GND, the rest are signals to far connector pads
_SIDES = {
"L": [(-2000, y, 0) for y in (500, 0, -500)],
"R": [(2000, y, 0) for y in (500, 0, -500)],
"T": [(x, 2000, 90) for x in (-500, 0, 500)],
"B": [(x, -2000, 90) for x in (-500, 0, 500)],
}
_PADS = [p for side in "LRTB" for p in _SIDES[side]]
def _dsn():
pins = "\n ".join(
f"(pin {'padv' if r else 'padh'} {i + 1} {x} {y})" for i, (x, y, r) in enumerate(_PADS)
)
far = " ".join(f"(place C{i} {3000 + 2000 * (i % 5)} {-12000 - 2000 * (i // 5)} front 0)" for i in range(11))
nets = "(net GND (pins U1-1))\n " + "\n ".join(f"(net S{i} (pins U1-{i + 2} C{i}-1))" for i in range(11))
return f"""(pcb pkg (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 -15000 15000 25000 15000 25000 -25000 -15000 -25000 -15000 15000))
(plane GND (polygon In1.Cu 0 -14500 14500 24500 14500 24500 -24500 -14500 -24500 -14500 14500))
(via "Via[0-3]_450:200_um") (rule (width 150) (clearance 150)))
(placement (component Q (place U1 0 0 front 0)) (component P {far}))
(library
(image Q
{pins})
(image P (pin padc 1 0 0))
(padstack padh (shape (rect F.Cu -400 -125 400 125)) (attach off))
(padstack padv (shape (rect F.Cu -125 -400 125 400)) (attach off))
(padstack padc (shape (rect F.Cu -500 -500 500 500)) (attach off))
(padstack "Via[0-3]_450:200_um" (shape (circle F.Cu 450)) (shape (circle B.Cu 450)) (attach off)))
(network {nets}))"""
def _rules(dsn):
s = dsn.resolution.value
return FanoutRules(trace_width=150 * s, clearance=150 * s, via_half=225 * s, via_drill=200 * s,
hole_to_hole=250 * s, edge_clearance=0, signal_layers=[0, 3],
via_padstack="Via[0-3]_450:200_um", min_pins=8)
def test_every_pin_gets_a_stub_outward_past_its_pad():
dsn = parse_dsn(_dsn())
board = build_board(dsn)
items = plan_fanout(board, _rules(dsn))
stubs = [i for i in items if isinstance(i, Trace)]
assert len(stubs) == 12
for stub in stubs:
a, b = stub.corners
pin = next(p for p in board.get_pins() if p.location == a)
pad = pin.shape.bounding_box()
# outward: the stub ends further from the package centre, outside its pad
assert abs(b.x) + abs(b.y) > abs(a.x) + abs(a.y)
assert not (pad.ll.x <= b.x <= pad.ur.x and pad.ll.y <= b.y <= pad.ur.y)
def test_a_plane_pin_gets_a_via_and_needs_no_route():
dsn = parse_dsn(_dsn())
board = build_board(dsn)
items = plan_fanout(board, _rules(dsn))
assert len([i for i in items if isinstance(i, Via)]) == 1
exact, _scale, _ = route_dsn_board_exact(dsn, resolution=2, fanout=True, fanout_min_pins=8)
gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND")
assert not any(f[0] == gnd for f in exact.result.failed)
assert exact.drc_clean
assert len(exact.result.vias.get(gnd, [])) == 1 # the fan-out's, written out
+108
View File
@@ -0,0 +1,108 @@
"""Tracks and vias already on the board (the DSN's wiring) are fixed copper."""
from __future__ import annotations
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
_DSN = """(pcb wired (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:300_um") (rule (width 250) (clearance 200)))
(placement (component S (place A1 5000 -10000 front 0) (place A2 25000 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -400 -400 400 400)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net SIG (pins A1-1 A2-1)) (net GND))
(wiring
(wire (path F.Cu 400 15000 0 15000 -20000) (net GND) (type route))
(via "Via[0-1]_600:300_um" 8000 -14000 (net GND) (type route))))"""
def test_wiring_is_read_with_its_nets():
dsn = parse_dsn(_DSN)
assert [(w.net, w.layer, w.width) for w in dsn.wires] == [("GND", "F.Cu", 400.0)]
assert [(v.net, v.x, v.y) for v in dsn.vias] == [("GND", 8000.0, -14000.0)]
def test_wiring_is_fixed_copper_and_not_a_connection_to_route():
board = build_board(parse_dsn(_DSN))
copper = board.get_fixed_copper()
gnd = next(n.net_number for n in board.nets if n.name == "GND")
assert copper and all(net == gnd for net, _, _ in copper)
assert not any(gnd in p.net_nos for p in board.get_pins())
def test_other_nets_route_around_a_fixed_track():
dsn = parse_dsn(_DSN)
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 exact.drc_clean
wall = 15000 * scale
for layer, pts in exact.result.wires.get(sig, []):
if names[layer] != "F.Cu":
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
+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}
+38
View File
@@ -202,6 +202,18 @@ def test_smd_pads_reach_an_inner_plane_by_their_own_via():
assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs)
def test_a_pad_outside_the_plane_routes_to_it():
# G1 sits left of a plane that covers only the right half, so its route
# has to reach the plane's cells: those ends are the plane, not a pin
dsn_text = _four_layer_dsn().replace(
"(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))",
"(plane GND (polygon In1.Cu 0 15000 -500 29500 -500 29500 -29500 15000 -29500 15000 -500))",
)
result, _scale, _names = route_dsn_board_exact(parse_dsn(dsn_text))
gnd = next(i + 1 for i, n in enumerate(parse_dsn(dsn_text).nets) if n.name == "GND")
assert gnd in result.result.routed_net_numbers
def test_a_through_hole_pad_inside_a_plane_needs_no_route():
from freeroute.route.grid_router import _connected_groups
@@ -211,3 +223,29 @@ def test_a_through_hole_pad_inside_a_plane_needs_no_route():
groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane))
hub = next(g for g in groups if g[1])
assert [p.component_ref for p in hub[0]] == ["GT"]
def test_a_pad_inside_a_via_keepout_reaches_the_plane_by_a_via_outside_it():
# a sensor's GND pad: vias are forbidden under the body, so the route
# has to leave on the top layer and drop to the plane past the keepout
dsn = parse_dsn("""(pcb repro (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_keepout "" (polygon signal 0 8500 -8500 11500 -8500 11500 -11500 8500 -11500 8500 -8500))
(via "Via[0-3]_600:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 10000 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -175 -300 175 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))))""")
exact, scale, _names = route_dsn_board_exact(dsn, resolution=2)
assert exact.result.failed == []
(via,) = exact.result.vias[1]
half = 300 * scale # the via's radius
outside = (via.x + half <= 8500 * scale or via.x - half >= 11500 * scale
or via.y - half >= -8500 * scale or via.y + half <= -11500 * scale)
assert outside, f"via at {via} is inside the via keepout"
+43
View File
@@ -0,0 +1,43 @@
"""--rollback: a rip-up that loses connections is undone, leaving no trace."""
from __future__ import annotations
from pathlib import Path
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
def _routed_router(name):
dsn = parse_dsn((FIXTURES / name).read_text())
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
rollback=True,
)
return router, router.route()
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_occupancy_only_holds_routed_connections(name):
router, _ = _routed_router(name)
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for i in router._via_owner.values())
assert all(conns[i].routed for claims in router._halo.values() for i in claims)
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_rollback_never_routes_fewer_than_plain_rip_up(name):
text = (FIXTURES / name).read_text()
plain, _, _ = route_dsn_board(parse_dsn(text), rollback=False)
undo, _, _ = route_dsn_board(parse_dsn(text))
assert undo.unrouted <= plain.unrouted
+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)
+3
View File
@@ -157,6 +157,9 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"seed": ["--seed", "3"],
"orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"],
"rollback": ["--no-rollback"],
"fanout": ["--fanout"],
}
for engine, options in ENGINE_OPTIONS.items():
for option in options: