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,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
|
||||
Reference in New Issue
Block a user