From 8a50bde531c281d8ab73684c0d03b6877a06d944 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 28 Sep 2026 21:06:35 -0600 Subject: [PATCH] Read the DSN's wiring as fixed copper of its nets Tracks and vias already on the board were ignored, so other nets could be routed straight through them (the flight controller's 82 GND stitching vias and its guard tracks). They are now copper of their net: other nets keep a pad's distance from them in the grid, the exact check and the room engine, and they add no connections to route. --- src/freeroute/board/board.py | 21 ++++++++++++ src/freeroute/board/build.py | 44 ++++++++++++++++++++++++- src/freeroute/board/items.py | 3 ++ src/freeroute/dsn/model.py | 25 +++++++++++++++ src/freeroute/dsn/reader.py | 38 +++++++++++++++++++++- src/freeroute/route/exact_router.py | 4 +++ src/freeroute/route/grid_router.py | 11 +++++++ src/freeroute/route/room_router.py | 4 +++ tests/route/test_fixed_wiring.py | 50 +++++++++++++++++++++++++++++ 9 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 tests/route/test_fixed_wiring.py diff --git a/src/freeroute/board/board.py b/src/freeroute/board/board.py index 08f0236..a49effa 100644 --- a/src/freeroute/board/board.py +++ b/src/freeroute/board/board.py @@ -70,6 +70,27 @@ class BasicBoard: def get_traces(self) -> list[Trace]: return [it for it in self._items if isinstance(it, Trace)] + def get_fixed_copper(self) -> list[tuple[int, frozenset[int], IntBox]]: + """Copper already routed on the board (the DSN's ``wiring``), as + ``(net, layers, box)``. A track is its segments, its corners and the + round caps at its ends (as squares); a via is its largest pad.""" + from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box + + out: list[tuple[int, frozenset[int], IntBox]] = [] + for item in self._items: + if not item.fixed or not item.net_nos: + continue + net = item.net_nos[0] + if isinstance(item, Trace) and not item.is_empty(): + layers = frozenset({item.layer}) + for box in PolylineShape(Polyline(item.corners), item.half_width).tiles(): + out.append((net, layers, box)) + for end in (item.corners[0], item.corners[-1]): + out.append((net, layers, corner_box(end, item.half_width))) + elif isinstance(item, Via) and item.shape is not None: + out.append((net, frozenset(item.layers), item.shape.bounding_box())) + return out + def get_vias(self) -> list[Via]: return [it for it in self._items if isinstance(it, Via)] diff --git a/src/freeroute/board/build.py b/src/freeroute/board/build.py index e8fc6c3..4f684cb 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 ConductionArea, ObstacleArea, Pin +from .items import ConductionArea, ObstacleArea, Pin, Trace, Via from .layer import Layer, LayerStructure from .net import Nets from .transform import CoordinateTransform @@ -56,6 +56,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard: _insert_keepouts(dsn, board, transform) _insert_image_keepouts(dsn, board, transform) _insert_zones(dsn, board, transform, nets) + _insert_wiring(dsn, board, transform, nets) return board @@ -471,6 +472,47 @@ def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> l return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()] +def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None: + """Tracks and vias already on the board, as fixed copper of their nets.""" + layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)} + signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal] + numbers = {net.name: net.net_number for net in nets} + for wire in dsn.wires: + if wire.net not in numbers or wire.layer not in layer_no: + continue + c = wire.coords + corners = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)] + if len(corners) < 2: + continue + board.insert( + Trace( + net_nos=[numbers[wire.net]], fixed=True, layer=layer_no[wire.layer], + half_width=max(round(transform.dsn_to_board(wire.width / 2)), 1), corners=corners, + ) + ) + for via in dsn.vias: + padstack = dsn.padstack(via.padstack) + if via.net not in numbers or padstack is None or not padstack.shapes: + continue + layers: set[int] = set() + for shape in padstack.shapes: + if shape.layer in ("signal", "pcb"): + layers.update(signal_layers) + elif shape.layer in layer_no: + layers.add(layer_no[shape.layer]) + location = transform.dsn_to_board_point(via.x, via.y) + pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform) + box = pad.bounding_box() + board.insert( + Via( + net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack, + location=location, layers=sorted(layers or signal_layers), + shape=IntBox(box.ll.x + location.x, box.ll.y + location.y, + box.ur.x + location.x, box.ur.y + location.y), + ) + ) + + def _insert_zones(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None: """Each DSN plane as a ConductionArea of its net on its layer.""" layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)} diff --git a/src/freeroute/board/items.py b/src/freeroute/board/items.py index 8ca35a6..9ab415d 100644 --- a/src/freeroute/board/items.py +++ b/src/freeroute/board/items.py @@ -114,6 +114,9 @@ class Via(Item): padstack_name: str = "" location: IntPoint = field(default_factory=lambda: IntPoint(0, 0)) + #: layer indices the via's pads occupy, and the largest pad as a box + layers: list[int] = field(default_factory=list) + shape: object = None @dataclass diff --git a/src/freeroute/dsn/model.py b/src/freeroute/dsn/model.py index 38f6fd3..ec99595 100644 --- a/src/freeroute/dsn/model.py +++ b/src/freeroute/dsn/model.py @@ -27,6 +27,8 @@ __all__ = [ "NetPin", "Net", "NetClass", + "WiringVia", + "WiringWire", "Keepout", "DsnBoard", ] @@ -186,6 +188,26 @@ class Keepout: kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout +@dataclass +class WiringWire: + """A track already on the board: ``(wire (path LAYER WIDTH x y ...) (net N))``.""" + + net: str + layer: str + width: float + coords: list[float] = field(default_factory=list) + + +@dataclass +class WiringVia: + """A via already on the board: ``(via PADSTACK x y (net N))``.""" + + net: str + padstack: str + x: float + y: float + + @dataclass class DsnBoard: """The fully parsed DSN file.""" @@ -197,6 +219,9 @@ class DsnBoard: layers: list[Layer] = field(default_factory=list) #: copper zones, as planes of a net planes: list[Plane] = field(default_factory=list) + #: copper already routed (the ``wiring`` scope), kept fixed + wires: list[WiringWire] = field(default_factory=list) + vias: list[WiringVia] = field(default_factory=list) #: bounding-box boundary shape (layer ``pcb``), if present boundary: Shape | None = None #: outline shapes (layer ``signal``) diff --git a/src/freeroute/dsn/reader.py b/src/freeroute/dsn/reader.py index 356ac10..7c3ce4f 100644 --- a/src/freeroute/dsn/reader.py +++ b/src/freeroute/dsn/reader.py @@ -27,6 +27,8 @@ from .model import ( Pin, Resolution, WidthRule, + WiringVia, + WiringWire, ) from .sexp import SExp, parse from .shapes import Shape, read_area, read_shape @@ -531,11 +533,45 @@ def parse_dsn(text: str) -> DsnBoard: _read_placement(it, board) elif head == "network": _read_network(it, board) - # `wiring` and unknown scopes are intentionally ignored for now. + elif head == "wiring": + _read_wiring(it, board) + # unknown scopes are ignored return board +def _read_wiring(node: SExp, board: DsnBoard) -> None: + """Tracks and vias already on the board. They stay where they are: the + router treats them as copper of their net that other nets keep clear of.""" + for it in node.items: + if not isinstance(it, SExp): + continue + net_scope = it.child("net") + net = _first_value(net_scope) if net_scope is not None else None + if not net: + continue # netless copper would need a keepout, not a wire + if it.head == "wire": + path = it.child("path") or it.child("polyline_path") + vals = [v.text for v in path.values()] if path is not None else [] + if len(vals) < 6: + continue + try: + width = float(vals[1]) + coords = [float(v) for v in vals[2:]] + except ValueError: + continue + board.wires.append(WiringWire(net=net, layer=vals[0], width=width, coords=coords)) + elif it.head == "via": + vals = [v.text for v in it.values()] + if len(vals) < 3: + continue + try: + x, y = float(vals[1]), float(vals[2]) + except ValueError: + continue + board.vias.append(WiringVia(net=net, padstack=vals[0], x=x, y=y)) + + def _read_plane(node: SExp): """``(plane (polygon x y ...))`` -> :class:`Plane`.""" from .model import Plane diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index fb18ac9..2a8840e 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -149,6 +149,10 @@ def route_board_exact( pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers) if pin_layers: tree.insert(TreeShape(next(owner), net, pin_layers, box)) + for net, copper_layers, box in board.get_fixed_copper(): + on = frozenset(layer for layer in copper_layers if layer in signal_layers) + if on and not box.is_empty(): + tree.insert(TreeShape(next(owner), net, on, box)) for obstacle in board.get_obstacle_areas(): if obstacle.layer not in signal_layers: continue diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 81bfdd9..283258d 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -250,6 +250,7 @@ class GridRouter: # trace halos: node -> indices of the connections claiming it self._halo: dict[Node, list[int]] = {} self._rasterize_obstacles() + self._rasterize_fixed_copper() # --- grid helpers ------------------------------------------------------- @@ -360,6 +361,16 @@ class GridRouter: if item.blocks_vias: self._via_keepout.add((*cell, item.layer)) + def _rasterize_fixed_copper(self) -> None: + """Tracks and vias already on the board: copper of their net, which + other nets keep the same distance from as from a pad.""" + margin = self.clearance + self.half_width + for net, layers, box in self.board.get_fixed_copper(): + on = [layer for layer in layers if layer in self.layers] + for cell in self._cells_in_box(box, margin): + for layer in on: + self._pad_block.setdefault((*cell, layer), set()).add(net) + # --- occupancy cost ----------------------------------------------------- def _enter_cost( diff --git a/src/freeroute/route/room_router.py b/src/freeroute/route/room_router.py index 2156148..04254ac 100644 --- a/src/freeroute/route/room_router.py +++ b/src/freeroute/route/room_router.py @@ -515,6 +515,10 @@ def route_board_rooms( pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers) if pin_layers: tree.insert(TreeShape(next(owner), net, pin_layers, box)) + for net, copper_layers, box in board.get_fixed_copper(): + on = frozenset(layer for layer in copper_layers if layer in signal_layers) + if on and not box.is_empty(): + tree.insert(TreeShape(next(owner), net, on, box)) for obstacle in board.get_obstacle_areas(): if obstacle.layer not in signal_layers: continue diff --git a/tests/route/test_fixed_wiring.py b/tests/route/test_fixed_wiring.py new file mode 100644 index 0000000..2888de5 --- /dev/null +++ b/tests/route/test_fixed_wiring.py @@ -0,0 +1,50 @@ +"""Tracks and vias already on the board (the DSN's wiring) are fixed copper.""" + +from __future__ import annotations + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.route.pipeline import route_dsn_board_exact + +_DSN = """(pcb wired (parser) (resolution um 10) (unit um) + (structure + (layer F.Cu (type signal)) (layer B.Cu (type signal)) + (boundary (path pcb 0 0 0 30000 0 30000 -20000 0 -20000 0 0)) + (via "Via[0-1]_600:300_um") (rule (width 250) (clearance 200))) + (placement (component S (place A1 5000 -10000 front 0) (place A2 25000 -10000 front 0))) + (library + (image S (pin smd 1 0 0)) + (padstack smd (shape (rect F.Cu -400 -400 400 400)) (attach off)) + (padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off))) + (network (net SIG (pins A1-1 A2-1)) (net GND)) + (wiring + (wire (path F.Cu 400 15000 0 15000 -20000) (net GND) (type route)) + (via "Via[0-1]_600:300_um" 8000 -14000 (net GND) (type route))))""" + + +def test_wiring_is_read_with_its_nets(): + dsn = parse_dsn(_DSN) + assert [(w.net, w.layer, w.width) for w in dsn.wires] == [("GND", "F.Cu", 400.0)] + assert [(v.net, v.x, v.y) for v in dsn.vias] == [("GND", 8000.0, -14000.0)] + + +def test_wiring_is_fixed_copper_and_not_a_connection_to_route(): + board = build_board(parse_dsn(_DSN)) + copper = board.get_fixed_copper() + gnd = next(n.net_number for n in board.nets if n.name == "GND") + assert copper and all(net == gnd for net, _, _ in copper) + assert not any(gnd in p.net_nos for p in board.get_pins()) + + +def test_other_nets_route_around_a_fixed_track(): + dsn = parse_dsn(_DSN) + exact, scale, names = route_dsn_board_exact(dsn, resolution=2) + sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG") + assert sig in exact.result.routed_net_numbers + assert exact.drc_clean + wall = 15000 * scale + for layer, pts in exact.result.wires.get(sig, []): + if names[layer] != "F.Cu": + continue + for p, q in zip(pts, pts[1:], strict=False): + assert not (min(p.x, q.x) < wall < max(p.x, q.x)), "crossed the GND track on F.Cu"