"""Tests der Kollisionserkennung für alle Formpaarungen einschließlich Randfällen.""" import random import pytest from sandbox.physics import Body, Vector2, collide from sandbox.physics.collision import ( COINCIDENT_NORMAL, collide_box_half_plane, collide_boxes, collide_circle_box, collide_circle_half_plane, collide_circles, ) from tests.helpers import material def circle(x: float, y: float, radius: float) -> Body: return Body.circle(Vector2(x, y), radius, material()) def box(x: float, y: float, half_width: float, half_height: float) -> Body: return Body.box(Vector2(x, y), half_width, half_height, material()) def floor_at(y: float) -> Body: return Body.wall(Vector2(0, -1), -y, material()) # frei ist alles oberhalb von y # --------------------------------------------------------------------- Kreis–Kreis def test_separated_circles_do_not_collide() -> None: assert collide_circles(circle(0, 0, 10), circle(21, 0, 10)) is None def test_touching_circles_form_a_contact_without_penetration() -> None: contact = collide_circles(circle(0, 0, 10), circle(20, 0, 10)) assert contact is not None assert contact.penetration == pytest.approx(0) def test_overlapping_circles() -> None: a = circle(0, 0, 10) b = circle(15, 0, 10) contact = collide_circles(a, b) assert contact is not None assert contact.body_a is a assert contact.body_b is b assert contact.normal.is_close(Vector2(1, 0)) assert contact.penetration == pytest.approx(5) assert contact.point.is_close(Vector2(7.5, 0)) # Mitte des Überlappungsbereichs def test_coincident_circles_use_a_fixed_fallback_normal() -> None: contact = collide_circles(circle(5, 5, 10), circle(5, 5, 8)) assert contact is not None assert contact.normal == COINCIDENT_NORMAL assert contact.penetration == pytest.approx(18) # --------------------------------------------------------------------- Kreis–Rechteck def test_circle_touching_the_top_face_of_a_box() -> None: contact = collide_circle_box(circle(0, -14, 5), box(0, 0, 20, 10)) assert contact is not None assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis (oben) zur Box (unten) assert contact.penetration == pytest.approx(1) assert contact.point.is_close(Vector2(0, -10)) def test_circle_touching_the_right_face_of_a_box() -> None: contact = collide_circle_box(circle(24, 0, 5), box(0, 0, 20, 10)) assert contact is not None assert contact.normal.is_close(Vector2(-1, 0)) assert contact.penetration == pytest.approx(1) def test_circle_touching_a_corner_gets_a_diagonal_normal() -> None: contact = collide_circle_box(circle(23, -13, 5), box(0, 0, 20, 10)) assert contact is not None assert contact.normal.is_close(Vector2(-1, 1).normalized()) assert contact.penetration == pytest.approx(5 - 18**0.5) assert contact.point.is_close(Vector2(20, -10)) def test_circle_near_a_corner_but_outside_does_not_collide() -> None: # Die umschließenden Boxen überlappen, der Kreis verfehlt die Ecke aber knapp. assert collide_circle_box(circle(24, -14, 5), box(0, 0, 20, 10)) is None def test_circle_center_inside_box_is_pushed_out_through_the_nearest_side_face() -> None: contact = collide_circle_box(circle(15, 2, 5), box(0, 0, 20, 10)) assert contact is not None assert contact.normal.is_close(Vector2(-1, 0)) # Kreis bewegt sich entlang −n nach rechts assert contact.penetration == pytest.approx(5 + 5) # Radius + Abstand zur Kante assert contact.point.is_close(Vector2(20, 2)) def test_circle_center_inside_box_is_pushed_out_through_the_nearest_top_face() -> None: contact = collide_circle_box(circle(3, -8, 5), box(0, 0, 20, 10)) assert contact is not None assert contact.normal.is_close(Vector2(0, 1)) # Kreis bewegt sich nach oben assert contact.penetration == pytest.approx(5 + 2) assert contact.point.is_close(Vector2(3, -10)) # --------------------------------------------------------------------- Rechteck–Rechteck def test_boxes_are_separated_along_the_axis_of_least_overlap() -> None: a = box(0, 0, 10, 10) contact = collide_boxes(a, box(15, 5, 10, 10)) assert contact is not None assert contact.normal.is_close(Vector2(1, 0)) assert contact.penetration == pytest.approx(5) assert contact.point.is_close(Vector2(7.5, 2.5)) # Mitte des Überlappungsrechtecks contact = collide_boxes(a, box(5, -18, 10, 10)) assert contact is not None assert contact.normal.is_close(Vector2(0, -1)) assert contact.penetration == pytest.approx(2) def test_equal_overlap_prefers_the_vertical_axis() -> None: contact = collide_boxes(box(0, 0, 10, 10), box(15, 15, 10, 10)) assert contact is not None assert contact.normal.is_close(Vector2(0, 1)) @pytest.mark.parametrize(("x", "y"), [(21, 0), (0, 25), (-21, 5), (3, -20.5)]) def test_separated_boxes_do_not_collide(x: float, y: float) -> None: assert collide_boxes(box(0, 0, 10, 10), box(x, y, 10, 10)) is None # --------------------------------------------------------------------- Wände def test_circle_resting_on_the_floor() -> None: contact = collide_circle_half_plane(circle(50, 95, 10), floor_at(100)) assert contact is not None assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis in die Wand assert contact.penetration == pytest.approx(5) assert contact.point.is_close(Vector2(50, 100)) def test_circle_above_the_floor_does_not_collide() -> None: assert collide_circle_half_plane(circle(50, 80, 10), floor_at(100)) is None def test_circle_completely_behind_the_wall_is_still_detected() -> None: # Der "Tunnel"-Fall: Selbst ein Körper hinter der Wandlinie wird erkannt # und in den freien Raum zurückgeschoben. contact = collide_circle_half_plane(circle(50, 130, 10), floor_at(100)) assert contact is not None assert contact.penetration == pytest.approx(40) assert contact.normal.is_close(Vector2(0, 1)) def test_box_against_floor_and_side_wall() -> None: contact = collide_box_half_plane(box(50, 95, 20, 10), floor_at(100)) assert contact is not None assert contact.penetration == pytest.approx(5) left_wall = Body.wall(Vector2(1, 0), 0, material()) contact = collide_box_half_plane(box(15, 50, 20, 10), left_wall) assert contact is not None assert contact.normal.is_close(Vector2(-1, 0)) assert contact.penetration == pytest.approx(5) assert collide_box_half_plane(box(25, 50, 20, 10), left_wall) is None # --------------------------------------------------------------------- Zuordnung def test_collide_orders_bodies_canonically() -> None: c = circle(0, -14, 5) b = box(0, 0, 20, 10) for first, second in ((c, b), (b, c)): contact = collide(first, second) assert contact is not None assert contact.body_a is c assert contact.body_b is b assert contact.normal.is_close(Vector2(0, 1)) wall = floor_at(100) contact = collide(wall, circle(50, 95, 10)) assert contact is not None assert contact.body_b is wall def test_two_walls_never_collide() -> None: assert collide(floor_at(100), floor_at(50)) is None def test_contact_key_and_repr() -> None: a = circle(0, 0, 10) b = circle(15, 0, 10) contact = collide(a, b) assert contact is not None assert contact.key == (a.id, b.id) assert "penetration=5.000" in repr(contact) # --------------------------------------------------------------------- Eigenschaften def _random_body(rng: random.Random) -> Body: x = rng.uniform(-40, 40) y = rng.uniform(-40, 40) if rng.random() < 0.5: return circle(x, y, rng.uniform(5, 30)) return box(x, y, rng.uniform(5, 30), rng.uniform(5, 30)) def _random_wall(rng: random.Random) -> Body: normal = rng.choice([Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)]) return Body.wall(normal, rng.uniform(-40, 40), material()) def test_random_contacts_are_consistent_and_resolvable() -> None: """Zufällige Kontakte sind in sich stimmig und lassen sich exakt auflösen. Für viele Zufallspaare gilt: Die Normale ist ein Einheitsvektor, die Tiefe ist ≥ 0, und verschiebt man Körper A um (Tiefe + ε) entgegen der Normalen, berühren sich die Körper nicht mehr. Genau diese Information braucht die Kollisionsauflösung. """ rng = random.Random(1234) checked = 0 for _ in range(3000): first = _random_body(rng) second = _random_wall(rng) if rng.random() < 0.25 else _random_body(rng) contact = collide(first, second) if contact is None: continue checked += 1 assert contact.normal.length() == pytest.approx(1) assert contact.penetration >= 0 mover = contact.body_a mover.position = mover.position - contact.normal * (contact.penetration + 1e-6) assert collide(contact.body_a, contact.body_b) is None assert checked > 500 # genug echte Kontakte geprüft