diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 4db2f55..c9dd55b 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -207,7 +207,9 @@ def route_board_exact( for i, (box, box_layers) in enumerate(boxes) for layer in box_layers ) or _end_in_rule_area(tree, wires, wire_half) or ( - outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes) + outline is not None and not _inside_outline( + outline, boxes[:wire_count], vias, (via_half or wire_half) + edge, edge + ) ): rejected.append((net_no, *ends)) continue # exact-clearance conflict -> drop (may be recovered by shove) @@ -670,6 +672,15 @@ def _copper_boxes(wires, vias, half_width, via_layers, via_half=None): return boxes +def _inside_outline(outline, wire_boxes, vias, via_reach, edge) -> bool: + """Copper inside the board, ``edge`` from its outline. A via is round, as + the grid and KiCad take it: its square box would reach past a chamfered + corner where the via itself does not.""" + return all(outline.box_clear(b, edge) for b, _ in wire_boxes) and all( + outline.point_clear(v, via_reach) for v in vias + ) + + def _is_rotated(shape) -> bool: return isinstance(shape, Simplex) and not shape.is_int_box() diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 47c3ca6..597794c 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -299,9 +299,9 @@ class GridRouter: self._zone_cells: dict[int, list[Node]] = {} # per-connection occupancy (owner = connection index) self._cell_owner: dict[Node, int] = {} - self._via_owner: dict[Cell, int] = {} # every connection whose via covers a cell, in claim order: a via two - # connections share still blocks after the first of them is ripped + # connections share still blocks after the first of them is ripped, + # and a route through it must rip all of them self._via_claims: dict[Cell, list[int]] = {} # trace halos: node -> indices of the connections claiming it self._halo: dict[Node, list[int]] = {} @@ -520,9 +520,7 @@ class GridRouter: if owner is not None and conns[owner].net_no != net_no: blocking = owner else: - via_owner = self._via_owner.get(cell) - if via_owner is not None and conns[via_owner].net_no != net_no: - blocking = via_owner + blocking = self._via_blocker(cell, net_no, conns) if blocking is None: for i in self._halo.get(node, ()): if conns[i].net_no != net_no: @@ -537,6 +535,17 @@ class GridRouter: return None return self.rip_penalty * (1 + rip_count) + def _via_others(self, cell: Cell, net_no: int, conns: list[_Connection]) -> list[int]: + """Connections of other nets whose via covers ``cell``: all of them, + since two connections of a net can share one via.""" + return [i for i in self._via_claims.get(cell, ()) if conns[i].net_no != net_no] + + def _via_blocker(self, cell: Cell, net_no: int, conns: list[_Connection]) -> int | None: + """The other net's via connection on ``cell`` hardest to rip (the + penalty and the rip cap go by it), or None.""" + others = self._via_others(cell, net_no, conns) + return max(others, key=lambda i: conns[i].rip_count) if others else None + def _via_cells(self, cell: Cell) -> list[Cell]: """The cell a via sits in plus the neighbours its copper reaches.""" r = self.via_reach @@ -927,9 +936,7 @@ class GridRouter: if not any(g.overlaps(t) for tiles in other for t in tiles for g in grown): continue owners.add(owner) - via_owner = self._via_owner.get(c) - if via_owner is not None and conns[via_owner].net_no != net_no: - owners.add(via_owner) + owners.update(self._via_others(c, net_no, conns)) return owners def _end_penalty(self, pin: Pin, node: Node, net_no: int, allow_ripup: bool, conns) -> float: @@ -1206,7 +1213,6 @@ class GridRouter: if conn.index not in claims: claims.append(conn.index) conn.via_halo.append(c) - self._via_owner.setdefault(c, conn.index) ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else () conn.stubs = [] for pin, (layer, cells), at in ends: @@ -1259,11 +1265,6 @@ class GridRouter: claims = self._via_claims.get(cell) if claims is not None and conn.index in claims: claims.remove(conn.index) - if self._via_owner.get(cell) == conn.index: - if claims: - self._via_owner[cell] = claims[0] - else: - del self._via_owner[cell] if claims is not None and not claims: del self._via_claims[cell] conn.via_halo = [] @@ -1308,9 +1309,7 @@ class GridRouter: owner = self._cell_owner.get((cx, cy, cl)) if owner is not None and conns[owner].net_no != net_no: owners.add(owner) - via_owner = self._via_owner.get((cx, cy)) - if via_owner is not None and conns[via_owner].net_no != net_no: - owners.add(via_owner) + owners.update(self._via_others((cx, cy), net_no, conns)) for i in self._halo.get((cx, cy, cl), ()): if conns[i].net_no != net_no: owners.add(i) diff --git a/tests/route/test_grid_exact_agreement.py b/tests/route/test_grid_exact_agreement.py index 9c08559..af234f7 100644 --- a/tests/route/test_grid_exact_agreement.py +++ b/tests/route/test_grid_exact_agreement.py @@ -143,11 +143,48 @@ def test_a_shared_via_keeps_its_footprint_when_one_user_is_ripped(): router._rip(first) assert second.routed and via in second.via_cells - assert all(router._via_owner.get(c) == second.index for c in footprint) + assert all(router._via_claims.get(c) == [second.index] for c in footprint) # another net still keeps its distance from the via that is left assert all(router._enter_cost_base(c, 0, sig.net_no, False, conns) is None for c in footprint) +def test_a_route_through_a_shared_via_rips_every_user(): + """Two connections of a net share a via. Another net routed through it + in rip-up mode has to rip both, or the one left keeps its via under the + new trace and the exact check throws one of them out.""" + dsn = parse_dsn(_SHARED_VIA) + s = dsn.resolution.value + router = GridRouter( + build_board(dsn), + trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), + via_half=_via_half_board(dsn, s), + ) + conns = router._conns = router._build_connections() + gnd = [c for c in conns if len({c.a.component_ref, c.b.component_ref} & {"G1", "G2", "G3"})] + sig = next(c for c in conns if c not in gnd) + first, second = gnd + g3 = router._cell_of(next(p for p in router.board.get_pins() if p.component_ref == "G3").location) + via = (g3[0] - 10, g3[1]) + + def through_via(conn): + a, b = router._cell_of(conn.a.location), router._cell_of(conn.b.location) + return _line(a, via, 0) + _line(via, b, 1) + + router._commit(first, through_via(first)) + router._commit(second, through_via(second)) + # the via's copper alone: footprint cells no trace or trace halo covers + crossing = [ + node + for c in router._via_cells(via) + for node in ((*c, 0), (*c, 1)) + if node not in router._cell_owner and node not in router._halo + ] + assert crossing + + assert router._crossed_owners(crossing, sig.net_no, conns) == {first.index, second.index} + + # --- a wider net class -------------------------------------------------------- _WIDE = """(pcb wide (parser) (resolution um 10) (unit um) @@ -225,3 +262,23 @@ def test_a_pad_cell_check_uses_the_rotated_outline(): c = router._cell_center(cell) square = IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin) assert oval.shape.intersection(square).dimension() != 2 + + +def test_a_via_by_a_chamfered_corner_is_judged_round(): + """The grid keeps a via's centre its radius plus the edge clearance from + the outline. The exact check used the via's square, whose corner reaches + past a 45-degree chamfer where the round via does not.""" + from freeroute.board.outline import BoardOutline + from freeroute.geometry import IntBox, IntPoint + from freeroute.route.exact_router import _inside_outline + + # a 10 mm square with its lower-left corner cut at 45 degrees + outline = BoardOutline([IntPoint(*p) for p in + [(30, 0), (100, 0), (100, 100), (0, 100), (0, 30)]]) + half, edge = 4, 1 + via = IntPoint(19, 20) # its centre 6.4 from the chamfer x + y = 30 + square = IntBox(via.x - half, via.y - half, via.x + half, via.y + half) + assert not outline.box_clear(square, edge) # the square's corner: 0.7 away + assert _inside_outline(outline, [], [via], half + edge, edge) + # 4.2 from the chamfer: the round via itself is too close + assert not _inside_outline(outline, [], [IntPoint(18, 18)], half + edge, edge) diff --git a/tests/route/test_rollback.py b/tests/route/test_rollback.py index 38ab250..1d3edd6 100644 --- a/tests/route/test_rollback.py +++ b/tests/route/test_rollback.py @@ -31,7 +31,7 @@ def test_occupancy_only_holds_routed_connections(name): router, _ = _routed_router(name) conns = router._conns assert all(conns[i].routed for i in router._cell_owner.values()) - assert all(conns[i].routed for i in router._via_owner.values()) + assert all(conns[i].routed for claims in router._via_claims.values() for i in claims) assert all(conns[i].routed for claims in router._halo.values() for i in claims)