bevy_symbios_shape 0.4.0

Bevy integration for Symbios Shape.
Documentation
//! Persistent procedural mesh cache shared across `spawn_shape` calls.
//!
//! For repetitive grammars (procedural cities, click-mutated buildings) the
//! same `(face_profile, size, stretch_uvs)` key is hit thousands of times.
//! [`ShapeMeshCache`] deduplicates the resulting `Handle<Mesh>` so identical
//! geometry uploads to the GPU exactly once.
//!
//! The cache is keyed by bit-exact `f32` values (`to_bits()`); identical
//! grammar inputs yield identical floats, so equality is deterministic.
//!
//! # Parity with `bevy_symbios::MeshCache`
//!
//! This cache and `bevy_symbios::MeshCache` (the L-system mesh cache)
//! deliberately expose the same operational vocabulary —
//! `get_or_insert_with`, `len`, `is_empty`, `clear`, and cumulative
//! `hits` / `misses` / `reset_stats` counters — so apps can wire
//! procedural-mesh observability uniformly across both pipelines. The
//! types stay separate (different keys, different value shapes:
//! structured terminal key → single `Handle<Mesh>` here vs. opaque
//! skeleton fingerprint → multi-material `HashMap` there) — what's shared
//! is the API surface, not the storage.

use bevy::math::DVec2;
use bevy::platform::collections::HashMap;
use bevy::prelude::{Handle, Mesh, Resource};
use symbios_shape::FaceProfile;

/// Hash-stable representation of a [`FaceProfile`].
///
/// `Polygon` carries an arbitrary vertex list; we capture each vertex as a
/// `(u32, u32)` bit-pattern so the key is comparable in `HashMap`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProfileKey {
    Rectangle,
    Taper(u32),
    Triangle(u32),
    Trapezoid(u32, u32),
    Polygon(Vec<(u32, u32)>),
}

impl ProfileKey {
    pub fn from_profile(profile: &FaceProfile) -> Self {
        match profile {
            FaceProfile::Rectangle => Self::Rectangle,
            FaceProfile::Taper(t) => Self::Taper((*t as f32).to_bits()),
            FaceProfile::Triangle { peak_offset } => {
                Self::Triangle((*peak_offset as f32).to_bits())
            }
            FaceProfile::Trapezoid {
                top_width,
                offset_x,
            } => Self::Trapezoid((*top_width as f32).to_bits(), (*offset_x as f32).to_bits()),
            FaceProfile::Polygon(pts) => Self::Polygon(
                pts.iter()
                    .map(|p: &DVec2| ((p.x as f32).to_bits(), (p.y as f32).to_bits()))
                    .collect(),
            ),
        }
    }
}

/// Composite cache key: `(profile, size_x, size_y, size_z, stretch_uvs)`.
///
/// Sizes are stored as `u32` bit-patterns to give `Eq`/`Hash`. Identical
/// grammar inputs produce identical floats, so equality is deterministic.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MeshCacheKey {
    pub profile: ProfileKey,
    pub size_x_bits: u32,
    pub size_y_bits: u32,
    pub size_z_bits: u32,
    pub stretch_uvs: bool,
}

/// Cross-spawn mesh asset cache.
///
/// Initialised by [`BevySymbiosShapePlugin`]. Accept this resource as
/// `ResMut<ShapeMeshCache>` in any system that calls
/// [`SpawnShapeExt::spawn_shape`].
///
/// Cache entries are reference-counted by Bevy via `Handle<Mesh>`. There is no
/// automatic eviction; call [`ShapeMeshCache::clear`] to reset between large
/// scene changes if memory becomes a concern.
///
/// [`SpawnShapeExt::spawn_shape`]: crate::spawner::SpawnShapeExt::spawn_shape
/// [`BevySymbiosShapePlugin`]: crate::BevySymbiosShapePlugin
#[derive(Resource, Default, Debug)]
pub struct ShapeMeshCache {
    entries: HashMap<MeshCacheKey, Handle<Mesh>>,
    hits: u64,
    misses: u64,
}

impl ShapeMeshCache {
    pub fn new() -> Self {
        Self::default()
    }

