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:
2026-07-12 08:29:43 -06:00
parent dd40aaba5d
commit 4220fe0cd6
5 changed files with 623 additions and 0 deletions
+271
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