"""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 freeroute.geometry.polygon_shape import PolygonShape 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: # A place_keepout only restricts where parts go, not copper. if keepout.kind == "place_keepout": continue area = keepout.area if area is None or area.border is None: continue tiles = _area_border_tiles(area.border, transform) if not tiles: 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=tiles, ) ) def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list: """Convex board tiles covering a keepout border shape. Polygons are split into convex pieces rather than wrapped in one convex tile: KiCad exports a rule area with a hole (a no-track ring along the board edge, say) as a single keyhole polygon, and its convex hull is the whole board. """ 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: pieces = [t for t in PolygonShape(pts).split_to_convex() if not t.is_empty()] if pieces: return pieces tile = _area_border_tile(shape, transform) return [tile] if tile is not None else [] 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