diff --git a/tests/dsn/fixtures/rooms_shove_channel.dsn b/tests/dsn/fixtures/rooms_shove_channel.dsn new file mode 100644 index 0000000..91e0135 --- /dev/null +++ b/tests/dsn/fixtures/rooms_shove_channel.dsn @@ -0,0 +1,70 @@ +(pcb "rooms_shove_channel.dsn" + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "freeroute-test") + (host_version "1.0") + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu + (type signal) + (property + (index 0) + ) + ) + (layer B.Cu + (type power) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 0 0 200000 0 200000 -30000 0 -30000 0 0) + ) + (keepout "seal_left" + (rect F.Cu 0 -30000 2500 0) + ) + (keepout "seal_right" + (rect F.Cu 197500 -30000 200000 0) + ) + (rule + (width 2000) + (clearance 2000) + ) + ) + (library + (padstack Rect_Pad + (shape (rect F.Cu -1000 -1000 1000 1000)) + (attach off) + ) + (image PAD + (pin Rect_Pad 1 0 0) + ) + ) + (placement + (component PAD + (place A1 7000 -15000 front 0) + (place A2 193000 -15000 front 0) + (place C1 100000 -6000 front 0) + (place C2 100000 -24000 front 0) + ) + ) + (network + (net NET_A + (pins A1-1 A2-1) + ) + (net NET_C + (pins C1-1 C2-1) + ) + (class default + (rule + (width 2000) + (clearance 2000) + ) + ) + ) + (wiring + ) +) diff --git a/tests/dsn/fixtures/rooms_wide_door.dsn b/tests/dsn/fixtures/rooms_wide_door.dsn new file mode 100644 index 0000000..fce17ec --- /dev/null +++ b/tests/dsn/fixtures/rooms_wide_door.dsn @@ -0,0 +1,70 @@ +(pcb "rooms_wide_door.dsn" + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "freeroute-test") + (host_version "1.0") + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu + (type signal) + (property + (index 0) + ) + ) + (layer B.Cu + (type power) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 0 0 200000 0 200000 -60000 0 -60000 0 0) + ) + (keepout "wall_left" + (rect F.Cu 0 -32000 90000 -28000) + ) + (keepout "wall_right" + (rect F.Cu 110000 -32000 200000 -28000) + ) + (rule + (width 2000) + (clearance 2000) + ) + ) + (library + (padstack Rect_Pad + (shape (rect F.Cu -1000 -1000 1000 1000)) + (attach off) + ) + (image PAD + (pin Rect_Pad 1 0 0) + ) + ) + (placement + (component PAD + (place A1 98000 -10000 front 0) + (place A2 98000 -50000 front 0) + (place B1 102000 -10000 front 0) + (place B2 102000 -50000 front 0) + ) + ) + (network + (net NET_A + (pins A1-1 A2-1) + ) + (net NET_B + (pins B1-1 B2-1) + ) + (class default + (rule + (width 2000) + (clearance 2000) + ) + ) + ) + (wiring + ) +) diff --git a/tests/route/test_room_router.py b/tests/route/test_room_router.py index b5eff6b..9292c4a 100644 --- a/tests/route/test_room_router.py +++ b/tests/route/test_room_router.py @@ -26,6 +26,8 @@ SIMPLE = FIXTURES / "simple_2net.dsn" CROSSING = FIXTURES / "crossing_2net.dsn" KICAD = FIXTURES / "kicad_routable.dsn" NARROW = FIXTURES / "narrow_channel.dsn" +SHOVE_CHANNEL = FIXTURES / "rooms_shove_channel.dsn" +WIDE_DOOR = FIXTURES / "rooms_wide_door.dsn" def _clearance(dsn, scale): @@ -37,7 +39,7 @@ def _clearance(dsn, scale): # --- DRC-clean invariant (same non-negotiable as the exact track) ----------- -@pytest.mark.parametrize("fixture", [SIMPLE, CROSSING, KICAD, NARROW]) +@pytest.mark.parametrize("fixture", [SIMPLE, CROSSING, KICAD, NARROW, SHOVE_CHANNEL, WIDE_DOOR]) def test_room_router_output_is_drc_clean(fixture): dsn = parse_dsn(fixture.read_text()) rooms, scale, _ = route_dsn_board_rooms(dsn) diff --git a/tests/route/test_room_shove.py b/tests/route/test_room_shove.py new file mode 100644 index 0000000..03b7283 --- /dev/null +++ b/tests/route/test_room_shove.py @@ -0,0 +1,228 @@ +"""Tests for gate-optimal placement and shove-in-rooms on the room router. + +Two opt-in (``shove=True``) upgrades to the continuous expansion-room track: + +* **Occupancy-aware gate placement** seats a trace at the best point in a door + rather than the raw midpoint. With ``margin = clearance + half_width`` the room + decomposition already keeps every door clearance-clear of committed copper, so + the projection provably reduces to the old clamp where nothing else crosses the + door — the four foundation fixtures stay byte-for-byte identical. +* **Shove-in-rooms** is the real continuous, sub-cell win the grid could not do: + when a net would be dropped, the committed trace blocking it is nudged aside + with the exact shove primitive so *both* fit through the same channel. The + headline fixture ``rooms_shove_channel`` drops a net without it and routes both + with it, DRC-clean. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.dsn.sexp import parse +from freeroute.geometry import IntBox, IntPoint +from freeroute.route import route, route_dsn_board_exact, route_dsn_board_rooms +from freeroute.route.pipeline import _rule_clearance_dsn + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" +SIMPLE = FIXTURES / "simple_2net.dsn" +CROSSING = FIXTURES / "crossing_2net.dsn" +KICAD = FIXTURES / "kicad_routable.dsn" +NARROW = FIXTURES / "narrow_channel.dsn" +SHOVE_CHANNEL = FIXTURES / "rooms_shove_channel.dsn" +WIDE_DOOR = FIXTURES / "rooms_wide_door.dsn" + +FOUNDATION = [SIMPLE, CROSSING, KICAD, NARROW] + + +def _clearance(dsn, scale): + return round(_rule_clearance_dsn(dsn) * scale) + + +def _wire_key(result): + return { + n: [(layer, [(p.x, p.y) for p in pts]) for layer, pts in segs] + for n, segs in result.wires.items() + } + + +def _pins_by_net(fixture): + board = build_board(parse_dsn(fixture.read_text())) + out: dict[int, set[tuple[int, int]]] = {} + for pin in board.get_pins(): + for net_no in pin.net_nos: + out.setdefault(net_no, set()).add((pin.location.x, pin.location.y)) + return out + + +# --- the headline density win ----------------------------------------------- + + +def test_room_shove_channel_density_win(): + dsn = parse_dsn(SHOVE_CHANNEL.read_text()) + off, _, _ = route_dsn_board_rooms(dsn, shove=False) + on, scale, _ = route_dsn_board_rooms(dsn, shove=True) + + # without shove NET_A's sealed wall leaves no top<->bottom door -> NET_C drops + assert off.routed_net_numbers == {1} + assert off.drc_clean + # with shove NET_A is nudged aside and both nets fit through the same channel + assert on.routed_net_numbers == {1, 2} + assert on.shoves >= 1 + assert on.drc_clean + assert on.tree.has_violation(_clearance(dsn, scale)) is None + + +def test_room_shove_channel_moves_blocker_but_keeps_its_pads(): + on, _, _ = route_dsn_board_rooms(parse_dsn(SHOVE_CHANNEL.read_text()), shove=True) + pins = _pins_by_net(SHOVE_CHANNEL) + # NET_A (net 1) is the shoved blocker: its endpoints stay on its pads... + seg = on.result.wires[1] + pts = [(p.x, p.y) for _, run in seg for p in run] + assert {pts[0], pts[-1]} == pins[1] + # ...but a middle corner moved off the original straight y (the shove jog) + ys = {y for _, y in pts} + assert len(ys) > 1 + + +def test_room_shove_channel_endpoints_on_pads(): + on, _, _ = route_dsn_board_rooms(parse_dsn(SHOVE_CHANNEL.read_text()), shove=True) + pins = _pins_by_net(SHOVE_CHANNEL) + for net_no, segments in on.result.wires.items(): + pts = [(p.x, p.y) for _, run in segments for p in run] + assert {pts[0], pts[-1]} == pins[net_no] + + +def test_room_shove_channel_is_deterministic(): + a, _, _ = route_dsn_board_rooms(parse_dsn(SHOVE_CHANNEL.read_text()), shove=True) + b, _, _ = route_dsn_board_rooms(parse_dsn(SHOVE_CHANNEL.read_text()), shove=True) + assert _wire_key(a.result) == _wire_key(b.result) + assert a.via_count() == b.via_count() + + +def test_room_shove_baseline_contrast_with_exact(): + # the exact track (no rip-up) also drops NET_C on this sealed board; the room + # engine's shove recovers it -- the concrete continuous-shove win. + dsn = parse_dsn(SHOVE_CHANNEL.read_text()) + exact, _, _ = route_dsn_board_exact(dsn, rip_up=False, shove=False) + assert len(exact.routed_net_numbers) < 2 + rooms, _, _ = route_dsn_board_rooms(dsn, shove=True) + assert rooms.routed_net_numbers == {1, 2} + + +def test_room_shove_channel_valid_ses(): + ses = route(SHOVE_CHANNEL.read_text(), engine="room", shove=True) + top = parse(ses) + assert top.head == "session" + nets = top.child("routes").child("network_out").children("net") + assert {n.values()[0].text for n in nets} == {"NET_A", "NET_C"} + + +# --- gate placement: two lanes through one wide door ------------------------ + + +def _wall_crossing_x(result, net_no, wall_y): + for _, run in result.wires[net_no]: + for a, b in zip(run, run[1:], strict=False): + if a.x == b.x and min(a.y, b.y) <= wall_y <= max(a.y, b.y): + return a.x + return None + + +def test_room_wide_door_seats_two_nets_at_distinct_points(): + dsn = parse_dsn(WIDE_DOOR.read_text()) + on, scale, _ = route_dsn_board_rooms(dsn, shove=True) + assert on.routed_net_numbers == {1, 2} + assert on.shoves == 0 # gate placement alone, no shove + assert on.drc_clean + assert on.tree.has_violation(_clearance(dsn, scale)) is None + + wall_y = -30000 * scale + x1 = _wall_crossing_x(on.result, 1, wall_y) + x2 = _wall_crossing_x(on.result, 2, wall_y) + assert x1 is not None and x2 is not None + width = round(2000 * scale) + clearance = _clearance(dsn, scale) + assert abs(x1 - x2) >= width + clearance # two DISTINCT lanes + + +def test_room_wide_door_is_deterministic(): + a, _, _ = route_dsn_board_rooms(parse_dsn(WIDE_DOOR.read_text()), shove=True) + b, _, _ = route_dsn_board_rooms(parse_dsn(WIDE_DOOR.read_text()), shove=True) + assert _wire_key(a.result) == _wire_key(b.result) + + +# --- DRC-clean invariant extends to the new fixtures ------------------------ + + +@pytest.mark.parametrize("fixture", [SHOVE_CHANNEL, WIDE_DOOR]) +def test_new_fixtures_drc_clean_under_shove(fixture): + dsn = parse_dsn(fixture.read_text()) + on, scale, _ = route_dsn_board_rooms(dsn, shove=True) + assert on.drc_clean + assert on.tree.has_violation(_clearance(dsn, scale)) is None + + +# --- shove/gate placement is a no-op where it isn't needed ------------------ + + +@pytest.mark.parametrize("fixture", FOUNDATION) +def test_shove_is_byte_identical_on_foundation_fixtures(fixture): + dsn = parse_dsn(fixture.read_text()) + off, scale, _ = route_dsn_board_rooms(dsn, shove=False) + on, _, _ = route_dsn_board_rooms(dsn, shove=True) + # identical connectivity, geometry, DRC-clean, and no shove was needed + assert on.routed_net_numbers == off.routed_net_numbers + assert on.shoves == 0 + assert off.drc_clean and on.drc_clean + assert on.tree.has_violation(_clearance(dsn, scale)) is None + # doors carry no other-net routed copper here, so projection == the old clamp + assert _wire_key(on.result) == _wire_key(off.result) + + +# --- unit coverage for the occupancy-aware projection ----------------------- + + +def test_project_gate_reduces_to_clamp_without_occupancy(): + from freeroute.board.search_tree import ShapeSearchTree + from freeroute.route.room_router import _Door, _edge_gate, _project_gate + + door = _Door(other=1, kind="edge", lo=IntPoint(0, 100), hi=IntPoint(200, 100)) + tree = ShapeSearchTree() + from_pt = IntPoint(140, 500) + plain = _edge_gate(door, from_pt, half_width=1) + projected = _project_gate(door, from_pt, 1, 2, tree, net_no=1, layer=0) + assert (projected.x, projected.y) == (plain.x, plain.y) == (140, 100) + + +def test_project_gate_avoids_other_net_copper_on_the_door(): + from freeroute.board.search_tree import ShapeSearchTree, TreeShape + from freeroute.route.room_router import _Door, _project_gate + + door = _Door(other=1, kind="edge", lo=IntPoint(0, 100), hi=IntPoint(200, 100)) + tree = ShapeSearchTree() + # a different-net routed trace crosses the middle of the door + tree.insert(TreeShape(0, 2, frozenset({0}), IntBox(90, 50, 110, 150), routed=True)) + # ideal projection is x=100, right inside the blocked band -> pushed to a free span + gate = _project_gate(door, IntPoint(100, 500), 1, 2, tree, net_no=1, layer=0) + reach = 2 + 1 # clearance + half_width + forbidden_lo, forbidden_hi = 90 - reach, 110 + reach + assert not (forbidden_lo < gate.x < forbidden_hi) # outside the open forbidden band + assert 1 <= gate.x <= 199 # inside the usable span + + +def test_free_intervals_splits_around_forbidden_band(): + from freeroute.route.room_router import _best_free_point, _free_intervals + + free = _free_intervals(0, 200, [(87, 113)]) + assert free == [(0, 87), (113, 200)] + # tie on size -> lowest-coordinate interval, clamped toward the ideal + assert _best_free_point(0, 200, [(87, 113)], ideal=100) == 87 + # no forbidden band -> the whole span, clamped to the ideal (old clamp) + assert _best_free_point(0, 200, [], ideal=100) == 100