Model pads and vias as KiCad places them

Measured against KiCad's own pads on the TerraHawk rc4 power stage:

- Back-side parts kept their pads on F.Cu (32 pads). Mirror the copper
  layer for a back-side part.
- Pad outlines were copied unrotated (29 pads on rotated parts). Place the
  outline through the same chain as the pin: pin rotation, back-side
  mirror, part rotation. Oval pads, a path with a width, came out as a
  zero-height sliver; pad them out by the aperture.
- Multi-shape padstacks used the first shape; use the largest.
- Footprint-level keepouts were parsed but never placed. KiCad exports a
  mounting hole's plated ring that way, so the router could not see it.
- Vias were sized like a trace (0.25 mm square for a 0.6 mm via), and every
  remaining short and clearance error was a via. Read the size from the
  DSN's via padstack and use it in all three engines; the grid engine now
  reserves the cells a via's copper reaches.

With these the exact and room engines route that board with no shorts,
clearance or other DRC errors. Pins now carry their component reference.
This commit is contained in:
2026-09-27 06:40:34 -06:00
parent 3ad5c983f5
commit f1df60fc31
7 changed files with 245 additions and 34 deletions
+144 -8
View File
@@ -53,6 +53,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
_insert_pins(dsn, board, transform, terminal_to_net)
_insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform)
return board
@@ -133,7 +134,7 @@ def _insert_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
padstack, place, dsn_pin, location, transform, layer_no, signal_layers
)
net_no = terminal_to_net.get((place.name, dsn_pin.name))
pin = Pin(
@@ -141,6 +142,7 @@ def _insert_pins(
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,
@@ -149,22 +151,44 @@ def _insert_pins(
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 + 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)
"""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 centred on ``location`` plus the layer indices it occupies."""
"""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)
@@ -177,12 +201,63 @@ def _pad_shape_and_layers(
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}
tile = _pad_tile(padstack.shapes[0], transform)
# 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):
@@ -298,3 +373,64 @@ def _area_border_tile(shape: Shape, transform: CoordinateTransform):
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 = [*image.keepouts, *image.via_keepouts, *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 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))
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()]