Files
rpmroute/src/freeroute/board/build.py
T
rsp2k 8a50bde531 Read the DSN's wiring as fixed copper of its nets
Tracks and vias already on the board were ignored, so other nets could
be routed straight through them (the flight controller's 82 GND
stitching vias and its guard tracks). They are now copper of their net:
other nets keep a pad's distance from them in the grid, the exact check
and the room engine, and they add no connections to route.
2026-09-28 21:06:35 -06:00

540 lines
22 KiB
Python

"""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 ConductionArea, ObstacleArea, Pin, Trace, Via
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)
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()
board.insert(
Via(
net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack,
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,
)
)