bevy_symbios_shape 0.7.0

Bevy integration for Symbios Shape.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! Persistent procedural mesh cache shared across `spawn_shape` calls.
//!
//! For repetitive grammars (procedural cities, click-mutated buildings) the
//! same `(face_profile, size, stretch_uvs, round_segments)` key is hit
//! thousands of times. [`ShapeMeshCache`] deduplicates the resulting
//! `Handle<Mesh>` so identical geometry uploads to the GPU exactly once.
//!
//! The map is **bounded** (see [`DEFAULT_MESH_CACHE_CAPACITY`]): a session
//! that keeps re-rolling grammars evicts least-recently-used entries instead
//! of growing without limit.
//!
//! 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, round_segments)`.
///
/// 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,
    /// Radial tessellation for round cross-sections: `0` renders the scope
    /// as a box (the default), any value ≥ 3 renders it as an elliptical
    /// prism with that many segments. Part of the key so a registry-level
    /// segment change re-bakes rather than serving stale tessellation.
    pub round_segments: u32,
}

impl MeshCacheKey {
    /// Box-section key — the common case.
    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,
        }
    }
}

/// Default entry ceiling for [`ShapeMeshCache`].
///
/// Sized so a dense procedural city (thousands of distinct
/// `(profile, size)` pairs) still runs almost entirely on cache hits, while
/// a long-lived session that keeps re-rolling grammars cannot grow the map
/// without bound.
pub const DEFAULT_MESH_CACHE_CAPACITY: usize = 8192;

/// 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>`, and the
/// map is **bounded**: once it holds [`capacity`] entries, the
/// least-recently-used ones are evicted to make room. Evicting a handle
/// does not free the GPU mesh while entities still reference it — it only
/// means an identical key later rebuilds and re-uploads. Set the capacity
/// to `None` for the pre-0.6 unbounded behaviour, or call
/// [`ShapeMeshCache::clear`] to reset between large scene changes.
///
/// [`capacity`]: ShapeMeshCache::capacity
/// [`SpawnShapeExt::spawn_shape`]: crate::spawner::SpawnShapeExt::spawn_shape
/// [`BevySymbiosShapePlugin`]: crate::BevySymbiosShapePlugin
#[derive(Resource, Debug)]
pub struct ShapeMeshCache {
    /// `(handle, last_used_tick)` — the tick powers LRU eviction.
    entries: HashMap<MeshCacheKey, (Handle<Mesh>, u64)>,
    /// Monotonic access counter; every get/insert stamps and advances it.
    clock: u64,
    /// Entry ceiling. `None` disables eviction entirely.
    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()
    }

    /// Constructs a cache with an explicit entry ceiling.
    pub fn with_capacity(capacity: usize) -> Self {
        Self {
            capacity: Some(capacity.max(1)),
            ..Self::default()
        }
    }

    /// Constructs a cache that never evicts (pre-0.6 behaviour). Prefer a
    /// bounded cache unless the grammar set is known-finite.
    pub fn unbounded() -> Self {
        Self {
            capacity: None,
            ..Self::default()
        }
    }

    /// The current entry ceiling, or `None` when eviction is disabled.
    pub fn capacity(&self) -> Option<usize> {
        self.capacity
    }

    /// Sets the entry ceiling, evicting immediately if the map already
    /// exceeds it. `None` disables eviction.
    pub fn set_capacity(&mut self, capacity: Option<usize>) {
        self.capacity = capacity.map(|c| c.max(1));
        self.evict_to_capacity();
    }

    /// Look up an entry; returns `None` on miss without recording a counter
    /// (use [`ShapeMeshCache::get_or_insert_with`] for the canonical path).
    /// Does not refresh the entry's LRU position — `get_or_insert_with`
    /// owns recency tracking.
    pub fn get(&self, key: &MeshCacheKey) -> Option<&Handle<Mesh>> {
        self.entries.get(key).map(|(h, _)| h)
    }

    /// 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.clock += 1;
        let prev = self.entries.insert(key, (handle, self.clock));
        self.evict_to_capacity();
        prev.map(|(h, _)| h)
    }

    /// 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`). Both paths refresh the entry's
    /// LRU recency.
    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
    }

    /// Evicts least-recently-used entries until the map fits its capacity.
    ///
    /// Overshoots by a small margin (10% of capacity) so a steady-state
    /// workload at the ceiling doesn't pay an O(n) scan on every insert.
    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);
        // `len > cap >= target`, so at least one entry goes.
        let excess = self.entries.len() - target;
        // Ticks are unique (every access stamps a fresh clock value), so
        // dropping everything at or below the `excess`-th oldest removes
        // exactly `excess` entries.
        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;
    }

    /// 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
    }

    /// Cumulative entries dropped by LRU eviction since construction (or
    /// last [`reset_stats`]). A steadily climbing count means the working
    /// set exceeds [`capacity`] — raise it, or accept the re-upload churn.
    ///
    /// [`reset_stats`]: ShapeMeshCache::reset_stats
    /// [`capacity`]: ShapeMeshCache::capacity
    pub fn evictions(&self) -> u64 {
        self.evictions
    }

    /// Resets the hit/miss/eviction counters without clearing entries.
    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),
        ]));
        // 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,
            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);
        // Fill to capacity, then touch key 0 so it is the most recent.
        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);

        // One more insert trips eviction down to the 90% watermark.
        cache.get_or_insert_with(key_n(99), Handle::default);
        assert!(
            cache.len() <= 10,
            "cache exceeded its ceiling: {}",
            cache.len()
        );
        assert!(cache.evictions() >= 1);
        // The freshly touched and freshly inserted keys survive; the oldest
        // untouched one is gone.
        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);
        // Raising the ceiling again keeps what survived.
        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);
    }
}