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.
This commit is contained in:
Ryan Malloy 2026-07-12 08:29:32 -06:00
parent 0e508107ba
commit dd40aaba5d
6 changed files with 354 additions and 0 deletions

View File

@ -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

View File

@ -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]

View File

@ -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()}

View File

@ -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,
)

View File

@ -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()

69
tests/board/test_board.py Normal file
View File

@ -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