447 lines
17 KiB
Python
447 lines
17 KiB
Python
"""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
|