proejct commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests der pygame-freien Physik-Schicht."""
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände)."""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2
|
||||
from sandbox.physics.vector import ZERO
|
||||
from tests.helpers import material
|
||||
|
||||
|
||||
def test_circle_area_and_moment_of_inertia() -> None:
|
||||
circle = Circle(10)
|
||||
assert circle.area() == pytest.approx(math.pi * 100)
|
||||
assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100)
|
||||
assert circle.half_extents() == (10, 10)
|
||||
|
||||
|
||||
def test_box_area_and_infinite_inertia() -> None:
|
||||
box = Box(3, 2)
|
||||
assert box.area() == 24
|
||||
assert math.isinf(box.moment_of_inertia(mass=5))
|
||||
assert box.half_extents() == (3, 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)],
|
||||
)
|
||||
def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None:
|
||||
with pytest.raises(ValueError, match="positiv"):
|
||||
factory()
|
||||
|
||||
|
||||
def test_half_plane_requires_unit_normal() -> None:
|
||||
with pytest.raises(ValueError, match="Länge 1"):
|
||||
HalfPlane(Vector2(0, 2), 0)
|
||||
|
||||
|
||||
def test_half_plane_distance_and_contains() -> None:
|
||||
floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100
|
||||
assert floor.distance(Vector2(0, 90)) == pytest.approx(10)
|
||||
assert floor.distance(Vector2(0, 110)) == pytest.approx(-10)
|
||||
assert floor.contains(Vector2(0, 110))
|
||||
assert not floor.contains(Vector2(0, 90))
|
||||
assert floor.contains(Vector2(0, 90), tolerance=10)
|
||||
assert math.isinf(floor.half_extents()[0])
|
||||
|
||||
|
||||
def test_point_containment_with_tolerance() -> None:
|
||||
circle = Body.circle(Vector2(100, 100), 10, material())
|
||||
assert circle.contains_point(Vector2(107, 107))
|
||||
assert not circle.contains_point(Vector2(110, 110))
|
||||
assert circle.contains_point(Vector2(110, 110), tolerance=5)
|
||||
|
||||
box = Body.box(Vector2(0, 0), 10, 5, material())
|
||||
assert box.contains_point(Vector2(10, -5))
|
||||
assert not box.contains_point(Vector2(10.5, 0))
|
||||
assert box.contains_point(Vector2(10.5, 0), tolerance=1)
|
||||
|
||||
|
||||
def test_mass_is_area_times_density() -> None:
|
||||
circle = Body.circle(Vector2(), 10, material(density=2))
|
||||
assert circle.mass == pytest.approx(math.pi * 100 * 2)
|
||||
assert circle.inv_mass == pytest.approx(1 / circle.mass)
|
||||
box = Body.box(Vector2(), 10, 5, material(density=0.5))
|
||||
assert box.mass == pytest.approx(20 * 10 * 0.5)
|
||||
|
||||
|
||||
def test_bigger_bodies_are_heavier() -> None:
|
||||
small = Body.circle(Vector2(), 10, material())
|
||||
large = Body.circle(Vector2(), 20, material())
|
||||
assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r²
|
||||
|
||||
|
||||
def test_static_bodies_have_infinite_mass_and_never_move() -> None:
|
||||
wall = Body.wall(Vector2(0, 1), 0, material())
|
||||
assert wall.is_static
|
||||
assert wall.inv_mass == 0
|
||||
assert wall.inv_inertia == 0
|
||||
assert math.isinf(wall.mass)
|
||||
wall.apply_impulse(Vector2(100, 100))
|
||||
assert wall.velocity == ZERO
|
||||
assert wall.kinetic_energy() == 0
|
||||
assert wall.momentum() == ZERO
|
||||
|
||||
|
||||
def test_half_planes_cannot_be_dynamic() -> None:
|
||||
with pytest.raises(ValueError, match="statisch"):
|
||||
Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material())
|
||||
|
||||
|
||||
def test_dynamic_bodies_need_positive_density() -> None:
|
||||
with pytest.raises(ValueError, match="Dichte"):
|
||||
Body.circle(Vector2(), 10, material(density=0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("density", "restitution", "friction"),
|
||||
[(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)],
|
||||
)
|
||||
def test_invalid_materials_are_rejected(
|
||||
density: float, restitution: float, friction: float
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"):
|
||||
Material(density, restitution, friction)
|
||||
|
||||
|
||||
def test_circles_can_rotate_but_boxes_cannot() -> None:
|
||||
circle = Body.circle(Vector2(), 10, material())
|
||||
box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5)
|
||||
assert circle.can_rotate
|
||||
assert not box.can_rotate
|
||||
assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert
|
||||
|
||||
|
||||
def test_off_center_impulse_spins_a_circle() -> None:
|
||||
circle = Body.circle(Vector2(), 10, material())
|
||||
# Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung.
|
||||
circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10))
|
||||
assert circle.velocity.is_close(Vector2(10, 0))
|
||||
expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0))
|
||||
assert circle.angular_velocity == pytest.approx(expected_spin)
|
||||
assert circle.angular_velocity < 0
|
||||
|
||||
|
||||
def test_off_center_impulse_does_not_spin_a_box() -> None:
|
||||
box = Body.box(Vector2(), 10, 10, material())
|
||||
box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10))
|
||||
assert box.angular_velocity == 0
|
||||
assert box.velocity.is_close(Vector2(1, 0))
|
||||
|
||||
|
||||
def test_kinetic_energy_and_momentum() -> None:
|
||||
circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4))
|
||||
circle.angular_velocity = 2
|
||||
expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4
|
||||
assert circle.kinetic_energy() == pytest.approx(expected)
|
||||
assert circle.momentum().is_close(Vector2(3, 4) * circle.mass)
|
||||
|
||||
|
||||
def test_bounds() -> None:
|
||||
assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25)
|
||||
assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22)
|
||||
|
||||
|
||||
def test_sleep_wake_and_teleport() -> None:
|
||||
body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0))
|
||||
body.angular_velocity = 1
|
||||
body.sleep_time = 1
|
||||
body.put_to_sleep()
|
||||
assert body.sleeping
|
||||
assert body.velocity == ZERO
|
||||
assert body.angular_velocity == 0
|
||||
body.teleport(Vector2(50, 60))
|
||||
assert not body.sleeping
|
||||
assert body.sleep_time == 0
|
||||
assert body.position == body.previous_position == Vector2(50, 60)
|
||||
|
||||
|
||||
def test_ids_are_unique() -> None:
|
||||
bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)]
|
||||
assert len({body.id for body in bodies}) == 5
|
||||
|
||||
|
||||
def test_repr_mentions_id_and_sleep_state() -> None:
|
||||
body = Body.circle(Vector2(1, 2), 10, material())
|
||||
assert f"id={body.id}" in repr(body)
|
||||
body.put_to_sleep()
|
||||
assert "schläft" in repr(body)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests der Broadphase (Sweep-and-Prune)."""
|
||||
|
||||
import random
|
||||
|
||||
from sandbox.physics import Body, Vector2
|
||||
from sandbox.physics.broadphase import find_candidate_pairs
|
||||
from tests.helpers import material
|
||||
|
||||
|
||||
def _random_scene(seed: int, count: int) -> list[Body]:
|
||||
rng = random.Random(seed)
|
||||
bodies = []
|
||||
for _ in range(count):
|
||||
position = Vector2(rng.uniform(0, 400), rng.uniform(0, 300))
|
||||
if rng.random() < 0.5:
|
||||
bodies.append(Body.circle(position, rng.uniform(5, 30), material()))
|
||||
else:
|
||||
bodies.append(Body.box(position, rng.uniform(5, 30), rng.uniform(5, 30), material()))
|
||||
return bodies
|
||||
|
||||
|
||||
def _overlap(a: Body, b: Body) -> bool:
|
||||
a_min_x, a_min_y, a_max_x, a_max_y = a.bounds()
|
||||
b_min_x, b_min_y, b_max_x, b_max_y = b.bounds()
|
||||
return a_min_x <= b_max_x and b_min_x <= a_max_x and a_min_y <= b_max_y and b_min_y <= a_max_y
|
||||
|
||||
|
||||
def test_matches_brute_force_on_random_scenes() -> None:
|
||||
for seed in range(5):
|
||||
bodies = _random_scene(seed, 60)
|
||||
expected = {
|
||||
(min(a.id, b.id), max(a.id, b.id))
|
||||
for i, a in enumerate(bodies)
|
||||
for b in bodies[i + 1 :]
|
||||
if _overlap(a, b)
|
||||
}
|
||||
found = [(a.id, b.id) for a, b in find_candidate_pairs(bodies)]
|
||||
assert len(found) == len(set(found)), "kein Paar doppelt"
|
||||
assert set(found) == expected
|
||||
|
||||
|
||||
def test_pairs_are_ordered_by_id() -> None:
|
||||
bodies = _random_scene(7, 40)
|
||||
for a, b in find_candidate_pairs(bodies):
|
||||
assert a.id < b.id
|
||||
|
||||
|
||||
def test_pairs_of_sleeping_bodies_are_skipped() -> None:
|
||||
a = Body.circle(Vector2(0, 0), 10, material())
|
||||
b = Body.circle(Vector2(15, 0), 10, material())
|
||||
c = Body.circle(Vector2(30, 0), 10, material())
|
||||
a.put_to_sleep()
|
||||
b.put_to_sleep()
|
||||
pairs = {(x.id, y.id) for x, y in find_candidate_pairs([a, b, c])}
|
||||
assert (a.id, b.id) not in pairs # beide schlafen
|
||||
assert (b.id, c.id) in pairs # schlafend + wach wird geprüft
|
||||
@@ -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
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests des Schlafmodus (Inseln, Einschlafen, Aufwecken)."""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from sandbox.config import PHYSICS
|
||||
from sandbox.physics import Body, Contact, Vector2, collide
|
||||
from sandbox.physics.sleep import UnionFind, find_islands, update_sleep, wake_touched_bodies
|
||||
from tests.helpers import material
|
||||
|
||||
|
||||
def _circle(x: float, y: float = 0.0, radius: float = 10.0) -> Body:
|
||||
return Body.circle(Vector2(x, y), radius, material())
|
||||
|
||||
|
||||
def _contacts(*pairs: tuple[Body, Body]) -> list[Contact]:
|
||||
contacts = [collide(a, b) for a, b in pairs]
|
||||
assert all(contact is not None for contact in contacts)
|
||||
return [contact for contact in contacts if contact is not None]
|
||||
|
||||
|
||||
def test_union_find_merges_sets() -> None:
|
||||
union_find = UnionFind([1, 2, 3, 4, 5])
|
||||
union_find.union(1, 2)
|
||||
union_find.union(3, 4)
|
||||
union_find.union(2, 4)
|
||||
assert union_find.find(1) == union_find.find(3) == union_find.find(4)
|
||||
assert union_find.find(5) != union_find.find(1)
|
||||
|
||||
|
||||
def test_touching_bodies_form_islands_but_walls_do_not_connect_them() -> None:
|
||||
a, b, c, d = _circle(0), _circle(15), _circle(30), _circle(200)
|
||||
floor = Body.wall(Vector2(0, -1), -5, material()) # berührt alle Kreise
|
||||
contacts = _contacts((a, b), (b, c), (a, floor), (d, floor))
|
||||
islands = find_islands([a, b, c, d], contacts)
|
||||
assert [set(island) for island in islands] == [{a, b, c}, {d}]
|
||||
|
||||
|
||||
def test_an_island_sleeps_only_when_all_its_bodies_rest() -> None:
|
||||
config = replace(PHYSICS, time_to_sleep=0.5)
|
||||
resting, moving = _circle(0), _circle(15)
|
||||
moving.velocity = Vector2(50, 0)
|
||||
contacts = _contacts((resting, moving))
|
||||
for _ in range(100):
|
||||
update_sleep([resting, moving], contacts, 0.01, config)
|
||||
assert resting.sleep_time >= 0.5
|
||||
assert not resting.sleeping # der bewegte Nachbar hält die Insel wach
|
||||
|
||||
moving.velocity = Vector2(0, 0)
|
||||
for _ in range(60):
|
||||
update_sleep([resting, moving], contacts, 0.01, config)
|
||||
assert resting.sleeping
|
||||
assert moving.sleeping
|
||||
|
||||
|
||||
def test_waking_one_body_wakes_its_whole_island() -> None:
|
||||
a, b, c, outsider = _circle(0), _circle(15), _circle(30), _circle(300)
|
||||
island = [a, b, c]
|
||||
for body in island:
|
||||
body.put_to_sleep(island)
|
||||
outsider.put_to_sleep()
|
||||
c.wake()
|
||||
assert not any(body.sleeping for body in island)
|
||||
assert all(body.sleep_island is None for body in island)
|
||||
assert outsider.sleeping
|
||||
|
||||
|
||||
def test_touching_a_sleeper_wakes_and_reports_its_whole_island() -> None:
|
||||
a, b, c = _circle(0), _circle(15), _circle(30)
|
||||
island = [b, c]
|
||||
for body in island:
|
||||
body.put_to_sleep(island)
|
||||
woken = wake_touched_bodies(_contacts((a, b)))
|
||||
assert set(woken) == {b, c}
|
||||
assert not c.sleeping
|
||||
|
||||
|
||||
def test_held_bodies_keep_their_island_awake() -> None:
|
||||
held, other = _circle(0), _circle(15)
|
||||
held.held = True
|
||||
contacts = _contacts((held, other))
|
||||
for _ in range(200):
|
||||
update_sleep([held, other], contacts, 0.01, PHYSICS)
|
||||
assert not held.sleeping
|
||||
assert not other.sleeping
|
||||
assert held.sleep_time == 0
|
||||
|
||||
|
||||
def test_sleepers_touched_by_awake_bodies_are_woken_but_not_by_walls() -> None:
|
||||
sleeper, awake = _circle(0), _circle(15)
|
||||
sleeper.put_to_sleep()
|
||||
floor = Body.wall(Vector2(0, -1), -5, material())
|
||||
lonely = _circle(300)
|
||||
lonely.put_to_sleep()
|
||||
both_asleep = _circle(500)
|
||||
both_asleep.put_to_sleep()
|
||||
neighbour = _circle(515)
|
||||
neighbour.put_to_sleep()
|
||||
|
||||
woken = wake_touched_bodies(
|
||||
_contacts((sleeper, awake), (lonely, floor), (both_asleep, neighbour))
|
||||
)
|
||||
assert woken == [sleeper]
|
||||
assert not sleeper.sleeping
|
||||
assert lonely.sleeping
|
||||
assert both_asleep.sleeping
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Tests der Kollisionsauflösung: Stöße, Restitution, Reibung, Rollen, Positionskorrektur.
|
||||
|
||||
Die Erwartungswerte stammen aus der klassischen Mechanik (Impuls- und Energieerhaltung,
|
||||
Coulomb-Reibung, Rollbedingung v = ω·r) und nicht aus der Implementierung selbst.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from sandbox.config import PHYSICS
|
||||
from tests.helpers import (
|
||||
add_box,
|
||||
add_circle,
|
||||
make_world,
|
||||
simulate,
|
||||
total_kinetic_energy,
|
||||
total_momentum,
|
||||
)
|
||||
|
||||
GRAVITY = PHYSICS.gravity[1]
|
||||
FLOOR = 700.0
|
||||
|
||||
|
||||
def _head_on_pair(restitution: float) -> tuple:
|
||||
"""Zwei Kreise ohne Schwerkraft, Massenverhältnis 1:3, frontal aufeinander zu."""
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
light = add_circle(world, 300, 350, 20, velocity=(200, 0), restitution=restitution)
|
||||
heavy = add_circle(
|
||||
world, 500, 350, 20, velocity=(-100, 0), density=3.0, restitution=restitution
|
||||
)
|
||||
return world, light, heavy
|
||||
|
||||
|
||||
def test_elastic_collision_conserves_momentum_and_energy() -> None:
|
||||
world, light, heavy = _head_on_pair(restitution=1.0)
|
||||
momentum_before = total_momentum(world.bodies)
|
||||
energy_before = total_kinetic_energy(world.bodies)
|
||||
simulate(world, 1.0) # Stoß bei t ≈ 0,53 s, danach noch weit weg von den Wänden
|
||||
|
||||
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
|
||||
assert total_kinetic_energy(world.bodies) == pytest.approx(energy_before, rel=1e-9)
|
||||
# Lehrbuchformel für den 1D-Stoß: v1' = ((m1 − m2)·v1 + 2·m2·v2) / (m1 + m2)
|
||||
m1, m2, v1, v2 = light.mass, heavy.mass, 200.0, -100.0
|
||||
assert light.velocity.x == pytest.approx(((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2))
|
||||
assert heavy.velocity.x == pytest.approx(((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2))
|
||||
assert light.velocity.x == pytest.approx(-250)
|
||||
assert heavy.velocity.x == pytest.approx(50)
|
||||
|
||||
|
||||
def test_equal_masses_exchange_velocities_in_an_elastic_collision() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
a = add_circle(world, 300, 350, 20, velocity=(150, 0), restitution=1.0)
|
||||
b = add_circle(world, 400, 350, 20, restitution=1.0)
|
||||
simulate(world, 0.8)
|
||||
assert a.velocity.x == pytest.approx(0, abs=1e-9)
|
||||
assert b.velocity.x == pytest.approx(150)
|
||||
|
||||
|
||||
def test_perfectly_inelastic_collision_leaves_a_common_velocity() -> None:
|
||||
world, light, heavy = _head_on_pair(restitution=0.0)
|
||||
momentum_before = total_momentum(world.bodies)
|
||||
energy_before = total_kinetic_energy(world.bodies)
|
||||
simulate(world, 1.0)
|
||||
|
||||
common = momentum_before.x / (light.mass + heavy.mass) # = −25 px/s
|
||||
assert light.velocity.x == pytest.approx(common)
|
||||
assert heavy.velocity.x == pytest.approx(common)
|
||||
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
|
||||
assert total_kinetic_energy(world.bodies) < energy_before
|
||||
|
||||
|
||||
def test_restitution_below_one_loses_energy_as_predicted() -> None:
|
||||
world, light, heavy = _head_on_pair(restitution=0.5)
|
||||
energy_before = total_kinetic_energy(world.bodies)
|
||||
simulate(world, 1.0)
|
||||
# Newtons Stoßgesetz: Trenngeschwindigkeit = e · Aufprallgeschwindigkeit (300 px/s).
|
||||
assert heavy.velocity.x - light.velocity.x == pytest.approx(0.5 * 300)
|
||||
# Energieverlust beim zentralen Stoß: ΔE = ½·μ·(1 − e²)·v_rel² mit μ = m1·m2/(m1 + m2).
|
||||
reduced_mass = light.mass * heavy.mass / (light.mass + heavy.mass)
|
||||
expected_loss = 0.5 * reduced_mass * (1 - 0.5**2) * 300**2
|
||||
energy_loss = energy_before - total_kinetic_energy(world.bodies)
|
||||
assert energy_loss == pytest.approx(expected_loss, rel=1e-6)
|
||||
|
||||
|
||||
def test_slow_impacts_below_the_threshold_do_not_bounce() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
approach = 0.8 * PHYSICS.restitution_threshold
|
||||
a = add_circle(world, 300, 350, 20, velocity=(approach, 0), restitution=1.0)
|
||||
b = add_circle(world, 345, 350, 20, restitution=1.0)
|
||||
simulate(world, 0.5)
|
||||
assert b.velocity.x - a.velocity.x == pytest.approx(0, abs=1e-9)
|
||||
|
||||
|
||||
def test_frictionless_oblique_impact_keeps_the_tangential_velocity() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0), wall_friction=0.0)
|
||||
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), restitution=1.0, friction=0.0)
|
||||
simulate(world, 0.4) # trifft die linke Wand bei t ≈ 0,27 s
|
||||
assert ball.velocity.x == pytest.approx(300)
|
||||
assert ball.velocity.y == pytest.approx(120)
|
||||
assert ball.angular_velocity == 0 # Normalimpulse erzeugen kein Drehmoment
|
||||
|
||||
|
||||
def test_friction_on_an_oblique_impact_makes_a_ball_spin() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), friction=0.8)
|
||||
simulate(world, 0.4)
|
||||
assert ball.angular_velocity != 0
|
||||
assert abs(ball.velocity.y) < 120 # Reibung hat die Tangentialgeschwindigkeit gebremst
|
||||
|
||||
|
||||
def test_sliding_box_decelerates_with_coulomb_friction() -> None:
|
||||
world = make_world(wall_friction=0.6)
|
||||
box = add_box(world, 200, FLOOR - 20, 20, 20, velocity=(300, 0), friction=0.4)
|
||||
simulate(world, 1.5)
|
||||
mu = math.sqrt(0.4 * 0.6) # gemischter Reibwert Box/Boden
|
||||
expected_distance = 300**2 / (2 * mu * GRAVITY) # s = v² / (2·μ·g)
|
||||
assert box.position.x - 200 == pytest.approx(expected_distance, rel=0.03)
|
||||
assert box.velocity.x == pytest.approx(0, abs=1e-6)
|
||||
|
||||
|
||||
def test_sliding_disk_starts_rolling_at_two_thirds_of_its_speed() -> None:
|
||||
"""Eine rutschende Scheibe geht ins Rollen über und behält dabei ⅔ ihres Tempos.
|
||||
|
||||
Reibung bremst die Scheibe (I = ½·m·r²) und versetzt sie in Drehung, bis v = ω·r gilt.
|
||||
Drehimpulserhaltung um den Kontaktpunkt liefert dann v = v0 / (1 + I/(m·r²)) = ⅔·v0 –
|
||||
unabhängig vom Reibwert.
|
||||
"""
|
||||
world = make_world(rolling_resistance=0.0, sleep_enabled=False)
|
||||
radius = 20
|
||||
disk = add_circle(world, 200, FLOOR - radius, radius, velocity=(300, 0), friction=0.5)
|
||||
simulate(world, 1.0)
|
||||
assert disk.velocity.x == pytest.approx(200, rel=0.01)
|
||||
assert disk.angular_velocity * radius == pytest.approx(disk.velocity.x, rel=0.01)
|
||||
|
||||
|
||||
def test_rolling_resistance_decelerates_a_rolling_ball() -> None:
|
||||
"""Rollwiderstand: Moment μ_r·N·r ⇒ Verzögerung a = μ_r·g / (1 + I/(m·r²)) = μ_r·g/1,5."""
|
||||
world = make_world(sleep_enabled=False)
|
||||
radius = 25
|
||||
ball = add_circle(
|
||||
world, 200, FLOOR - radius, radius, velocity=(200, 0), angular_velocity=200 / radius
|
||||
)
|
||||
simulate(world, 1.0)
|
||||
expected_deceleration = PHYSICS.rolling_resistance * GRAVITY / 1.5
|
||||
assert 200 - ball.velocity.x == pytest.approx(expected_deceleration, rel=0.02)
|
||||
|
||||
|
||||
def test_resting_contact_carries_exactly_the_weight() -> None:
|
||||
world = make_world(sleep_enabled=False)
|
||||
box = add_box(world, 300, FLOOR - 20, 30, 20)
|
||||
simulate(world, 0.5)
|
||||
(contact,) = world.contacts
|
||||
# Pro Schritt muss der Boden den Impuls m·g·dt aufbringen, damit die Box liegen bleibt.
|
||||
assert contact.normal_impulse == pytest.approx(box.mass * GRAVITY * PHYSICS.time_step)
|
||||
assert contact.tangent_impulse == pytest.approx(0, abs=1e-9)
|
||||
|
||||
|
||||
def test_impulses_respect_non_penetration_and_coulomb_bounds() -> None:
|
||||
world = make_world()
|
||||
for i in range(12):
|
||||
add_circle(world, 300 + 25 * (i % 4), 200 + 30 * (i // 4), 14, velocity=(40 * i, 0))
|
||||
add_box(world, 600 + 30 * (i % 3), 150 + 35 * (i // 3), 14, 16)
|
||||
for _ in range(240):
|
||||
world.step(PHYSICS.time_step)
|
||||
for contact in world.contacts:
|
||||
assert contact.normal_impulse >= 0
|
||||
mu = math.sqrt(contact.body_a.material.friction * contact.body_b.material.friction)
|
||||
assert abs(contact.tangent_impulse) <= mu * contact.normal_impulse + 1e-9
|
||||
|
||||
|
||||
def test_position_correction_separates_without_adding_velocity() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0), sleep_enabled=False)
|
||||
a = add_circle(world, 500, 350, 20)
|
||||
b = add_circle(world, 510, 350, 20) # 30 px Überlappung
|
||||
simulate(world, 1.0)
|
||||
assert a.position.distance_to(b.position) >= 40 - PHYSICS.penetration_slop - 1e-9
|
||||
assert a.velocity.length() == 0
|
||||
assert b.velocity.length() == 0
|
||||
|
||||
|
||||
def test_warm_starting_can_be_disabled() -> None:
|
||||
world = make_world(warm_starting=False)
|
||||
box = add_box(world, 300, FLOOR - 20, 30, 20)
|
||||
simulate(world, 2.0)
|
||||
assert box.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests des festen Zeitschritts (Akkumulator)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from sandbox.physics import FixedTimestep
|
||||
|
||||
STEP = 1 / 120
|
||||
|
||||
|
||||
def test_a_60_fps_frame_runs_two_physics_steps() -> None:
|
||||
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
|
||||
assert timestep.advance(1 / 60) == 2
|
||||
assert timestep.alpha == pytest.approx(0, abs=1e-6)
|
||||
|
||||
|
||||
def test_short_frames_accumulate() -> None:
|
||||
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
|
||||
assert timestep.advance(1 / 240) == 0
|
||||
assert timestep.alpha == pytest.approx(0.5)
|
||||
assert timestep.advance(1 / 240) == 1
|
||||
|
||||
|
||||
def test_no_steps_are_lost_through_rounding() -> None:
|
||||
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
|
||||
total = sum(timestep.advance(1 / 60) for _ in range(1000))
|
||||
assert total == 2000
|
||||
|
||||
|
||||
def test_alpha_stays_in_the_unit_interval() -> None:
|
||||
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
|
||||
for frame in (0.003, 0.011, 0.017, 0.0001, 0.02):
|
||||
timestep.advance(frame)
|
||||
assert 0 <= timestep.alpha < 1
|
||||
|
||||
|
||||
def test_huge_frames_are_capped_to_avoid_the_spiral_of_death() -> None:
|
||||
timestep = FixedTimestep(STEP, max_steps_per_frame=5)
|
||||
assert timestep.advance(2.0) == 5
|
||||
assert timestep.alpha == 0 # der Rückstand wurde verworfen
|
||||
assert timestep.advance(1 / 60) == 2
|
||||
|
||||
|
||||
def test_reset_discards_accumulated_time() -> None:
|
||||
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
|
||||
timestep.advance(STEP * 0.9)
|
||||
timestep.reset()
|
||||
assert timestep.alpha == 0
|
||||
assert timestep.advance(STEP * 0.5) == 0
|
||||
|
||||
|
||||
def test_invalid_arguments_are_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="positiv"):
|
||||
FixedTimestep(0, 8)
|
||||
with pytest.raises(ValueError, match="mindestens"):
|
||||
FixedTimestep(STEP, 0)
|
||||
with pytest.raises(ValueError, match="negativ"):
|
||||
FixedTimestep(STEP, 8).advance(-0.1)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests der eigenen Vektorklasse."""
|
||||
|
||||
import copy
|
||||
import math
|
||||
import pickle
|
||||
|
||||
import pytest
|
||||
|
||||
from sandbox.physics.vector import ZERO, Vector2
|
||||
|
||||
|
||||
def test_addition_and_subtraction() -> None:
|
||||
assert Vector2(1, 2) + Vector2(3, -5) == Vector2(4, -3)
|
||||
assert Vector2(1, 2) - Vector2(3, -5) == Vector2(-2, 7)
|
||||
|
||||
|
||||
def test_scaling_from_both_sides_and_division() -> None:
|
||||
assert Vector2(1.5, -2) * 2 == Vector2(3, -4)
|
||||
assert 2 * Vector2(1.5, -2) == Vector2(3, -4)
|
||||
assert Vector2(3, -4) / 2 == Vector2(1.5, -2)
|
||||
|
||||
|
||||
def test_negation() -> None:
|
||||
assert -Vector2(1, -2) == Vector2(-1, 2)
|
||||
|
||||
|
||||
def test_dot_product() -> None:
|
||||
assert Vector2(1, 2).dot(Vector2(3, 4)) == 11
|
||||
assert Vector2(1, 0).dot(Vector2(0, 5)) == 0 # orthogonal
|
||||
|
||||
|
||||
def test_cross_product_sign_and_value() -> None:
|
||||
assert Vector2(1, 0).cross(Vector2(0, 1)) == 1
|
||||
assert Vector2(0, 1).cross(Vector2(1, 0)) == -1
|
||||
assert Vector2(2, 3).cross(Vector2(4, 6)) == 0 # parallel
|
||||
|
||||
|
||||
def test_length_and_length_squared() -> None:
|
||||
v = Vector2(3, 4)
|
||||
assert v.length() == 5
|
||||
assert v.length_squared() == 25
|
||||
|
||||
|
||||
def test_normalized_has_unit_length_and_same_direction() -> None:
|
||||
n = Vector2(3, 4).normalized()
|
||||
assert n.length() == pytest.approx(1.0)
|
||||
assert n.is_close(Vector2(0.6, 0.8))
|
||||
|
||||
|
||||
def test_normalizing_the_zero_vector_returns_zero_instead_of_nan() -> None:
|
||||
assert Vector2(0, 0).normalized() == ZERO
|
||||
|
||||
|
||||
def test_perpendicular_is_rotated_by_plus_90_degrees() -> None:
|
||||
v = Vector2(2, 1)
|
||||
p = v.perpendicular()
|
||||
assert p == Vector2(-1, 2)
|
||||
assert v.dot(p) == 0
|
||||
assert v.cross(p) > 0
|
||||
|
||||
|
||||
def test_distance() -> None:
|
||||
assert Vector2(1, 1).distance_to(Vector2(4, 5)) == 5
|
||||
|
||||
|
||||
def test_clamped_limits_only_long_vectors() -> None:
|
||||
short = Vector2(1, 1)
|
||||
assert short.clamped(10) is short
|
||||
clamped = Vector2(30, 40).clamped(10)
|
||||
assert clamped.length() == pytest.approx(10)
|
||||
assert clamped.is_close(Vector2(6, 8))
|
||||
|
||||
|
||||
def test_lerp() -> None:
|
||||
a = Vector2(0, 10)
|
||||
b = Vector2(10, 20)
|
||||
assert a.lerp(b, 0) == a
|
||||
assert a.lerp(b, 1) == b
|
||||
assert a.lerp(b, 0.5) == Vector2(5, 15)
|
||||
|
||||
|
||||
def test_is_finite() -> None:
|
||||
assert Vector2(1, 2).is_finite()
|
||||
assert not Vector2(math.nan, 0).is_finite()
|
||||
assert not Vector2(0, math.inf).is_finite()
|
||||
|
||||
|
||||
def test_is_close_uses_absolute_tolerance() -> None:
|
||||
assert Vector2(1, 1).is_close(Vector2(1 + 1e-12, 1))
|
||||
assert not Vector2(1, 1).is_close(Vector2(1.1, 1))
|
||||
assert Vector2(1, 1).is_close(Vector2(1.1, 1), tolerance=0.2)
|
||||
|
||||
|
||||
def test_equality_hash_and_comparison_with_other_types() -> None:
|
||||
assert Vector2(1, 2) == Vector2(1.0, 2.0)
|
||||
assert Vector2(1, 2) != Vector2(2, 1)
|
||||
assert Vector2(1, 2) != (1, 2)
|
||||
assert len({Vector2(1, 2), Vector2(1, 2), Vector2(2, 1)}) == 2
|
||||
|
||||
|
||||
def test_vectors_are_immutable() -> None:
|
||||
v = Vector2(1, 2)
|
||||
with pytest.raises(AttributeError):
|
||||
v.x = 5 # type: ignore[misc]
|
||||
with pytest.raises(AttributeError):
|
||||
del v.y
|
||||
assert v == Vector2(1, 2)
|
||||
|
||||
|
||||
def test_operations_do_not_modify_operands() -> None:
|
||||
a = Vector2(1, 2)
|
||||
b = Vector2(3, 4)
|
||||
_ = (a + b, a - b, a * 3, a / 2, -a, a.normalized(), a.clamped(0.1), a.lerp(b, 0.5))
|
||||
assert a == Vector2(1, 2)
|
||||
assert b == Vector2(3, 4)
|
||||
|
||||
|
||||
def test_copy_and_pickle_round_trip() -> None:
|
||||
v = Vector2(1.5, -2.5)
|
||||
assert copy.copy(v) == v
|
||||
assert copy.deepcopy(v) == v
|
||||
assert pickle.loads(pickle.dumps(v)) == v
|
||||
|
||||
|
||||
def test_repr_and_tuple() -> None:
|
||||
assert repr(Vector2(1.5, 2)) == "Vector2(1.5, 2)"
|
||||
assert Vector2(1.5, 2).to_tuple() == (1.5, 2)
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Szenario-Tests der Physikwelt: Fallen, Aufprallen, Stapeln, Ruhen, Grenzen, Stabilität."""
|
||||
|
||||
import itertools
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from sandbox.config import MIN_BODY_SIZE, PHYSICS, TIME_STEP
|
||||
from sandbox.physics import Body, Vector2, World
|
||||
from tests.helpers import (
|
||||
WORLD_HEIGHT,
|
||||
WORLD_WIDTH,
|
||||
add_box,
|
||||
add_circle,
|
||||
make_world,
|
||||
material,
|
||||
simulate,
|
||||
total_kinetic_energy,
|
||||
)
|
||||
|
||||
GRAVITY = PHYSICS.gravity[1]
|
||||
FLOOR = WORLD_HEIGHT
|
||||
#: Geduldete Rest-Überlappung ruhender Körper: die Toleranz ``penetration_slop`` plus
|
||||
#: ein tausendstel Pixel für die nie ganz auskonvergierte Iteration.
|
||||
MAX_RESTING_OVERLAP = PHYSICS.penetration_slop + 1e-3
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Fallen und Aufprallen
|
||||
|
||||
|
||||
def test_free_fall_matches_half_g_t_squared() -> None:
|
||||
world = make_world(height=2000)
|
||||
ball = add_circle(world, 500, 100, 10)
|
||||
steps = 60
|
||||
simulate(world, steps * TIME_STEP)
|
||||
t = steps * TIME_STEP
|
||||
|
||||
fallen = ball.position.y - 100
|
||||
assert fallen == pytest.approx(0.5 * GRAVITY * t * t, rel=0.02)
|
||||
# Semi-impliziter Euler exakt: v_k = k·g·dt und s_n = g·dt²·n·(n+1)/2.
|
||||
assert ball.velocity.y == pytest.approx(GRAVITY * t)
|
||||
assert fallen == pytest.approx(GRAVITY * TIME_STEP**2 * steps * (steps + 1) / 2)
|
||||
|
||||
|
||||
def test_bodies_without_gravity_stay_put() -> None:
|
||||
world = make_world()
|
||||
world.gravity_enabled = False
|
||||
ball = add_circle(world, 500, 100, 10)
|
||||
simulate(world, 1.0)
|
||||
assert ball.position == Vector2(500, 100)
|
||||
assert world.gravity == Vector2(0, 0)
|
||||
|
||||
|
||||
def test_fallen_ball_comes_to_rest_on_the_floor_and_never_leaves_the_world() -> None:
|
||||
world = make_world()
|
||||
radius = 20
|
||||
ball = add_circle(world, 500, 100, radius, restitution=0.6)
|
||||
lowest = 0.0
|
||||
for _ in range(round(8 / TIME_STEP)):
|
||||
world.step(TIME_STEP)
|
||||
lowest = max(lowest, ball.position.y + radius)
|
||||
assert ball.position.x - radius >= 0
|
||||
assert ball.position.x + radius <= WORLD_WIDTH
|
||||
# Tiefer als die Notbremse erlaubt, sinkt der Ball nie in den Boden ein.
|
||||
assert lowest <= FLOOR + PHYSICS.max_wall_penetration + 1e-9
|
||||
assert ball.sleeping
|
||||
assert ball.velocity == Vector2(0, 0)
|
||||
assert ball.position.y == pytest.approx(FLOOR - radius, abs=PHYSICS.penetration_slop)
|
||||
|
||||
|
||||
def _first_bounce_apex(restitution: float) -> tuple[float, float]:
|
||||
"""Lässt einen Ball fallen und liefert (Fallhöhe, Steighöhe nach dem 1. Aufprall)."""
|
||||
world = make_world()
|
||||
radius = 20
|
||||
start_y = 200.0
|
||||
ball = add_circle(world, 500, start_y, radius, restitution=restitution)
|
||||
resting_y = FLOOR - radius
|
||||
bounced = False
|
||||
apex = resting_y
|
||||
for _ in range(round(4 / TIME_STEP)):
|
||||
world.step(TIME_STEP)
|
||||
if ball.velocity.y < 0:
|
||||
bounced = True
|
||||
apex = min(apex, ball.position.y)
|
||||
elif bounced:
|
||||
break # Scheitelpunkt überschritten
|
||||
return resting_y - start_y, resting_y - apex
|
||||
|
||||
|
||||
def test_bounce_height_follows_the_restitution_squared() -> None:
|
||||
# Energieerhaltung beim Flug: h' / h = (v'/v)² = e².
|
||||
drop, rise = _first_bounce_apex(restitution=0.8)
|
||||
assert rise < drop # Energie geht verloren
|
||||
assert rise == pytest.approx(0.8**2 * drop, rel=0.01)
|
||||
|
||||
|
||||
def test_perfectly_elastic_ball_keeps_its_bounce_height() -> None:
|
||||
"""Ein vollkommen elastischer Ball springt immer wieder genau auf seine Fallhöhe.
|
||||
|
||||
Er darf nie höher springen – sonst entstünde Energie aus dem Nichts, etwa durch die im
|
||||
Aufprallschritt schon addierte Schwerkraft.
|
||||
"""
|
||||
world = make_world()
|
||||
radius = 20
|
||||
start_y = 250.0
|
||||
ball = add_circle(world, 500, start_y, radius, restitution=1.0)
|
||||
apexes = []
|
||||
rising = False
|
||||
for _ in range(round(8 / TIME_STEP)):
|
||||
world.step(TIME_STEP)
|
||||
if ball.velocity.y < 0:
|
||||
rising = True
|
||||
elif rising:
|
||||
apexes.append(ball.position.y)
|
||||
rising = False
|
||||
assert len(apexes) >= 4
|
||||
for apex in apexes:
|
||||
assert apex == pytest.approx(start_y, abs=2.0) # 2 px bei 430 px Fallhöhe
|
||||
|
||||
|
||||
def test_ball_without_restitution_does_not_bounce() -> None:
|
||||
drop, rise = _first_bounce_apex(restitution=0.0)
|
||||
assert drop > 0
|
||||
assert rise == pytest.approx(0, abs=1.0)
|
||||
|
||||
|
||||
def test_total_energy_never_increases_without_external_forces() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
rng = random.Random(42)
|
||||
for _ in range(30):
|
||||
velocity = (rng.uniform(-300, 300), rng.uniform(-300, 300))
|
||||
x, y = rng.uniform(60, 940), rng.uniform(60, 640)
|
||||
if rng.random() < 0.5:
|
||||
add_circle(world, x, y, rng.uniform(12, 35), velocity=velocity)
|
||||
else:
|
||||
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35), velocity=velocity)
|
||||
energy = total_kinetic_energy(world.bodies)
|
||||
for _ in range(round(5 / TIME_STEP)):
|
||||
world.step(TIME_STEP)
|
||||
new_energy = total_kinetic_energy(world.bodies)
|
||||
assert new_energy <= energy * (1 + 1e-9) + 1e-6
|
||||
energy = new_energy
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Überlappen und Stapeln
|
||||
|
||||
|
||||
def test_overlapping_bodies_are_pushed_apart() -> None:
|
||||
world = make_world()
|
||||
bottom = add_box(world, 500, FLOOR - 30, 40, 30)
|
||||
top = add_box(world, 510, FLOOR - 70, 30, 30) # steckt 20 px in der unteren Box
|
||||
simulate(world, 2.0)
|
||||
gap = (bottom.position.y - 30) - (top.position.y + 30)
|
||||
assert gap >= -MAX_RESTING_OVERLAP
|
||||
|
||||
|
||||
def test_circle_spawned_inside_a_box_pops_out() -> None:
|
||||
world = make_world()
|
||||
base = add_box(world, 500, FLOOR - 40, 60, 40)
|
||||
ball = add_circle(world, 520, FLOOR - 50, 15) # Mittelpunkt liegt in der Box
|
||||
simulate(world, 2.0)
|
||||
assert ball.position.y + 15 <= base.position.y - 40 + MAX_RESTING_OVERLAP
|
||||
|
||||
|
||||
def test_box_stack_is_stable_and_falls_asleep() -> None:
|
||||
world = make_world()
|
||||
half_height = 20
|
||||
boxes = [
|
||||
add_box(world, 500, FLOOR - half_height - i * 2 * half_height, 30, half_height)
|
||||
for i in range(6)
|
||||
]
|
||||
simulate(world, 5.0)
|
||||
for lower, upper in itertools.pairwise(boxes):
|
||||
assert upper.position.x == pytest.approx(500, abs=1e-6) # kein seitliches Wandern
|
||||
gap = (lower.position.y - half_height) - (upper.position.y + half_height)
|
||||
assert -MAX_RESTING_OVERLAP <= gap <= 1e-9
|
||||
assert all(box.sleeping for box in boxes)
|
||||
|
||||
|
||||
def test_pile_of_mixed_bodies_settles_completely() -> None:
|
||||
world = make_world()
|
||||
rng = random.Random(3)
|
||||
for _ in range(60):
|
||||
x, y = rng.uniform(60, 940), rng.uniform(40, 500)
|
||||
if rng.random() < 0.5:
|
||||
add_circle(world, x, y, rng.uniform(12, 35))
|
||||
else:
|
||||
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35))
|
||||
simulate(world, 8.0)
|
||||
assert all(body.sleeping for body in world.bodies)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Geschwindigkeit und Tunneln
|
||||
|
||||
|
||||
def test_speed_is_clamped_to_the_maximum() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
ball = add_circle(world, 500, 350, 20, velocity=(50_000, 0))
|
||||
world.step(TIME_STEP)
|
||||
assert ball.velocity.length() == pytest.approx(PHYSICS.max_speed)
|
||||
|
||||
|
||||
def test_spin_is_clamped_to_the_maximum_in_both_directions() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
clockwise = add_circle(world, 300, 350, 20, angular_velocity=10_000)
|
||||
counter = add_circle(world, 600, 350, 20, angular_velocity=-10_000)
|
||||
world.step(TIME_STEP)
|
||||
assert clockwise.angular_velocity == pytest.approx(PHYSICS.max_angular_speed)
|
||||
assert counter.angular_velocity == pytest.approx(-PHYSICS.max_angular_speed)
|
||||
|
||||
|
||||
def test_emergency_brake_never_interferes_with_free_flight() -> None:
|
||||
# Die Notbremse muss über der größten Eindringtiefe pro Schritt liegen, sonst würde sie
|
||||
# normale Aufpralle verfälschen (Positionen verschieben und Energie hinzufügen).
|
||||
assert PHYSICS.max_wall_penetration > PHYSICS.max_speed * PHYSICS.time_step
|
||||
|
||||
|
||||
def test_emergency_brake_limits_deep_wall_penetration() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
box = add_box(world, -10, 350, 20, 20) # steckt 30 px in der linken Wand
|
||||
world.step(TIME_STEP)
|
||||
assert box.bounds()[0] >= -PHYSICS.max_wall_penetration - 1e-9
|
||||
simulate(world, 0.5)
|
||||
assert box.bounds()[0] >= -MAX_RESTING_OVERLAP
|
||||
|
||||
|
||||
def test_fast_small_ball_does_not_tunnel_through_a_wall() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
radius = MIN_BODY_SIZE
|
||||
ball = add_circle(world, 500, 350, radius, velocity=(-50_000, -30_000), restitution=1.0)
|
||||
for _ in range(round(3 / TIME_STEP)):
|
||||
world.step(TIME_STEP)
|
||||
allowed = PHYSICS.max_wall_penetration + 1e-9
|
||||
assert -allowed <= ball.position.x - radius
|
||||
assert ball.position.x + radius <= WORLD_WIDTH + allowed
|
||||
assert -allowed <= ball.position.y - radius
|
||||
assert ball.position.y + radius <= WORLD_HEIGHT + allowed
|
||||
|
||||
|
||||
def test_two_smallest_bodies_at_max_speed_do_not_pass_through_each_other() -> None:
|
||||
world = make_world(gravity=(0.0, 0.0))
|
||||
radius = MIN_BODY_SIZE
|
||||
speed = PHYSICS.max_speed
|
||||
left = add_circle(world, 300, 350, radius, velocity=(speed, 0), restitution=1.0)
|
||||
right = add_circle(world, 700, 350, radius, velocity=(-speed, 0), restitution=1.0)
|
||||
simulate(world, 0.25) # Zusammenstoß nach ≈ 0,15 s
|
||||
assert left.position.x < right.position.x
|
||||
assert left.velocity.x < 0 < right.velocity.x
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Schlafmodus
|
||||
|
||||
|
||||
def test_sleeping_body_is_woken_by_an_impact() -> None:
|
||||
world = make_world()
|
||||
resting = add_box(world, 500, FLOOR - 20, 40, 20)
|
||||
simulate(world, 1.5)
|
||||
assert resting.sleeping
|
||||
falling = add_circle(world, 500, 600, 15) # trifft die Box nach ≈ 0,3 s
|
||||
simulate(world, 0.4)
|
||||
assert not falling.sleeping
|
||||
assert not resting.sleeping
|
||||
|
||||
|
||||
def test_removing_a_support_wakes_the_body_on_top() -> None:
|
||||
world = make_world()
|
||||
support = add_box(world, 500, FLOOR - 20, 40, 20)
|
||||
load = add_box(world, 500, FLOOR - 60, 30, 20)
|
||||
simulate(world, 2.0)
|
||||
assert support.sleeping
|
||||
assert load.sleeping
|
||||
world.remove_body(support)
|
||||
assert not load.sleeping
|
||||
simulate(world, 1.5)
|
||||
assert load.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
|
||||
|
||||
|
||||
def test_removing_a_body_only_wakes_sleeping_neighbours() -> None:
|
||||
world = make_world()
|
||||
removed = add_box(world, 300, FLOOR - 20, 40, 20)
|
||||
neighbour = add_box(world, 360, FLOOR - 20, 20, 20)
|
||||
far_away = add_box(world, 800, FLOOR - 20, 20, 20)
|
||||
simulate(world, 2.0)
|
||||
awake = add_circle(world, 300, 300, 15) # fällt noch, ist also wach
|
||||
world.remove_body(removed)
|
||||
assert not neighbour.sleeping
|
||||
assert far_away.sleeping # weit weg: bleibt ungestört liegen
|
||||
assert not awake.sleeping
|
||||
assert removed not in world.bodies
|
||||
|
||||
|
||||
def _is_supported(body: Body, world: World) -> bool:
|
||||
"""Liegt der Körper auf dem Boden oder auf einem tiefer liegenden Körper auf?"""
|
||||
min_x, _, max_x, max_y = body.bounds()
|
||||
if max_y >= world.height - 1.0:
|
||||
return True
|
||||
for other in world.bodies:
|
||||
if other is body or other.position.y <= body.position.y:
|
||||
continue # nur Körper darunter können tragen
|
||||
other_min_x, other_min_y, other_max_x, _ = other.bounds()
|
||||
overlaps_horizontally = other_min_x <= max_x and min_x <= other_max_x
|
||||
if overlaps_horizontally and other_min_y <= max_y + 1.0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_moving_the_base_of_a_sleeping_stack_wakes_the_whole_stack() -> None:
|
||||
"""Ein schlafender Stapel verliert seine Basis – die Kisten darüber fallen.
|
||||
|
||||
Die unterste Kiste wird (wie beim Ziehen im Pausenmodus) ohne Physikschritt weggesetzt.
|
||||
Die Kisten darüber dürfen danach nicht in der Luft weiterschlafen.
|
||||
"""
|
||||
world = make_world()
|
||||
stack = [add_box(world, 500, FLOOR - 20 - 40 * i, 30, 20) for i in range(3)]
|
||||
simulate(world, 2.0)
|
||||
assert all(box.sleeping for box in stack)
|
||||
stack[0].teleport(Vector2(150, FLOOR - 20))
|
||||
assert not any(box.sleeping for box in stack)
|
||||
simulate(world, 2.0)
|
||||
assert stack[1].position.y == pytest.approx(FLOOR - 20, abs=MAX_RESTING_OVERLAP)
|
||||
assert stack[2].position.y == pytest.approx(FLOOR - 60, abs=2 * MAX_RESTING_OVERLAP)
|
||||
|
||||
|
||||
def test_knocking_away_a_support_does_not_leave_bodies_floating() -> None:
|
||||
"""Wird die Unterlage eines schlafenden Körpers weggestoßen, fällt er herunter.
|
||||
|
||||
Die flache Box verliert den Kontakt zur außermittig liegenden Kugel schon im ersten
|
||||
Schritt nach dem Treffer. Ohne gemeinsames Wecken der Insel bliebe die Kugel schlafend
|
||||
in der Luft hängen.
|
||||
"""
|
||||
world = make_world()
|
||||
base = add_box(world, 500, FLOOR - 12, 40, 12) # flache Box am Boden
|
||||
ball = add_circle(world, 530, FLOOR - 24 - 15, 15) # liegt 30 px außermittig darauf
|
||||
simulate(world, 2.0)
|
||||
assert base.sleeping
|
||||
assert ball.sleeping
|
||||
# Schwerer, flacher Rammbock trifft nur die Box, nicht die Kugel.
|
||||
add_box(world, 800, FLOOR - 10, 20, 10, velocity=(-PHYSICS.max_speed, 0), density=5.0)
|
||||
simulate(world, 2.0)
|
||||
assert abs(ball.position.x - base.position.x) > 40 + 15 # Box ist unter der Kugel weg
|
||||
assert ball.position.y == pytest.approx(FLOOR - 15, abs=MAX_RESTING_OVERLAP)
|
||||
for body in world.bodies:
|
||||
if body.sleeping:
|
||||
assert _is_supported(body, world), f"{body} schläft in der Luft"
|
||||
|
||||
|
||||
def test_toggling_gravity_wakes_everything() -> None:
|
||||
world = make_world()
|
||||
box = add_box(world, 500, FLOOR - 20, 40, 20)
|
||||
simulate(world, 1.5)
|
||||
assert box.sleeping
|
||||
world.gravity_enabled = False
|
||||
assert not box.sleeping
|
||||
world.gravity_enabled = False # keine Änderung, kein erneutes Wecken nötig
|
||||
assert not world.gravity_enabled
|
||||
|
||||
|
||||
def test_held_bodies_ignore_gravity_and_never_sleep() -> None:
|
||||
world = make_world()
|
||||
ball = add_circle(world, 500, 300, 20)
|
||||
ball.held = True
|
||||
simulate(world, 2.0)
|
||||
assert ball.position == Vector2(500, 300)
|
||||
assert not ball.sleeping
|
||||
|
||||
|
||||
def test_sleeping_can_be_disabled() -> None:
|
||||
world = make_world(sleep_enabled=False)
|
||||
box = add_box(world, 500, FLOOR - 20, 40, 20)
|
||||
simulate(world, 3.0)
|
||||
assert not box.sleeping
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Verwaltung
|
||||
|
||||
|
||||
def test_world_rejects_invalid_sizes_and_static_bodies() -> None:
|
||||
with pytest.raises(ValueError, match="positive Größe"):
|
||||
World(0, 100, PHYSICS)
|
||||
world = make_world()
|
||||
with pytest.raises(ValueError, match="Wände"):
|
||||
world.add_body(Body.wall(Vector2(0, 1), 0, material()))
|
||||
|
||||
|
||||
def test_body_at_returns_the_topmost_body() -> None:
|
||||
world = make_world()
|
||||
lower = add_box(world, 100, 100, 30, 30)
|
||||
upper = add_circle(world, 110, 100, 20)
|
||||
assert world.body_at(Vector2(110, 100)) is upper
|
||||
assert world.body_at(Vector2(80, 80)) is lower
|
||||
assert world.body_at(Vector2(300, 300)) is None
|
||||
assert world.body_at(Vector2(133, 100), tolerance=5) is upper
|
||||
|
||||
|
||||
def test_clamp_inside_keeps_the_whole_body_in_the_world() -> None:
|
||||
world = make_world()
|
||||
assert world.clamp_inside(Vector2(-50, 900), (20, 10)) == Vector2(20, WORLD_HEIGHT - 10)
|
||||
inside = Vector2(300, 300)
|
||||
assert world.clamp_inside(inside, (20, 10)) is inside
|
||||
|
||||
|
||||
def test_clear_removes_all_bodies() -> None:
|
||||
world = make_world()
|
||||
for i in range(5):
|
||||
add_circle(world, 100 + 50 * i, 600, 20)
|
||||
simulate(world, 0.2)
|
||||
world.clear()
|
||||
assert world.bodies == []
|
||||
assert world.contacts == []
|
||||
simulate(world, 0.1) # auch eine leere Welt lässt sich weiter simulieren
|
||||
|
||||
|
||||
def test_simulation_is_deterministic() -> None:
|
||||
def run() -> list[tuple[float, float]]:
|
||||
world = make_world()
|
||||
rng = random.Random(5)
|
||||
for _ in range(25):
|
||||
add_circle(world, rng.uniform(50, 950), rng.uniform(50, 400), rng.uniform(12, 30))
|
||||
simulate(world, 2.0)
|
||||
return [body.position.to_tuple() for body in world.bodies]
|
||||
|
||||
assert run() == run()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_hundred_bodies_stay_stable_for_a_thousand_steps() -> None:
|
||||
world = make_world()
|
||||
rng = random.Random(99)
|
||||
for _ in range(100):
|
||||
x, y = rng.uniform(40, 960), rng.uniform(40, 660)
|
||||
velocity = (rng.uniform(-600, 600), rng.uniform(-600, 600))
|
||||
if rng.random() < 0.5:
|
||||
add_circle(world, x, y, rng.uniform(12, 30), velocity=velocity)
|
||||
else:
|
||||
add_box(world, x, y, rng.uniform(12, 30), rng.uniform(12, 30), velocity=velocity)
|
||||
|
||||
allowed = PHYSICS.max_wall_penetration + 1e-6
|
||||
for _ in range(1000):
|
||||
world.step(TIME_STEP)
|
||||
for body in world.bodies:
|
||||
assert body.position.is_finite()
|
||||
assert body.velocity.is_finite()
|
||||
assert body.velocity.length() <= PHYSICS.max_speed + 1e-6
|
||||
min_x, min_y, max_x, max_y = body.bounds()
|
||||
assert min_x >= -allowed and max_x <= WORLD_WIDTH + allowed
|
||||
assert min_y >= -allowed and max_y <= WORLD_HEIGHT + allowed
|
||||
Reference in New Issue
Block a user