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