bevy_symbios 0.4.0

Bevy integration for the Symbios L-System ecosystem.
Documentation
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy_symbios::{LSystemMeshBuilder, MeshCache, compute_skeleton_fingerprint};
use symbios_turtle_3d::{Skeleton, SkeletonPoint};

fn make_skeleton(positions: &[Vec3]) -> Skeleton {
    let mut s = Skeleton::new();
    for (i, p) in positions.iter().enumerate() {
        let point = SkeletonPoint {
            position: *p,
            rotation: Quat::IDENTITY,
            radius: 0.1,
            color: Vec4::ONE,
            material_id: 0,
            uv_scale: 1.0,
        };
        if i == 0 {
            s.start_strand(point, None);
        } else {
            s.push_node(point);
        }
    }
    s
}

fn test_app() -> App {
    let mut app = App::new();
    app.add_plugins(MinimalPlugins)
        .add_plugins(bevy::asset::AssetPlugin::default())
        .init_asset::<Mesh>();
    app
}

#[test]
fn cache_hit_returns_same_handle() {
    let mut app = test_app();
    let skel = make_skeleton(&[Vec3::ZERO, Vec3::Y, Vec3::Y * 2.0]);
    let mut cache = MeshCache::new();

    let handles_first = {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes)
    };
    let handles_second = {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes)
    };

    assert_eq!(cache.len(), 1, "Same skeleton must produce a single entry");
    let h1 = handles_first.get(&0).unwrap();
    let h2 = handles_second.get(&0).unwrap();
    assert_eq!(
        h1.id(),
        h2.id(),
        "Cache hit must return the same Mesh handle"
    );
}

#[test]
fn cache_hit_does_not_grow_assets() {
    let mut app = test_app();
    let skel = make_skeleton(&[Vec3::ZERO, Vec3::Y, Vec3::Y * 2.0]);
    let mut cache = MeshCache::new();

    {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes);
    }
    let count_after_first = app.world().resource::<Assets<Mesh>>().len();

    for _ in 0..10 {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes);
    }
    let count_after_repeats = app.world().resource::<Assets<Mesh>>().len();

    assert_eq!(
        count_after_first, count_after_repeats,
        "Cache hits must not insert new Mesh assets"
    );
}

#[test]
fn cache_miss_on_skeleton_change() {
    let mut app = test_app();
    let skel_a = make_skeleton(&[Vec3::ZERO, Vec3::Y]);
    let skel_b = make_skeleton(&[Vec3::ZERO, Vec3::Y * 2.0]);
    let mut cache = MeshCache::new();

    {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel_a, &mut cache, &mut meshes);
        LSystemMeshBuilder::new().build_cached(&skel_b, &mut cache, &mut meshes);
    }

    assert_eq!(
        cache.len(),
        2,
        "Different skeletons must produce different fingerprints"
    );
    assert!(cache.contains(&skel_a, 8));
    assert!(cache.contains(&skel_b, 8));
}

#[test]
fn cache_miss_on_resolution_change() {
    let mut app = test_app();
    let skel = make_skeleton(&[Vec3::ZERO, Vec3::Y]);
    let mut cache = MeshCache::new();

    {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new()
            .with_resolution(8)
            .build_cached(&skel, &mut cache, &mut meshes);
        LSystemMeshBuilder::new()
            .with_resolution(16)
            .build_cached(&skel, &mut cache, &mut meshes);
    }

    assert_eq!(
        cache.len(),
        2,
        "Different resolutions must yield different cache entries"
    );
}

#[test]
fn clear_drops_all_entries() {
    let mut app = test_app();
    let skel = make_skeleton(&[Vec3::ZERO, Vec3::Y]);
    let mut cache = MeshCache::new();

    {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes);
    }
    assert_eq!(cache.len(), 1);
    cache.clear();
    assert!(cache.is_empty());
}

#[test]
fn build_cached_bumps_hits_and_misses() {
    let mut app = test_app();
    let skel = make_skeleton(&[Vec3::ZERO, Vec3::Y, Vec3::Y * 2.0]);
    let mut cache = MeshCache::new();

    {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes);
    }
    assert_eq!(cache.misses(), 1, "first build must record a miss");
    assert_eq!(cache.hits(), 0);

    {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes);
    }
    assert_eq!(cache.misses(), 1, "second build must hit, not miss");
    assert_eq!(cache.hits(), 1);
}

#[test]
fn reset_stats_clears_counters_but_keeps_entries() {
    let mut app = test_app();
    let skel = make_skeleton(&[Vec3::ZERO, Vec3::Y]);
    let mut cache = MeshCache::new();
    {
        let mut meshes = app.world_mut().resource_mut::<Assets<Mesh>>();
        LSystemMeshBuilder::new().build_cached(&skel, &mut cache, &mut meshes);
    }
    assert_eq!(cache.misses(), 1);
    assert_eq!(cache.len(), 1);
    cache.reset_stats();
    assert_eq!(cache.misses(), 0);
    assert_eq!(cache.hits(), 0);
    assert_eq!(cache.len(), 1, "reset_stats must not drop entries");
}

#[test]
fn get_or_insert_with_supports_external_fingerprints() {
    let skel = make_skeleton(&[Vec3::ZERO, Vec3::Y]);
    let fingerprint = compute_skeleton_fingerprint(&skel, 6);
    let mut cache = MeshCache::new();

    let made: HashMap<u16, Handle<Mesh>> = {
        let mut map = HashMap::default();
        map.insert(0u16, Handle::<Mesh>::default());
        map
    };

    // First call: miss — runs the closure.
    let mut closure_calls = 0;
    let h1 = cache.get_or_insert_with(fingerprint, || {
        closure_calls += 1;
        made.clone()
    });
    assert_eq!(closure_calls, 1);
    assert_eq!(cache.misses(), 1);
    assert_eq!(cache.hits(), 0);
    assert_eq!(cache.len(), 1);

    // Second call: hit — closure not invoked.
    let h2 = cache.get_or_insert_with(fingerprint, || {
        closure_calls += 1;
        made.clone()
    });
    assert_eq!(closure_calls, 1, "closure must not run on hit");
    assert_eq!(cache.hits(), 1);
    assert_eq!(h1.get(&0).unwrap().id(), h2.get(&0).unwrap().id());
}