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 .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 .net import Nets
@@ -57,7 +57,15 @@ class BasicBoard:
return [it for it in self._items if isinstance(it, Pin)]
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]:
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 .clearance import ClearanceMatrix
from .items import ObstacleArea, Pin
from .items import ConductionArea, ObstacleArea, Pin
from .layer import Layer, LayerStructure
from .net import Nets
from .transform import CoordinateTransform
@@ -55,6 +55,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
_insert_pins(dsn, board, transform, terminal_to_net)
_insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets)
return board
@@ -330,6 +331,8 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
name=keepout.area.name,
layer=layer_index,
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)
if image is None:
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:
continue
for place in placement.places:
if place.x is None or place.y is None:
continue
for area in areas:
for area, blocks_wires, blocks_vias in areas:
border = area.border
if border is None:
continue
@@ -423,7 +430,12 @@ def _insert_image_keepouts(dsn: DsnBoard, board: BasicBoard, transform: Coordina
top, bottom = min(signal_layers), max(signal_layers)
layers = [top + bottom - layer for layer 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:
@@ -447,3 +459,29 @@ def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> l
if len(pts) < 3:
return []
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
layer: int = 0
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:
return True
@@ -87,7 +91,16 @@ class ObstacleArea(Item):
@dataclass
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
+8 -1
View File
@@ -47,6 +47,9 @@ class TreeShape:
routed: bool = False
exact: Simplex | None = None
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:
return layer in self.layers
@@ -118,7 +121,9 @@ class ShapeSearchTree:
result.append(shape)
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
than* ``clearance`` to a different net's tile on that layer (exact).
@@ -129,6 +134,8 @@ class ShapeSearchTree:
for shape in self.overlapping(expanded):
if shape.net_no == net_no or not shape.on_layer(layer):
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 overlaps_2d(shape.exact, expanded):
return True
+10
View File
@@ -170,6 +170,14 @@ class NetClass:
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
class Keepout:
"""A structure-level keepout area with its kind."""
@@ -187,6 +195,8 @@ class DsnBoard:
resolution: Resolution = field(default_factory=Resolution)
unit: str = "mil"
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
boundary: Shape | None = None
#: outline shapes (layer ``signal``)
+21
View File
@@ -183,6 +183,11 @@ def _read_structure(node: SExp, board: DsnBoard) -> None:
area = read_area(it)
if area is not None:
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":
area = read_area(it)
if area is not None:
@@ -513,3 +518,19 @@ def parse_dsn(text: str) -> DsnBoard:
# `wiring` and unknown scopes are intentionally ignored for now.
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():
if obstacle.layer not in signal_layers:
continue
blocks = (
"all" if obstacle.blocks_wires and obstacle.blocks_vias
else "wires" if obstacle.blocks_wires else "vias"
)
for tile in obstacle.tiles:
box = tile.bounding_box()
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)
accepted: dict[int, _Accepted] = {}
@@ -155,9 +161,12 @@ def route_board_exact(
# 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]):
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(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
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)):
continue # exact-clearance conflict -> drop (may be recovered by shove)
+152 -28
View File
@@ -30,7 +30,7 @@ import heapq
import math
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
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -102,6 +102,10 @@ class _Connection:
"via_cells",
"via_halo",
"halo_nodes",
"a_group",
"b_group",
"a_end",
"b_end",
)
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_halo: list[Cell] = []
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:
@@ -192,6 +202,8 @@ class GridRouter:
self._pad_block: dict[Node, set[int]] = {}
self._pad_own: dict[Node, set[int]] = {}
self._keepout: set[Node] = set()
self._via_keepout: set[Node] = set()
self._zone_cells: dict[int, list[Node]] = {}
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
self._via_owner: dict[Cell, int] = {}
@@ -280,6 +292,8 @@ class GridRouter:
if self._inside_own_pad(cell, item.shape):
for layer in pin_layers:
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):
if item.layer not in self.layers:
continue
@@ -291,12 +305,21 @@ class GridRouter:
continue
# the same distance the exact engine verifies against
for cell in self._cells_in_box(box, margin):
self._keepout.add((*cell, item.layer))
if item.blocks_wires:
self._keepout.add((*cell, item.layer))
if item.blocks_vias:
self._via_keepout.add((*cell, item.layer))
# --- occupancy 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:
"""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).
"""
node = (*cell, layer)
if node in self._keepout:
if wire_keepout and node in self._keepout:
return None
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, ()):
@@ -347,33 +370,38 @@ class GridRouter:
"""A through via needs its whole footprint free of other nets on every layer."""
if cell in self._via_edge_block:
return False
return all(
self._enter_cost(c, layer, net_no, False, conns) == 0.0
for c in self._via_cells(cell)
for layer in self.layers
)
for c in self._via_cells(cell):
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:
return False
return True
# --- A* search ----------------------------------------------------------
def _search(
self, conn: _Connection, allow_ripup: bool, conns: list[_Connection]
) -> list[Node] | None:
goal_cell = self._cell_of(conn.b.location)
starts = self._end_nodes(conn.a, conn.net_no, conns)
goals = self._end_nodes(conn.b, conn.net_no, conns)
goal_cell = self._group_target(conn.b_group)
starts = self._group_ends(conn.a_group, conn.net_no, conns)
goals = self._group_ends(conn.b_group, conn.net_no, conns)
if not starts or not goals:
return None
if starts & goals:
return [min(starts & goals)]
common = sorted(set(starts) & set(goals))
if common:
conn.a_end, conn.b_end = starts[common[0]], goals[common[0]]
return [common[0]]
net_no = conn.net_no
open_heap: list[tuple[float, Node]] = []
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(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):
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))
# Via footprints are checked lazily: a layer change goes on the heap as
@@ -397,7 +425,9 @@ class GridRouter:
continue
current = entry[1]
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
base = g_score[current]
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):
continue
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]
else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
@@ -459,6 +489,40 @@ class GridRouter:
return False
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:
c = self._cell_center(node[:2])
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:
self._via_owner[c] = conn.index
conn.via_halo.append(c)
for pin, (layer, cells) in ((conn.a, wire_segs[0]), (conn.b, wire_segs[-1])) if wire_segs else ():
cell = cells[0] if pin is conn.a else cells[-1]
ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else ()
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):
node = (*c, layer)
if node not in self._cell_owner:
@@ -533,10 +600,10 @@ class GridRouter:
conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs):
points = [self._cell_center(c) for c in cells]
if idx == 0:
points.insert(0, conn.a.location)
if idx == len(wire_segs) - 1:
points.append(conn.b.location)
if idx == 0 and conn.a_end is not None:
points.insert(0, conn.a_end.location)
if idx == len(wire_segs) - 1 and conn.b_end is not None:
points.append(conn.b_end.location)
points = _simplify(points)
if len(points) >= 2:
conn.wires.append((layer, points))
@@ -624,12 +691,19 @@ class GridRouter:
def _build_connections(self) -> list[_Connection]:
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] = []
nets = set(pins_by_net) | set(zones_by_net)
# 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)):
pins = pins_by_net[net_no]
for a, b in zip(pins, pins[1:], strict=False):
conns.append(_Connection(len(conns), net_no, a, b))
for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)):
groups = _connected_groups(pins_by_net.get(net_no, []), zones_by_net.get(net_no, []))
for ga, gb in zip(groups, groups[1:], strict=False):
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
def _pins_by_net(self) -> dict[int, list[Pin]]:
@@ -660,6 +734,56 @@ class GridRouter:
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]]:
"""Split a node path into per-layer wire segments and via cells."""
wires: list[tuple[int, list[Cell]]] = []
+44
View File
@@ -94,3 +94,47 @@ def test_heuristic_weight_bounds_route_length():
shortest = length(1.0)
assert shortest > 0
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])