diff --git a/src/freeroute/board/board.py b/src/freeroute/board/board.py index cbfe0ce..08f0236 100644 --- a/src/freeroute/board/board.py +++ b/src/freeroute/board/board.py @@ -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)] diff --git a/src/freeroute/board/build.py b/src/freeroute/board/build.py index 640be84..aaebc5c 100644 --- a/src/freeroute/board/build.py +++ b/src/freeroute/board/build.py @@ -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, + ) + ) diff --git a/src/freeroute/board/items.py b/src/freeroute/board/items.py index ed9d6b1..18a6de3 100644 --- a/src/freeroute/board/items.py +++ b/src/freeroute/board/items.py @@ -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 diff --git a/src/freeroute/board/search_tree.py b/src/freeroute/board/search_tree.py index caaa49b..0de4b13 100644 --- a/src/freeroute/board/search_tree.py +++ b/src/freeroute/board/search_tree.py @@ -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 diff --git a/src/freeroute/dsn/model.py b/src/freeroute/dsn/model.py index 531f710..38f6fd3 100644 --- a/src/freeroute/dsn/model.py +++ b/src/freeroute/dsn/model.py @@ -170,6 +170,14 @@ class NetClass: via_rule: str | None = None +@dataclass +class Plane: + """A ``(plane )``: 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``) diff --git a/src/freeroute/dsn/reader.py b/src/freeroute/dsn/reader.py index 679139e..c9ad30f 100644 --- a/src/freeroute/dsn/reader.py +++ b/src/freeroute/dsn/reader.py @@ -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 ): 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 (polygon 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 diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 365b001..d16663f 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -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) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 4f3c541..905e693 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -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]]] = [] diff --git a/tests/route/test_real_board_geometry.py b/tests/route/test_real_board_geometry.py index f4ed1cc..43e56fa 100644 --- a/tests/route/test_real_board_geometry.py +++ b/tests/route/test_real_board_geometry.py @@ -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])