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:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user