#[path = "../examples/testbed_3d/control.rs"]
#[allow(dead_code)]
mod control;
#[path = "../examples/testbed_3d/lab.rs"]
#[allow(dead_code)]
mod lab;
#[path = "../examples/testbed_3d/picking.rs"]
#[allow(dead_code)]
mod picking;
#[path = "../examples/testbed_3d/scenes.rs"]
mod scenes;
use std::collections::BTreeSet;
use bevy::prelude::*;
use bevy_boxddd::prelude::*;
use bevy_ecs::message::Messages;
use bevy_ecs::system::RunSystemOnce;
use bevy_time::{TimePlugin, TimeUpdateStrategy};
use control::{
DEFAULT_HERTZ, DEFAULT_MATERIAL_FRICTION, DEFAULT_MATERIAL_RESTITUTION,
DEFAULT_QUERY_AABB_HALF_EXTENT, DEFAULT_QUERY_MOVER_CAST_LENGTH, DEFAULT_QUERY_RAY_LENGTH,
DEFAULT_QUERY_SHAPE_CAST_LENGTH, DEFAULT_QUERY_SHAPE_CAST_RADIUS, DEFAULT_SUB_STEPS,
DebugDrawPreset, MAX_HERTZ, MAX_MATERIAL_FRICTION, MAX_MATERIAL_RESTITUTION,
MAX_QUERY_AABB_HALF_EXTENT, MAX_QUERY_MOVER_CAST_LENGTH, MAX_QUERY_RAY_LENGTH,
MAX_QUERY_SHAPE_CAST_LENGTH, MAX_QUERY_SHAPE_CAST_RADIUS, MAX_SUB_STEPS, MIN_HERTZ,
MIN_MATERIAL_FRICTION, MIN_MATERIAL_RESTITUTION, MIN_QUERY_AABB_HALF_EXTENT,
MIN_QUERY_MOVER_CAST_LENGTH, MIN_QUERY_RAY_LENGTH, MIN_QUERY_SHAPE_CAST_LENGTH,
MIN_QUERY_SHAPE_CAST_RADIUS, MIN_SUB_STEPS, TestbedState,
};
use scenes::{
ALL_SCENES, MoverProbe, ParityMode, SCENE_REGISTRY, TestbedEntity, TestbedScene, spawn_scene,
};
const SAMPLE_CASE_TABLE_HEADER: &str =
"| Category | Official sample | Source location | Parity mode | Target | Notes |";
#[derive(Resource)]
struct SelectedScene(TestbedScene);
fn physics_app(scene: TestbedScene) -> App {
let mut app = App::new();
app.add_plugins(TimePlugin)
.insert_resource(TimeUpdateStrategy::FixedTimesteps(1))
.insert_resource(SelectedScene(scene))
.insert_resource(TestbedState::launch(scene.index(), false))
.init_resource::<lab::LabDiagnostics>()
.init_resource::<Assets<Mesh>>()
.init_resource::<Assets<StandardMaterial>>()
.add_plugins(BoxdddPhysicsPlugin::new(BoxdddPhysicsSettings::default()));
app
}
fn spawn_selected_scene(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
scene: Res<SelectedScene>,
) {
spawn_scene(&mut commands, &mut meshes, &mut materials, scene.0);
}
fn spawn_scene_once(app: &mut App, scene: TestbedScene) {
app.insert_resource(SelectedScene(scene));
app.world_mut()
.run_system_once(spawn_selected_scene)
.unwrap();
}
fn run_fixed_frames(app: &mut App, count: usize) {
for _ in 0..count {
app.update();
}
}
fn testbed_entities(app: &mut App) -> Vec<Entity> {
let mut query = app
.world_mut()
.query_filtered::<Entity, With<TestbedEntity>>();
query.iter(app.world()).collect()
}
fn testbed_body_ids(app: &mut App) -> Vec<boxddd::BodyId> {
let mut query = app
.world_mut()
.query_filtered::<&BoxdddBody, With<TestbedEntity>>();
query.iter(app.world()).map(|body| body.id()).collect()
}
fn testbed_shape_ids(app: &mut App) -> Vec<boxddd::ShapeId> {
let mut query = app
.world_mut()
.query_filtered::<&BoxdddShape, With<TestbedEntity>>();
query.iter(app.world()).map(|shape| shape.id()).collect()
}
fn testbed_joint_types(app: &mut App) -> Vec<boxddd::JointType> {
let mut query = app
.world_mut()
.query_filtered::<&BoxdddJoint, With<TestbedEntity>>();
let joint_ids = query
.iter(app.world())
.map(|joint| joint.id())
.collect::<Vec<_>>();
let context = app.world().get_non_send::<BoxdddPhysicsContext>().unwrap();
let world = context.world().unwrap();
joint_ids
.into_iter()
.map(|joint_id| world.try_joint_type(joint_id).unwrap())
.collect()
}
fn despawn_testbed_entities(app: &mut App) {
for entity in testbed_entities(app) {
app.world_mut().entity_mut(entity).despawn();
}
}
#[test]
fn testbed_scene_registry_has_complete_unique_metadata() {
assert_eq!(SCENE_REGISTRY.len(), ALL_SCENES.len());
for (index, metadata) in SCENE_REGISTRY.iter().enumerate() {
assert_eq!(metadata.scene, ALL_SCENES[index]);
assert_eq!(metadata.scene.metadata().id, metadata.id);
assert_eq!(TestbedScene::from_id(metadata.id), Some(metadata.scene));
assert_eq!(metadata.scene.index(), index);
assert!(!metadata.id.is_empty());
assert!(!metadata.category.is_empty());
assert!(!metadata.name.is_empty());
assert!(!metadata.description.is_empty());
let has_upstream = !metadata.upstream.is_empty();
let has_showcase_lesson = metadata.showcase_lesson.is_some();
assert!(
has_upstream ^ has_showcase_lesson,
"scene {} should name exactly one source taxonomy",
metadata.id
);
if let Some(lesson) = metadata.showcase_lesson {
assert!(
!lesson.trim().is_empty(),
"scene {} has an empty showcase lesson",
metadata.id
);
assert_eq!(metadata.source_label(), "boxddd showcase");
} else {
assert_eq!(metadata.source_label(), "official Box3D sample");
}
assert!(
metadata.id.is_ascii() && !metadata.id.contains(' '),
"scene id should be an ASCII slug: {}",
metadata.id
);
assert_ne!(
metadata.camera.position, metadata.camera.target,
"camera position and target should differ for {}",
metadata.id
);
assert!(
metadata
.camera
.position
.iter()
.chain(metadata.camera.target.iter())
.all(|value| value.is_finite()),
"camera values should be finite for {}",
metadata.id
);
assert!(
metadata.camera.transform().translation.is_finite(),
"camera transform should be finite for {}",
metadata.id
);
let mut upstream_refs = BTreeSet::new();
for upstream in metadata.upstream {
assert!(
!upstream.category.is_empty() && !upstream.name.is_empty(),
"scene {} has an empty upstream sample ref",
metadata.id
);
assert!(
matches!(
upstream.mode,
ParityMode::FaithfulPort | ParityMode::TeachingAdaptation
),
"scene {} should only reference visual parity modes",
metadata.id
);
assert!(
!upstream.mode.as_str().is_empty(),
"scene {} has an invalid upstream parity mode",
metadata.id
);
assert!(
upstream_refs.insert((upstream.category, upstream.name)),
"scene {} has duplicate upstream ref {}/{}",
metadata.id,
upstream.category,
upstream.name
);
}
}
for (left_index, left) in SCENE_REGISTRY.iter().enumerate() {
for right in SCENE_REGISTRY.iter().skip(left_index + 1) {
assert_ne!(left.id, right.id, "duplicate scene id {}", left.id);
assert_ne!(left.scene, right.scene, "duplicate scene {:?}", left.scene);
}
}
}
#[test]
fn testbed_upstream_refs_exist_in_official_sample_matrix() {
let matrix = include_str!("../../docs/upstream-parity/box3d-sample-matrix.md");
let matrix_cases = official_sample_cases(matrix);
for metadata in SCENE_REGISTRY {
for upstream in metadata.upstream {
assert!(
matrix_cases.contains(&(upstream.category, upstream.name)),
"scene {} references unknown upstream sample {}/{}",
metadata.id,
upstream.category,
upstream.name
);
}
}
}
fn official_sample_cases(matrix: &str) -> BTreeSet<(&str, &str)> {
let mut cases = BTreeSet::new();
let mut in_table = false;
for line in matrix.lines() {
let trimmed = line.trim();
if trimmed == SAMPLE_CASE_TABLE_HEADER {
in_table = true;
continue;
}
if !in_table || trimmed.starts_with("|---") {
continue;
}
if !trimmed.starts_with('|') {
if !cases.is_empty() {
break;
}
continue;
}
let cells = trimmed
.trim_matches('|')
.split('|')
.map(str::trim)
.collect::<Vec<_>>();
assert_eq!(cells.len(), 6, "sample matrix row should have 6 cells");
cases.insert((cells[0], cells[1]));
}
cases
}
#[test]
fn debug_draw_presets_map_to_explicit_box3d_options() {
assert!(!DebugDrawPreset::Off.is_enabled());
assert!(!DebugDrawPreset::Off.options().draw_shapes);
let shapes = DebugDrawPreset::Shapes.options();
assert!(shapes.draw_shapes);
assert!(!shapes.draw_joints);
assert!(!shapes.draw_contacts);
let joints = DebugDrawPreset::ShapesAndJoints.options();
assert!(joints.draw_shapes);
assert!(joints.draw_joints);
assert!(joints.draw_joint_extras);
let contacts = DebugDrawPreset::Contacts.options();
assert!(contacts.draw_contacts);
assert!(contacts.draw_contact_normals);
assert!(contacts.draw_contact_forces);
let bounds = DebugDrawPreset::Bounds.options();
assert!(bounds.draw_shapes);
assert!(bounds.draw_bounds);
}
#[test]
fn testbed_controls_clamp_solver_settings_to_safe_ranges() {
let mut state = TestbedState {
hertz: 1.0,
sub_step_count: 0,
query_lab_ray_length: -1.0,
query_lab_aabb_half_extent: -1.0,
query_lab_shape_cast_length: -1.0,
query_lab_shape_cast_radius: -1.0,
query_lab_mover_cast_length: -1.0,
material_lab_friction: -1.0,
material_lab_restitution: -1.0,
..Default::default()
};
state.clamp_controls();
assert_eq!(state.hertz, MIN_HERTZ);
assert_eq!(state.sub_step_count, MIN_SUB_STEPS);
assert_eq!(state.query_lab_ray_length, MIN_QUERY_RAY_LENGTH);
assert_eq!(state.query_lab_aabb_half_extent, MIN_QUERY_AABB_HALF_EXTENT);
assert_eq!(
state.query_lab_shape_cast_length,
MIN_QUERY_SHAPE_CAST_LENGTH
);
assert_eq!(
state.query_lab_shape_cast_radius,
MIN_QUERY_SHAPE_CAST_RADIUS
);
assert_eq!(
state.query_lab_mover_cast_length,
MIN_QUERY_MOVER_CAST_LENGTH
);
assert_eq!(state.material_lab_friction, MIN_MATERIAL_FRICTION);
assert_eq!(state.material_lab_restitution, MIN_MATERIAL_RESTITUTION);
state.hertz = 10_000.0;
state.sub_step_count = 10_000;
state.query_lab_ray_length = 10_000.0;
state.query_lab_aabb_half_extent = 10_000.0;
state.query_lab_shape_cast_length = 10_000.0;
state.query_lab_shape_cast_radius = 10_000.0;
state.query_lab_mover_cast_length = 10_000.0;
state.material_lab_friction = 10_000.0;
state.material_lab_restitution = 10_000.0;
state.clamp_controls();
assert_eq!(state.hertz, MAX_HERTZ);
assert_eq!(state.sub_step_count, MAX_SUB_STEPS);
assert_eq!(state.query_lab_ray_length, MAX_QUERY_RAY_LENGTH);
assert_eq!(state.query_lab_aabb_half_extent, MAX_QUERY_AABB_HALF_EXTENT);
assert_eq!(
state.query_lab_shape_cast_length,
MAX_QUERY_SHAPE_CAST_LENGTH
);
assert_eq!(
state.query_lab_shape_cast_radius,
MAX_QUERY_SHAPE_CAST_RADIUS
);
assert_eq!(
state.query_lab_mover_cast_length,
MAX_QUERY_MOVER_CAST_LENGTH
);
assert_eq!(state.material_lab_friction, MAX_MATERIAL_FRICTION);
assert_eq!(state.material_lab_restitution, MAX_MATERIAL_RESTITUTION);
let default_state = TestbedState::default();
assert_eq!(default_state.hertz, DEFAULT_HERTZ);
assert_eq!(default_state.sub_step_count, DEFAULT_SUB_STEPS);
assert_eq!(default_state.query_lab_ray_length, DEFAULT_QUERY_RAY_LENGTH);
assert_eq!(
default_state.query_lab_aabb_half_extent,
DEFAULT_QUERY_AABB_HALF_EXTENT
);
assert_eq!(
default_state.query_lab_shape_cast_length,
DEFAULT_QUERY_SHAPE_CAST_LENGTH
);
assert_eq!(
default_state.query_lab_shape_cast_radius,
DEFAULT_QUERY_SHAPE_CAST_RADIUS
);
assert_eq!(
default_state.query_lab_mover_cast_length,
DEFAULT_QUERY_MOVER_CAST_LENGTH
);
assert_eq!(
default_state.material_lab_friction,
DEFAULT_MATERIAL_FRICTION
);
assert_eq!(
default_state.material_lab_restitution,
DEFAULT_MATERIAL_RESTITUTION
);
assert!((default_state.fixed_timestep_seconds() - 1.0 / DEFAULT_HERTZ).abs() < f64::EPSILON);
}
#[test]
fn physics_drag_helpers_project_and_clamp_throw_velocity() {
let projected = picking::point_on_ray(Vec3::new(1.0, 2.0, 3.0), Vec3::Z, 4.0);
assert_eq!(projected, Vec3::new(1.0, 2.0, 7.0));
let clamped = picking::clamp_throw_velocity(Vec3::new(100.0, 0.0, 0.0));
assert!(clamped.length() <= 35.0 + f32::EPSILON);
}
#[test]
fn ray_picking_scene_uses_dynamic_drag_targets() {
for scene in [TestbedScene::RayPicking, TestbedScene::QueryLab] {
let mut app = physics_app(scene);
spawn_scene_once(&mut app, scene);
let mut query = app
.world_mut()
.query_filtered::<&RigidBody, With<TestbedEntity>>();
let dynamic_count = query
.iter(app.world())
.filter(|body| **body == RigidBody::Dynamic)
.count();
assert!(
dynamic_count >= 2,
"{scene:?} should expose multiple dynamic drag targets"
);
}
}
#[test]
fn query_lab_diagnostics_track_native_queries() {
let mut app = physics_app(TestbedScene::QueryLab);
spawn_scene_once(&mut app, TestbedScene::QueryLab);
run_fixed_frames(&mut app, 3);
{
let mut state = app.world_mut().resource_mut::<TestbedState>();
state.query_lab_ray_length = DEFAULT_QUERY_RAY_LENGTH;
state.query_lab_aabb_half_extent = DEFAULT_QUERY_AABB_HALF_EXTENT;
state.query_lab_shape_cast_length = DEFAULT_QUERY_SHAPE_CAST_LENGTH;
state.query_lab_shape_cast_radius = DEFAULT_QUERY_SHAPE_CAST_RADIUS;
state.query_lab_mover_cast_length = DEFAULT_QUERY_MOVER_CAST_LENGTH;
}
app.world_mut()
.run_system_once(lab::update_lab_diagnostics)
.unwrap();
let diagnostics = app.world().resource::<lab::LabDiagnostics>();
assert!(
diagnostics.query_ray_supported,
"native QueryLab should support full ray visitors"
);
assert!(
diagnostics.query_ray_hit_count > 0,
"QueryLab should report ray hits from Box3D"
);
assert!(
diagnostics.query_overlap_supported,
"native QueryLab should support overlap visitors"
);
assert!(
diagnostics.query_overlap_hit_count > 0,
"QueryLab should report overlap hits from Box3D"
);
assert!(diagnostics.query_closest_fraction.is_some());
assert!(
diagnostics.query_shape_cast_supported,
"native QueryLab should support world shape casts"
);
assert!(
diagnostics.query_shape_cast_hit_count > 0,
"QueryLab should report shape-cast hits from Box3D"
);
assert!(diagnostics.query_shape_cast_closest_fraction.is_some());
assert!(
diagnostics.query_mover_supported,
"native QueryLab should support mover casts"
);
assert!(
diagnostics.query_mover_planes_supported,
"native QueryLab should support mover collision planes"
);
assert!(
diagnostics
.query_mover_fraction
.is_some_and(|fraction| (0.0..=1.0).contains(&fraction)),
"QueryLab should report a valid mover cast fraction"
);
}
#[test]
fn debug_draw_inspector_scene_collects_debug_commands() {
let mut app = physics_app(TestbedScene::DebugDrawInspector);
app.insert_resource(BoxdddDebugDrawSettings {
enabled: true,
options: DebugDrawPreset::Shapes.options(),
});
spawn_scene_once(&mut app, TestbedScene::DebugDrawInspector);
run_fixed_frames(&mut app, 2);
let debug_frame = app.world().resource::<BoxdddDebugDrawFrame>();
assert!(
debug_frame
.commands()
.iter()
.any(|command| matches!(command, boxddd::DebugDrawCommand::Shape { .. })),
"DebugDrawInspector should collect native shape debug commands"
);
app.world_mut()
.run_system_once(lab::update_lab_diagnostics)
.unwrap();
let diagnostics = app.world().resource::<lab::LabDiagnostics>();
assert!(diagnostics.debug_command_count > 0);
assert_eq!(diagnostics.query_ray_hit_count, 0);
}
#[test]
fn visual_showcase_scenes_cover_representative_concepts() {
for scene in [
TestbedScene::DominoRun,
TestbedScene::ArchStack,
TestbedScene::WindField,
TestbedScene::RagdollChain,
] {
let mut app = physics_app(scene);
spawn_scene_once(&mut app, scene);
run_fixed_frames(&mut app, 3);
let metadata = scene.metadata();
assert!(!metadata.category.is_empty());
assert!(!metadata.description.is_empty());
assert!(
testbed_body_ids(&mut app)
.iter()
.any(|body_id| body_id.is_valid()),
"{scene:?} should create native bodies"
);
assert!(
testbed_shape_ids(&mut app)
.iter()
.any(|shape_id| shape_id.is_valid()),
"{scene:?} should create native shapes"
);
}
}
#[test]
fn original_showcase_scenes_are_not_counted_as_upstream_ports() {
for scene in [
TestbedScene::QueryLab,
TestbedScene::DebugDrawInspector,
TestbedScene::MaterialLab,
TestbedScene::StatsDashboard,
] {
let metadata = scene.metadata();
assert!(
metadata.upstream.is_empty(),
"{scene:?} should not fake upstream references"
);
assert!(
metadata.showcase_lesson.is_some(),
"{scene:?} should explain its integration lesson"
);
assert_eq!(metadata.source_label(), "boxddd showcase");
}
}
#[test]
fn stats_dashboard_reports_world_counters_and_profile() {
let mut app = physics_app(TestbedScene::StatsDashboard);
spawn_scene_once(&mut app, TestbedScene::StatsDashboard);
run_fixed_frames(&mut app, 20);
app.world_mut()
.run_system_once(lab::update_lab_diagnostics)
.unwrap();
let diagnostics = app.world().resource::<lab::LabDiagnostics>();
assert!(diagnostics.stats_available);
assert!(diagnostics.stats_body_count >= 2);
assert!(diagnostics.stats_shape_count >= 2);
assert!(diagnostics.stats_awake_body_count > 0);
assert!(diagnostics.stats_tree_height >= 0);
assert!(diagnostics.stats_profile_step >= 0.0);
assert_eq!(diagnostics.query_ray_hit_count, 0);
assert_eq!(diagnostics.debug_command_count, 0);
}
#[test]
fn every_testbed_scene_factory_spawns_without_panic() {
for scene in ALL_SCENES {
let mut app = physics_app(scene);
spawn_scene_once(&mut app, scene);
}
}
#[test]
fn every_testbed_scene_creates_native_shapes_after_fixed_updates() {
for scene in ALL_SCENES {
let mut app = physics_app(scene);
spawn_scene_once(&mut app, scene);
run_fixed_frames(&mut app, 3);
let body_ids = testbed_body_ids(&mut app);
let shape_ids = testbed_shape_ids(&mut app);
assert!(
body_ids.iter().any(|body_id| body_id.is_valid()),
"{scene:?} should create at least one native body"
);
assert!(
shape_ids.iter().any(|shape_id| shape_id.is_valid()),
"{scene:?} should create at least one native shape"
);
}
}
#[test]
fn advanced_collider_scene_separates_static_resources_from_dynamic_bodies() {
let mut app = physics_app(TestbedScene::AdvancedColliders);
spawn_scene_once(&mut app, TestbedScene::AdvancedColliders);
let mut resources = app
.world_mut()
.query_filtered::<(&RigidBody, &Collider), With<TestbedEntity>>();
let static_resource_colliders = resources
.iter(app.world())
.filter(|(body, collider)| **body == RigidBody::Static && collider.requires_static_body())
.count();
assert!(static_resource_colliders >= 3);
let mut dynamic_query = app
.world_mut()
.query_filtered::<(Entity, &RigidBody, &Transform), With<TestbedEntity>>();
let initial_dynamic_y = dynamic_query
.iter(app.world())
.filter(|(_, body, _)| **body == RigidBody::Dynamic)
.map(|(entity, _, transform)| (entity, transform.translation.y))
.collect::<Vec<_>>();
assert!(initial_dynamic_y.len() >= 6);
run_fixed_frames(&mut app, 12);
let mut transform_query = app.world_mut().query::<&Transform>();
assert!(initial_dynamic_y.iter().any(|(entity, initial_y)| {
transform_query
.get(app.world(), *entity)
.is_ok_and(|transform| transform.translation.y < *initial_y)
}));
}
#[test]
fn cylinder_hull_descriptor_validates_box3d_constraints() {
assert!(HullDescriptor::cylinder(0.8, 0.3, 16).validate().is_ok());
assert!(
HullDescriptor::offset_cylinder(0.8, 0.3, 0.1, 16)
.validate()
.is_ok()
);
for invalid in [
HullDescriptor::cylinder(0.0, 0.3, 16),
HullDescriptor::cylinder(0.8, 0.0, 16),
HullDescriptor::cylinder(0.8, 0.3, 2),
HullDescriptor::cylinder(0.8, 0.3, 33),
HullDescriptor::offset_cylinder(0.8, 0.3, f32::NAN, 16),
] {
assert!(
invalid.validate().is_err(),
"expected invalid cylinder hull descriptor to fail validation: {invalid:?}"
);
}
}
#[test]
fn cylinder_hull_collider_creates_native_shape() {
let mut app = physics_app(TestbedScene::FallingStack);
app.world_mut().spawn((
Transform::from_xyz(0.0, 2.0, 0.0),
RigidBody::Dynamic,
Collider::cylinder_hull(0.8, 0.3, 16),
TestbedEntity,
));
run_fixed_frames(&mut app, 3);
let body_ids = testbed_body_ids(&mut app);
let shape_ids = testbed_shape_ids(&mut app);
assert!(body_ids.iter().any(|body_id| body_id.is_valid()));
assert!(shape_ids.iter().any(|shape_id| shape_id.is_valid()));
}
#[test]
fn falling_stack_scene_contains_official_stack_shape_variants() {
let mut app = physics_app(TestbedScene::FallingStack);
spawn_scene_once(&mut app, TestbedScene::FallingStack);
let mut query = app
.world_mut()
.query_filtered::<(&RigidBody, &Collider), With<TestbedEntity>>();
let mut has_box = false;
let mut has_sphere = false;
let mut has_capsule = false;
let mut has_cylinder = false;
let mut dynamic_count = 0;
for (body, collider) in query.iter(app.world()) {
if *body != RigidBody::Dynamic {
continue;
}
dynamic_count += 1;
match collider {
Collider::Cuboid { .. } => has_box = true,
Collider::Sphere { .. } => has_sphere = true,
Collider::Capsule { .. } => has_capsule = true,
Collider::CreatedHull {
hull: HullDescriptor::Cylinder { .. },
} => has_cylinder = true,
_ => {}
}
}
assert!(dynamic_count >= 16, "expected a visible unstable stack");
assert!(has_box, "expected a Box Stack-style cuboid stack");
assert!(has_sphere, "expected a Sphere Stack-style dynamic stack");
assert!(has_capsule, "expected a Capsule Stack-style dynamic stack");
assert!(
has_cylinder,
"expected a Cylinder Stack-style dynamic stack"
);
}
#[test]
fn materials_scene_contains_friction_and_restitution_variants() {
for scene in [TestbedScene::Materials, TestbedScene::MaterialLab] {
let mut app = physics_app(scene);
spawn_scene_once(&mut app, scene);
let mut query = app
.world_mut()
.query_filtered::<&PhysicsMaterial, With<TestbedEntity>>();
let materials = query.iter(app.world()).copied().collect::<Vec<_>>();
assert!(
materials.iter().any(|material| material.friction <= 0.05),
"{scene:?} should include a low-friction material"
);
assert!(
materials.iter().any(|material| material.friction >= 1.0),
"{scene:?} should include a high-friction material"
);
assert!(
materials.iter().any(|material| material.restitution >= 0.8),
"{scene:?} should include a high-restitution material"
);
}
}
#[test]
fn material_lab_controls_update_native_target_shapes() {
let mut app = physics_app(TestbedScene::MaterialLab);
spawn_scene_once(&mut app, TestbedScene::MaterialLab);
run_fixed_frames(&mut app, 3);
{
let mut state = app.world_mut().resource_mut::<TestbedState>();
state.material_lab_friction = 1.4;
state.material_lab_restitution = 0.25;
}
app.world_mut()
.run_system_once(lab::apply_material_lab_controls)
.unwrap();
let mut query = app
.world_mut()
.query_filtered::<&BoxdddShape, With<scenes::MaterialLabTarget>>();
let shape_ids = query
.iter(app.world())
.map(|shape| shape.id())
.collect::<Vec<_>>();
assert!(!shape_ids.is_empty());
let diagnostics = app.world().resource::<lab::LabDiagnostics>();
assert_eq!(diagnostics.material_shape_count, shape_ids.len());
let context = app.world().get_non_send::<BoxdddPhysicsContext>().unwrap();
let world = context.world().unwrap();
for shape_id in shape_ids {
assert!((world.try_shape_friction(shape_id).unwrap() - 1.4).abs() < 1.0e-5);
assert!((world.try_shape_restitution(shape_id).unwrap() - 0.25).abs() < 1.0e-5);
}
}
#[test]
fn body_controls_scene_applies_body_settings_and_controls() {
let mut app = physics_app(TestbedScene::BodyControls);
spawn_scene_once(&mut app, TestbedScene::BodyControls);
run_fixed_frames(&mut app, 3);
let mut query = app
.world_mut()
.query_filtered::<(Entity, &BodySettings, Option<&ExternalForce>), With<TestbedEntity>>();
let controlled = query
.iter(app.world())
.map(|(entity, settings, force)| (entity, *settings, force.is_some()))
.collect::<Vec<_>>();
assert!(
controlled
.iter()
.any(|(_, settings, has_force)| settings.motion_locks.linear_z && *has_force)
);
assert!(
controlled
.iter()
.any(|(_, settings, _)| (settings.gravity_scale - 0.25).abs() < f32::EPSILON)
);
let context = app.world().get_non_send::<BoxdddPhysicsContext>().unwrap();
let world = context.world().unwrap();
for (entity, settings, _) in controlled {
let body_id = app.world().entity(entity).get::<BoxdddBody>().unwrap().id();
assert_eq!(
world.try_body_motion_locks(body_id).unwrap(),
settings.motion_locks
);
assert_eq!(
world.try_body_gravity_scale(body_id).unwrap(),
settings.gravity_scale
);
}
}
#[test]
fn continuous_collision_scene_creates_bullet_bodies() {
let mut app = physics_app(TestbedScene::ContinuousCollision);
spawn_scene_once(&mut app, TestbedScene::ContinuousCollision);
run_fixed_frames(&mut app, 3);
let mut query = app
.world_mut()
.query_filtered::<(Entity, &BodySettings), With<TestbedEntity>>();
let bullet_entities = query
.iter(app.world())
.filter_map(|(entity, settings)| settings.bullet.then_some(entity))
.collect::<Vec<_>>();
assert!(bullet_entities.len() >= 3);
let context = app.world().get_non_send::<BoxdddPhysicsContext>().unwrap();
let world = context.world().unwrap();
for entity in bullet_entities {
let body_id = app.world().entity(entity).get::<BoxdddBody>().unwrap().id();
assert!(world.try_body_bullet(body_id).unwrap());
assert_eq!(world.try_body_gravity_scale(body_id).unwrap(), 0.0);
}
}
#[test]
fn character_mover_scene_probe_hits_obstacle() {
let mut app = physics_app(TestbedScene::CharacterMover);
spawn_scene_once(&mut app, TestbedScene::CharacterMover);
run_fixed_frames(&mut app, 3);
let mut query = app
.world_mut()
.query_filtered::<&MoverProbe, With<TestbedEntity>>();
let probe = *query
.iter(app.world())
.next()
.expect("character mover scene should contain a probe");
let context = app.world().get_non_send::<BoxdddPhysicsContext>().unwrap();
let world = context.world().unwrap();
let mover = boxddd::Capsule::new(
probe.point1.to_boxddd_vec3(),
probe.point2.to_boxddd_vec3(),
probe.radius,
);
let fraction = world
.cast_mover(
probe.origin.to_boxddd_pos(),
&mover,
probe.delta.to_boxddd_vec3(),
boxddd::QueryFilter::default(),
)
.unwrap();
assert!(
(0.0..1.0).contains(&fraction),
"expected character mover probe to stop before an obstacle, got {fraction}"
);
}
#[test]
fn joint_scene_creates_every_public_joint_variant() {
let mut app = physics_app(TestbedScene::Joints);
spawn_scene_once(&mut app, TestbedScene::Joints);
run_fixed_frames(&mut app, 3);
let joint_types = testbed_joint_types(&mut app);
for expected_type in [
boxddd::JointType::Distance,
boxddd::JointType::Revolute,
boxddd::JointType::Spherical,
boxddd::JointType::Weld,
boxddd::JointType::Prismatic,
boxddd::JointType::Wheel,
] {
assert!(
joint_types.contains(&expected_type),
"missing {expected_type:?}; got {joint_types:?}"
);
}
assert_eq!(joint_types.len(), 6);
}
#[test]
fn contact_scene_emits_physics_messages() {
let mut app = physics_app(TestbedScene::Contacts);
spawn_scene_once(&mut app, TestbedScene::Contacts);
let mut saw_contact = false;
let mut saw_sensor = false;
for _ in 0..180 {
app.update();
let contacts = app
.world_mut()
.resource_mut::<Messages<BoxdddContactBeginMessage>>()
.drain()
.collect::<Vec<_>>();
let sensors = app
.world_mut()
.resource_mut::<Messages<BoxdddSensorBeginMessage>>()
.drain()
.collect::<Vec<_>>();
saw_contact |= contacts.iter().any(|message| message.contact_id.is_valid());
saw_sensor |= sensors
.iter()
.any(|message| message.sensor_shape.is_valid() && message.visitor_shape.is_valid());
if saw_contact && saw_sensor {
break;
}
}
assert!(saw_contact || saw_sensor);
}
#[test]
fn despawning_testbed_scene_releases_native_body_ids() {
let mut app = physics_app(TestbedScene::FallingStack);
spawn_scene_once(&mut app, TestbedScene::FallingStack);
run_fixed_frames(&mut app, 2);
let body_ids = testbed_body_ids(&mut app);
assert!(!body_ids.is_empty());
despawn_testbed_entities(&mut app);
run_fixed_frames(&mut app, 2);
for body_id in body_ids {
assert!(!body_id.is_valid());
}
}
#[test]
fn switching_testbed_scenes_releases_old_ids_and_creates_new_scene() {
let mut app = physics_app(TestbedScene::FallingStack);
spawn_scene_once(&mut app, TestbedScene::FallingStack);
run_fixed_frames(&mut app, 2);
let old_body_ids = testbed_body_ids(&mut app);
let old_shape_ids = testbed_shape_ids(&mut app);
assert!(!old_body_ids.is_empty());
assert!(!old_shape_ids.is_empty());
despawn_testbed_entities(&mut app);
spawn_scene_once(&mut app, TestbedScene::Joints);
run_fixed_frames(&mut app, 3);
for body_id in old_body_ids {
assert!(!body_id.is_valid());
}
for shape_id in old_shape_ids {
assert!(!shape_id.is_valid());
}
let new_body_ids = testbed_body_ids(&mut app);
assert!(new_body_ids.iter().any(|body_id| body_id.is_valid()));
let mut joints = app
.world_mut()
.query_filtered::<&BoxdddJoint, With<TestbedEntity>>();
assert!(joints.iter(app.world()).any(|joint| joint.id().is_valid()));
}