diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 1295aab..a98c679 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -212,6 +212,10 @@ class GridRouter: self._pad_own: dict[Node, set[int]] = {} self._keepout: set[Node] = set() self._via_keepout: set[Node] = set() + # inner power (plane) layers: not routed on, reached by vias + self._power_layers = { + i for i, layer in enumerate(board.layer_structure.arr) if not layer.is_signal + } self._zone_cells: dict[int, list[Node]] = {} # per-connection occupancy (owner = connection index) self._cell_owner: dict[Node, int] = {} @@ -393,6 +397,9 @@ class GridRouter: self, conn: _Connection, allow_ripup: bool, conns: list[_Connection] ) -> list[Node] | None: goal_cell = self._group_target(conn.b_group) + if not conn.b_group[0] and any(z.layer in self._power_layers for z in conn.b_group[1]): + # the plane is everywhere: aim at where the route starts instead + goal_cell = self._group_target(conn.a_group) starts = self._group_ends(conn.a_group, conn.net_no, conns) goals = self._group_ends(conn.b_group, conn.net_no, conns) if not starts or not goals: @@ -434,9 +441,18 @@ class GridRouter: continue current = entry[1] if current in goals: - path = _reconstruct(came_from, current) - conn.a_end, conn.b_end = starts.get(path[0]), goals[current] - return path + end = goals[current] + cell = (current[0], current[1]) + if isinstance(end, ConductionArea): # a plane end needs its via to fit + ok = via_ok.get(cell) + if ok is None: + ok = via_ok[cell] = self._via_placeable(cell, net_no, conns) + else: + ok = True + if ok: + path = _reconstruct(came_from, current) + conn.a_end, conn.b_end = starts.get(path[0]), end + return path cx, cy, cl = current base = g_score[current] for dx, dy in self._neighbours: @@ -499,10 +515,12 @@ class GridRouter: return True def _zone_nodes(self, zone) -> list[Node]: - """Cells of a zone where a via of this net fits inside its copper.""" + """Cells of a zone where a via of this net fits inside its copper; for a + plane on a power layer, on every routing layer (a via reaches it).""" key = id(zone) if key not in self._zone_cells: h = self.via_half + layers = [zone.layer] if zone.layer in self.layers else list(self.layers) nodes = [] for tile in zone.tiles: for cell in self._cells_in_box(tile.bounding_box(), 0): @@ -511,7 +529,7 @@ class GridRouter: zone.polygon.contains(IntPoint(c.x + dx, c.y + dy)) for dx in (-h, h) for dy in (-h, h) ): - nodes.append((*cell, zone.layer)) + nodes.extend((*cell, layer) for layer in layers) self._zone_cells[key] = sorted(set(nodes)) return self._zone_cells[key] @@ -549,8 +567,10 @@ class GridRouter: for node in sorted(self._end_nodes(pin, net_no, conns)): ends.setdefault(node, pin) for zone in group[1]: + plane = zone.layer in self._power_layers for node in self._zone_nodes(zone): - ends.setdefault(node, None) + # a plane end is marked with the plane: the route ends in a via + ends.setdefault(node, zone if plane else None) return ends def _group_target(self, group) -> Cell: @@ -704,6 +724,11 @@ class GridRouter: def _commit(self, conn: _Connection, path: list[Node]) -> None: wire_segs, via_cells = _split_path(path) + via_cells = list(via_cells) + if isinstance(conn.a_end, ConductionArea): + via_cells.append((path[0][0], path[0][1])) + if isinstance(conn.b_end, ConductionArea): + via_cells.append((path[-1][0], path[-1][1])) conn.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells] conn.via_cells = list(via_cells) for cell, layer in conn.trace_cells: @@ -724,8 +749,8 @@ class GridRouter: conn.via_halo.append(c) ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else () for pin, (layer, cells), at in ends: - if pin is None: - continue # ended on a zone: no stub + if not isinstance(pin, Pin): + continue # ended on a zone or a plane: no stub cell = cells[at] for c in self._stub_cells(pin.location, cell): node = (*c, layer) @@ -735,9 +760,9 @@ class GridRouter: conn.wires = [] for idx, (layer, cells) in enumerate(wire_segs): points = [self._cell_center(c) for c in cells] - if idx == 0 and conn.a_end is not None: + if idx == 0 and isinstance(conn.a_end, Pin): points.insert(0, conn.a_end.location) - if idx == len(wire_segs) - 1 and conn.b_end is not None: + if idx == len(wire_segs) - 1 and isinstance(conn.b_end, Pin): points.append(conn.b_end.location) points = _simplify(points) if len(points) >= 2: @@ -835,12 +860,26 @@ class GridRouter: for zone in self.board.get_conduction_areas(): if zone.layer in self.layers and zone.net_nos and self._zone_is_solid(zone): zones_by_net.setdefault(zone.net_nos[0], []).append(zone) + # planes: zones of a net on an inner power layer, solid by construction + planes_by_net: dict[int, list] = {} + for zone in self.board.get_conduction_areas(): + if zone.net_nos and zone.layer in self._power_layers: + planes_by_net.setdefault(zone.net_nos[0], []).append(zone) + plane_ids = frozenset(id(z) for zs in planes_by_net.values() for z in zs) conns: list[_Connection] = [] - nets = set(pins_by_net) | set(zones_by_net) + nets = set(pins_by_net) | set(zones_by_net) | set(planes_by_net) # deterministic: fewer-pin nets first, then by net number for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)): - groups = _connected_groups(pins_by_net.get(net_no, []), zones_by_net.get(net_no, [])) - for ga, gb in zip(groups, groups[1:], strict=False): + zones = zones_by_net.get(net_no, []) + planes_by_net.get(net_no, []) + groups = _connected_groups(pins_by_net.get(net_no, []), zones, plane_ids) + hubs = [g for g in groups if any(id(z) in plane_ids for z in g[1])] + if hubs: + # a plane net: every other group goes straight to the plane + # (usually a stub and a via), not pad to pad across the board + pairs = [(g, hubs[0]) for g in groups if g is not hubs[0]] + else: + pairs = list(zip(groups, groups[1:], strict=False)) + for ga, gb in pairs: conn = _Connection(len(conns), net_no, _first(ga[0]), _first(gb[0])) conn.a_group, conn.b_group = ga, gb conns.append(conn) @@ -900,8 +939,15 @@ def _ends_of(conn) -> tuple[str, str]: return _group_label(conn.a_group), _group_label(conn.b_group) -def _pin_touches_zone(pin: Pin, zone) -> bool: - """A pad on the zone's layer whose centre or a corner lies in the zone.""" +def _pin_touches_zone(pin: Pin, zone, plane: bool = False) -> bool: + """A pad on the zone's layer whose centre or a corner lies in the zone. + + For a plane on an inner power layer: a through-hole pad (on more than one + copper layer) inside it passes through the plane and is joined to it; an + SMD pad is not, and needs a via. + """ + if plane: + return len(pin.layers) > 1 and zone.polygon is not None and zone.polygon.contains(pin.location) if zone.layer not in pin.layers or zone.polygon is None: return False if zone.polygon.contains(pin.location): @@ -913,7 +959,9 @@ def _pin_touches_zone(pin: Pin, zone) -> bool: return any(zone.polygon.contains(c) for c in corners) -def _connected_groups(pins: list[Pin], zones: list) -> list[tuple[list[Pin], list]]: +def _connected_groups( + pins: list[Pin], zones: list, planes: frozenset = frozenset() +) -> list[tuple[list[Pin], list]]: """Pins and zones of one net that copper already joins: a pad in a zone is connected to it, and two zones sharing a pad are one. Only the groups need routing between them. Pin groups come first, in pin order.""" @@ -927,7 +975,7 @@ def _connected_groups(pins: list[Pin], zones: list) -> list[tuple[list[Pin], lis for j, zone in enumerate(zones): for i, pin in enumerate(pins): - if _pin_touches_zone(pin, zone): + if _pin_touches_zone(pin, zone, id(zone) in planes): parent[find(i)] = find(len(pins) + j) groups: dict[int, tuple[list[Pin], list]] = {} order: list[int] = [] diff --git a/tests/route/test_real_board_geometry.py b/tests/route/test_real_board_geometry.py index 6e20e0b..5d208b2 100644 --- a/tests/route/test_real_board_geometry.py +++ b/tests/route/test_real_board_geometry.py @@ -164,3 +164,50 @@ def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys): assert main([str(dsn), "--engine", "exact", "-do", str(tmp_path / "out.ses")]) == 0 err = capsys.readouterr().err assert "1 connection(s) not routed" in err and "N: A-1 to B-1" in err + + +# --- inner plane layers (a 4-layer board with a GND plane on In1.Cu) -------- + +def _four_layer_dsn(): + return """(pcb four (parser) (resolution um 10) (unit um) + (structure + (layer F.Cu (type signal)) (layer In1.Cu (type power)) + (layer In2.Cu (type power)) (layer B.Cu (type signal)) + (boundary (path pcb 0 0 0 30000 0 30000 -30000 0 -30000 0 0)) + (plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500)) + (via "Via[0-3]_600:300_um") (rule (width 250) (clearance 150))) + (placement + (component S (place G1 5000 -5000 front 0) (place G2 25000 -5000 front 0) + (place G3 15000 -25000 front 0) (place A 5000 -15000 front 0) (place B 25000 -15000 front 0)) + (component T (place GT 15000 -15000 front 0))) + (library + (image S (pin smd 1 0 0)) + (image T (pin tht 1 0 0)) + (padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off)) + (padstack tht (shape (circle F.Cu 1000)) (shape (circle B.Cu 1000)) (attach off)) + (padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off))) + (network (net GND (pins G1-1 G2-1 G3-1 GT-1)) (net SIG (pins A-1 B-1))))""" + + +def test_smd_pads_reach_an_inner_plane_by_their_own_via(): + dsn = parse_dsn(_four_layer_dsn()) + result, scale, names = route_dsn_board_exact(dsn) + gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND") + sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG") + assert len(result.result.vias.get(gnd, [])) == 3 # one per SMD pad, none for the THT pad + for _layer, pts in result.result.wires.get(gnd, []): # stubs, not tracks across the board + length = sum(abs(q.x - p.x) + abs(q.y - p.y) for p, q in zip(pts, pts[1:], strict=False)) + assert length < 3000 * scale # under 3 mm: a stub to its via + assert sig in result.result.routed_net_numbers # signals still route on the outer layers + assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs) + + +def test_a_through_hole_pad_inside_a_plane_needs_no_route(): + from freeroute.route.grid_router import _connected_groups + + board = build_board(parse_dsn(_four_layer_dsn())) + plane = [z for z in board.get_conduction_areas() if z.name == "GND"] + gnd_pins = [p for p in board.get_pins() if p.component_ref in ("G1", "G2", "G3", "GT")] + groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane)) + hub = next(g for g in groups if g[1]) + assert [p.component_ref for p in hub[0]] == ["GT"]