Files
rpmroute/src/freeroute/board/build.py
T
rsp2k 3ad5c983f5 Honour KiCad's keepouts: keyhole rings, wire_keepout, per-tile rasterizing
On the TerraHawk rc4 power stage every engine misread the board's rule areas:

- A no-track ring along the board edge arrives as one keyhole polygon, and
  keepout polygons were wrapped in a single convex tile, so the ring became
  the whole board. Split keepout polygons into convex pieces instead.
- The grid engine rasterized an area by its overall bounding box, which for
  that ring is again the whole board; it routed nothing. Rasterize per tile.
- "Keep router off" rule areas arrive as wire_keepout, which the reader did
  not know, so tracks ran through them (172 DRC items_not_allowed). Read it
  at board and image level. For now it blocks vias too, which is stricter
  than KiCad and cannot create a violation.
- place_keepout only restricts part placement; stop treating it as copper.

The oracle now runs the JAR headless; it was opening FreeRouting's GUI on
the desktop for every oracle test.
2026-09-27 06:30:43 -06:00

301 lines
12 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 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