From dd40aaba5d63d8a4d00e19dbfaa4bdab6a3f9379 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 12 Jul 2026 08:29:32 -0600 Subject: [PATCH] Add board data-model foundation: units, layers, transform, nets, clearance Ports the foundational board classes: Unit (mil/inch/mm/um with micrometer scaling), Layer/LayerStructure (the layer stack with name and signal-layer lookups), CoordinateTransform (DSN<->board scaling by the resolution), Net/Nets (connectivity keyed by (name, subnet) and a board- unique net number), and ClearanceMatrix (class x class spacing with a reserved null class and a default class). The per-layer axis of ClearanceMatrix is simplified to a single value per class pair (DSN default clearance rules are layer-independent for the boards we build); the router phase can add the layer dimension. Unit-tested: unit scaling/parsing, layer lookups, transform round-trip, net registration/lookup, and clearance default/append/symmetry. --- src/freeroute/board/clearance.py | 75 ++++++++++++++++++++++++++++++++ src/freeroute/board/layer.py | 59 +++++++++++++++++++++++++ src/freeroute/board/net.py | 65 +++++++++++++++++++++++++++ src/freeroute/board/transform.py | 50 +++++++++++++++++++++ src/freeroute/board/unit.py | 36 +++++++++++++++ tests/board/test_board.py | 69 +++++++++++++++++++++++++++++ 6 files changed, 354 insertions(+) create mode 100644 src/freeroute/board/clearance.py create mode 100644 src/freeroute/board/layer.py create mode 100644 src/freeroute/board/net.py create mode 100644 src/freeroute/board/transform.py create mode 100644 src/freeroute/board/unit.py create mode 100644 tests/board/test_board.py diff --git a/src/freeroute/board/clearance.py b/src/freeroute/board/clearance.py new file mode 100644 index 0000000..3f5ee93 --- /dev/null +++ b/src/freeroute/board/clearance.py @@ -0,0 +1,75 @@ +"""``ClearanceMatrix`` — required spacing between clearance classes. + +Ports the data-model core of ``rules/ClearanceMatrix.java``. Each clearance +class has a name; the matrix stores the required clearance between every pair of +classes. This port keeps a single value per class pair (the DSN default rules +are layer-independent for the boards we build); the per-layer axis in the Java +source is a refinement the router phase can add. + +Class 0 is the reserved ``null`` class (no clearance); class 1 is ``default``. +""" + +from __future__ import annotations + +__all__ = ["ClearanceMatrix"] + +#: safety margin added on lookup, matching ClearanceMatrix.clearance_safety_margin +CLEARANCE_SAFETY_MARGIN = 16 + + +class ClearanceMatrix: + """Symmetric class x class clearance table.""" + + __slots__ = ("_names", "_values") + + def __init__(self, names: list[str]) -> None: + self._names: list[str] = list(names) + n = len(self._names) + self._values: list[list[int]] = [[0] * n for _ in range(n)] + + @staticmethod + def default_instance(default_value: int = 0) -> ClearanceMatrix: + """A matrix with the reserved ``null`` class and a ``default`` class.""" + matrix = ClearanceMatrix(["null", "default"]) + matrix.set_default_value(default_value) + return matrix + + def get_class_count(self) -> int: + return len(self._names) + + def get_no(self, name: str) -> int: + try: + return self._names.index(name) + except ValueError: + return -1 + + def get_name(self, class_no: int) -> str | None: + if 0 <= class_no < len(self._names): + return self._names[class_no] + return None + + def append_class(self, name: str) -> int: + """Add a new clearance class (copying the default row/column) and return its index.""" + self._names.append(name) + for row in self._values: + row.append(0) + self._values.append([0] * len(self._names)) + return len(self._names) - 1 + + def set_default_value(self, value: int) -> None: + """Set the clearance between every non-null class pair to ``value``.""" + for i in range(1, len(self._names)): + for j in range(1, len(self._names)): + self.set_value(i, j, value) + + def set_value(self, i: int, j: int, value: int) -> None: + self._values[i][j] = value + self._values[j][i] = value + + def get_value(self, i: int, j: int, add_safety_margin: bool = False) -> int: + if not (0 <= i < len(self._names) and 0 <= j < len(self._names)): + return 0 + value = self._values[i][j] + if add_safety_margin and value > 0: + value += CLEARANCE_SAFETY_MARGIN + return value diff --git a/src/freeroute/board/layer.py b/src/freeroute/board/layer.py new file mode 100644 index 0000000..5e91f2a --- /dev/null +++ b/src/freeroute/board/layer.py @@ -0,0 +1,59 @@ +"""``Layer`` and ``LayerStructure`` — the board's layer stack. + +Ports ``board/Layer.java`` and ``board/LayerStructure.java``. Layer index 0 is +the component (top) side; ``is_signal`` marks routable copper layers (as opposed +to power/ground planes). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +__all__ = ["Layer", "LayerStructure"] + + +@dataclass(frozen=True) +class Layer: + """A single board layer.""" + + name: str + is_signal: bool = True + + def __str__(self) -> str: + return self.name + + +class LayerStructure: + """The ordered layer stack, with name/index lookups.""" + + __slots__ = ("arr",) + + def __init__(self, layers: list[Layer]) -> None: + self.arr: list[Layer] = list(layers) + + def __len__(self) -> int: + return len(self.arr) + + def get_no(self, layer_or_name) -> int: + """Index of a layer (by :class:`Layer` or by name), or -1.""" + if isinstance(layer_or_name, Layer): + for i, layer in enumerate(self.arr): + if layer is layer_or_name or layer == layer_or_name: + return i + return -1 + for i, layer in enumerate(self.arr): + if layer.name == layer_or_name: + return i + return -1 + + def signal_layer_count(self) -> int: + return sum(1 for layer in self.arr if layer.is_signal) + + def get_signal_layer(self, no: int) -> Layer: + found = 0 + for layer in self.arr: + if layer.is_signal: + if found == no: + return layer + found += 1 + return self.arr[-1] diff --git a/src/freeroute/board/net.py b/src/freeroute/board/net.py new file mode 100644 index 0000000..dae3be6 --- /dev/null +++ b/src/freeroute/board/net.py @@ -0,0 +1,65 @@ +"""``Net`` and ``Nets`` — the board connectivity model. + +Ports the data-model core of ``rules/Net.java`` and ``rules/Nets.java``: nets +are identified by a ``(name, subnet_number)`` pair and a board-unique +``net_number`` (assigned sequentially from 1). Items reference nets by number. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +__all__ = ["Net", "Nets"] + + +@dataclass +class Net: + """A single net. ``net_number`` is the board-unique id (>= 1).""" + + name: str + subnet_number: int + net_number: int + contains_plane: bool = False + #: the ``(component, pin)`` terminals declared for this net in the DSN + pins: list[tuple[str, str]] = field(default_factory=list) + + def __repr__(self) -> str: + return f"Net(#{self.net_number} {self.name!r})" + + +class Nets: + """Container of :class:`Net`, indexed by number and by ``(name, subnet)``.""" + + __slots__ = ("_by_number", "_by_key") + + def __init__(self) -> None: + self._by_number: dict[int, Net] = {} + self._by_key: dict[tuple[str, int], Net] = {} + + def __len__(self) -> int: + return len(self._by_number) + + def __iter__(self): + return iter(self._by_number.values()) + + def max_net_no(self) -> int: + return max(self._by_number, default=0) + + def add(self, name: str, subnet_number: int = 1, contains_plane: bool = False) -> Net: + """Create and register a net with the next available number.""" + number = self.max_net_no() + 1 + net = Net( + name=name, subnet_number=subnet_number, net_number=number, contains_plane=contains_plane + ) + self._by_number[number] = net + self._by_key[(name, subnet_number)] = net + return net + + def get(self, name: str, subnet_number: int = 1) -> Net | None: + return self._by_key.get((name, subnet_number)) + + def get_by_number(self, net_number: int) -> Net | None: + return self._by_number.get(net_number) + + def names(self) -> set[str]: + return {net.name for net in self._by_number.values()} diff --git a/src/freeroute/board/transform.py b/src/freeroute/board/transform.py new file mode 100644 index 0000000..cc3101f --- /dev/null +++ b/src/freeroute/board/transform.py @@ -0,0 +1,50 @@ +"""``CoordinateTransform`` — DSN <-> board coordinate scaling. + +Ports ``io/CoordinateTransform.java``. Board coordinates are the external +(DSN) coordinates multiplied by ``scale_factor`` (the DSN resolution), plus an +optional base offset. ``FreeRouting`` builds this with ``scale_factor = +resolution`` and zero offset (see ``Structure.create_board``). +""" + +from __future__ import annotations + +from freeroute.geometry import FloatPoint, IntPoint + +__all__ = ["CoordinateTransform"] + + +class CoordinateTransform: + """Scales values and points between the DSN and board coordinate systems.""" + + __slots__ = ("scale_factor", "base_x", "base_y") + + def __init__(self, scale_factor: float, base_x: float = 0.0, base_y: float = 0.0) -> None: + self.scale_factor = float(scale_factor) + self.base_x = float(base_x) + self.base_y = float(base_y) + + # scalar ----------------------------------------------------------------- + + def board_to_dsn(self, value: float) -> float: + return value / self.scale_factor + + def dsn_to_board(self, value: float) -> float: + return value * self.scale_factor + + # points ----------------------------------------------------------------- + + def dsn_to_board_point(self, x: float, y: float) -> IntPoint: + """Transform an absolute DSN coordinate pair to a board :class:`IntPoint`.""" + bx = round((x - self.base_x) * self.scale_factor) + by = round((y - self.base_y) * self.scale_factor) + return IntPoint(bx, by) + + def dsn_to_board_rel(self, x: float, y: float) -> IntPoint: + """Transform a relative (vector) DSN coordinate pair to board units.""" + return IntPoint(round(x * self.scale_factor), round(y * self.scale_factor)) + + def board_to_dsn_point(self, point: IntPoint) -> FloatPoint: + return FloatPoint( + self.board_to_dsn(point.x) + self.base_x, + self.board_to_dsn(point.y) + self.base_y, + ) diff --git a/src/freeroute/board/unit.py b/src/freeroute/board/unit.py new file mode 100644 index 0000000..684f15c --- /dev/null +++ b/src/freeroute/board/unit.py @@ -0,0 +1,36 @@ +"""``Unit`` — the user measurement units (mil / inch / mm / um). + +Ports ``board/Unit.java``. Each unit records its size in micrometers so values +can be scaled between units. +""" + +from __future__ import annotations + +from enum import Enum + +__all__ = ["Unit"] + + +class Unit(Enum): + MIL = 25.4 + INCH = 25_400.0 + MM = 1000.0 + UM = 1.0 + + @property + def micrometers(self) -> float: + return self.value + + @staticmethod + def scale(value: float, from_unit: Unit, to_unit: Unit) -> float: + return value * from_unit.micrometers / to_unit.micrometers + + @staticmethod + def from_string(text: str) -> Unit | None: + try: + return Unit[text.upper()] + except KeyError: + return None + + def __str__(self) -> str: + return self.name.lower() diff --git a/tests/board/test_board.py b/tests/board/test_board.py new file mode 100644 index 0000000..96e37be --- /dev/null +++ b/tests/board/test_board.py @@ -0,0 +1,69 @@ +"""Unit tests for the board data model (layers, units, transform, nets, clearance).""" + +from __future__ import annotations + +from freeroute.board import ( + ClearanceMatrix, + CoordinateTransform, + Layer, + LayerStructure, + Nets, + Unit, +) + + +def test_unit_scale_and_parse(): + assert Unit.from_string("um") is Unit.UM + assert Unit.from_string("MM") is Unit.MM + assert Unit.from_string("bogus") is None + assert Unit.scale(1.0, Unit.MM, Unit.UM) == 1000.0 + assert Unit.scale(1.0, Unit.INCH, Unit.MIL) == 1000.0 + assert str(Unit.MIL) == "mil" + + +def test_layer_structure_lookup(): + ls = LayerStructure([Layer("F.Cu", True), Layer("In1", False), Layer("B.Cu", True)]) + assert len(ls) == 3 + assert ls.get_no("F.Cu") == 0 + assert ls.get_no("B.Cu") == 2 + assert ls.get_no("missing") == -1 + assert ls.signal_layer_count() == 2 + assert ls.get_signal_layer(1).name == "B.Cu" + + +def test_coordinate_transform_roundtrip(): + t = CoordinateTransform(10) + assert t.dsn_to_board(5) == 50 + assert t.board_to_dsn(50) == 5 + p = t.dsn_to_board_point(100, -200) + assert (p.x, p.y) == (1000, -2000) + back = t.board_to_dsn_point(p) + assert (back.x, back.y) == (100.0, -200.0) + + +def test_nets_add_and_lookup(): + nets = Nets() + a = nets.add("GND") + b = nets.add("VCC", subnet_number=1) + assert a.net_number == 1 + assert b.net_number == 2 + assert nets.max_net_no() == 2 + assert nets.get("GND") is a + assert nets.get_by_number(2) is b + assert nets.get("missing") is None + assert nets.names() == {"GND", "VCC"} + assert len(nets) == 2 + + +def test_clearance_matrix_default_and_classes(): + cm = ClearanceMatrix.default_instance(200) + assert cm.get_class_count() == 2 # null + default + assert cm.get_name(0) == "null" + assert cm.get_name(1) == "default" + assert cm.get_value(1, 1) == 200 + assert cm.get_value(0, 1) == 0 # null class has no clearance + via = cm.append_class("via") + assert cm.get_no("via") == via + cm.set_value(1, via, 300) + assert cm.get_value(via, 1) == 300 # symmetric + assert cm.get_value(1, 1, add_safety_margin=True) == 216 # +16 margin