    /// Look up an entry; returns `None` on miss without recording a counter
    /// (use [`ShapeMeshCache::get_or_insert_with`] for the canonical path).
    pub fn get(&self, key: &MeshCacheKey) -> Option<&Handle<Mesh>> {
        self.entries.get(key)
    }

    /// Insert a handle for `key`, returning the previous handle if any.
    pub fn insert(&mut self, key: MeshCacheKey, handle: Handle<Mesh>) -> Option<Handle<Mesh>> {
        self.entries.insert(key, handle)
    }

    /// Lookup-or-build: returns a clone of the cached handle on hit
    /// (incrementing `hits`), or builds a new one via `build`, inserts, and
    /// returns it (incrementing `misses`).
    pub fn get_or_insert_with<F: FnOnce() -> Handle<Mesh>>(
        &mut self,
        key: MeshCacheKey,
        build: F,
    ) -> Handle<Mesh> {
        if let Some(h) = self.entries.get(&key) {
            self.hits += 1;
            return h.clone();
        }
        self.misses += 1;
        let handle = build();
        self.entries.insert(key, handle.clone());
        handle
    }

    /// Drop every cached handle. Bevy will free the underlying GPU mesh once
    /// no other strong reference remains.
    pub fn clear(&mut self) {
        self.entries.clear();
    }

    /// Number of cached entries.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Cumulative cache hits since construction (or last [`reset_stats`]).
    ///
    /// [`reset_stats`]: ShapeMeshCache::reset_stats
    pub fn hits(&self) -> u64 {
        self.hits
    }

    /// Cumulative cache misses since construction (or last [`reset_stats`]).
    ///
    /// [`reset_stats`]: ShapeMeshCache::reset_stats
    pub fn misses(&self) -> u64 {
        self.misses
    }

    /// Resets the hit/miss counters without clearing entries.
    pub fn reset_stats(&mut self) {
        self.hits = 0;
        self.misses = 0;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn key_round_trips_for_all_profiles() {
        let r = ProfileKey::from_profile(&FaceProfile::Rectangle);
        let t = ProfileKey::from_profile(&FaceProfile::Taper(0.5));
        let tri = ProfileKey::from_profile(&FaceProfile::Triangle { peak_offset: 0.4 });
        let trap = ProfileKey::from_profile(&FaceProfile::Trapezoid {
            top_width: 0.6,
            offset_x: 0.2,
        });
        let poly = ProfileKey::from_profile(&FaceProfile::Polygon(vec![
            DVec2::new(0.0, 0.0),
            DVec2::new(1.0, 0.0),
            DVec2::new(1.0, 1.0),
        ]));
        // Distinct profiles get distinct keys.
        assert_ne!(r, t);
        assert_ne!(t, tri);
        assert_ne!(tri, trap);
        assert_ne!(trap, poly);
    }

    #[test]
    fn get_or_insert_increments_counters() {
        let mut cache = ShapeMeshCache::new();
        let key = MeshCacheKey {
            profile: ProfileKey::Rectangle,
            size_x_bits: 1.0_f32.to_bits(),
            size_y_bits: 2.0_f32.to_bits(),
            size_z_bits: 3.0_f32.to_bits(),
            stretch_uvs: false,
        };
        let h1 = cache.get_or_insert_with(key.clone(), Handle::default);
        let h2 = cache.get_or_insert_with(key.clone(), Handle::default);
        assert_eq!(h1.id(), h2.id(), "same key should yield same handle id");
        assert_eq!(cache.misses(), 1);
        assert_eq!(cache.hits(), 1);
        assert_eq!(cache.len(), 1);
    }

    #[test]
    fn clear_drops_entries_but_keeps_stats() {
        let mut cache = ShapeMeshCache::new();
        let key = MeshCacheKey {
            profile: ProfileKey::Rectangle,
            size_x_bits: 0,
            size_y_bits: 0,
            size_z_bits: 0,
            stretch_uvs: false,
        };
        cache.get_or_insert_with(key, Handle::default);
        cache.clear();
        assert_eq!(cache.len(), 0);
        assert_eq!(cache.misses(), 1);
        cache.reset_stats();
        assert_eq!(cache.misses(), 0);
    }
}