"""Fan out fine-pitch pins before routing: commit each pin's way out as copper. On a dense board the pins of a fine-pitch package get walled in: their own neighbours' pads on either side, and other nets' copper that got there first across the only way out. Reserving the way out did not help (the reserved cells still had to be routed, and lost to congestion there); committing it does, which is what a person does by hand. Each fine-pitch pin of a perimeter package (QFP, QFN, LGA: pads on the edge, none inside) gets a straight stub outward, perpendicular to its side of the package, just past its pad row. A pin whose net has an inner plane also gets a via at the stub's end, staggered between neighbouring pins so their holes keep their spacing, and so reaches the plane with nothing left to route. Every stub and via is checked exactly against pads, keepouts, the board edge, copper already on the board and earlier fan-out, and left out where it would not fit. The result goes on the board as fixed copper of its net (marked ``generated``), so the router starts from the stub's end or the via, and the session writes it out with the routes. """ from __future__ import annotations from dataclasses import dataclass import math from freeroute.board.board import BasicBoard from freeroute.board.items import Pin, Trace, Via from freeroute.geometry import IntBox, IntPoint from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box # a package is fanned out when it has this many pins and they are this close _MIN_PINS = 8 _FINE_PITCH_FACTOR = 1.2 # neighbour centres within this many (trace + 2 clearance) @dataclass class FanoutRules: trace_width: int clearance: int via_half: int via_drill: int hole_to_hole: int edge_clearance: int signal_layers: list[int] via_padstack: str #: smallest package fanned out (a big dense part is where pins get walled in) min_pins: int = 32 #: fan out plane pins only (a stub and a via each), as a person would first supply_only: bool = False def plan_fanout(board: BasicBoard, rules: FanoutRules) -> list: """The stubs and vias to put on the board before routing.""" half = max(rules.trace_width // 2, 1) # a net may have several plane zones (a main plane and an island) planes: dict[int, list] = {} for z in board.get_conduction_areas(): if z.net_nos and not board.layer_structure.arr[z.layer].is_signal: planes.setdefault(z.net_nos[0], []).append(z) # copper pours on the routing layers, by net: a pad inside one is joined # by the fill, and a stub there would take the room its thermal spokes need pours: dict[int, list] = {} for z in board.get_conduction_areas(): if z.net_nos and board.layer_structure.arr[z.layer].is_signal: pours.setdefault(z.net_nos[0], []).append(z) placed = _Placed(board, rules) by_net: dict[int, list[Pin]] = {} for pin in board.get_pins(): for n in pin.net_nos: by_net.setdefault(n, []).append(pin) items: list = [] pitch = 2 * rules.via_half + rules.hole_to_hole for pins in _packages(board, rules).values(): centre = IntPoint( sum(p.location.x for p in pins) // len(pins), sum(p.location.y for p in pins) // len(pins) ) for k, pin in enumerate(sorted(pins, key=lambda p: (p.location.x, p.location.y))): net = pin.net_nos[0] if pin.net_nos else None if net is None: continue layer = next(layer for layer in pin.layers if layer in rules.signal_layers) if any(z.layer == layer and z.polygon is not None and z.polygon.contains(pin.location) for z in pours.get(net, [])): continue # the pour joins it direction = _outward(pin.location, centre) reach = _pad_reach(pin, direction) + rules.clearance + half + rules.trace_width end = _step(pin.location, direction, reach) plane = planes.get(net) partners = [q for q in by_net.get(net, []) if q is not pin] # a signal whose other pins are all on other layers has to change # layer somewhere: do it at the stub's end, away from the pads elsewhere = bool(partners) and all(layer not in q.layers for q in partners) if plane is None and (rules.supply_only or not elsewhere): if not rules.supply_only and placed.stub_fits(pin.location, end, layer, net, half): items.append(placed.add_stub(pin.location, end, layer, net, half)) continue # stagger the via of every other pin along the side outward options = [reach + rules.via_half + rules.clearance + (k % 2) * pitch, reach + rules.via_half + rules.clearance + ((k + 1) % 2) * pitch] for d in options: at = _step(pin.location, direction, d) if plane is not None and not any( z.polygon is not None and z.polygon.contains(at) for z in plane ): continue if placed.stub_fits(pin.location, at, layer, net, half) and placed.via_fits(at, net): items.append(placed.add_stub(pin.location, at, layer, net, half)) items.append(placed.add_via(at, net)) break return items def _packages(board: BasicBoard, rules: FanoutRules) -> dict[str, list[Pin]]: """Fine-pitch perimeter packages: many single-layer pins, close together.""" by_ref: dict[str, list[Pin]] = {} for pin in board.get_pins(): if pin.shape is None or not pin.component_ref: continue on = [layer for layer in pin.layers if layer in rules.signal_layers] if len(on) != 1: continue # through-hole pins change layer for free by_ref.setdefault(pin.component_ref, []).append(pin) fine = _FINE_PITCH_FACTOR * (rules.trace_width + 2 * rules.clearance) out = {} for ref, pins in by_ref.items(): if len(pins) < max(rules.min_pins, _MIN_PINS): continue close = sum( 1 for p in pins if min(math.hypot(p.location.x - q.location.x, p.location.y - q.location.y) for q in pins if q is not p) < fine * 2 ) if close >= len(pins) // 2: out[ref] = pins return out def _outward(point: IntPoint, centre: IntPoint) -> tuple[int, int]: """Away from the package centre, along the nearer axis (its side's normal).""" dx, dy = point.x - centre.x, point.y - centre.y if abs(dx) >= abs(dy): return (1 if dx >= 0 else -1, 0) return (0, 1 if dy >= 0 else -1) def _pad_reach(pin: Pin, direction: tuple[int, int]) -> int: """How far the pad reaches from its centre in ``direction``.""" box = pin.shape.bounding_box() if direction[0] > 0: return box.ur.x - pin.location.x if direction[0] < 0: return pin.location.x - box.ll.x if direction[1] > 0: return box.ur.y - pin.location.y return pin.location.y - box.ll.y def _step(point: IntPoint, direction: tuple[int, int], distance: float) -> IntPoint: return IntPoint(point.x + round(direction[0] * distance), point.y + round(direction[1] * distance)) class _Placed: """Copper a fan-out stub or via has to clear, growing as fan-out is placed.""" def __init__(self, board: BasicBoard, rules: FanoutRules) -> None: self.board, self.rules = board, rules self.copper: list[tuple[IntBox, frozenset[int], int]] = [] # box, layers, net for pin in board.get_pins(): if pin.shape is not None: net = pin.net_nos[0] if pin.net_nos else -1 self.copper.append((pin.shape.bounding_box(), frozenset(pin.layers), net)) for net, layers, box in board.get_fixed_copper(): self.copper.append((box, layers, net)) self.keepouts = [ (tile.bounding_box(), o.layer, o.blocks_wires, o.blocks_vias, o.clearance) for o in board.get_obstacle_areas() for tile in o.tiles ] self.holes: list[tuple[IntPoint, int]] = [] # centre, radius for item in board.get_items(): if isinstance(item, Via) and item.fixed: self.holes.append((item.location, (item.drill or rules.via_drill) // 2)) for pin in board.get_pins(): if pin.shape is not None and sum(1 for layer in pin.layers if layer in rules.signal_layers) > 1: b = pin.shape.bounding_box() self.holes.append((pin.location, min(b.ur.x - b.ll.x, b.ur.y - b.ll.y) // 2)) def _clear(self, box: IntBox, layers: frozenset[int], net: int, kind: str) -> bool: grown = box.offset(self.rules.clearance) for other, other_layers, other_net in self.copper: if other_net != net and layers & other_layers and other.overlaps(grown): return False for kbox, klayer, wires, vias, needs in self.keepouts: if klayer not in layers or not (wires if kind == "wire" else vias): continue if kbox.overlaps(grown if needs else box): return False outline = self.board.outline return outline is None or outline.box_clear(box, self.rules.edge_clearance) def stub_fits(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> bool: boxes = list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)] return all(self._clear(box, frozenset({layer}), net, "wire") for box in boxes) def via_fits(self, at: IntPoint, net: int) -> bool: h = self.rules.via_half box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h) if not self._clear(box, frozenset(self.rules.signal_layers), net, "via"): return False mine = self.rules.via_drill // 2 return all( math.hypot(c.x - at.x, c.y - at.y) >= mine + r + self.rules.hole_to_hole for c, r in self.holes ) def add_stub(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> Trace: for box in list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)]: self.copper.append((box, frozenset({layer}), net)) return Trace(net_nos=[net], fixed=True, layer=layer, half_width=half, corners=[a, b], generated=True) def add_via(self, at: IntPoint, net: int) -> Via: h = self.rules.via_half box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h) layers = list(self.rules.signal_layers) self.copper.append((box, frozenset(layers), net)) self.holes.append((at, self.rules.via_drill // 2)) return Via(net_nos=[net], fixed=True, padstack_name=self.rules.via_padstack, location=at, layers=layers, shape=box, drill=self.rules.via_drill, generated=True)