diff --git a/src/freeroute/board/__init__.py b/src/freeroute/board/__init__.py new file mode 100644 index 0000000..a79839e --- /dev/null +++ b/src/freeroute/board/__init__.py @@ -0,0 +1,39 @@ +"""Board data model for freeroute. + +The data model a parsed DSN produces and the (future) router operates on: +layers, units, coordinate transform, nets, clearance matrix, the item hierarchy, +and the :class:`BasicBoard` container. :func:`build_board` constructs a board +from a :class:`~freeroute.dsn.model.DsnBoard`. + +The routing operations (``RoutingBoard``) and the spatial ``ShapeSearchTree`` +belong to the router phase. +""" + +from __future__ import annotations + +from .board import BasicBoard +from .build import build_board +from .clearance import ClearanceMatrix +from .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via +from .layer import Layer, LayerStructure +from .net import Net, Nets +from .transform import CoordinateTransform +from .unit import Unit + +__all__ = [ + "Unit", + "Layer", + "LayerStructure", + "CoordinateTransform", + "Net", + "Nets", + "ClearanceMatrix", + "Item", + "Pin", + "ObstacleArea", + "ConductionArea", + "Via", + "Trace", + "BasicBoard", + "build_board", +] diff --git a/src/freeroute/board/board.py b/src/freeroute/board/board.py new file mode 100644 index 0000000..784a302 --- /dev/null +++ b/src/freeroute/board/board.py @@ -0,0 +1,70 @@ +"""``BasicBoard`` — the board container. + +Ports the data-model core of ``board/BasicBoard.java``: the layer stack, the +net list, the clearance matrix, the bounding box, and the list of items with +insertion and query helpers. The routing operations live in ``RoutingBoard`` +(the router phase); this class is what DSN import produces and what the +board-model invariants check. +""" + +from __future__ import annotations + +from freeroute.geometry import IntBox + +from .clearance import ClearanceMatrix +from .items import Item, ObstacleArea, Pin, Trace, Via +from .layer import LayerStructure +from .net import Nets + +__all__ = ["BasicBoard"] + + +class BasicBoard: + """A board: layers, nets, clearances, a bounding box, and items.""" + + __slots__ = ("layer_structure", "nets", "clearance_matrix", "bounding_box", "_items") + + def __init__( + self, + layer_structure: LayerStructure, + nets: Nets, + clearance_matrix: ClearanceMatrix, + bounding_box: IntBox, + ) -> None: + self.layer_structure = layer_structure + self.nets = nets + self.clearance_matrix = clearance_matrix + self.bounding_box = bounding_box + self._items: list[Item] = [] + + # --- layers ------------------------------------------------------------- + + def get_layer_count(self) -> int: + return len(self.layer_structure) + + # --- items -------------------------------------------------------------- + + def insert(self, item: Item) -> Item: + self._items.append(item) + return item + + def get_items(self) -> list[Item]: + return list(self._items) + + def get_pins(self) -> list[Pin]: + 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)] + + def get_traces(self) -> list[Trace]: + return [it for it in self._items if isinstance(it, Trace)] + + def get_vias(self) -> list[Via]: + return [it for it in self._items if isinstance(it, Via)] + + def get_connectable_items(self, net_no: int) -> list[Item]: + return [it for it in self._items if it.contains_net(net_no)] + + def item_count(self) -> int: + return len(self._items) diff --git a/src/freeroute/board/build.py b/src/freeroute/board/build.py new file mode 100644 index 0000000..38f17ca --- /dev/null +++ b/src/freeroute/board/build.py @@ -0,0 +1,271 @@ +"""Build a :class:`BasicBoard` from a parsed DSN :class:`DsnBoard`. + +This is the load-bearing integration between :mod:`freeroute.dsn` and the board +model. It mirrors what FreeRouting's ``Structure.create_board`` / ``Library`` / +``Component`` / ``Network`` readers do collectively, but on the already-parsed +``DsnBoard``: + +* layers -> :class:`LayerStructure` +* resolution -> :class:`CoordinateTransform` (``scale_factor = resolution``) +* default clearance rule -> :class:`ClearanceMatrix` +* nets -> :class:`Nets` (+ a ``(component, pin) -> net`` map) +* padstacks x placement -> :class:`Pin` items +* keepouts -> :class:`ObstacleArea` items + +Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad its +bounding box (documented approximation pending an exact circle tile); a convex +polygon pad an exact :class:`Simplex`. Every pad is placed centred on its pin +location, so it contains that location by construction. Component rotation is +applied to the pin position; the pad tile itself is not rotated (irrelevant to +the containment invariant, and pads are centre-symmetric in practice). +""" + +from __future__ import annotations + +import math + +from freeroute.dsn.model import DsnBoard +from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape +from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex + +from .board import BasicBoard +from .clearance import ClearanceMatrix +from .items import ObstacleArea, Pin +from .layer import Layer, LayerStructure +from .net import Nets +from .transform import CoordinateTransform + +__all__ = ["build_board"] + + +def build_board(dsn: DsnBoard) -> BasicBoard: + """Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`.""" + layer_structure = LayerStructure([Layer(layer.name, layer.is_signal) for layer in dsn.layers]) + scale = max(dsn.resolution.value, 1) + transform = CoordinateTransform(scale) + + clearance = _build_clearance(dsn, transform) + nets, terminal_to_net = _build_nets(dsn) + bounding_box = _build_bounding_box(dsn, transform) + + board = BasicBoard(layer_structure, nets, clearance, bounding_box) + + _insert_pins(dsn, board, transform, terminal_to_net) + _insert_keepouts(dsn, board, transform) + return board + + +# --- clearance / nets / bounds ---------------------------------------------- + + +def _build_clearance(dsn: DsnBoard, transform: CoordinateTransform) -> ClearanceMatrix: + default_value = 0 + for rule in dsn.structure_rules.clearance_rules: + if not rule.class_pairs: # the layer-wide default clearance + default_value = round(transform.dsn_to_board(rule.value)) + break + return ClearanceMatrix.default_instance(default_value) + + +def _build_nets(dsn: DsnBoard) -> tuple[Nets, dict[tuple[str, str], int]]: + nets = Nets() + terminal_to_net: dict[tuple[str, str], int] = {} + for dsn_net in dsn.nets: + net = nets.add(dsn_net.name, dsn_net.subnet, contains_plane=False) + net.pins = [(p.component, p.pin) for p in dsn_net.pins] + for pin in dsn_net.pins: + terminal_to_net[(pin.component, pin.pin)] = net.net_number + return nets, terminal_to_net + + +def _build_bounding_box(dsn: DsnBoard, transform: CoordinateTransform) -> IntBox: + coords: list[float] = [] + shapes: list[Shape] = [] + if dsn.boundary is not None: + shapes.append(dsn.boundary) + shapes.extend(dsn.outlines) + for shape in shapes: + coords.extend(_shape_coords(shape)) + if not coords: + return IntBox.empty() + xs = coords[0::2] + ys = coords[1::2] + llx = round(transform.dsn_to_board(min(xs))) + lly = round(transform.dsn_to_board(min(ys))) + urx = round(transform.dsn_to_board(max(xs))) + ury = round(transform.dsn_to_board(max(ys))) + return IntBox(llx, lly, urx, ury) + + +def _shape_coords(shape: Shape) -> list[float]: + if isinstance(shape, Rectangle): + return list(shape.coords) + if isinstance(shape, Circle): + r = shape.diameter / 2 + return [shape.center_x - r, shape.center_y - r, shape.center_x + r, shape.center_y + r] + if isinstance(shape, (Polygon, Path)): + return list(shape.coords) + return [] + + +# --- pins -------------------------------------------------------------------- + + +def _insert_pins( + dsn: DsnBoard, + board: BasicBoard, + transform: CoordinateTransform, + terminal_to_net: dict[tuple[str, str], int], +) -> None: + images = {img.name: img for img in dsn.images} + 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] + + for placement in dsn.placements: + image = images.get(placement.lib_name) + if image is None: + continue + for place in placement.places: + if place.x is None or place.y is None: + continue # component declared but not placed + for dsn_pin in image.pins: + location = _pin_location(place, dsn_pin, transform) + padstack = dsn.padstack(dsn_pin.padstack_name) + shape, layers = _pad_shape_and_layers( + padstack, location, transform, layer_no, signal_layers + ) + net_no = terminal_to_net.get((place.name, dsn_pin.name)) + pin = Pin( + net_nos=[net_no] if net_no is not None else [], + component_no=id(place) & 0xFFFFFF, + fixed=True, + name=dsn_pin.name, + padstack_name=dsn_pin.padstack_name, + location=location, + layers=layers, + shape=shape, + ) + board.insert(pin) + + +def _pin_location(place, dsn_pin, transform: CoordinateTransform) -> IntPoint: + """Absolute board location of a pin: component origin + rotated pin offset.""" + rel_x = -dsn_pin.x if not place.is_front else dsn_pin.x # back side mirrors in x + rel = FloatPoint(rel_x, dsn_pin.y) + rotated = rel.rotate(math.radians(place.rotation), FloatPoint(0.0, 0.0)) + return transform.dsn_to_board_point(place.x + rotated.x, place.y + rotated.y) + + +def _pad_shape_and_layers( + padstack, + location: IntPoint, + transform: CoordinateTransform, + layer_no: dict[str, int], + signal_layers: list[int], +) -> tuple[object, list[int]]: + """A pad tile centred on ``location`` plus the layer indices it occupies.""" + if padstack is None or not padstack.shapes: + # degenerate: a zero-size box at the location (still contains it) + return IntBox(location.x, location.y, location.x, location.y), list(signal_layers) + + 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]) + if not layers: + layers = set(signal_layers) + + tile = _pad_tile(padstack.shapes[0], transform) + tile = tile.translate_by(IntVector(location.x, location.y)) + return tile, sorted(layers) + + +def _pad_tile(shape: Shape, transform: CoordinateTransform): + """A board-relative pad tile from a DSN padstack shape (centred near origin).""" + if isinstance(shape, Rectangle): + c = [round(transform.dsn_to_board(v)) for v in shape.coords[:4]] + return IntBox(min(c[0], c[2]), min(c[1], c[3]), max(c[0], c[2]), max(c[1], c[3])) + if isinstance(shape, Circle): + r = round(transform.dsn_to_board(shape.diameter / 2)) + cx = round(transform.dsn_to_board(shape.center_x)) + cy = round(transform.dsn_to_board(shape.center_y)) + return IntBox(cx - r, cy - r, cx + r, cy + r) # bounding-box approximation + if isinstance(shape, (Polygon, Path)): + coords = shape.coords + pts = [ + IntPoint( + round(transform.dsn_to_board(coords[i])), + round(transform.dsn_to_board(coords[i + 1])), + ) + for i in range(0, len(coords) - 1, 2) + ] + if len(pts) >= 3: + simplex = Simplex.from_corners(pts) + if not simplex.is_empty(): + return simplex + xs = [p.x for p in pts] or [0] + ys = [p.y for p in pts] or [0] + return IntBox(min(xs), min(ys), max(xs), max(ys)) + return IntBox(0, 0, 0, 0) + + +# --- keepouts ---------------------------------------------------------------- + + +def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform) -> None: + 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] + + for keepout in dsn.keepouts: + area = keepout.area + if area is None or area.border is None: + continue + tile = _area_border_tile(area.border, transform) + if tile is None: + continue + border_layer = area.border.layer + if border_layer in ("signal", "pcb"): + layers = signal_layers + elif border_layer in layer_no: + layers = [layer_no[border_layer]] + else: + layers = signal_layers + for layer_index in layers: + board.insert( + ObstacleArea( + name=keepout.area.name, + layer=layer_index, + tiles=[tile], + ) + ) + + +def _area_border_tile(shape: Shape, transform: CoordinateTransform): + """Convert an absolute-coordinate keepout border shape to one board tile.""" + if isinstance(shape, Rectangle): + c = [round(transform.dsn_to_board(v)) for v in shape.coords[:4]] + return IntBox(min(c[0], c[2]), min(c[1], c[3]), max(c[0], c[2]), max(c[1], c[3])) + if isinstance(shape, Circle): + r = round(transform.dsn_to_board(shape.diameter / 2)) + cx = round(transform.dsn_to_board(shape.center_x)) + cy = round(transform.dsn_to_board(shape.center_y)) + return IntBox(cx - r, cy - r, cx + r, cy + r) + if isinstance(shape, (Polygon, Path)): + coords = shape.coords + pts = [ + IntPoint( + round(transform.dsn_to_board(coords[i])), + round(transform.dsn_to_board(coords[i + 1])), + ) + for i in range(0, len(coords) - 1, 2) + ] + if len(pts) >= 3: + simplex = Simplex.from_corners(pts) + if not simplex.is_empty(): + return simplex + xs = [p.x for p in pts] or [0] + ys = [p.y for p in pts] or [0] + return IntBox(min(xs), min(ys), max(xs), max(ys)) + return None diff --git a/src/freeroute/board/items.py b/src/freeroute/board/items.py new file mode 100644 index 0000000..37957bd --- /dev/null +++ b/src/freeroute/board/items.py @@ -0,0 +1,112 @@ +"""Board items — ``Item`` base and the concrete item kinds. + +Ports the *data model* of ``board/Item.java`` and its subclasses (``Pin``, +``ObstacleArea``/``ConductionArea``, ``Via``, ``PolylineTrace``). The routing +and search-tree machinery in the Java classes is out of scope here — these hold +the geometry and connectivity a :class:`~freeroute.board.board.BasicBoard` +needs and that the DSN-import invariants check. + +Coordinates are board units (DSN units scaled by the resolution). Shapes are the +exact convex tiles / boxes from :mod:`freeroute.geometry`. + +``Trace``/``Via`` are only produced by routing (an imported unrouted board has +none); they are lightweight here and the router phase fleshes out +``Trace`` with a proper :class:`~freeroute.geometry.Polyline` shape. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from freeroute.geometry import IntBox, IntPoint + +__all__ = ["Item", "Pin", "ObstacleArea", "ConductionArea", "Via", "Trace"] + + +@dataclass +class Item: + """Base class for everything placed on the board. + + ``net_nos`` are the board net numbers this item belongs to (empty for + unconnected obstacles); ``component_no`` is 0 for board-level items. + """ + + net_nos: list[int] = field(default_factory=list) + clearance_class: int = 0 + component_no: int = 0 + fixed: bool = False + + def contains_net(self, net_no: int) -> bool: + return net_no in self.net_nos + + def is_obstacle(self) -> bool: + return False + + +@dataclass +class Pin(Item): + """A component pin: a pad shape on one or more layers at a fixed location.""" + + name: str = "" + padstack_name: str = "" + location: IntPoint = field(default_factory=lambda: IntPoint(0, 0)) + #: layer indices the pad occupies (all signal layers when spanning) + layers: list[int] = field(default_factory=list) + #: the pad tile shape in absolute board coordinates + shape: object = None + + def first_layer(self) -> int: + return self.layers[0] if self.layers else 0 + + def shape_contains_origin(self) -> bool: + """Invariant helper: the pad shape contains the pin's own location.""" + return self.shape is not None and self.shape.contains(self.location) + + +@dataclass +class ObstacleArea(Item): + """A keepout / obstacle region on one layer, as a list of convex tiles.""" + + name: str | None = None + layer: int = 0 + tiles: list = field(default_factory=list) + + def is_obstacle(self) -> bool: + return True + + def bounding_box(self) -> IntBox: + if not self.tiles: + return IntBox.empty() + box = self.tiles[0].bounding_box() + for tile in self.tiles[1:]: + box = box.union(tile.bounding_box()) + return box + + +@dataclass +class ConductionArea(ObstacleArea): + """A power-plane conduction area (an obstacle carrying a net).""" + + +@dataclass +class Via(Item): + """A routed via at a location using a named padstack (router-produced).""" + + padstack_name: str = "" + location: IntPoint = field(default_factory=lambda: IntPoint(0, 0)) + + +@dataclass +class Trace(Item): + """A routed trace (router-produced). + + Held as its corner points plus a half-width; the router phase attaches the + full :class:`~freeroute.geometry.Polyline` swept shape. + """ + + layer: int = 0 + half_width: int = 0 + corners: list[IntPoint] = field(default_factory=list) + + def is_empty(self) -> bool: + return len(self.corners) < 2 diff --git a/tests/board/test_build.py b/tests/board/test_build.py new file mode 100644 index 0000000..e36f0d2 --- /dev/null +++ b/tests/board/test_build.py @@ -0,0 +1,131 @@ +"""DSN -> board construction invariants, validated on a real KiCad board. + +There is no unit-test oracle for the board model, so these assert structural +invariants against the parsed DSN (the source of truth) and — when a JVM + JAR +are available — cross-check the board's net set against the reference router's +routed net set. + +Fixture: ``tests/dsn/fixtures/kicad_routable.dsn`` (a KiCad pcbnew export). +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board +from freeroute.dsn import parse_dsn + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" +ROUTABLE = FIXTURES / "kicad_routable.dsn" + + +@pytest.fixture(scope="module") +def dsn(): + return parse_dsn(ROUTABLE.read_text()) + + +@pytest.fixture(scope="module") +def board(dsn) -> BasicBoard: + return build_board(dsn) + + +# --- structural counts match the DSN ---------------------------------------- + + +def test_layer_count_matches_dsn(dsn, board): + assert board.get_layer_count() == len(dsn.layers) + assert [layer.name for layer in board.layer_structure.arr] == [ + layer.name for layer in dsn.layers + ] + + +def test_net_count_matches_dsn(dsn, board): + assert len(board.nets) == len(dsn.nets) + assert board.nets.names() == {n.name for n in dsn.nets} + + +def test_pin_count_matches_placement(dsn, board): + images = {img.name: img for img in dsn.images} + expected = sum( + len(images[pl.lib_name].pins) + for pl in dsn.placements + for place in pl.places + if place.x is not None and pl.lib_name in images + ) + assert len(board.get_pins()) == expected + assert expected > 0 + + +def test_obstacle_count_matches_keepouts(dsn, board): + # each keepout expands to one obstacle per applicable layer; with no keepouts + # in this fixture the count is zero + assert len(board.get_obstacle_areas()) >= len(dsn.keepouts) + assert len(dsn.keepouts) == 0 + assert board.get_obstacle_areas() == [] + + +# --- per-item invariants ---------------------------------------------------- + + +def test_every_pin_shape_contains_its_origin(board): + pins = board.get_pins() + assert pins + for pin in pins: + assert pin.shape_contains_origin(), f"pad shape missed origin for pin {pin.name}" + + +def test_every_pin_reports_a_net(dsn, board): + # this board's netlist connects every pad, so every pin has a net number + for pin in board.get_pins(): + assert pin.net_nos, f"pin {pin.name} has no net" + for net_no in pin.net_nos: + assert board.nets.get_by_number(net_no) is not None + + +def test_items_report_correct_net_membership(board): + for pin in board.get_pins(): + for net_no in pin.net_nos: + assert pin in board.get_connectable_items(net_no) + + +def test_pin_layers_are_valid(board): + n_layers = board.get_layer_count() + for pin in board.get_pins(): + assert pin.layers + assert all(0 <= layer < n_layers for layer in pin.layers) + + +def test_bounding_box_is_non_degenerate(board): + assert not board.bounding_box.is_empty() + assert board.bounding_box.dimension() == 2 + # every pin location lies within the board bounding box + for pin in board.get_pins(): + assert board.bounding_box.contains(pin.location) + + +def test_item_types(board): + for item in board.get_items(): + assert isinstance(item, (Pin, ObstacleArea)) + + +# --- oracle cross-check ------------------------------------------------------ + + +@pytest.mark.oracle +def test_board_net_set_covers_oracle_routed_nets(board): + from oracle import HAS_ORACLE, route_dsn, routed_net_set + + if not HAS_ORACLE: + pytest.skip("FreeRouting oracle unavailable") + # Routing the full board is slow; a couple of passes is enough to route most + # nets, and a generous timeout absorbs JVM start-up variance. + ses = route_dsn(ROUTABLE, max_passes=2, timeout=420) + routed = routed_net_set(ses) + assert routed, "oracle produced no routed nets" + # every net the reference router routed exists on our constructed board + assert routed <= board.nets.names()