use bevy::math::DVec2;
use bevy::platform::collections::HashMap;
use bevy::prelude::{Handle, Mesh, Resource};
use symbios_shape::FaceProfile;
#[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(),
),
}
}
}
#[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,
pub round_segments: u32,
}
impl MeshCacheKey {
pub fn boxed(profile: ProfileKey, size: bevy::math::Vec3, stretch_uvs: bool) -> Self {
Self {
profile,
size_x_bits: size.x.to_bits(),
size_y_bits: size.y.to_bits(),
size_z_bits: size.z.to_bits(),
stretch_uvs,
round_segments: 0,
}
}
}
pub const DEFAULT_MESH_CACHE_CAPACITY: usize = 8192;
#[derive(Resource, Debug)]
pub struct ShapeMeshCache {
entries: HashMap<MeshCacheKey, (Handle<Mesh>, u64)>,
clock: u64,
capacity: Option<usize>,
hits: u64,
misses: u64,
evictions: u64,
}
impl Default for ShapeMeshCache {
fn default() -> Self {
Self {
entries: HashMap::default(),
clock: 0,
capacity: Some(DEFAULT_MESH_CACHE_CAPACITY),
hits: 0,
misses: 0,
evictions: 0,
}
}
}
impl ShapeMeshCache {
pub fn new() -> Self {
Self::default()
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
capacity: Some(capacity.max(1)),
..Self::default()
}
}
pub fn unbounded() -> Self {
Self {
capacity: None,
..Self::default()
}
}
pub fn capacity(&self) -> Option<usize> {
self.capacity
}
pub fn set_capacity(&mut self, capacity: Option<usize>) {
self.capacity = capacity.map(|c| c.max(1));
self.evict_to_capacity();
}
pub fn get(&self, key: &MeshCacheKey) -> Option<&Handle<Mesh>> {
self.entries.get(key).map(|(h, _)| h)
}
pub fn insert(&mut self, key: MeshCacheKey, handle: Handle<Mesh>) -> Option<Handle<Mesh>> {
self.clock += 1;
let prev = self.entries.insert(key, (handle, self.clock));
self.evict_to_capacity();
prev.map(|(h, _)| h)
}
pub fn get_or_insert_with<F: FnOnce() -> Handle<Mesh>>(
&mut self,
key: MeshCacheKey,
build: F,
) -> Handle<Mesh> {
self.clock += 1;
let tick = self.clock;
if let Some((h, last_used)) = self.entries.get_mut(&key) {
*last_used = tick;
self.hits += 1;
return h.clone();
}
self.misses += 1;
let handle = build();
self.entries.insert(key, (handle.clone(), tick));
self.evict_to_capacity();
handle
}
fn evict_to_capacity(&mut self) {
let Some(cap) = self.capacity else {
return;
};
if self.entries.len() <= cap {
return;
}
let target = cap.saturating_sub(cap / 10).max(1);
let excess = self.entries.len() - target;
let mut ticks: Vec<u64> = self.entries.values().map(|(_, t)| *t).collect();
ticks.sort_unstable();
let cutoff = ticks[excess - 1];
let before = self.entries.len();
self.entries.retain(|_, (_, t)| *t > cutoff);
self.evictions += (before - self.entries.len()) as u64;
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn hits(&self) -> u64 {
self.hits
}
pub fn misses(&self) -> u64 {
self.misses
}
pub fn evictions(&self) -> u64 {
self.evictions
}
pub fn reset_stats(&mut self) {
self.hits = 0;
self.misses = 0;
self.evictions = 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),
]));
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,
round_segments: 0,
};
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);
}
fn key_n(n: u32) -> MeshCacheKey {
MeshCacheKey {
profile: ProfileKey::Rectangle,
size_x_bits: n,
size_y_bits: 0,
size_z_bits: 0,
stretch_uvs: false,
round_segments: 0,
}
}
#[test]
fn round_segments_participate_in_the_key() {
let boxed = MeshCacheKey::boxed(ProfileKey::Rectangle, bevy::math::Vec3::ONE, false);
let round = MeshCacheKey {
round_segments: 24,
..boxed.clone()
};
assert_ne!(boxed, round, "a round terminal must not reuse box geometry");
assert_eq!(boxed.round_segments, 0);
}
#[test]
fn lru_eviction_bounds_the_map_and_keeps_recent_entries() {
let mut cache = ShapeMeshCache::with_capacity(10);
for n in 0..10 {
cache.get_or_insert_with(key_n(n), Handle::default);
}
assert_eq!(cache.len(), 10);
assert_eq!(cache.evictions(), 0);
cache.get_or_insert_with(key_n(0), Handle::default);
cache.get_or_insert_with(key_n(99), Handle::default);
assert!(
cache.len() <= 10,
"cache exceeded its ceiling: {}",
cache.len()
);
assert!(cache.evictions() >= 1);
assert!(
cache.get(&key_n(0)).is_some(),
"recently used entry evicted"
);
assert!(cache.get(&key_n(99)).is_some(), "newest entry evicted");
assert!(cache.get(&key_n(1)).is_none(), "LRU victim survived");
}
#[test]
fn eviction_never_overshoots_under_sustained_churn() {
let mut cache = ShapeMeshCache::with_capacity(16);
for n in 0..500 {
cache.get_or_insert_with(key_n(n), Handle::default);
assert!(
cache.len() <= 16,
"ceiling breached at n={n}: {}",
cache.len()
);
}
assert!(cache.evictions() > 0);
}
#[test]
fn unbounded_cache_never_evicts() {
let mut cache = ShapeMeshCache::unbounded();
assert_eq!(cache.capacity(), None);
for n in 0..200 {
cache.get_or_insert_with(key_n(n), Handle::default);
}
assert_eq!(cache.len(), 200);
assert_eq!(cache.evictions(), 0);
}
#[test]
fn set_capacity_evicts_immediately() {
let mut cache = ShapeMeshCache::unbounded();
for n in 0..50 {
cache.get_or_insert_with(key_n(n), Handle::default);
}
cache.set_capacity(Some(10));
assert!(cache.len() <= 10);
let survived = cache.len();
cache.set_capacity(Some(100));
assert_eq!(cache.len(), survived);
}
#[test]
fn default_cache_is_bounded() {
let cache = ShapeMeshCache::new();
assert_eq!(cache.capacity(), Some(DEFAULT_MESH_CACHE_CAPACITY));
}
#[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,
round_segments: 0,
};
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);
}
}