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

187 lines
8.1 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.
"""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)