"""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 import re 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 ConductionArea, ObstacleArea, Pin, Trace, Via from .layer import Layer, LayerStructure from .net import Nets from .transform import CoordinateTransform __all__ = ["build_board"] # DSN units per micrometre, for drills KiCad gives in um in via padstack names _DSN_PER_UM = {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400} 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) board.outline = _build_outline(dsn, transform) _insert_pins(dsn, board, transform, terminal_to_net) _insert_keepouts(dsn, board, transform) _insert_image_keepouts(dsn, board, transform) _insert_zones(dsn, board, transform, nets) _insert_wiring(dsn, board, transform, nets) 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 _build_outline(dsn: DsnBoard, transform: CoordinateTransform): """The DSN boundary as a polygon, or None when it is not a polygon.""" from .outline import BoardOutline shape = dsn.boundary if not isinstance(shape, (Polygon, Path)): return None c = shape.coords pts = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)] return BoardOutline(pts) if len(pts) >= 3 else None 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, place, dsn_pin, 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, component_ref=place.name, padstack_name=dsn_pin.padstack_name, location=location, layers=layers, shape=shape, ) board.insert(pin) def _to_board_offset(place, x: float, y: float, pin_rotation: float = 0.0) -> FloatPoint: """Image-relative DSN offset -> board-oriented DSN offset for a placed part. The chain is: the pin's own rotation (pad shapes only), then the mirror of a back-side part (x -> -x), then the part's rotation. Pin positions use the same chain without the pin rotation, so pads and positions always agree. """ p = FloatPoint(x, y) if pin_rotation: p = p.rotate(math.radians(pin_rotation), FloatPoint(0.0, 0.0)) if not place.is_front: p = FloatPoint(-p.x, p.y) if place.rotation: p = p.rotate(math.radians(place.rotation), FloatPoint(0.0, 0.0)) return p def _pin_location(place, dsn_pin, transform: CoordinateTransform) -> IntPoint: """Absolute board location of a pin: component origin + placed pin offset.""" rel = _to_board_offset(place, dsn_pin.x, dsn_pin.y) return transform.dsn_to_board_point(place.x + rel.x, place.y + rel.y) def _pad_shape_and_layers( padstack, place, dsn_pin, location: IntPoint, transform: CoordinateTransform, layer_no: dict[str, int], signal_layers: list[int], ) -> tuple[object, list[int]]: """A pad tile around ``location`` plus the layer indices it occupies. A back-side part's pads move to the mirrored copper layer (F.Cu -> B.Cu on a two-layer board), and the pad outline is placed through the same rotation and mirror as the pin itself. """ 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) if not place.is_front and signal_layers: top, bottom = min(signal_layers), max(signal_layers) layers = {top + bottom - layer if layer in signal_layers else layer for layer in layers} # The largest of the padstack's shapes: per-layer shapes of a through-hole # pad usually match, and when they do not the bigger one is the safe side. shape = max(padstack.shapes, key=_shape_extent) tile = _placed_pad_tile(shape, place, dsn_pin, transform) tile = tile.translate_by(IntVector(location.x, location.y)) return tile, sorted(layers) def _shape_extent(shape: Shape) -> float: coords = _shape_coords(shape) if isinstance(shape, Circle): return shape.diameter ** 2 if len(coords) < 4: return 0.0 xs, ys = coords[0::2], coords[1::2] return (max(xs) - min(xs)) * (max(ys) - min(ys)) def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransform): """The pad outline, rotated and mirrored with its part, relative to the pin.""" if isinstance(shape, Circle): centre = _to_board_offset(place, shape.center_x, shape.center_y, dsn_pin.rotation) r = round(transform.dsn_to_board(shape.diameter / 2)) cx = round(transform.dsn_to_board(centre.x)) cy = round(transform.dsn_to_board(centre.y)) return IntBox(cx - r, cy - r, cx + r, cy + r) if isinstance(shape, Rectangle): x0, y0, x1, y1 = shape.coords[:4] corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] elif isinstance(shape, (Polygon, Path)): c = shape.coords corners = [(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)] else: return _pad_tile(shape, transform) # An oval pad is a path with a width (the aperture). Pad the centreline # out by half the width in every direction; the square ends cover more # than the round ones do, which is the safe side. half = getattr(shape, "width", 0.0) / 2 if isinstance(shape, Path) else 0.0 if half: corners = [(x + dx, y + dy) for x, y in corners for dx in (-half, half) for dy in (-half, half)] pts = [] for x, y in corners: p = _to_board_offset(place, x, y, dsn_pin.rotation) pts.append(IntPoint(round(transform.dsn_to_board(p.x)), round(transform.dsn_to_board(p.y)))) 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)) 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, blocks_wires=keepout.kind != "via_keepout", blocks_vias=keepout.kind != "wire_keepout", clearance=not is_rule_area(keepout.area.name), ) ) #: a structure keepout named with this prefix is a KiCad rule area (mckicad #: names them on export); it is checked for overlap only RULE_AREA_PREFIX = "rule_area" def is_rule_area(name: str | None) -> bool: return bool(name) and name.startswith(RULE_AREA_PREFIX) 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 def _insert_image_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform) -> None: """Keepouts declared inside a footprint, placed with each instance. KiCad exports copper that has no pin number this way, for instance the plated ring of a mounting hole, so skipping them leaves copper the router cannot see. """ 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 areas = [ *((a, True, True) for a in image.keepouts), *((a, False, True) for a in image.via_keepouts), *((a, True, False) for a in image.wire_keepouts), ] if not areas: continue for place in placement.places: if place.x is None or place.y is None: continue for area, blocks_wires, blocks_vias in areas: border = area.border if border is None: continue tiles = _placed_area_tiles(border, place, transform) if not tiles: continue if border.layer in ("signal", "pcb") or border.layer not in layer_no: layers = list(signal_layers) else: layers = [layer_no[border.layer]] if not place.is_front and signal_layers: top, bottom = min(signal_layers), max(signal_layers) layers = [top + bottom - layer for layer in layers] for layer_index in layers: board.insert( ObstacleArea( name=area.name, layer=layer_index, tiles=tiles, blocks_wires=blocks_wires, blocks_vias=blocks_vias, ) ) def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> list: """An image-relative area border placed with its part, as convex board tiles.""" def to_board(x: float, y: float) -> IntPoint: p = _to_board_offset(place, x, y) return transform.dsn_to_board_point(place.x + p.x, place.y + p.y) if isinstance(shape, Circle): c = to_board(shape.center_x, shape.center_y) r = round(transform.dsn_to_board(shape.diameter / 2)) return [IntBox(c.x - r, c.y - r, c.x + r, c.y + r)] if isinstance(shape, Rectangle): x0, y0, x1, y1 = shape.coords[:4] pts = [to_board(x, y) for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))] elif isinstance(shape, (Polygon, Path)): c = shape.coords pts = [to_board(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)] else: return [] if len(pts) < 3: return [] return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()] def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None: """Tracks and vias already on the board, as fixed copper of their nets.""" 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] numbers = {net.name: net.net_number for net in nets} for wire in dsn.wires: if wire.net not in numbers or wire.layer not in layer_no: continue c = wire.coords corners = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)] if len(corners) < 2: continue board.insert( Trace( net_nos=[numbers[wire.net]], fixed=True, layer=layer_no[wire.layer], half_width=max(round(transform.dsn_to_board(wire.width / 2)), 1), corners=corners, ) ) for via in dsn.vias: padstack = dsn.padstack(via.padstack) if via.net not in numbers or padstack is None or not padstack.shapes: continue 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]) location = transform.dsn_to_board_point(via.x, via.y) pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform) box = pad.bounding_box() drill = re.search(r"_([\d.]+):([\d.]+)_um", via.padstack) drill_board = ( round(transform.dsn_to_board(float(drill.group(2)) * _DSN_PER_UM.get(dsn.unit, 1.0))) if drill else 0 ) board.insert( Via( net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack, drill=drill_board, location=location, layers=sorted(layers or signal_layers), shape=IntBox(box.ll.x + location.x, box.ll.y + location.y, box.ur.x + location.x, box.ur.y + location.y), ) ) def _insert_zones(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None: """Each DSN plane as a ConductionArea of its net on its layer.""" layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)} numbers = {net.name: net.net_number for net in nets} for plane in dsn.planes: shape = plane.shape if plane.net not in numbers or shape.layer not in layer_no: continue if not isinstance(shape, (Polygon, Path)): continue c = shape.coords pts = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)] if len(pts) < 3: continue polygon = PolygonShape(pts) tiles = [t for t in polygon.split_to_convex() if not t.is_empty()] if not tiles: continue board.insert( ConductionArea( net_nos=[numbers[plane.net]], name=plane.net, layer=layer_no[shape.layer], tiles=tiles, polygon=polygon, blocks_wires=False, blocks_vias=False, ) )