boxddd 0.2.0

Safe, ergonomic Rust bindings for Box3D
Documentation
use boxddd::{
    Aabb, BodyDef, BodyType, BoxHull, DebugDrawOptions, Error, QueryFilter, RecPlayer, Recording,
    ShapeDef, Sphere, Vec3, World, WorldDef, validate_replay_bytes,
};
use std::path::PathBuf;

fn record_basic_scene(frame_count: usize) -> Recording {
    let mut world = World::new(WorldDef::builder().gravity([0.0, -10.0, 0.0]).build()).unwrap();
    let mut recording = Recording::new().unwrap();
    world.try_start_recording(&mut recording).unwrap();

    let ground = world.create_body(BodyDef::builder().position([0.0, -1.0, 0.0]).build());
    world.create_hull_shape(ground, &ShapeDef::default(), &BoxHull::new(10.0, 0.5, 10.0));
    let body = world.create_body(
        BodyDef::builder()
            .body_type(BodyType::Dynamic)
            .position([0.0, 2.0, 0.0])
            .build(),
    );
    world.create_sphere_shape(
        body,
        &ShapeDef::builder().density(1.0).build(),
        &Sphere::new([0.0, 0.0, 0.0], 0.25),
    );

    for _ in 0..frame_count {
        world.step(1.0 / 60.0, 4);
    }
    world.try_stop_recording(&mut recording).unwrap();
    recording
}

fn temp_recording_path(name: &str) -> PathBuf {
    std::env::temp_dir().join(format!("boxddd-{name}-{}.b3rec", std::process::id()))
}

#[test]
fn recording_round_trips_and_player_controls_work() {
    let recording = record_basic_scene(8);
    assert!(!recording.is_empty());
    assert!(recording.bytes().unwrap().len() > 128);
    assert!(recording.validate_replay(1).unwrap());

    let mut player = recording.create_player(1).unwrap();
    assert!(player.world_id().is_valid());
    assert_eq!(player.frame(), 0);
    assert!(player.frame_count() >= 8);
    let info = player.info();
    assert_eq!(info.frame_count, player.frame_count());
    assert_eq!(info.sub_step_count, 4);
    assert_eq!(info.worker_count, 1);

    assert!(player.step_frame().unwrap());
    assert_eq!(player.frame(), 1);
    player.seek_frame(player.frame_count()).unwrap();
    assert_eq!(player.frame(), player.frame_count());
    assert!(!player.step_frame().unwrap());
    assert!(player.is_at_end());
    assert!(!player.has_diverged());
    assert_eq!(player.diverge_frame(), None);
    assert!(player.body_count() >= 2);
    assert!(player.body_id(0).unwrap().is_some());

    player.restart().unwrap();
    assert_eq!(player.frame(), 0);
    assert!(!player.is_at_end());
    let world_id = player.world_id();
    player.seek_frame(1).unwrap();
    player.restart().unwrap();
    assert_eq!(player.world_id(), world_id);

    player.set_worker_count(1).unwrap();
    player.set_keyframe_policy(256 * 1024, 4).unwrap();
    assert_eq!(player.keyframe_budget(), 256 * 1024);
    assert_eq!(player.keyframe_min_interval(), 4);
    assert!(player.keyframe_interval() >= 4);
    assert!(player.keyframe_bytes() <= player.keyframe_budget());
}

#[test]
fn recording_can_be_saved_loaded_and_validated_from_bytes() {
    let recording = record_basic_scene(4);
    let bytes = recording.to_vec().unwrap();
    assert!(validate_replay_bytes(&bytes, 1).unwrap());

    let path = temp_recording_path("roundtrip");
    let _ = std::fs::remove_file(&path);
    recording.save_to_file(&path).unwrap();
    let loaded = Recording::load_from_file(&path).unwrap();
    assert_eq!(loaded.bytes().unwrap(), bytes.as_slice());
    assert!(loaded.validate_replay(1).unwrap());
    let _ = std::fs::remove_file(&path);
}

