proejct commit

This commit is contained in:
2026-09-24 15:45:31 +02:00
commit 82d52be8a5
63 changed files with 8748 additions and 0 deletions
+244
View File
@@ -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