Route with copper zones, and let vias into no-tracks areas

freeroute ignored the DSN's planes (KiCad's zones), so it never knew that
pads in a pour are joined by it. On the rc4 power stage those pads sit
inside "keep router off" areas as well, and every connection to them was
dropped without a word.

- Read (plane <net> <shape>) and put each zone on the board as a
  ConductionArea of its net. Zones are not obstacles: KiCad refills them
  around other nets' copper.
- The grid groups each net's pads and zone pieces that copper already
  joins (a pad in a zone, two zones sharing a pad) and routes only between
  groups. A connection may end on a zone by a via inside its copper, or by
  a track where tracks are allowed.
- wire_keepout now blocks traces only, as KiCad's "no tracks" rule area
  does; via_keepout only vias; keepout both, in the grid and in the exact
  verifier.

rc4 power stage, exact engine, resolution 2: 2 unconnected, down from 8
(FreeRouting JAR: 12), no DRC errors.
This commit is contained in:
2026-09-27 11:54:04 -06:00
parent 83b2248a97
commit 354c89a8d5
9 changed files with 313 additions and 39 deletions
+10 -2
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from freeroute.geometry import IntBox from freeroute.geometry import IntBox
from .clearance import ClearanceMatrix from .clearance import ClearanceMatrix
from .items import Item, ObstacleArea, Pin, Trace, Via from .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via
from .layer import LayerStructure from .layer import LayerStructure
from .net import Nets from .net import Nets
@@ -57,7 +57,15 @@ class BasicBoard:
return [it for it in self._items if isinstance(it, Pin)] return [it for it in self._items if isinstance(it, Pin)]
def get_obstacle_areas(self) -> list[ObstacleArea]: def get_obstacle_areas(self) -> list[ObstacleArea]:
return [it for it in self._items if isinstance(it, ObstacleArea)] """Keepouts. Copper zones are not obstacles; see get_conduction_areas."""
return [
it
for it in self._items
if isinstance(it, ObstacleArea) and not isinstance(it, ConductionArea)
]
def get_conduction_areas(self) -> list[ConductionArea]:
return [it for it in self._items if isinstance(it, ConductionArea)]
def get_traces(self) -> list[Trace]: def get_traces(self) -> list[Trace]:
return [it for it in self._items if isinstance(it, Trace)] return [it for it in self._items if isinstance(it, Trace)]
+42 -4
View File
@@ -31,7 +31,7 @@ from freeroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard from .board import BasicBoard
from .clearance import ClearanceMatrix from .clearance import ClearanceMatrix
from .items import ObstacleArea, Pin from .items import ConductionArea, ObstacleArea, Pin
from .layer import Layer, LayerStructure from .layer import Layer, LayerStructure
from .net import Nets from .net import Nets
from .transform import CoordinateTransform from .transform import CoordinateTransform
@@ -55,6 +55,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
_insert_pins(dsn, board, transform, terminal_to_net) _insert_pins(dsn, board, transform, terminal_to_net)
_insert_keepouts(dsn, board, transform) _insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform) _insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets)
return board return board
@@ -330,6 +331,8 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
name=keepout.area.name, name=keepout.area.name,
layer=layer_index, layer=layer_index,
tiles=tiles, tiles=tiles,
blocks_wires=keepout.kind != "via_keepout",
blocks_vias=keepout.kind != "wire_keepout",
) )
) )
@@ -402,13 +405,17 @@ def _insert_image_keepouts(dsn: DsnBoard, board: BasicBoard, transform: Coordina
image = images.get(placement.lib_name) image = images.get(placement.lib_name)
if image is None: if image is None:
continue continue
areas = [*image.keepouts, *image.via_keepouts, *image.wire_keepouts] areas = [
*((a, True, True) for a in image.keepouts),
*((a, False, True) for a in image.via_keepouts),
*((a, True, False) for a in image.wire_keepouts),
]
if not areas: if not areas:
continue continue
for place in placement.places: for place in placement.places:
if place.x is None or place.y is None: if place.x is None or place.y is None:
continue continue
for area in areas: for area, blocks_wires, blocks_vias in areas:
border = area.border border = area.border
if border is None: if border is None:
continue continue
@@ -423,7 +430,12 @@ def _insert_image_keepouts(dsn: DsnBoard, board: BasicBoard, transform: Coordina
top, bottom = min(signal_layers), max(signal_layers) top, bottom = min(signal_layers), max(signal_layers)
layers = [top + bottom - layer for layer in layers] layers = [top + bottom - layer for layer in layers]
for layer_index in layers: for layer_index in layers:
board.insert(ObstacleArea(name=area.name, layer=layer_index, tiles=tiles)) board.insert(
ObstacleArea(
name=area.name, layer=layer_index, tiles=tiles,
blocks_wires=blocks_wires, blocks_vias=blocks_vias,
)
)
def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> list: def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> list:
@@ -447,3 +459,29 @@ def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> l
if len(pts) < 3: if len(pts) < 3:
return [] return []
return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()] return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()]
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)}
numbers = {net.name: net.net_number for net in nets}
for plane in dsn.planes:
shape = plane.shape
if plane.net not in numbers or shape.layer not in layer_no:
continue
if not isinstance(shape, (Polygon, Path)):
continue
c = shape.coords
pts = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
if len(pts) < 3:
continue
polygon = PolygonShape(pts)
tiles = [t for t in polygon.split_to_convex() if not t.is_empty()]
if not tiles:
continue
board.insert(
ConductionArea(
net_nos=[numbers[plane.net]], name=plane.net, layer=layer_no[shape.layer],
tiles=tiles, polygon=polygon, blocks_wires=False, blocks_vias=False,
)
)
+14 -1
View File
@@ -72,6 +72,10 @@ class ObstacleArea(Item):
name: str | None = None name: str | None = None
layer: int = 0 layer: int = 0
tiles: list = field(default_factory=list) tiles: list = field(default_factory=list)
#: a DSN keepout blocks both; wire_keepout only traces (KiCad's "no
#: tracks" rule area, where vias are allowed), via_keepout only vias
blocks_wires: bool = True
blocks_vias: bool = True
def is_obstacle(self) -> bool: def is_obstacle(self) -> bool:
return True return True
@@ -87,7 +91,16 @@ class ObstacleArea(Item):
@dataclass @dataclass
class ConductionArea(ObstacleArea): class ConductionArea(ObstacleArea):
"""A power-plane conduction area (an obstacle carrying a net).""" """A copper zone of one net (a DSN plane): not an obstacle to routing,
since KiCad refills it around other nets' copper, but copper of its net:
pads it touches are connected through it, and a route of its net ends on
it by placing a via inside it."""
#: the zone outline, for point containment
polygon: object = None
def is_obstacle(self) -> bool:
return False
@dataclass @dataclass
+8 -1
View File
@@ -47,6 +47,9 @@ class TreeShape:
routed: bool = False routed: bool = False
exact: Simplex | None = None exact: Simplex | None = None
seg: tuple | None = None # (a: IntPoint, b: IntPoint, half_width: int) seg: tuple | None = None # (a: IntPoint, b: IntPoint, half_width: int)
#: which copper a keepout tile stops: "all", "wires" (a wire_keepout,
#: vias allowed) or "vias" (a via_keepout)
blocks: str = "all"
def on_layer(self, layer: int) -> bool: def on_layer(self, layer: int) -> bool:
return layer in self.layers return layer in self.layers
@@ -118,7 +121,9 @@ class ShapeSearchTree:
result.append(shape) result.append(shape)
return result return result
def clearance_conflict(self, box: IntBox, net_no: int, layer: int, clearance: int) -> bool: def clearance_conflict(
self, box: IntBox, net_no: int, layer: int, clearance: int, kind: str = "wire"
) -> bool:
"""True if placing ``box`` (of ``net_no`` on ``layer``) would come *closer """True if placing ``box`` (of ``net_no`` on ``layer``) would come *closer
than* ``clearance`` to a different net's tile on that layer (exact). than* ``clearance`` to a different net's tile on that layer (exact).
@@ -129,6 +134,8 @@ class ShapeSearchTree:
for shape in self.overlapping(expanded): for shape in self.overlapping(expanded):
if shape.net_no == net_no or not shape.on_layer(layer): if shape.net_no == net_no or not shape.on_layer(layer):
continue continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue # a wire-only keepout does not stop a via, and so on
if shape.exact is not None: if shape.exact is not None:
if overlaps_2d(shape.exact, expanded): if overlaps_2d(shape.exact, expanded):
return True return True
+10
View File
@@ -170,6 +170,14 @@ class NetClass:
via_rule: str | None = None via_rule: str | None = None
@dataclass
class Plane:
"""A ``(plane <net> <shape>)``: a copper zone of that net (KiCad's zones)."""
net: str
shape: Shape
@dataclass @dataclass
class Keepout: class Keepout:
"""A structure-level keepout area with its kind.""" """A structure-level keepout area with its kind."""
@@ -187,6 +195,8 @@ class DsnBoard:
resolution: Resolution = field(default_factory=Resolution) resolution: Resolution = field(default_factory=Resolution)
unit: str = "mil" unit: str = "mil"
layers: list[Layer] = field(default_factory=list) layers: list[Layer] = field(default_factory=list)
#: copper zones, as planes of a net
planes: list[Plane] = field(default_factory=list)
#: bounding-box boundary shape (layer ``pcb``), if present #: bounding-box boundary shape (layer ``pcb``), if present
boundary: Shape | None = None boundary: Shape | None = None
#: outline shapes (layer ``signal``) #: outline shapes (layer ``signal``)
+21
View File
@@ -183,6 +183,11 @@ def _read_structure(node: SExp, board: DsnBoard) -> None:
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
board.keepouts.append(Keepout(area=area, kind="wire_keepout")) board.keepouts.append(Keepout(area=area, kind="wire_keepout"))
elif head == "plane":
# (plane <net> <shape>): KiCad exports each zone this way
plane = _read_plane(it)
if plane is not None:
board.planes.append(plane)
elif head == "place_keepout": elif head == "place_keepout":
area = read_area(it) area = read_area(it)
if area is not None: if area is not None:
@@ -513,3 +518,19 @@ def parse_dsn(text: str) -> DsnBoard:
# `wiring` and unknown scopes are intentionally ignored for now. # `wiring` and unknown scopes are intentionally ignored for now.
return board return board
def _read_plane(node: SExp):
"""``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`."""
from .model import Plane
items = node.items
if not items or not isinstance(items[0], Token):
return None
net = items[0].text
for sub in items[1:]:
if isinstance(sub, SExp):
shape = read_shape(sub)
if shape is not None:
return Plane(net=net, shape=shape)
return None
+12 -3
View File
@@ -143,10 +143,16 @@ def route_board_exact(
for obstacle in board.get_obstacle_areas(): for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers: if obstacle.layer not in signal_layers:
continue continue
blocks = (
"all" if obstacle.blocks_wires and obstacle.blocks_vias
else "wires" if obstacle.blocks_wires else "vias"
)
for tile in obstacle.tiles: for tile in obstacle.tiles:
box = tile.bounding_box() box = tile.bounding_box()
if not box.is_empty(): if not box.is_empty():
tree.insert(TreeShape(next(owner), -1, frozenset({obstacle.layer}), box)) tree.insert(
TreeShape(next(owner), -1, frozenset({obstacle.layer}), box, blocks=blocks)
)
via_layers = frozenset(signal_layers) via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {} accepted: dict[int, _Accepted] = {}
@@ -155,9 +161,12 @@ def route_board_exact(
# 25-pin supply net costs that one connection, not the whole net. # 25-pin supply net costs that one connection, not the whole net.
for net_no, wires, vias in sorted(result.connections, key=lambda c: c[0]): for net_no, wires, vias in sorted(result.connections, key=lambda c: c[0]):
boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half) boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half))
if any( if any(
tree.clearance_conflict(box, net_no, layer, clearance) tree.clearance_conflict(
for box, box_layers in boxes 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 for layer in box_layers
) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)): ) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)):
continue # exact-clearance conflict -> drop (may be recovered by shove) continue # exact-clearance conflict -> drop (may be recovered by shove)
+151 -27
View File
@@ -30,7 +30,7 @@ import heapq
import math import math
from freeroute.board.board import BasicBoard from freeroute.board.board import BasicBoard
from freeroute.board.items import ObstacleArea, Pin from freeroute.board.items import ConductionArea, ObstacleArea, Pin
from freeroute.geometry import IntBox, IntPoint from freeroute.geometry import IntBox, IntPoint
__all__ = ["GridRouter", "RouteResult", "route_board"] __all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -102,6 +102,10 @@ class _Connection:
"via_cells", "via_cells",
"via_halo", "via_halo",
"halo_nodes", "halo_nodes",
"a_group",
"b_group",
"a_end",
"b_end",
) )
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:
@@ -117,6 +121,12 @@ class _Connection:
self.via_cells: list[Cell] = [] self.via_cells: list[Cell] = []
self.via_halo: list[Cell] = [] self.via_halo: list[Cell] = []
self.halo_nodes: list[Node] = [] self.halo_nodes: list[Node] = []
# the pins and zones each end may use, and which pin the route ended
# on (None when it ended on a zone)
self.a_group: tuple[list[Pin], list] = ([a], [])
self.b_group: tuple[list[Pin], list] = ([b], [])
self.a_end: Pin | None = a
self.b_end: Pin | None = b
class GridRouter: class GridRouter:
@@ -192,6 +202,8 @@ class GridRouter:
self._pad_block: dict[Node, set[int]] = {} self._pad_block: dict[Node, set[int]] = {}
self._pad_own: dict[Node, set[int]] = {} self._pad_own: dict[Node, set[int]] = {}
self._keepout: set[Node] = set() self._keepout: set[Node] = set()
self._via_keepout: set[Node] = set()
self._zone_cells: dict[int, list[Node]] = {}
# per-connection occupancy (owner = connection index) # per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {} self._cell_owner: dict[Node, int] = {}
self._via_owner: dict[Cell, int] = {} self._via_owner: dict[Cell, int] = {}
@@ -280,6 +292,8 @@ class GridRouter:
if self._inside_own_pad(cell, item.shape): if self._inside_own_pad(cell, item.shape):
for layer in pin_layers: for layer in pin_layers:
self._pad_own.setdefault((*cell, layer), set()).add(net) self._pad_own.setdefault((*cell, layer), set()).add(net)
elif isinstance(item, ConductionArea):
continue # a zone is copper of its net, not an obstacle
elif isinstance(item, ObstacleArea): elif isinstance(item, ObstacleArea):
if item.layer not in self.layers: if item.layer not in self.layers:
continue continue
@@ -291,12 +305,21 @@ class GridRouter:
continue continue
# the same distance the exact engine verifies against # the same distance the exact engine verifies against
for cell in self._cells_in_box(box, margin): for cell in self._cells_in_box(box, margin):
if item.blocks_wires:
self._keepout.add((*cell, item.layer)) self._keepout.add((*cell, item.layer))
if item.blocks_vias:
self._via_keepout.add((*cell, item.layer))
# --- occupancy cost ----------------------------------------------------- # --- occupancy cost -----------------------------------------------------
def _enter_cost( def _enter_cost(
self, cell: Cell, layer: int, net_no: int, allow_ripup: bool, conns: list[_Connection] 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.
@@ -306,7 +329,7 @@ class GridRouter:
already hit ``rip_cap`` (then it hardens into a wall). already hit ``rip_cap`` (then it hardens into a wall).
""" """
node = (*cell, layer) node = (*cell, layer)
if node in self._keepout: if wire_keepout and node in self._keepout:
return None return None
pads = self._pad_block.get(node) pads = self._pad_block.get(node)
if pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()): if pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()):
@@ -347,33 +370,38 @@ class GridRouter:
"""A through via needs its whole footprint free of other nets on every layer.""" """A through via needs its whole footprint free of other nets on every layer."""
if cell in self._via_edge_block: if cell in self._via_edge_block:
return False return False
return all( for c in self._via_cells(cell):
self._enter_cost(c, layer, net_no, False, conns) == 0.0 for layer in self.layers:
for c in self._via_cells(cell) if (*c, layer) in self._via_keepout:
for layer in self.layers return False
) if self._enter_cost(c, layer, net_no, False, conns, wire_keepout=False) != 0.0:
return False
return True
# --- A* search ---------------------------------------------------------- # --- A* search ----------------------------------------------------------
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: ) -> list[Node] | None:
goal_cell = self._cell_of(conn.b.location) goal_cell = self._group_target(conn.b_group)
starts = self._end_nodes(conn.a, conn.net_no, conns) starts = self._group_ends(conn.a_group, conn.net_no, conns)
goals = self._end_nodes(conn.b, 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
if starts & goals: common = sorted(set(starts) & set(goals))
return [min(starts & goals)] if common:
conn.a_end, conn.b_end = starts[common[0]], goals[common[0]]
return [common[0]]
net_no = conn.net_no net_no = conn.net_no
open_heap: list[tuple[float, Node]] = [] open_heap: list[tuple[float, Node]] = []
g_score: dict[Node, float] = {} g_score: dict[Node, float] = {}
came_from: dict[Node, Node] = {} came_from: dict[Node, Node] = {}
# a stub costs its length, so the nearest cell wins unless it is blocked # a stub costs its length, so the nearest cell wins unless it is blocked
goal_stub = {n: self._stub_cost(conn.b, n) for n in goals} goal_stub = {n: self._stub_cost(p, n) for n, p in goals.items() if p is not None}
for s in sorted(starts): for s in sorted(starts):
g_score[s] = self._stub_cost(conn.a, s) pin = starts[s]
g_score[s] = self._stub_cost(pin, s) if pin is not None else 0.0
heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s)) 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 # Via footprints are checked lazily: a layer change goes on the heap as
@@ -397,7 +425,9 @@ class GridRouter:
continue continue
current = entry[1] current = entry[1]
if current in goals: if current in goals:
return _reconstruct(came_from, current) path = _reconstruct(came_from, current)
conn.a_end, conn.b_end = starts.get(path[0]), goals[current]
return path
cx, cy, cl = current cx, cy, cl = current
base = g_score[current] base = g_score[current]
for dx, dy in self._neighbours: for dx, dy in self._neighbours:
@@ -405,7 +435,7 @@ class GridRouter:
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue continue
nxt = (*cell, cl) nxt = (*cell, cl)
if nxt in goals: if nxt in goal_stub: # a pin end; a zone end is entered normally
enter = goal_stub[nxt] enter = goal_stub[nxt]
else: else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns) enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
@@ -459,6 +489,40 @@ class GridRouter:
return False return False
return True return True
def _zone_nodes(self, zone) -> list[Node]:
"""Cells of a zone where a via of this net fits inside its copper."""
key = id(zone)
if key not in self._zone_cells:
h = self.via_half
nodes = []
for tile in zone.tiles:
for cell in self._cells_in_box(tile.bounding_box(), 0):
c = self._cell_center(cell)
if all(
zone.polygon.contains(IntPoint(c.x + dx, c.y + dy))
for dx in (-h, h) for dy in (-h, h)
):
nodes.append((*cell, zone.layer))
self._zone_cells[key] = sorted(set(nodes))
return self._zone_cells[key]
def _group_ends(self, group, net_no: int, conns) -> dict[Node, Pin | None]:
"""End nodes of a group, each mapped to its pin (None for a zone cell)."""
ends: dict[Node, Pin | None] = {}
for pin in group[0]:
for node in sorted(self._end_nodes(pin, net_no, conns)):
ends.setdefault(node, pin)
for zone in group[1]:
for node in self._zone_nodes(zone):
ends.setdefault(node, None)
return ends
def _group_target(self, group) -> Cell:
if group[0]:
return self._cell_of(group[0][0].location)
box = group[1][0].bounding_box()
return self._cell_of(IntPoint((box.ll.x + box.ur.x) // 2, (box.ll.y + box.ur.y) // 2))
def _stub_cost(self, pin: Pin, node: Node) -> float: def _stub_cost(self, pin: Pin, node: Node) -> float:
c = self._cell_center(node[:2]) c = self._cell_center(node[:2])
dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y) dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y)
@@ -523,8 +587,11 @@ class GridRouter:
if c not in self._via_owner: if c not in self._via_owner:
self._via_owner[c] = conn.index self._via_owner[c] = conn.index
conn.via_halo.append(c) conn.via_halo.append(c)
for pin, (layer, cells) in ((conn.a, wire_segs[0]), (conn.b, wire_segs[-1])) if wire_segs else (): ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else ()
cell = cells[0] if pin is conn.a else cells[-1] for pin, (layer, cells), at in ends:
if pin is None:
continue # ended on a zone: no stub
cell = cells[at]
for c in self._stub_cells(pin.location, cell): for c in self._stub_cells(pin.location, cell):
node = (*c, layer) node = (*c, layer)
if node not in self._cell_owner: if node not in self._cell_owner:
@@ -533,10 +600,10 @@ class GridRouter:
conn.wires = [] conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs): for idx, (layer, cells) in enumerate(wire_segs):
points = [self._cell_center(c) for c in cells] points = [self._cell_center(c) for c in cells]
if idx == 0: if idx == 0 and conn.a_end is not None:
points.insert(0, conn.a.location) points.insert(0, conn.a_end.location)
if idx == len(wire_segs) - 1: if idx == len(wire_segs) - 1 and conn.b_end is not None:
points.append(conn.b.location) points.append(conn.b_end.location)
points = _simplify(points) points = _simplify(points)
if len(points) >= 2: if len(points) >= 2:
conn.wires.append((layer, points)) conn.wires.append((layer, points))
@@ -624,12 +691,19 @@ class GridRouter:
def _build_connections(self) -> list[_Connection]: def _build_connections(self) -> list[_Connection]:
pins_by_net = self._pins_by_net() pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {}
for zone in self.board.get_conduction_areas():
if zone.layer in self.layers and zone.net_nos:
zones_by_net.setdefault(zone.net_nos[0], []).append(zone)
conns: list[_Connection] = [] conns: list[_Connection] = []
nets = set(pins_by_net) | set(zones_by_net)
# deterministic: fewer-pin nets first, then by net number # deterministic: fewer-pin nets first, then by net number
for net_no in sorted(pins_by_net, key=lambda n: (len(pins_by_net[n]), n)): for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)):
pins = pins_by_net[net_no] groups = _connected_groups(pins_by_net.get(net_no, []), zones_by_net.get(net_no, []))
for a, b in zip(pins, pins[1:], strict=False): for ga, gb in zip(groups, groups[1:], strict=False):
conns.append(_Connection(len(conns), net_no, a, b)) conn = _Connection(len(conns), net_no, _first(ga[0]), _first(gb[0]))
conn.a_group, conn.b_group = ga, gb
conns.append(conn)
return conns return conns
def _pins_by_net(self) -> dict[int, list[Pin]]: def _pins_by_net(self) -> dict[int, list[Pin]]:
@@ -660,6 +734,56 @@ class GridRouter:
return result return result
def _first(items):
return items[0] if items else None
def _pin_touches_zone(pin: Pin, zone) -> bool:
"""A pad on the zone's layer whose centre or a corner lies in the zone."""
if zone.layer not in pin.layers or zone.polygon is None:
return False
if zone.polygon.contains(pin.location):
return True
box = pin.shape.bounding_box() if pin.shape is not None else None
if box is None:
return False
corners = (box.ll, IntPoint(box.ur.x, box.ll.y), box.ur, IntPoint(box.ll.x, box.ur.y))
return any(zone.polygon.contains(c) for c in corners)
def _connected_groups(pins: list[Pin], zones: list) -> 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)))
def find(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
for j, zone in enumerate(zones):
for i, pin in enumerate(pins):
if _pin_touches_zone(pin, zone):
parent[find(i)] = find(len(pins) + j)
groups: dict[int, tuple[list[Pin], list]] = {}
order: list[int] = []
for i, pin in enumerate(pins):
root = find(i)
if root not in groups:
groups[root] = ([], [])
order.append(root)
groups[root][0].append(pin)
for j, zone in enumerate(zones):
root = find(len(pins) + j)
if root not in groups:
groups[root] = ([], [])
order.append(root)
groups[root][1].append(zone)
return [groups[r] for r in order]
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]]] = []
+44
View File
@@ -94,3 +94,47 @@ def test_heuristic_weight_bounds_route_length():
shortest = length(1.0) shortest = length(1.0)
assert shortest > 0 assert shortest > 0
assert length(2.0) <= 2.0 * shortest assert length(2.0) <= 2.0 * shortest
# --- copper zones (DSN planes) -----------------------------------------------
# rc4 power stage: pads inside "keep router off" pours connect through the pour,
# and freeroute, not knowing zones, dropped every such connection.
_ZONE = "(plane N (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000 4000 -4000))"
_NO_TRACKS = "(wire_keepout \"pour\" (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000))"
def _zone_board():
places = "(place P1 5500 -7000 front 0) (place P2 8500 -7000 front 0) (place P3 16000 -16000 front 0)"
return parse_dsn(_dsn(f"(component P {places})", "(net N (pins P1-1 P2-1 P3-1))", _ZONE + " " + _NO_TRACKS))
def test_planes_are_read_and_are_not_obstacles():
dsn = _zone_board()
assert [(p.net, p.shape.layer) for p in dsn.planes] == [("N", "F.Cu")]
board = build_board(dsn)
assert len(board.get_conduction_areas()) == 1
assert all(not a.is_obstacle() or a.name == "pour" for a in board.get_obstacle_areas())
def test_pads_in_one_zone_need_no_route_between_them():
from freeroute.route.grid_router import _connected_groups
board = build_board(_zone_board())
pins = sorted(board.get_pins(), key=lambda p: p.component_ref)
groups = _connected_groups(pins, board.get_conduction_areas())
assert [sorted(p.component_ref for p in g[0]) for g in groups] == [["P1", "P2"], ["P3"]]
def test_a_pad_reaches_a_zone_under_a_no_tracks_area_by_a_via():
dsn = _zone_board()
result, scale, names = route_dsn_board_exact(dsn)
net = next(iter(result.routed_net_numbers))
assert result.result.vias.get(net) # it had to drop into the zone
board = build_board(dsn)
pour = next(a for a in board.get_obstacle_areas() if a.name == "pour")
f_cu = names.index("F.Cu")
for layer, pts in result.result.wires[net]:
if layer == f_cu: # no track runs inside the no-tracks area
assert not any(t.contains(p) and not t.bounding_box().is_empty()
for t in pour.tiles for p in pts[1:-1])