Add item hierarchy, BasicBoard, and DSN->board construction

Ports the data model of the board Item hierarchy (Item base, Pin,
ObstacleArea/ConductionArea, Via, Trace) and BasicBoard (layers, nets,
clearance, bounding box, items with query helpers), then build_board:
the load-bearing integration that constructs a BasicBoard from a parsed
DsnBoard.

build_board maps layers -> LayerStructure, resolution -> transform,
default clearance rule -> ClearanceMatrix, nets -> Nets plus a
(component,pin)->net map, padstacks x placement -> Pin items, and
keepouts -> ObstacleArea items. Rectangle pads become exact IntBoxes,
convex polygon pads exact Simplexes, circle pads their bounding box
(documented approximation); every pad is centred on its pin location so
it contains that location by construction. Trace/Via are router-produced
and lightweight here (an imported unrouted board has none).

Validated on a real KiCad export (kicad_routable.dsn): layer/net/pin
counts match the parsed DSN, every pin's pad shape contains its origin,
every pin reports a valid net, and pin locations lie in the board
bounding box. An oracle-gated test cross-checks that every net the
reference FreeRouting JAR routes exists on the constructed board.
This commit is contained in:
Ryan Malloy 2026-07-12 08:29:43 -06:00
parent dd40aaba5d
commit 4220fe0cd6
5 changed files with 623 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

131
tests/board/test_build.py Normal file
View File

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