#[test]
fn recording_buffer_survives_world_drop_while_active() {
    let mut recording = Recording::new().unwrap();
    {
        let mut world = World::new(WorldDef::default()).unwrap();
        world.try_start_recording(&mut recording).unwrap();
        let body = world.create_body(BodyDef::builder().body_type(BodyType::Dynamic).build());
        world.create_sphere_shape(
            body,
            &ShapeDef::builder().density(1.0).build(),
            &Sphere::new([0.0, 0.0, 0.0], 0.25),
        );
        world.step(1.0 / 60.0, 4);
        assert_eq!(
            recording.bytes().unwrap_err(),
            Error::ResourceLifetimeViolation
        );
    }

    assert!(!recording.bytes().unwrap().is_empty());
    assert!(recording.validate_replay(1).unwrap());
}

#[test]
fn world_rejects_a_second_active_recording() {
    let mut world = World::new(WorldDef::default()).unwrap();
    let mut first = Recording::new().unwrap();
    let mut second = Recording::new().unwrap();

    world.try_start_recording(&mut first).unwrap();
    assert_eq!(
        world.try_start_recording(&mut second).unwrap_err(),
        Error::ResourceLifetimeViolation
    );

    world.step(1.0 / 60.0, 4);
    world.try_stop_recording(&mut first).unwrap();
    assert!(first.validate_replay(1).unwrap());
    assert!(second.bytes().unwrap().is_empty());
}

#[test]
fn stopping_with_the_wrong_recording_does_not_detach_the_active_recording() {
    let mut world = World::new(WorldDef::default()).unwrap();
    let mut active = Recording::new().unwrap();
    let mut inactive = Recording::new().unwrap();

    world.try_start_recording(&mut active).unwrap();
    world.step(1.0 / 60.0, 4);
    assert_eq!(
        world.try_stop_recording(&mut inactive).unwrap_err(),
        Error::ResourceLifetimeViolation
    );
    assert_eq!(
        active.bytes().unwrap_err(),
        Error::ResourceLifetimeViolation
    );

    world.try_stop_recording(&mut active).unwrap();
    assert!(!active.bytes().unwrap().is_empty());
    assert!(active.validate_replay(1).unwrap());
}

#[test]
fn malformed_recordings_fail_without_panicking() {
    let malformed = [0_u8; 64];
    assert_eq!(
        RecPlayer::from_bytes(&malformed, 1).unwrap_err(),
        Error::CreateRecPlayerFailed
    );
    assert!(!validate_replay_bytes(&malformed, 1).unwrap());
    assert_eq!(
        validate_replay_bytes(&[], 1).unwrap_err(),
        Error::InvalidArgument
    );
    assert_eq!(
        RecPlayer::from_bytes(&malformed, 0).unwrap_err(),
        Error::InvalidArgument
    );
}

#[test]
fn replay_exposes_recorded_queries_and_draws_them() {
    let mut world = World::new(WorldDef::default()).unwrap();
    let body = world.create_body(BodyDef::builder().position([0.0, 0.0, 0.0]).build());
    world.create_sphere_shape(
        body,
        &ShapeDef::default(),
        &Sphere::new([0.0, 0.0, 0.0], 0.5),
    );
    let mut recording = Recording::new().unwrap();
    world.try_start_recording(&mut recording).unwrap();

    let aabb = Aabb {
        lower_bound: Vec3::new(-1.0, -1.0, -1.0),
        upper_bound: Vec3::new(1.0, 1.0, 1.0),
    };
    for _ in 0..3 {
        let hits = world.overlap_aabb(aabb, QueryFilter::new().id(42)).unwrap();
        assert_eq!(hits.len(), 1);
        world.step(1.0 / 60.0, 4);
    }
    world.try_stop_recording(&mut recording).unwrap();

    let mut player = recording.create_player(1).unwrap();
    player.seek_frame(1).unwrap();
    assert_eq!(player.frame_query_count(), 1);
    let query = player.frame_query(0).unwrap();
    assert_eq!(query.id, 42);
    assert_eq!(query.hit_count, 1);
    let hit = player.frame_query_hit(0, 0).unwrap();
    assert!(hit.shape_id.is_valid());

    let commands = player
        .draw_frame_queries_collect(DebugDrawOptions::default(), None, None)
        .unwrap();
    assert!(!commands.is_empty());
}