diff --git a/src/freeroute/board/build.py b/src/freeroute/board/build.py index c85e133..8b1dc5b 100644 --- a/src/freeroute/board/build.py +++ b/src/freeroute/board/build.py @@ -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()] diff --git a/src/freeroute/board/items.py b/src/freeroute/board/items.py index 37957bd..ed9d6b1 100644 --- a/src/freeroute/board/items.py +++ b/src/freeroute/board/items.py @@ -48,6 +48,8 @@ class Pin(Item): """A component pin: a pad shape on one or more layers at a fixed location.""" name: str = "" + #: reference designator of the owning component, for diagnostics + component_ref: str = "" padstack_name: str = "" location: IntPoint = field(default_factory=lambda: IntPoint(0, 0)) #: layer indices the pad occupies (all signal layers when spanning) diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index e6349cc..6053a8a 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -90,6 +90,7 @@ def route_board_exact( diagonal: bool = False, max_shove_depth: int = 5, shove_cap: int = 3, + via_half: int | None = None, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -102,6 +103,7 @@ def route_board_exact( i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal ] half_width = max(trace_width // 2, 1) + via_half = via_half or half_width step = trace_width + clearance result = route_board( @@ -113,6 +115,7 @@ def route_board_exact( max_passes=max_passes, rip_up=rip_up, orthogonal=True, + via_half=via_half, ) tree = ShapeSearchTree() @@ -141,7 +144,7 @@ def route_board_exact( accepted: dict[int, _Accepted] = {} for net_no in sorted(set(result.wires) | set(result.vias)): - boxes = _net_boxes(result, net_no, half_width, via_layers) + boxes = _net_boxes(result, net_no, half_width, via_layers, via_half) if any( tree.clearance_conflict(box, net_no, layer, clearance) for box, box_layers in boxes @@ -543,15 +546,14 @@ def _rollback(shoved, accepted, tree, owner) -> int: # noqa: ARG001 # --- helpers ----------------------------------------------------------------- -def _net_boxes(result, net_no, half_width, via_layers): +def _net_boxes(result, net_no, half_width, via_layers, via_half=None): + via_half = via_half or half_width boxes: list[tuple[IntBox, frozenset[int]]] = [] for layer, points in result.wires.get(net_no, []): for box in PolylineShape(Polyline(points), half_width).tiles(): boxes.append((box, frozenset({layer}))) for loc in result.vias.get(net_no, []): - vbox = IntBox( - loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width - ) + vbox = IntBox(loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half) boxes.append((vbox, via_layers)) return boxes diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 354ede9..6225bdc 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -98,6 +98,7 @@ class _Connection: "vias", "trace_cells", "via_cells", + "via_halo", ) def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: @@ -111,6 +112,7 @@ class _Connection: self.vias: list[IntPoint] = [] self.trace_cells: list[tuple[Cell, int]] = [] self.via_cells: list[Cell] = [] + self.via_halo: list[Cell] = [] class GridRouter: @@ -129,6 +131,7 @@ class GridRouter: rip_cap: int = 4, rip_penalty: float = 30.0, orthogonal: bool = False, + via_half: int | None = None, ) -> None: self.board = board self.trace_width = max(trace_width, 1) @@ -144,6 +147,12 @@ class GridRouter: self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS # cell size must fit a trace plus its clearance to a neighbouring trace self.step = max(self.trace_width + self.clearance, 1) + # A via's copper is wider than a trace. Another net's trace needs its + # centre via_half + clearance + half_width from the via's centre, so a + # via also claims every cell within via_reach of its own. + self.via_half = via_half or self.half_width + need = self.via_half + self.clearance + self.half_width + self.via_reach = max(0, math.ceil(need / self.step) - 1) if layers is None: layers = [i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal] @@ -243,10 +252,22 @@ class GridRouter: return None return self.rip_penalty * (1 + rip_count) + def _via_cells(self, cell: Cell) -> list[Cell]: + """The cell a via sits in plus the neighbours its copper reaches.""" + r = self.via_reach + cx, cy = cell + return [ + (x, y) + for x in range(max(cx - r, 0), min(cx + r, self.cols - 1) + 1) + for y in range(max(cy - r, 0), min(cy + r, self.rows - 1) + 1) + ] + def _via_placeable(self, cell: Cell, net_no: int, conns: list[_Connection]) -> bool: - """A through via needs every signal layer strictly free of other nets.""" + """A through via needs its whole footprint free of other nets on every layer.""" return all( - self._enter_cost(cell, layer, net_no, False, conns) == 0.0 for layer in self.layers + self._enter_cost(c, layer, net_no, False, conns) == 0.0 + for c in self._via_cells(cell) + for layer in self.layers ) # --- A* search ---------------------------------------------------------- @@ -321,8 +342,12 @@ class GridRouter: conn.via_cells = list(via_cells) for cell, layer in conn.trace_cells: self._cell_owner[(*cell, layer)] = conn.index + conn.via_halo = [] for cell in conn.via_cells: - self._via_owner[cell] = conn.index + for c in self._via_cells(cell): + if c not in self._via_owner: + self._via_owner[c] = conn.index + conn.via_halo.append(c) conn.wires = [] for idx, (layer, cells) in enumerate(wire_segs): points = [self._cell_center(c) for c in cells] @@ -340,9 +365,10 @@ class GridRouter: for cell, layer in conn.trace_cells: if self._cell_owner.get((*cell, layer)) == conn.index: del self._cell_owner[(*cell, layer)] - for cell in conn.via_cells: + for cell in conn.via_halo: if self._via_owner.get(cell) == conn.index: del self._via_owner[cell] + conn.via_halo = [] conn.trace_cells = [] conn.via_cells = [] conn.wires = [] @@ -493,6 +519,7 @@ def route_board( rip_up: bool = True, max_passes: int = 10, orthogonal: bool = False, + via_half: int | None = None, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -504,5 +531,6 @@ def route_board( rip_up=rip_up, max_passes=max_passes, orthogonal=orthogonal, + via_half=via_half, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 60bdc72..482a64f 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -45,6 +45,25 @@ def _via_padstack(dsn: DsnBoard) -> str: return dsn.via_padstack_names[0] if dsn.via_padstack_names else _DEFAULT_VIA +def _via_half_board(dsn: DsnBoard, scale: int) -> int | None: + """Half the via pad's size in board units, from the DSN's via padstack. + + None when the DSN names no via padstack with a shape, so the engines fall + back to their old estimate. + """ + padstack = dsn.padstack(_via_padstack(dsn)) + if padstack is None or not padstack.shapes: + return None + half = 0.0 + for shape in padstack.shapes: + if hasattr(shape, "diameter"): + half = max(half, shape.diameter / 2) + elif len(getattr(shape, "coords", [])) >= 4: + xs, ys = shape.coords[0::2], shape.coords[1::2] + half = max(half, (max(xs) - min(xs)) / 2, (max(ys) - min(ys)) / 2) + return round(half * scale) or None + + def route_dsn_board( dsn: DsnBoard, *, @@ -62,6 +81,7 @@ def route_dsn_board( clearance_board = round(_rule_clearance_dsn(dsn) * scale) result = route_board( board, + via_half=_via_half_board(dsn, scale), trace_width=width_board, clearance=clearance_board, layers=layers, @@ -130,6 +150,7 @@ def route_dsn_board_exact( clearance_board = round(_rule_clearance_dsn(dsn) * scale) exact = route_board_exact( board, + via_half=_via_half_board(dsn, scale), trace_width=width_board, clearance=clearance_board, layers=layers, @@ -175,6 +196,7 @@ def route_dsn_board_rooms( clearance_board = round(_rule_clearance_dsn(dsn) * scale) rooms = route_board_rooms( board, + via_half=_via_half_board(dsn, scale), trace_width=width_board, clearance=clearance_board, layers=layers, diff --git a/src/freeroute/route/room_router.py b/src/freeroute/route/room_router.py index 1aa0581..91a1e57 100644 --- a/src/freeroute/route/room_router.py +++ b/src/freeroute/route/room_router.py @@ -471,6 +471,7 @@ def route_board_rooms( pack: bool = False, max_shove_depth: int = 5, shove_cap: int = 3, + via_half: int | None = None, ) -> ExactRouteResult: """Route ``board`` with the continuous expansion-room track. @@ -494,6 +495,9 @@ def route_board_rooms( i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal ] half_width = max(trace_width // 2, 1) + # half the via pad's size: a via is not a trace, and sizing it like one + # let routes pass within a via's real copper + via_half = via_half or half_width margin = clearance + half_width outline = board.bounding_box @@ -537,7 +541,7 @@ def route_board_rooms( via_layers, accepted, shove, - ) + via_half=via_half) for net_no in sorted(_routable_nets(board, signal_layers)): if net_no in packed: continue @@ -562,7 +566,7 @@ def route_board_rooms( accepted, max_shove_depth, shove_cap, - ) + via_half=via_half) drc_clean = tree.has_violation(clearance) is None return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean, shoves=shoves) @@ -587,6 +591,7 @@ def _route_connection( accepted=None, max_shove_depth=5, shove_cap=3, + via_half=None, ): rooms_by_layer = { layer: _free_rooms(tree, outline, layer, net_no, margin, half_width) @@ -607,7 +612,7 @@ def _route_connection( if realized is not None: wires, vias = realized - boxes = _collect_boxes(wires, vias, half_width, via_layers, outline) + boxes = _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=via_half) if boxes is not None and not any( tree.clearance_conflict(box, net_no, layer, clearance) for box, box_layers in boxes @@ -615,7 +620,7 @@ def _route_connection( ): _commit( net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted - ) + , via_half=via_half) return 0 # the net would be dropped: try a transactional shove-in-rooms recovery @@ -634,11 +639,12 @@ def _route_connection( clearance, max_shove_depth, shove_cap, - ) + via_half=via_half) return 0 -def _collect_boxes(wires, vias, half_width, via_layers, outline): +def _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=None): + via_half = via_half or half_width """The ``(box, layers)`` copper tiles of a realized route, or ``None`` if any tile leaves the board outline (the same early-out as the old inline check).""" boxes: list[tuple[IntBox, frozenset[int]]] = [] @@ -649,13 +655,16 @@ def _collect_boxes(wires, vias, half_width, via_layers, outline): boxes.append((box, frozenset({layer}))) for loc in vias: vbox = IntBox( - loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width + loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half ) boxes.append((vbox, via_layers)) return boxes -def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted): +def _commit( + net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted, via_half=None +): + via_half = via_half or half_width """Insert a verified route's copper and record it in the routed output. With ``shove=False`` this is the foundation router's exact commit sequence. @@ -668,7 +677,7 @@ def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, sh tree.insert(TreeShape(next(owner), net_no, frozenset({layer}), box, routed=True)) for loc in vias: vbox = IntBox( - loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width + loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half ) tree.insert(TreeShape(next(owner), net_no, via_layers, vbox, routed=True)) for layer, corners in wires: @@ -693,7 +702,7 @@ def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, sh acc.wire_owners.append(w_owners) for loc in vias: vbox = IntBox( - loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width + loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half ) oid = next(owner) tree.insert(TreeShape(oid, net_no, via_layers, vbox, routed=True)) @@ -854,6 +863,7 @@ def _pack_channels( via_layers, accepted, shove, + via_half=None, ) -> set[int]: """Coordinated multi-trace channel packing (opt-in pre-pass). @@ -922,7 +932,7 @@ def _pack_channels( spacing_min, via_layers, outline, - ) + via_half=via_half) if placed is None: continue # could not pack every member cleanly -> leave to greedy for net_no, wires in placed: @@ -937,7 +947,7 @@ def _pack_channels( via_layers, shove, accepted, - ) + via_half=via_half) routed.add(net_no) return routed @@ -958,6 +968,7 @@ def _pack_group( spacing_min, via_layers, outline, + via_half=None, ): """Assign parallel lanes + staggered fan-in trunks to every net crossing one gap, and return ``[(net_no, wires), ...]`` if ALL of them realize exactly @@ -1014,7 +1025,7 @@ def _pack_group( if len(corners) < 2: return None wires = [(layer, corners)] - boxes = _collect_boxes(wires, [], half_width, via_layers, outline) + boxes = _collect_boxes(wires, [], half_width, via_layers, outline, via_half=via_half) if boxes is None: return None if any( @@ -1049,6 +1060,7 @@ def _shove_recover_room( clearance, max_shove_depth, shove_cap, + via_half=None, ) -> int: """Recover a dropped net by nudging the committed trace(s) that block it. diff --git a/tests/board/test_build.py b/tests/board/test_build.py index adddd6d..f31cfd4 100644 --- a/tests/board/test_build.py +++ b/tests/board/test_build.py @@ -63,11 +63,20 @@ def test_pin_count_matches_placement(dsn, board): def test_obstacle_count_matches_keepouts(dsn, board): - # each keepout expands to one obstacle per applicable layer; with no keepouts - # in this fixture the count is zero - assert len(board.get_obstacle_areas()) >= len(dsn.keepouts) + # Board-level keepouts plus every placed instance of a footprint-level one. + # This fixture has none of the first and six plated mounting holes, whose + # copper KiCad exports as a keepout on each layer. + images = {img.name: img for img in dsn.images} + placed = 0 + for placement in dsn.placements: + image = images.get(placement.lib_name) + if image is None: + continue + per_part = len(image.keepouts) + len(image.via_keepouts) + len(image.wire_keepouts) + placed += per_part * sum(p.x is not None for p in placement.places) assert len(dsn.keepouts) == 0 - assert board.get_obstacle_areas() == [] + assert placed == 12 + assert len(board.get_obstacle_areas()) == placed # --- per-item invariants ----------------------------------------------------