Files
pygame-physics-opus5.5/tests/physics/test_collision.py
T
2026-09-24 15:45:31 +02:00

245 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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