diff --git a/tests/bullet/__init__.py b/tests/bullet/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/bullet/conftest.py b/tests/bullet/conftest.py new file mode 100644 index 0000000000..9c2750be38 --- /dev/null +++ b/tests/bullet/conftest.py @@ -0,0 +1,71 @@ +import pytest + +@pytest.fixture +def world(): + bullet = pytest.importorskip("panda3d.bullet") + world = bullet.BulletWorld() + + return world + +def simulate_until(world, cond, dt=1.0/60, limit=600): + for x in range(limit): + world.do_physics(dt) + if cond(): + return True + + return False + +@pytest.fixture +def scene(world): + """ + This test scene contains a "ramp" which slopes down at a 45-degree angle at + locations where X<0, and is flat at X>0. A bowling ball is placed above the + ramp at X=-5, Z=7. A stack of 2 very light boxes is placed at X=5, Z=0. + The world is given gravity. + + When running the Bullet simulation, the ball should fall, travel down the + ramp, and hit the lower box with enough force to displace it and cause the + upper box to topple to the ground. + """ + + core = pytest.importorskip("panda3d.core") + bullet = pytest.importorskip("panda3d.bullet") + + bodies = [] + + ramp = bullet.BulletRigidBodyNode('ramp') + ramp.add_shape(bullet.BulletPlaneShape(core.Vec4(0, 0, 1, 0))) + ramp.add_shape(bullet.BulletPlaneShape(core.Vec4(1, 0, 1, 0).normalized())) + bodies.append(ramp) + + ball = bullet.BulletRigidBodyNode('ball') + ball.add_shape(bullet.BulletSphereShape(1)) + ball.set_mass(100) + ball.set_transform(core.TransformState.make_pos(core.Point3(-5, 0, 7))) + bodies.append(ball) + + lower_box = bullet.BulletRigidBodyNode('lower_box') + lower_box.add_shape(bullet.BulletBoxShape(core.Vec3(2.5))) + lower_box.set_mass(1) + lower_box.set_transform(core.TransformState.make_pos(core.Point3(5, 0, 2.5))) + bodies.append(lower_box) + + upper_box = bullet.BulletRigidBodyNode('upper_box') + upper_box.add_shape(bullet.BulletBoxShape(core.Vec3(2.5))) + upper_box.set_mass(1) + upper_box.set_transform(core.TransformState.make_pos(core.Point3(5, 0, 7.5))) + bodies.append(upper_box) + + world.set_gravity(core.Vec3(0, 0, -9.8)) + + scene = core.NodePath('scene') + for body in bodies: + scene.attach_new_node(body) + world.attach(body) + + yield scene + + scene.remove_node() + + for body in bodies: + world.remove(body) diff --git a/tests/bullet/test_bullet_callbacks.py b/tests/bullet/test_bullet_callbacks.py new file mode 100644 index 0000000000..3efd39a306 --- /dev/null +++ b/tests/bullet/test_bullet_callbacks.py @@ -0,0 +1,79 @@ +import pytest +from .conftest import simulate_until + +# Skip these tests if we can't import bullet. +bullet = pytest.importorskip("panda3d.bullet") +from panda3d import core + +bullet_filter_algorithm = core.ConfigVariableString('bullet-filter-algorithm') + + +def test_tick(world): + fired = [] + + def callback(cd): + fired.append(isinstance(cd, bullet.BulletTickCallbackData)) + + world.set_tick_callback(callback, False) + + assert fired == [] + + world.do_physics(0.1) + + assert fired == [True] + + world.clear_tick_callback() + + world.do_physics(0.1) + + assert fired == [True] + +@pytest.mark.skipif(bullet_filter_algorithm != 'callback', reason='bullet-filter-algorithm not set to callback') +def test_filter(world, scene): + # This is very similar to the basic physics test, but we're using + # a filter callback to prevent collisions between the lower box and ball. + # This should have the effect of the ball rolling to X>+10 without the + # upper box falling at all. + + def callback(cd): + assert isinstance(cd, bullet.BulletFilterCallbackData) + if {cd.node_0.name, cd.node_1.name} == {'ball', 'lower_box'}: + # ball<->lower_box collisions are excluded + cd.collide = False + else: + # Everything else can collide + cd.collide = True + + world.set_filter_callback(callback) + + ball = scene.find('**/ball') + assert simulate_until(world, lambda: ball.get_x() > 10) + + # The upper box shouldn't fall + upper_box = scene.find('**/upper_box') + assert not simulate_until(world, lambda: upper_box.get_z() < 5) + +def test_contact(world, scene): + # This just runs the basic physics test, but detects the toppling of the + # upper box by a contact between upper_box<->ramp + + contacts = [] + + def callback(cd): + assert isinstance(cd, bullet.BulletContactCallbackData) + if {cd.node0.name, cd.node1.name} == {'upper_box', 'ramp'}: + if not contacts: + contacts.append(True) + + world.set_contact_added_callback(callback) + + ball = scene.find('**/ball') + ramp = scene.find('**/ramp') + + ball.node().notify_collisions(True) + ramp.node().notify_collisions(True) + + assert simulate_until(world, lambda: ball.get_x() > 0) + + # Now we wait for the upper box to topple + assert simulate_until(world, lambda: bool(contacts)) diff --git a/tests/bullet/test_bullet_simulation.py b/tests/bullet/test_bullet_simulation.py new file mode 100644 index 0000000000..439d3b47e5 --- /dev/null +++ b/tests/bullet/test_bullet_simulation.py @@ -0,0 +1,64 @@ +import pytest +from .conftest import simulate_until + +# Skip these tests if we can't import bullet. +bullet = pytest.importorskip("panda3d.bullet") +from panda3d import core + + +def test_basics(world, scene): + # N.B. see `scene` fixture's docstring in conftest.py to understand what's + # being simulated here + + # Step forward until the ball crosses the threshold + ball = scene.find('**/ball') + assert simulate_until(world, lambda: ball.get_x() >= 0) + + # Continue simulating until upper box falls + upper_box = scene.find('**/upper_box') + assert upper_box.get_z() > 5.0 + assert simulate_until(world, lambda: upper_box.get_z() < 5.0) + +def test_restitution(world, scene): + ball = scene.find('**/ball') + scene.find('**/ramp').node().restitution = 1.0 + + for with_bounce in (False, True): + # Reset ball + ball.node().set_angular_velocity(core.Vec3(0)) + ball.node().set_linear_velocity(core.Vec3(0)) + ball.set_pos(-2, 0, 100) + + ball.node().restitution = 1.0 * with_bounce + + # Simulate until ball rolls/bounces across Y axis + assert simulate_until(world, lambda: ball.get_x() >= 0) + + if with_bounce: + # The ball bounced across, so it should be off the ground a bit + assert ball.get_z() > 1.2 + else: + # The ball rolled, so it should be on the ground + assert ball.get_z() < 1.2 + +def test_friction(world, scene): + ball = scene.find('**/ball') + + for with_friction in (False, True): + # Reset ball, give it a huge negative (CCW) spin about the X axis so + # it'll roll in +Y direction if there's any friction + ball.node().set_angular_velocity(core.Vec3(-1000,0,0)) + ball.node().set_linear_velocity(core.Vec3(0)) + ball.set_pos(-2, 0, 5) + + ball.node().friction = 1.0 * with_friction + + # Simulate until ball crosses Y axis + assert simulate_until(world, lambda: ball.get_x() >= 0) + + if with_friction: + # The ball had friction, so should've gone off in the +Y direction + assert ball.get_y() > 1 + else: + # No friction means the Y axis should be unaffected + assert abs(ball.get_y()) < 0.1