"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände).""" import math from collections.abc import Callable import pytest from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2 from sandbox.physics.vector import ZERO from tests.helpers import material def test_circle_area_and_moment_of_inertia() -> None: circle = Circle(10) assert circle.area() == pytest.approx(math.pi * 100) assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100) assert circle.half_extents() == (10, 10) def test_box_area_and_infinite_inertia() -> None: box = Box(3, 2) assert box.area() == 24 assert math.isinf(box.moment_of_inertia(mass=5)) assert box.half_extents() == (3, 2) @pytest.mark.parametrize( "factory", [lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)], ) def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None: with pytest.raises(ValueError, match="positiv"): factory() def test_half_plane_requires_unit_normal() -> None: with pytest.raises(ValueError, match="Länge 1"): HalfPlane(Vector2(0, 2), 0) def test_half_plane_distance_and_contains() -> None: floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100 assert floor.distance(Vector2(0, 90)) == pytest.approx(10) assert floor.distance(Vector2(0, 110)) == pytest.approx(-10) assert floor.contains(Vector2(0, 110)) assert not floor.contains(Vector2(0, 90)) assert floor.contains(Vector2(0, 90), tolerance=10) assert math.isinf(floor.half_extents()[0]) def test_point_containment_with_tolerance() -> None: circle = Body.circle(Vector2(100, 100), 10, material()) assert circle.contains_point(Vector2(107, 107)) assert not circle.contains_point(Vector2(110, 110)) assert circle.contains_point(Vector2(110, 110), tolerance=5) box = Body.box(Vector2(0, 0), 10, 5, material()) assert box.contains_point(Vector2(10, -5)) assert not box.contains_point(Vector2(10.5, 0)) assert box.contains_point(Vector2(10.5, 0), tolerance=1) def test_mass_is_area_times_density() -> None: circle = Body.circle(Vector2(), 10, material(density=2)) assert circle.mass == pytest.approx(math.pi * 100 * 2) assert circle.inv_mass == pytest.approx(1 / circle.mass) box = Body.box(Vector2(), 10, 5, material(density=0.5)) assert box.mass == pytest.approx(20 * 10 * 0.5) def test_bigger_bodies_are_heavier() -> None: small = Body.circle(Vector2(), 10, material()) large = Body.circle(Vector2(), 20, material()) assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r² def test_static_bodies_have_infinite_mass_and_never_move() -> None: wall = Body.wall(Vector2(0, 1), 0, material()) assert wall.is_static assert wall.inv_mass == 0 assert wall.inv_inertia == 0 assert math.isinf(wall.mass) wall.apply_impulse(Vector2(100, 100)) assert wall.velocity == ZERO assert wall.kinetic_energy() == 0 assert wall.momentum() == ZERO def test_half_planes_cannot_be_dynamic() -> None: with pytest.raises(ValueError, match="statisch"): Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material()) def test_dynamic_bodies_need_positive_density() -> None: with pytest.raises(ValueError, match="Dichte"): Body.circle(Vector2(), 10, material(density=0)) @pytest.mark.parametrize( ("density", "restitution", "friction"), [(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)], ) def test_invalid_materials_are_rejected( density: float, restitution: float, friction: float ) -> None: with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"): Material(density, restitution, friction) def test_circles_can_rotate_but_boxes_cannot() -> None: circle = Body.circle(Vector2(), 10, material()) box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5) assert circle.can_rotate assert not box.can_rotate assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert def test_off_center_impulse_spins_a_circle() -> None: circle = Body.circle(Vector2(), 10, material()) # Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung. circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10)) assert circle.velocity.is_close(Vector2(10, 0)) expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0)) assert circle.angular_velocity == pytest.approx(expected_spin) assert circle.angular_velocity < 0 def test_off_center_impulse_does_not_spin_a_box() -> None: box = Body.box(Vector2(), 10, 10, material()) box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10)) assert box.angular_velocity == 0 assert box.velocity.is_close(Vector2(1, 0)) def test_kinetic_energy_and_momentum() -> None: circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4)) circle.angular_velocity = 2 expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4 assert circle.kinetic_energy() == pytest.approx(expected) assert circle.momentum().is_close(Vector2(3, 4) * circle.mass) def test_bounds() -> None: assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25) assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22) def test_sleep_wake_and_teleport() -> None: body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0)) body.angular_velocity = 1 body.sleep_time = 1 body.put_to_sleep() assert body.sleeping assert body.velocity == ZERO assert body.angular_velocity == 0 body.teleport(Vector2(50, 60)) assert not body.sleeping assert body.sleep_time == 0 assert body.position == body.previous_position == Vector2(50, 60) def test_ids_are_unique() -> None: bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)] assert len({body.id for body in bodies}) == 5 def test_repr_mentions_id_and_sleep_state() -> None: body = Body.circle(Vector2(1, 2), 10, material()) assert f"id={body.id}" in repr(body) body.put_to_sleep() assert "schläft" in repr(body)