Compare commits

..
Author SHA1 Message Date
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
10 changed files with 636 additions and 25 deletions
+32 -2
View File
@@ -106,6 +106,15 @@ def _capture_conflicts() -> list:
return log 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: def _gap(a, b) -> int:
dx = max(b.ll.x - a.ur.x, a.ll.x - b.ur.x, 0) 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) dy = max(b.ll.y - a.ur.y, a.ll.y - b.ur.y, 0)
@@ -118,6 +127,25 @@ def _fresh(seen) -> GridRouter:
return router 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]: 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), """How many nodes a pin can reach through free cells (up to FLOOD_LIMIT),
and which other nets' copper bounds the region.""" and which other nets' copper bounds the region."""
@@ -146,7 +174,7 @@ def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
for owner in owners: for owner in owners:
walls[conns[owner].net_no] += 1 walls[conns[owner].net_no] += 1
if not owners: if not owners:
walls[0] += 1 # a pad or keepout walls[_static_wall(router, n)] += 1
continue continue
seen.add(n) seen.add(n)
frontier.append(n) frontier.append(n)
@@ -224,7 +252,9 @@ def diagnose(
if reach < FLOOD_LIMIT: if reach < FLOOD_LIMIT:
walled.append({ walled.append({
"pin": end, "reach": reach, "pin": end, "reach": reach,
"walls": [names.get(n, "pads/keepouts") for n, _ in walls.most_common(4)], "walls": [
_wall_name(n, names) for n, _ in walls.most_common(4)
],
}) })
row["kind"] = "walled-in" if walled else "congestion" row["kind"] = "walled-in" if walled else "congestion"
if walled: if walled:
+18
View File
@@ -175,6 +175,14 @@ def build_parser() -> argparse.ArgumentParser:
"connections a rip-up displaces are rerouted at once and the rip-up is undone " "connections a rip-up displaces are rerouted at once and the rip-up is undone "
"if fewer connections end up routed", "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( parser.add_argument(
"--jobs", "--jobs",
type=int, type=int,
@@ -239,6 +247,7 @@ def main(argv: list[str] | None = None) -> int:
"resolution": 2 if args.grid_resolution is None else args.grid_resolution, "resolution": 2 if args.grid_resolution is None else args.grid_resolution,
"heuristic_weight": args.heuristic_weight, "heuristic_weight": args.heuristic_weight,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback, "rollback": args.rollback,
} }
elif args.engine == "grid": elif args.engine == "grid":
@@ -249,6 +258,7 @@ def main(argv: list[str] | None = None) -> int:
"edge_clearance_mm": args.edge_clearance, "edge_clearance_mm": args.edge_clearance,
"resolution": 1 if args.grid_resolution is None else args.grid_resolution, "resolution": 1 if args.grid_resolution is None else args.grid_resolution,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"hole_to_hole_mm": args.hole_to_hole,
"rollback": args.rollback, "rollback": args.rollback,
} }
if args.engine == "room": if args.engine == "room":
@@ -287,6 +297,14 @@ def main(argv: list[str] | None = None) -> int:
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr) print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
for net, a, b, _zone in missing: for net, a, b, _zone in missing:
print(f" {net}: {a} to {b}", file=sys.stderr) 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: if in_zone:
print( print(
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:", f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
+35 -5
View File
@@ -31,7 +31,7 @@ from dataclasses import dataclass, field
import math import math
from freeroute.board.board import BasicBoard 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.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import ( from freeroute.geometry import (
IntBox, IntBox,
@@ -99,6 +99,9 @@ def route_board_exact(
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True, rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -131,6 +134,9 @@ def route_board_exact(
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, 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 edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline outline = board.outline
@@ -172,23 +178,32 @@ def route_board_exact(
via_layers = frozenset(signal_layers) via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {} 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 # Verify connection by connection, so one conflicting segment of a
# 25-pin supply net costs that one connection, not the whole net. # 25-pin supply net costs that one connection, not the whole net.
rejected: list[tuple[int, str, str]] = [] rejected: list[tuple[int, str, str]] = []
ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections) 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( for (net_no, wires, vias), ends in sorted(
zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0] 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_half = net_half.get(net_no, half_width)
wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half)) 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( if any(
tree.clearance_conflict( tree.clearance_conflict(
box, net_no, layer, clearance, "wire" if i < wire_count else "via" box, net_no, layer, clearance, "wire" if i < wire_count else "via"
) )
for i, (box, box_layers) in enumerate(boxes) for i, (box, box_layers) in enumerate(boxes)
for layer in box_layers for layer in box_layers
) or _end_in_rule_area(tree, wires, half_width) or ( ) or _end_in_rule_area(tree, wires, wire_half) or (
outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes) outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)
): ):
rejected.append((net_no, *ends)) rejected.append((net_no, *ends))
@@ -197,7 +212,11 @@ def route_board_exact(
if acc is None: if acc is None:
acc = accepted[net_no] = _Accepted(wires=[], vias=[]) acc = accepted[net_no] = _Accepted(wires=[], vias=[])
acc.wires.extend(wires) 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 (net_no, v) not in fixed_vias
)
for box, box_layers in boxes: for box, box_layers in boxes:
oid = next(owner) oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True)) tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
@@ -247,6 +266,17 @@ def route_board_exact(
for net_no, acc in accepted.items(): for net_no, acc in accepted.items():
clean.wires[net_no] = acc.wires clean.wires[net_no] = acc.wires
clean.vias[net_no] = acc.vias 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 drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean, shoves=shoves) return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean, shoves=shoves)
+327 -17
View File
@@ -31,7 +31,7 @@ import math
import random import random
from freeroute.board.board import BasicBoard 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 import IntBox, IntPoint
from freeroute.geometry.polyline import corner_box from freeroute.geometry.polyline import corner_box
@@ -61,7 +61,10 @@ class RouteResult:
``routed_net_numbers`` are the nets that got any wire or via. ``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: def __init__(self, half_width: int) -> None:
self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {} self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {}
@@ -76,6 +79,10 @@ class RouteResult:
self.connection_ends: list[tuple[str, str, str]] = [] self.connection_ends: list[tuple[str, str, str]] = []
#: connections left unrouted: (net_no, one end, the other end) #: connections left unrouted: (net_no, one end, the other end)
self.failed: list[tuple[int, str, str]] = [] 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: def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None:
self.wires.setdefault(net_no, []).append((layer, path)) self.wires.setdefault(net_no, []).append((layer, path))
@@ -114,6 +121,8 @@ class _Connection:
"a_end", "a_end",
"b_end", "b_end",
"path", "path",
"narrow",
"half",
) )
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -137,6 +146,10 @@ class _Connection:
self.b_end: Pin | None = b self.b_end: Pin | None = b
# the node path last committed, so a rip-up can be undone # the node path last committed, so a rip-up can be undone
self.path: list[Node] | None = None 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: class GridRouter:
@@ -163,6 +176,9 @@ class GridRouter:
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True, rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> None: ) -> None:
self.board = board self.board = board
# Pin access: while a pin still has connections to route, the cells # Pin access: while a pin still has connections to route, the cells
@@ -223,6 +239,34 @@ class GridRouter:
self.edge_clearance = 0 if edge_clearance is None else edge_clearance self.edge_clearance = 0 if edge_clearance is None else edge_clearance
need = self.via_half + self.clearance + self.half_width need = self.via_half + self.clearance + self.half_width
self.via_reach = max(0, math.ceil(need / self.step) - 1) 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)
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._fixed_vias: dict[Cell, int] = {}
self._fixed_via_points: set[tuple[int, IntPoint]] = set()
if layers is None: if layers is None:
layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal] layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal]
@@ -251,6 +295,10 @@ class GridRouter:
self._halo: dict[Node, list[int]] = {} self._halo: dict[Node, list[int]] = {}
self._rasterize_obstacles() self._rasterize_obstacles()
self._rasterize_fixed_copper() 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))
# --- grid helpers ------------------------------------------------------- # --- grid helpers -------------------------------------------------------
@@ -311,6 +359,9 @@ class GridRouter:
self._via_edge_block: set[Cell] = set() self._via_edge_block: set[Cell] = set()
# wire keepouts as boxes, to check a pin stub the way the exact engine will # wire keepouts as boxes, to check a pin stub the way the exact engine will
self._keepout_boxes: dict[int, list[tuple[IntBox, bool]]] = {} 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 # pin end nodes no wire can reach without entering a keepout
self._via_only: set[Node] = set() self._via_only: set[Node] = set()
if outline is not None: if outline is not None:
@@ -360,6 +411,8 @@ class GridRouter:
self._keepout.add((*cell, item.layer)) self._keepout.add((*cell, item.layer))
if item.blocks_vias: if item.blocks_vias:
self._via_keepout.add((*cell, item.layer)) 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: def _rasterize_fixed_copper(self) -> None:
"""Tracks and vias already on the board: copper of their net, which """Tracks and vias already on the board: copper of their net, which
@@ -381,6 +434,30 @@ class GridRouter:
allow_ripup: bool, allow_ripup: bool,
conns: list[_Connection], conns: list[_Connection],
wire_keepout: bool = True, 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: ) -> float | None:
"""Cost to enter ``(cell, layer)`` for ``net_no``; ``None`` if blocked. """Cost to enter ``(cell, layer)`` for ``net_no``; ``None`` if blocked.
@@ -435,14 +512,72 @@ class GridRouter:
for layer in self.layers: for layer in self.layers:
if (*c, layer) in self._via_keepout: if (*c, layer) in self._via_keepout:
return False 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 False
return True return self._holes_clear(cell, net_no)
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."""
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 ---------------------------------------------------------- # --- 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( def _search(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection] 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: ) -> list[Node] | None:
goal_cell = self._group_target(conn.b_group) 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]): if not conn.b_group[0] and any(z.layer in self._power_layers for z in conn.b_group[1]):
@@ -452,7 +587,14 @@ class GridRouter:
goals = self._group_ends(conn.b_group, conn.net_no, conns) goals = self._group_ends(conn.b_group, conn.net_no, conns)
if not starts or not goals: if not starts or not goals:
return None 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: if common:
conn.a_end, conn.b_end = starts[common[0]], goals[common[0]] conn.a_end, conn.b_end = starts[common[0]], goals[common[0]]
return [common[0]] return [common[0]]
@@ -485,6 +627,12 @@ class GridRouter:
ok = via_ok.get(cell) ok = via_ok.get(cell)
if ok is None: if ok is None:
ok = via_ok[cell] = self._via_placeable(cell, net_no, conns) 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: if ok:
self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap) self._relax(source, target, g_via, goal_cell, g_score, came_from, open_heap)
continue continue
@@ -548,12 +696,35 @@ class GridRouter:
# copper too, or a stub ends up closer than clearance to a trace in the # copper too, or a stub ends up closer than clearance to a trace in the
# cell beside it. # 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
return not any(net != net_no and box.overlaps(grown) for box, net in self._static_copper)
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.""" """Cells where another net's trace would come too close to the stub."""
from freeroute.board.outline import _point_segment from freeroute.board.outline import _point_segment
end = self._cell_center(cell) 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( box = IntBox(
min(pin_location.x, end.x), min(pin_location.y, end.y), min(pin_location.x, end.x), min(pin_location.y, end.y),
max(pin_location.x, end.x), max(pin_location.y, end.y), max(pin_location.x, end.x), max(pin_location.y, end.y),
@@ -575,12 +746,12 @@ class GridRouter:
end = self._cell_center(cell) end = self._cell_center(cell)
if end != pin.location: if end != pin.location:
for tile in PolylineShape(Polyline([pin.location, end]), self.half_width).tiles(): for tile in PolylineShape(Polyline([pin.location, end]), self._cur_half).tiles():
grown = tile.offset(self.clearance) grown = tile.offset(self.clearance)
if any(k.overlaps(grown if needs else tile) for k, needs in keepouts): if any(k.overlaps(grown if needs else tile) for k, needs in keepouts):
return False return False
# the track's round end at the pin, for a rule area # the track's round end at the pin, for a rule area
cap = corner_box(pin.location, self.half_width) cap = corner_box(pin.location, self._cur_half)
if any(not needs and k.overlaps(cap) for k, needs in keepouts): if any(not needs and k.overlaps(cap) for k, needs in keepouts):
return False return False
if keepouts_only: if keepouts_only:
@@ -819,11 +990,16 @@ class GridRouter:
via_cells.append((path[-1][0], path[-1][1])) 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.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells]
conn.via_cells = list(via_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
for cell, layer in conn.trace_cells: for cell, layer in conn.trace_cells:
self._cell_owner[(*cell, layer)] = conn.index self._cell_owner[(*cell, layer)] = conn.index
conn.half = half = self._conn_half(conn)
halo = self._halo_for(half)
conn.halo_nodes = [] conn.halo_nodes = []
for (cx, cy), layer in conn.trace_cells: 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 x, y = cx + dx, cy + dy
if 0 <= x < self.cols and 0 <= y < self.rows: if 0 <= x < self.cols and 0 <= y < self.rows:
node = (x, y, layer) node = (x, y, layer)
@@ -840,7 +1016,7 @@ class GridRouter:
if not isinstance(pin, Pin): if not isinstance(pin, Pin):
continue # ended on a zone or a plane: no stub continue # ended on a zone or a plane: no stub
cell = cells[at] 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) node = (*c, layer)
if node not in self._cell_owner: if node not in self._cell_owner:
self._cell_owner[node] = conn.index self._cell_owner[node] = conn.index
@@ -891,6 +1067,10 @@ class GridRouter:
del self._halo[node] del self._halo[node]
conn.halo_nodes = [] conn.halo_nodes = []
conn.trace_cells = [] 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
conn.via_cells = [] conn.via_cells = []
conn.wires = [] conn.wires = []
conn.vias = [] conn.vias = []
@@ -971,6 +1151,7 @@ class GridRouter:
best_count = -1 best_count = -1
best_snapshot: list[tuple[int, list, list]] | None = None best_snapshot: list[tuple[int, list, list]] | None = None
best_unrouted = len(conns) 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 # conns[i].index == i is what rip-up relies on, so shuffle a view
order = list(conns) order = list(conns)
if self.seed is not None: if self.seed is not None:
@@ -990,13 +1171,25 @@ class GridRouter:
best_count = routed_count best_count = routed_count
best_unrouted = len(conns) - routed_count best_unrouted = len(conns) - routed_count
best_snapshot = self._snapshot(conns) best_snapshot = self._snapshot(conns)
best_widths = self._net_widths(conns)
if routed_count == len(conns): if routed_count == len(conns):
break break
if not changed: if not changed:
break 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: 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) path = self._search(conn, allow_ripup=False, conns=conns)
if path is None: if path is None:
if not self.rip_up: if not self.rip_up:
@@ -1060,7 +1253,9 @@ class GridRouter:
# deterministic: fewer-pin nets first, then by net number # 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)): 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, []) 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])] hubs = [g for g in groups if any(id(z) in plane_ids for z in g[1])]
if hubs: if hubs:
# a plane net: every other group goes straight to the plane # a plane net: every other group goes straight to the plane
@@ -1074,6 +1269,28 @@ class GridRouter:
conns.append(conn) conns.append(conn)
return conns return conns
def _fixed_pieces(self, net_no: int) -> list[_FixedPiece]:
"""The net's tracks and vias already on the board, as connectivity."""
cache = getattr(self, "_fixed_cache", None)
if cache is None:
from freeroute.board.items import Trace
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box
cache = self._fixed_cache = {}
for item in self.board.get_items():
if not item.fixed or not item.net_nos:
continue
if isinstance(item, Via) and item.shape is not None:
piece = _FixedPiece(item.layers, [item.shape.bounding_box()], via=item)
elif isinstance(item, Trace) and not item.is_empty():
boxes = list(PolylineShape(Polyline(item.corners), item.half_width).tiles())
boxes += [corner_box(e, item.half_width) for e in (item.corners[0], item.corners[-1])]
piece = _FixedPiece([item.layer], boxes)
else:
continue
cache.setdefault(item.net_nos[0], []).append(piece)
return cache.get(net_no, [])
def _pins_by_net(self) -> dict[int, list[Pin]]: def _pins_by_net(self) -> dict[int, list[Pin]]:
by_net: dict[int, list[Pin]] = {} by_net: dict[int, list[Pin]] = {}
for pin in self.board.get_pins(): for pin in self.board.get_pins():
@@ -1083,6 +1300,21 @@ class GridRouter:
by_net.setdefault(net_no, []).append(pin) by_net.setdefault(net_no, []).append(pin)
return by_net 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]: def _snapshot(self, conns: list[_Connection]) -> list[tuple]:
return [ return [
(c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias), (c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias),
@@ -1093,11 +1325,16 @@ class GridRouter:
def _result_from(self, snapshot, unrouted: int) -> RouteResult: def _result_from(self, snapshot, unrouted: int) -> RouteResult:
result = RouteResult(self.half_width) result = RouteResult(self.half_width)
written: set[tuple[int, IntPoint]] = set()
result.unrouted = unrouted result.unrouted = unrouted
for net_no, wires, vias, ends in snapshot: for net_no, wires, vias, ends in snapshot:
for layer, points in wires: for layer, points in wires:
result.add_wire(net_no, layer, points) result.add_wire(net_no, layer, points)
for location in vias: 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.add_via(net_no, location)
result.connections.append((net_no, wires, vias)) result.connections.append((net_no, wires, vias))
result.connection_ends.append(ends) result.connection_ends.append(ends)
@@ -1148,13 +1385,49 @@ def _pin_touches_zone(pin: Pin, zone, plane: bool = False) -> bool:
return any(zone.polygon.contains(c) for c in corners) return any(zone.polygon.contains(c) for c in corners)
def _touches(a: IntBox, b: IntBox) -> bool:
return not (a.ur.x < b.ll.x or b.ur.x < a.ll.x or a.ur.y < b.ll.y or b.ur.y < a.ll.y)
def _fixed_joins_zone(piece, zone, plane: bool) -> bool:
"""A through via inside a plane joins it; copper inside a zone on its
own layer joins that zone."""
if zone.polygon is None:
return False
if piece.via is not None:
if plane or zone.layer in piece.layers:
return zone.polygon.contains(piece.via.location)
return False
return zone.layer in piece.layers and any(
zone.polygon.contains(IntPoint((b.ll.x + b.ur.x) // 2, (b.ll.y + b.ur.y) // 2))
for b in piece.boxes
)
class _FixedPiece:
"""One track or via already on the board, as connectivity."""
__slots__ = ("layers", "boxes", "via")
def __init__(self, layers, boxes, via=None) -> None:
self.layers = frozenset(layers)
self.boxes = boxes
self.via = via # the Via item, for a via
def _connected_groups( 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]]: ) -> list[tuple[list[Pin], list]]:
"""Pins and zones of one net that copper already joins: a pad in a zone """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 is connected to it, two zones sharing a pad are one, and copper already
need routing between them. Pin groups come first, in pin order.""" on the board (tracks and vias, ``fixed``) joins whatever it touches. Only
parent = list(range(len(pins) + len(zones))) 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: def find(i: int) -> int:
while parent[i] != i: while parent[i] != i:
@@ -1166,6 +1439,24 @@ def _connected_groups(
for i, pin in enumerate(pins): for i, pin in enumerate(pins):
if _pin_touches_zone(pin, zone, id(zone) in planes): if _pin_touches_zone(pin, zone, id(zone) in planes):
parent[find(i)] = find(len(pins) + j) 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]] = {} groups: dict[int, tuple[list[Pin], list]] = {}
order: list[int] = [] order: list[int] = []
for i, pin in enumerate(pins): for i, pin in enumerate(pins):
@@ -1180,9 +1471,22 @@ def _connected_groups(
groups[root] = ([], []) groups[root] = ([], [])
order.append(root) order.append(root)
groups[root][1].append(zone) groups[root][1].append(zone)
for k, piece in enumerate(fixed):
root = find(base + k)
if piece.via is not None and root in groups:
groups[root][0].append(_via_end(piece.via))
return [groups[r] for r in order] return [groups[r] for r in order]
def _via_end(via) -> Pin:
"""A fixed via as a place a route may start or end (not a pad to route)."""
return Pin(
net_nos=list(via.net_nos), fixed=True, name="via", component_ref="",
padstack_name=via.padstack_name, location=via.location,
layers=list(via.layers), shape=via.shape,
)
def _split_path(path: list[Node]) -> tuple[list[tuple[int, list[Cell]]], list[Cell]]: 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.""" """Split a node path into per-layer wire segments and via cells."""
wires: list[tuple[int, list[Cell]]] = [] wires: list[tuple[int, list[Cell]]] = []
@@ -1243,6 +1547,9 @@ def route_board(
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True, rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> RouteResult: ) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board.""" """Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter( router = GridRouter(
@@ -1262,5 +1569,8 @@ def route_board(
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, rollback=rollback,
net_widths=net_widths,
via_drill=via_drill,
hole_to_hole=hole_to_hole,
) )
return router.route() return router.route()
+54 -1
View File
@@ -12,6 +12,8 @@ to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and
from __future__ import annotations from __future__ import annotations
import re
from freeroute.board import build_board from freeroute.board import build_board
from freeroute.dsn import DsnBoard, parse_dsn from freeroute.dsn import DsnBoard, parse_dsn
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses 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 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: def _rule_clearance_dsn(dsn: DsnBoard) -> float:
for rule in dsn.structure_rules.clearance_rules: for rule in dsn.structure_rules.clearance_rules:
if not rule.class_pairs: # the layer-wide default clearance if not rule.class_pairs: # the layer-wide default clearance
@@ -53,6 +74,20 @@ def _mm_to_board(dsn: DsnBoard, scale: int, value_mm: float | None) -> int | Non
return round(value_mm / unit_mm * scale) return round(value_mm / unit_mm * scale)
def _via_drill_board(dsn: DsnBoard, scale: int) -> int | None:
"""The via's drill in board units, from KiCad's padstack name
(``Via[0-1]_600:300_um`` is a 600 um pad with a 300 um drill); None when
the name does not say, and the pad size stands in for it."""
match = re.search(r"_([\d.]+):([\d.]+)_um", _via_padstack(dsn))
if not match:
return None
return round(float(match.group(2)) * _dsn_units_per_um(dsn) * scale)
def _dsn_units_per_um(dsn: DsnBoard) -> float:
return {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}.get(dsn.unit, 1.0)
def _via_half_board(dsn: DsnBoard, scale: int) -> int | None: 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. """Half the via pad's size in board units, from the DSN's via padstack.
@@ -83,6 +118,7 @@ def route_dsn_board(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]: ) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
@@ -108,6 +144,9 @@ def route_dsn_board(
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, rollback=rollback,
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
) )
return result, scale, [layer.name for layer in dsn.layers] return result, scale, [layer.name for layer in dsn.layers]
@@ -122,6 +161,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
result = RoutingResult() result = RoutingResult()
for net_no, segments in route.wires.items(): for net_no, segments in route.wires.items():
name = numbers_to_names.get(net_no, str(net_no)) 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: for layer_index, path in segments:
layer_name = layer_names[layer_index] if layer_index < len(layer_names) else "F.Cu" layer_name = layer_names[layer_index] if layer_index < len(layer_names) else "F.Cu"
coords: list[float] = [] coords: list[float] = []
@@ -129,7 +171,7 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
coords.append(point.x / scale) coords.append(point.x / scale)
coords.append(point.y / scale) coords.append(point.y / scale)
if len(coords) >= 4: 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(): for net_no, locations in route.vias.items():
name = numbers_to_names.get(net_no, str(net_no)) name = numbers_to_names.get(net_no, str(net_no))
for loc in locations: for loc in locations:
@@ -137,6 +179,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
result.unrouted = [ result.unrouted = [
(numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", []) (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 return result
@@ -151,6 +196,7 @@ def build_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
@@ -159,6 +205,7 @@ def build_routing_result(
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed, edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback, rollback=rollback,
) )
return _to_routing_result(route, dsn, scale, layer_names) return _to_routing_result(route, dsn, scale, layer_names)
@@ -178,6 +225,7 @@ def route_dsn_board_exact(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
@@ -207,6 +255,9 @@ def route_dsn_board_exact(
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback, rollback=rollback,
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
) )
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
@@ -225,6 +276,7 @@ def build_exact_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True, rollback: bool = True,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
@@ -241,6 +293,7 @@ def build_exact_routing_result(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback, rollback=rollback,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
+3
View File
@@ -60,6 +60,9 @@ class RoutingResult:
#: connections the router could not make: (net name, one end, other end, #: 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 #: 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) 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: def net(self, name: str, *, create: bool = False) -> RoutedNet | None:
"""Return the :class:`RoutedNet` with ``name`` (creating it if asked).""" """Return the :class:`RoutedNet` with ``name`` (creating it if asked)."""
+43
View File
@@ -48,3 +48,46 @@ def test_other_nets_route_around_a_fixed_track():
continue continue
for p, q in zip(pts, pts[1:], strict=False): 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" 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"}
+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}
+26
View File
@@ -223,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)) groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane))
hub = next(g for g in groups if g[1]) hub = next(g for g in groups if g[1])
assert [p.component_ref for p in hub[0]] == ["GT"] 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"
+56
View File
@@ -0,0 +1,56 @@
"""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"