Skip to main content

alice_sdf/cache/
mod.rs

1//! SDF hash-based mesh cache with LRU eviction
2//!
3//! Maps SDF tree hashes to cached meshes for fast retrieval when the same
4//! SDF is meshed repeatedly (e.g. editor preview, LOD regeneration).
5//!
6//! Also provides chunked incremental mesh caching via [`chunked`].
7//!
8//! # Features
9//!
10//! - **LRU eviction**: Oldest entries are evicted when the cache is full.
11//! - **Thread-safe**: Uses `RwLock` for concurrent reads, exclusive writes.
12//! - **Deterministic hashing**: Same SDF tree + bounds + resolution = same key.
13//! - **Zero-copy sharing**: Cached meshes are returned as `Arc<Mesh>`.
14//! - **`get_or_generate`**: Atomic check-and-insert to avoid redundant work.
15//!
16//! # Example
17//!
18//! ```rust
19//! use alice_sdf::cache::{MeshCache, CacheConfig, compute_cache_key};
20//! use alice_sdf::prelude::*;
21//!
22//! let cache = MeshCache::new(CacheConfig::default());
23//! let sphere = SdfNode::sphere(1.0);
24//! let min = Vec3::splat(-2.0);
25//! let max = Vec3::splat(2.0);
26//! let config = MarchingCubesConfig { resolution: 32, ..Default::default() };
27//!
28//! let key = compute_cache_key(&sphere, min, max, config.resolution);
29//! let mesh = cache.get_or_generate(key, || {
30//!     sdf_to_mesh(&sphere, min, max, &config)
31//! });
32//! ```
33//!
34//! Author: Moroya Sakamoto
35
36pub mod chunked;
37pub use chunked::{ChunkCoord, ChunkedCacheConfig, ChunkedMeshCache};
38
39use std::collections::HashMap;
40use std::hash::{Hash, Hasher};
41use std::path::PathBuf;
42use std::sync::{Arc, RwLock};
43
44use crate::mesh::Mesh;
45use crate::types::SdfNode;
46
47/// Configuration for the mesh cache.
48///
49/// Build it with `..Default::default()` for the fields you do not set, so the
50/// deprecated fields below are not named:
51///
52/// ```
53/// use alice_sdf::cache::CacheConfig;
54/// let config = CacheConfig { max_entries: 16, ..Default::default() };
55/// assert_eq!(config.max_entries, 16);
56/// ```
57#[derive(Debug, Clone)]
58pub struct CacheConfig {
59    /// Maximum number of cached meshes in memory.
60    ///
61    /// When an insert of a new key finds `max_entries` entries, the least
62    /// recently used one is evicted first. **`0` means unbounded**: nothing is
63    /// ever evicted and the cache grows with every distinct key.
64    pub max_entries: usize,
65    /// Not implemented: [`MeshCache`] never reads or writes this directory.
66    ///
67    /// Deprecated since 5.0.0. For `.abm` persistence use
68    /// [`ChunkedMeshCache::persist_dirty`] / [`ChunkedMeshCache::load_chunk`]
69    /// or [`crate::io::save_abm`] directly.
70    #[deprecated(
71        since = "5.0.0",
72        note = "not implemented: MeshCache never touches the disk; persist with crate::io::save_abm or ChunkedMeshCache"
73    )]
74    pub disk_cache_dir: Option<PathBuf>,
75    /// Not implemented: evicted meshes are dropped whatever this is set to.
76    ///
77    /// Deprecated since 5.0.0, see [`Self::disk_cache_dir`].
78    #[deprecated(
79        since = "5.0.0",
80        note = "not implemented: evicted meshes are dropped; persist with crate::io::save_abm or ChunkedMeshCache"
81    )]
82    pub persist_on_evict: bool,
83}
84
85#[allow(deprecated)] // the deprecated fields still have to be initialised
86impl Default for CacheConfig {
87    fn default() -> Self {
88        Self {
89            max_entries: 64,
90            disk_cache_dir: None,
91            persist_on_evict: false,
92        }
93    }
94}
95
96/// Cache key computed from SDF tree + generation parameters.
97///
98/// Two identical SDF trees with the same bounds and resolution will produce
99/// the same `MeshCacheKey`, enabling cache hits across frames or sessions.
100#[derive(Debug, Clone, PartialEq, Eq, Hash)]
101pub struct MeshCacheKey {
102    /// Hash of the SDF node tree.
103    pub sdf_hash: u64,
104    /// Resolution used for mesh generation.
105    pub resolution: u32,
106    /// Quantized bounds hash (to avoid float comparison issues).
107    pub bounds_hash: u64,
108}
109
110/// Internal cache entry holding the mesh and its approximate memory footprint.
111struct CacheEntry {
112    mesh: Arc<Mesh>,
113    /// Approximate size in bytes (vertices * vertex_size + indices * 4).
114    size_bytes: usize,
115}
116
117/// Internal LRU-ordered map.
118///
119/// `order` tracks access recency: front = oldest, back = newest.
120struct LruMap {
121    map: HashMap<MeshCacheKey, CacheEntry>,
122    /// LRU order: front = oldest, back = newest.
123    order: Vec<MeshCacheKey>,
124}
125
126impl LruMap {
127    fn new() -> Self {
128        Self {
129            map: HashMap::new(),
130            order: Vec::new(),
131        }
132    }
133
134    /// Move `key` to the back (most-recently-used position).
135    #[inline(always)]
136    fn touch(&mut self, key: &MeshCacheKey) {
137        if let Some(pos) = self.order.iter().position(|k| k == key) {
138            self.order.remove(pos);
139            self.order.push(key.clone());
140        }
141    }
142
143    /// Insert a new entry, returning the evicted key (if any) when over capacity.
144    fn insert(
145        &mut self,
146        key: MeshCacheKey,
147        entry: CacheEntry,
148        max_entries: usize,
149    ) -> Option<MeshCacheKey> {
150        // If key already exists, update in-place and touch.
151        if self.map.contains_key(&key) {
152            self.map.insert(key.clone(), entry);
153            self.touch(&key);
154            return None;
155        }
156
157        // Evict oldest if at capacity.
158        let evicted = if self.map.len() >= max_entries && max_entries > 0 {
159            if let Some(oldest_key) = self.order.first().cloned() {
160                self.map.remove(&oldest_key);
161                self.order.remove(0);
162                Some(oldest_key)
163            } else {
164                None
165            }
166        } else {
167            None
168        };
169
170        self.map.insert(key.clone(), entry);
171        self.order.push(key);
172        evicted
173    }
174
175    /// Remove an entry by key.
176    fn remove(&mut self, key: &MeshCacheKey) -> Option<Arc<Mesh>> {
177        if let Some(entry) = self.map.remove(key) {
178            if let Some(pos) = self.order.iter().position(|k| k == key) {
179                self.order.remove(pos);
180            }
181            Some(entry.mesh)
182        } else {
183            None
184        }
185    }
186
187    /// Get a reference to a cached mesh, touching it for LRU.
188    fn get(&mut self, key: &MeshCacheKey) -> Option<Arc<Mesh>> {
189        if self.map.contains_key(key) {
190            self.touch(key);
191            Some(Arc::clone(&self.map[key].mesh))
192        } else {
193            None
194        }
195    }
196
197    /// Clear all entries.
198    fn clear(&mut self) {
199        self.map.clear();
200        self.order.clear();
201    }
202
203    /// Number of entries.
204    #[inline(always)]
205    fn len(&self) -> usize {
206        self.map.len()
207    }
208
209    /// Total memory usage across all cached meshes.
210    fn memory_usage(&self) -> usize {
211        self.map.values().map(|e| e.size_bytes).sum()
212    }
213}
214
215/// Thread-safe LRU mesh cache.
216///
217/// Stores generated meshes keyed by SDF tree hash, bounds, and resolution.
218/// Uses `RwLock` internally so multiple readers can access the cache
219/// concurrently, while writes (insert/evict) take exclusive access.
220pub struct MeshCache {
221    config: CacheConfig,
222    entries: RwLock<LruMap>,
223}
224
225impl MeshCache {
226    /// Create a new mesh cache with the given configuration.
227    pub fn new(config: CacheConfig) -> Self {
228        Self {
229            config,
230            entries: RwLock::new(LruMap::new()),
231        }
232    }
233
234    /// Get a cached mesh by key. Returns `None` if not cached.
235    ///
236    /// This promotes the entry to most-recently-used position.
237    pub fn get(&self, key: &MeshCacheKey) -> Option<Arc<Mesh>> {
238        // We need write access because `get` updates LRU order.
239        let mut entries = self
240            .entries
241            .write()
242            .expect("MeshCache: RwLock poisoned on entries.write() in get()");
243        entries.get(key)
244    }
245
246    /// Insert a mesh into the cache, evicting the oldest entry if full.
247    ///
248    /// Returns an `Arc<Mesh>` pointing to the cached copy.
249    pub fn insert(&self, key: MeshCacheKey, mesh: Mesh) -> Arc<Mesh> {
250        let size_bytes = estimate_mesh_size(&mesh);
251        let arc_mesh = Arc::new(mesh);
252        let entry = CacheEntry {
253            mesh: Arc::clone(&arc_mesh),
254            size_bytes,
255        };
256
257        let mut entries = self
258            .entries
259            .write()
260            .expect("MeshCache: RwLock poisoned on entries.write() in insert()");
261        let _evicted = entries.insert(key, entry, self.config.max_entries);
262
263        arc_mesh
264    }
265
266    /// Remove an entry from the cache.
267    pub fn remove(&self, key: &MeshCacheKey) -> Option<Arc<Mesh>> {
268        let mut entries = self
269            .entries
270            .write()
271            .expect("MeshCache: RwLock poisoned on entries.write() in remove()");
272        entries.remove(key)
273    }
274
275    /// Clear all cached meshes.
276    pub fn clear(&self) {
277        let mut entries = self
278            .entries
279            .write()
280            .expect("MeshCache: RwLock poisoned on entries.write() in clear()");
281        entries.clear();
282    }
283
284    /// Number of cached entries.
285    #[inline(always)]
286    pub fn len(&self) -> usize {
287        let entries = self
288            .entries
289            .read()
290            .expect("MeshCache: RwLock poisoned on entries.read() in len()");
291        entries.len()
292    }
293
294    /// Check if the cache is empty.
295    #[inline(always)]
296    pub fn is_empty(&self) -> bool {
297        self.len() == 0
298    }
299
300    /// Total approximate memory usage of all cached meshes in bytes.
301    pub fn memory_usage(&self) -> usize {
302        let entries = self
303            .entries
304            .read()
305            .expect("MeshCache: RwLock poisoned on entries.read() in memory_usage()");
306        entries.memory_usage()
307    }
308
309    /// Get a cached mesh or generate it using the provided closure.
310    ///
311    /// If the key is found in the cache, returns the cached `Arc<Mesh>`.
312    /// Otherwise, calls `generator()` to produce a new mesh, inserts it
313    /// into the cache, and returns it.
314    ///
315    /// **Note**: The generator is called outside the lock to avoid holding
316    /// the lock during potentially expensive mesh generation.
317    pub fn get_or_generate<F>(&self, key: MeshCacheKey, generator: F) -> Arc<Mesh>
318    where
319        F: FnOnce() -> Mesh,
320    {
321        // Fast path: check if already cached.
322        {
323            let mut entries = self
324                .entries
325                .write()
326                .expect("MeshCache: RwLock poisoned on entries.write() in get_or_generate()");
327            if let Some(mesh) = entries.get(&key) {
328                return mesh;
329            }
330        }
331
332        // Slow path: generate mesh outside the lock.
333        let mesh = generator();
334
335        // Insert into cache (another thread may have inserted in the meantime).
336        let size_bytes = estimate_mesh_size(&mesh);
337        let arc_mesh = Arc::new(mesh);
338        let entry = CacheEntry {
339            mesh: Arc::clone(&arc_mesh),
340            size_bytes,
341        };
342
343        let mut entries = self
344            .entries
345            .write()
346            .expect("MeshCache: RwLock poisoned on entries.write() in get_or_generate()");
347        // Double-check: if another thread inserted while we were generating,
348        // return the existing entry instead.
349        if let Some(existing) = entries.get(&key) {
350            return existing;
351        }
352        entries.insert(key, entry, self.config.max_entries);
353
354        arc_mesh
355    }
356}
357
358/// Compute a deterministic hash of an SDF node tree.
359///
360/// Uses JSON serialization of the node for a stable, content-based hash.
361/// The same logical SDF tree will always produce the same hash.
362pub fn hash_sdf_node(node: &SdfNode) -> u64 {
363    let mut hasher = std::collections::hash_map::DefaultHasher::new();
364    serde_json::to_string(node)
365        .unwrap_or_default()
366        .hash(&mut hasher);
367    hasher.finish()
368}
369
370/// Compute a cache key from an SDF node, bounds, and resolution.
371///
372/// Bounds are quantized to fixed-point (multiply by 1000, cast to i32) to
373/// avoid floating-point comparison issues.
374pub fn compute_cache_key(
375    node: &SdfNode,
376    min_bounds: glam::Vec3,
377    max_bounds: glam::Vec3,
378    resolution: usize,
379) -> MeshCacheKey {
380    let sdf_hash = hash_sdf_node(node);
381    let bounds_hash = hash_bounds(min_bounds, max_bounds);
382    MeshCacheKey {
383        sdf_hash,
384        resolution: resolution as u32,
385        bounds_hash,
386    }
387}
388
389/// Hash bounds by quantizing floats to fixed-point integers.
390#[inline(always)]
391fn hash_bounds(min_bounds: glam::Vec3, max_bounds: glam::Vec3) -> u64 {
392    let mut hasher = std::collections::hash_map::DefaultHasher::new();
393    quantize_f32(min_bounds.x).hash(&mut hasher);
394    quantize_f32(min_bounds.y).hash(&mut hasher);
395    quantize_f32(min_bounds.z).hash(&mut hasher);
396    quantize_f32(max_bounds.x).hash(&mut hasher);
397    quantize_f32(max_bounds.y).hash(&mut hasher);
398    quantize_f32(max_bounds.z).hash(&mut hasher);
399    hasher.finish()
400}
401
402/// Quantize a float to fixed-point (multiply by 1000, cast to i32).
403#[inline(always)]
404fn quantize_f32(value: f32) -> i32 {
405    (value * 1000.0) as i32
406}
407
408/// Estimate the memory footprint of a mesh in bytes.
409#[inline(always)]
410fn estimate_mesh_size(mesh: &Mesh) -> usize {
411    let vertex_size = std::mem::size_of::<crate::mesh::Vertex>();
412    mesh.vertices.len() * vertex_size + mesh.indices.len() * std::mem::size_of::<u32>()
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::mesh::Vertex;
419    use glam::Vec3;
420
421    /// Helper: create a simple test mesh with the given number of vertices and triangles.
422    fn make_test_mesh(num_verts: usize, num_tris: usize) -> Mesh {
423        let vertices: Vec<Vertex> = (0..num_verts)
424            .map(|i| {
425                let f = i as f32;
426                Vertex::new(Vec3::new(f, f, f), Vec3::Y)
427            })
428            .collect();
429        let indices: Vec<u32> = (0..num_tris * 3).map(|i| (i % num_verts) as u32).collect();
430        Mesh { vertices, indices }
431    }
432
433    #[test]
434    fn test_insert_and_retrieve() {
435        let cache = MeshCache::new(CacheConfig {
436            max_entries: 4,
437            ..Default::default()
438        });
439
440        let key = MeshCacheKey {
441            sdf_hash: 42,
442            resolution: 32,
443            bounds_hash: 100,
444        };
445        let mesh = make_test_mesh(8, 4);
446        let expected_verts = mesh.vertices.len();
447
448        cache.insert(key.clone(), mesh);
449
450        let retrieved = cache.get(&key);
451        assert!(retrieved.is_some());
452        assert_eq!(retrieved.unwrap().vertex_count(), expected_verts);
453    }
454
455    #[test]
456    fn test_lru_eviction() {
457        let cache = MeshCache::new(CacheConfig {
458            max_entries: 3,
459            ..Default::default()
460        });
461
462        // Insert 3 entries (fills cache).
463        for i in 0..3u64 {
464            let key = MeshCacheKey {
465                sdf_hash: i,
466                resolution: 16,
467                bounds_hash: 0,
468            };
469            cache.insert(key, make_test_mesh(4, 2));
470        }
471        assert_eq!(cache.len(), 3);
472
473        // Insert a 4th entry -- should evict key with sdf_hash=0 (oldest).
474        let key4 = MeshCacheKey {
475            sdf_hash: 99,
476            resolution: 16,
477            bounds_hash: 0,
478        };
479        cache.insert(key4, make_test_mesh(4, 2));
480        assert_eq!(cache.len(), 3);
481
482        // The oldest (sdf_hash=0) should be evicted.
483        let evicted_key = MeshCacheKey {
484            sdf_hash: 0,
485            resolution: 16,
486            bounds_hash: 0,
487        };
488        assert!(cache.get(&evicted_key).is_none());
489
490        // The rest should still be present.
491        for i in 1..3u64 {
492            let key = MeshCacheKey {
493                sdf_hash: i,
494                resolution: 16,
495                bounds_hash: 0,
496            };
497            assert!(cache.get(&key).is_some());
498        }
499        let key99 = MeshCacheKey {
500            sdf_hash: 99,
501            resolution: 16,
502            bounds_hash: 0,
503        };
504        assert!(cache.get(&key99).is_some());
505    }
506
507    #[test]
508    fn test_lru_access_updates_order() {
509        let cache = MeshCache::new(CacheConfig {
510            max_entries: 3,
511            ..Default::default()
512        });
513
514        // Insert keys 0, 1, 2.
515        for i in 0..3u64 {
516            let key = MeshCacheKey {
517                sdf_hash: i,
518                resolution: 16,
519                bounds_hash: 0,
520            };
521            cache.insert(key, make_test_mesh(4, 2));
522        }
523
524        // Access key 0 to make it most-recently-used.
525        let key0 = MeshCacheKey {
526            sdf_hash: 0,
527            resolution: 16,
528            bounds_hash: 0,
529        };
530        assert!(cache.get(&key0).is_some());
531
532        // Insert key 3 -- should evict key 1 (now the oldest).
533        let key3 = MeshCacheKey {
534            sdf_hash: 3,
535            resolution: 16,
536            bounds_hash: 0,
537        };
538        cache.insert(key3, make_test_mesh(4, 2));
539
540        let evicted_key = MeshCacheKey {
541            sdf_hash: 1,
542            resolution: 16,
543            bounds_hash: 0,
544        };
545        assert!(cache.get(&evicted_key).is_none());
546
547        // Key 0 should still be present (was touched).
548        assert!(cache.get(&key0).is_some());
549    }
550
551    #[test]
552    fn test_get_or_generate_caches() {
553        let cache = MeshCache::new(CacheConfig::default());
554        let key = MeshCacheKey {
555            sdf_hash: 1,
556            resolution: 16,
557            bounds_hash: 0,
558        };
559
560        let call_count = std::sync::atomic::AtomicU32::new(0);
561
562        // First call: generator runs.
563        let mesh1 = cache.get_or_generate(key.clone(), || {
564            call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
565            make_test_mesh(10, 5)
566        });
567        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
568        assert_eq!(mesh1.vertex_count(), 10);
569
570        // Second call: generator should NOT run.
571        let mesh2 = cache.get_or_generate(key, || {
572            call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
573            make_test_mesh(20, 10)
574        });
575        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
576        assert_eq!(mesh2.vertex_count(), 10); // Same mesh as first call.
577    }
578
579    #[test]
580    fn test_cache_key_determinism() {
581        let node = SdfNode::sphere(1.0);
582        let hash1 = hash_sdf_node(&node);
583        let hash2 = hash_sdf_node(&node);
584        assert_eq!(hash1, hash2, "Same SDF tree must produce the same hash");
585
586        // Different tree should (very likely) produce different hash.
587        let node2 = SdfNode::sphere(2.0);
588        let hash3 = hash_sdf_node(&node2);
589        assert_ne!(
590            hash1, hash3,
591            "Different SDF trees should produce different hashes"
592        );
593    }
594
595    #[test]
596    fn test_compute_cache_key_determinism() {
597        let node = SdfNode::sphere(1.0).subtract(SdfNode::box3d(0.5, 0.5, 0.5));
598        let min = Vec3::splat(-2.0);
599        let max = Vec3::splat(2.0);
600
601        let key1 = compute_cache_key(&node, min, max, 32);
602        let key2 = compute_cache_key(&node, min, max, 32);
603        assert_eq!(key1, key2);
604
605        // Different resolution => different key.
606        let key3 = compute_cache_key(&node, min, max, 64);
607        assert_ne!(key1, key3);
608
609        // Different bounds => different key.
610        let key4 = compute_cache_key(&node, Vec3::splat(-3.0), max, 32);
611        assert_ne!(key1, key4);
612    }
613
614    #[test]
615    fn test_thread_safety_concurrent_reads() {
616        use std::sync::Arc as StdArc;
617
618        let cache = StdArc::new(MeshCache::new(CacheConfig {
619            max_entries: 16,
620            ..Default::default()
621        }));
622
623        // Pre-populate.
624        for i in 0..8u64 {
625            let key = MeshCacheKey {
626                sdf_hash: i,
627                resolution: 16,
628                bounds_hash: 0,
629            };
630            cache.insert(key, make_test_mesh(4, 2));
631        }
632
633        // Spawn threads that all read concurrently.
634        let mut handles = Vec::new();
635        for i in 0..8u64 {
636            let cache_clone = StdArc::clone(&cache);
637            handles.push(std::thread::spawn(move || {
638                let key = MeshCacheKey {
639                    sdf_hash: i,
640                    resolution: 16,
641                    bounds_hash: 0,
642                };
643                let result = cache_clone.get(&key);
644                assert!(result.is_some(), "Key {} should be in cache", i);
645                result.unwrap().vertex_count()
646            }));
647        }
648
649        for handle in handles {
650            let count = handle.join().unwrap();
651            assert_eq!(count, 4);
652        }
653    }
654
655    #[test]
656    fn test_clear() {
657        let cache = MeshCache::new(CacheConfig::default());
658        let key = MeshCacheKey {
659            sdf_hash: 1,
660            resolution: 16,
661            bounds_hash: 0,
662        };
663        cache.insert(key.clone(), make_test_mesh(4, 2));
664        assert_eq!(cache.len(), 1);
665
666        cache.clear();
667        assert_eq!(cache.len(), 0);
668        assert!(cache.is_empty());
669        assert!(cache.get(&key).is_none());
670    }
671
672    #[test]
673    fn test_remove() {
674        let cache = MeshCache::new(CacheConfig::default());
675
676        let key1 = MeshCacheKey {
677            sdf_hash: 1,
678            resolution: 16,
679            bounds_hash: 0,
680        };
681        let key2 = MeshCacheKey {
682            sdf_hash: 2,
683            resolution: 16,
684            bounds_hash: 0,
685        };
686        cache.insert(key1.clone(), make_test_mesh(4, 2));
687        cache.insert(key2.clone(), make_test_mesh(8, 4));
688        assert_eq!(cache.len(), 2);
689
690        let removed = cache.remove(&key1);
691        assert!(removed.is_some());
692        assert_eq!(removed.unwrap().vertex_count(), 4);
693        assert_eq!(cache.len(), 1);
694        assert!(cache.get(&key1).is_none());
695        assert!(cache.get(&key2).is_some());
696
697        // Remove non-existent key.
698        let removed_none = cache.remove(&key1);
699        assert!(removed_none.is_none());
700    }
701
702    #[test]
703    fn test_memory_usage() {
704        let cache = MeshCache::new(CacheConfig::default());
705
706        let key = MeshCacheKey {
707            sdf_hash: 1,
708            resolution: 16,
709            bounds_hash: 0,
710        };
711        let mesh = make_test_mesh(100, 50);
712        let expected_size = mesh.vertices.len() * std::mem::size_of::<Vertex>()
713            + mesh.indices.len() * std::mem::size_of::<u32>();
714
715        cache.insert(key, mesh);
716
717        assert_eq!(cache.memory_usage(), expected_size);
718    }
719
720    #[test]
721    fn test_empty_cache() {
722        let cache = MeshCache::new(CacheConfig::default());
723        assert!(cache.is_empty());
724        assert_eq!(cache.len(), 0);
725        assert_eq!(cache.memory_usage(), 0);
726    }
727
728    #[test]
729    fn test_insert_duplicate_key_updates() {
730        let cache = MeshCache::new(CacheConfig {
731            max_entries: 4,
732            ..Default::default()
733        });
734
735        let key = MeshCacheKey {
736            sdf_hash: 42,
737            resolution: 32,
738            bounds_hash: 100,
739        };
740
741        cache.insert(key.clone(), make_test_mesh(4, 2));
742        assert_eq!(cache.len(), 1);
743
744        // Insert with same key but different mesh.
745        cache.insert(key.clone(), make_test_mesh(16, 8));
746        assert_eq!(cache.len(), 1); // Should not increase count.
747
748        let retrieved = cache.get(&key).unwrap();
749        assert_eq!(retrieved.vertex_count(), 16); // Should be the updated mesh.
750    }
751}