Files
2026-09-24 15:45:31 +02:00

447 lines
17 KiB
Python
Raw Permalink Blame History

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