"""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)