proejct commit
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user