Skip to main content

alice_sdf/cache/
chunked.rs

1//! Chunked incremental mesh cache (Deep Fried Edition)
2//!
3//! Provides spatial partitioning of SDF evaluation space into chunks.
4//! Only dirty chunks are re-meshed when the SDF changes, enabling
5//! incremental updates for real-time editing workflows.
6//!
7//! # Design
8//! - Space is divided into a uniform grid of chunks
9//! - Each chunk is independently meshable
10//! - Dirty tracking: only modified chunks are re-generated
11//! - Chunk meshes can be persisted individually as .abm files
12//!
13//! Author: Moroya Sakamoto
14
15use crate::io::abm::{load_abm, save_abm};
16use crate::io::IoError;
17use crate::mesh::{Mesh, Vertex};
18use glam::Vec3;
19use std::collections::HashMap;
20use std::path::PathBuf;
21use std::sync::{Arc, RwLock};
22
23// ---------------------------------------------------------------------------
24// ChunkCoord
25// ---------------------------------------------------------------------------
26
27/// 3D chunk coordinate in the spatial grid.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub struct ChunkCoord {
30    /// X index
31    pub x: i32,
32    /// Y index
33    pub y: i32,
34    /// Z index
35    pub z: i32,
36}
37
38impl ChunkCoord {
39    /// Create a new chunk coordinate.
40    #[inline(always)]
41    pub const fn new(x: i32, y: i32, z: i32) -> Self {
42        Self { x, y, z }
43    }
44}
45
46impl std::fmt::Display for ChunkCoord {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "({}, {}, {})", self.x, self.y, self.z)
49    }
50}
51
52// ---------------------------------------------------------------------------
53// ChunkedCacheConfig
54// ---------------------------------------------------------------------------
55
56/// Configuration for the chunked mesh cache.
57#[derive(Debug, Clone)]
58pub struct ChunkedCacheConfig {
59    /// Size of each chunk in world units.
60    pub chunk_size: f32,
61    /// Resolution (voxels per axis) used when meshing a single chunk.
62    pub chunk_resolution: usize,
63    /// Optional disk persistence directory for `.abm` files.
64    pub cache_dir: Option<PathBuf>,
65    /// Maximum number of chunk meshes kept in memory.
66    pub max_cached_chunks: usize,
67}
68
69impl Default for ChunkedCacheConfig {
70    fn default() -> Self {
71        Self {
72            chunk_size: 1.0,
73            chunk_resolution: 32,
74            cache_dir: None,
75            max_cached_chunks: 512,
76        }
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Internal: ChunkEntry
82// ---------------------------------------------------------------------------
83
84/// A single cached chunk entry.
85struct ChunkEntry {
86    /// The generated mesh for this chunk.
87    mesh: Arc<Mesh>,
88    /// Hash of the SDF at the time this mesh was generated.
89    sdf_hash: u64,
90    /// Whether this entry has been modified since last persist.
91    dirty: bool,
92}
93
94/// Internal storage combining entries with insertion-order tracking for FIFO eviction.
95struct ChunkedStore {
96    entries: HashMap<ChunkCoord, ChunkEntry>,
97    /// Insertion order: oldest at front, newest at back.
98    order: Vec<ChunkCoord>,
99}
100
101// ---------------------------------------------------------------------------
102// ChunkedMeshCache
103// ---------------------------------------------------------------------------
104
105/// Thread-safe chunked incremental mesh cache.
106///
107/// Divides the SDF evaluation space into a uniform grid of chunks. Each
108/// chunk is independently cached and can be marked dirty when the underlying
109/// SDF changes — either globally or within a specific region.
110pub struct ChunkedMeshCache {
111    config: ChunkedCacheConfig,
112    chunks: RwLock<ChunkedStore>,
113    /// Current global SDF hash used for invalidation.
114    current_sdf_hash: RwLock<u64>,
115}
116
117impl ChunkedMeshCache {
118    /// Create a new chunked mesh cache.
119    pub fn new(config: ChunkedCacheConfig) -> Self {
120        Self {
121            config,
122            chunks: RwLock::new(ChunkedStore {
123                entries: HashMap::new(),
124                order: Vec::new(),
125            }),
126            current_sdf_hash: RwLock::new(0),
127        }
128    }
129
130    // -- Query ---------------------------------------------------------------
131
132    /// Get the chunk mesh at the given coordinate, if cached and valid.
133    pub fn get_chunk(&self, coord: &ChunkCoord) -> Option<Arc<Mesh>> {
134        let chunks = self
135            .chunks
136            .read()
137            .expect("ChunkedMeshCache: RwLock poisoned on chunks.read() in get_chunk()");
138        chunks.entries.get(coord).map(|e| Arc::clone(&e.mesh))
139    }
140
141    /// Insert or update a chunk mesh.
142    ///
143    /// Uses FIFO eviction: when at capacity, the oldest-inserted chunk is
144    /// removed first — deterministic and cache-friendly compared to random.
145    pub fn set_chunk(&self, coord: ChunkCoord, mesh: Mesh, sdf_hash: u64) {
146        let mut chunks = self
147            .chunks
148            .write()
149            .expect("ChunkedMeshCache: RwLock poisoned on chunks.write() in set_chunk()");
150
151        // FIFO eviction: remove the oldest entry when at capacity.
152        if chunks.entries.len() >= self.config.max_cached_chunks
153            && !chunks.entries.contains_key(&coord)
154        {
155            if let Some(evict_key) = chunks.order.first().copied() {
156                chunks.entries.remove(&evict_key);
157                chunks.order.remove(0);
158            }
159        }
160
161        // If updating an existing entry, remove old position from order.
162        if chunks.entries.contains_key(&coord) {
163            if let Some(pos) = chunks.order.iter().position(|c| *c == coord) {
164                chunks.order.remove(pos);
165            }
166        }
167
168        chunks.entries.insert(
169            coord,
170            ChunkEntry {
171                mesh: Arc::new(mesh),
172                sdf_hash,
173                dirty: true,
174            },
175        );
176        chunks.order.push(coord);
177    }
178
179    // -- Invalidation --------------------------------------------------------
180
181    /// Mark **all** chunks as dirty (e.g. the SDF changed globally).
182    pub fn invalidate_all(&self) {
183        let mut chunks = self
184            .chunks
185            .write()
186            .expect("ChunkedMeshCache: RwLock poisoned on chunks.write() in invalidate_all()");
187        for entry in chunks.entries.values_mut() {
188            entry.dirty = true;
189        }
190    }
191
192    /// Mark chunks overlapping a world-space bounding box as dirty.
193    pub fn invalidate_region(&self, min: Vec3, max: Vec3) {
194        let coords = self.chunks_in_bounds(min, max);
195        let mut chunks = self
196            .chunks
197            .write()
198            .expect("ChunkedMeshCache: RwLock poisoned on chunks.write() in invalidate_region()");
199        for c in &coords {
200            if let Some(entry) = chunks.entries.get_mut(c) {
201                entry.dirty = true;
202            }
203        }
204    }
205
206    /// Get the coordinates of all currently dirty chunks.
207    pub fn dirty_chunks(&self) -> Vec<ChunkCoord> {
208        let chunks = self
209            .chunks
210            .read()
211            .expect("ChunkedMeshCache: RwLock poisoned on chunks.read() in dirty_chunks()");
212        chunks
213            .entries
214            .iter()
215            .filter(|(_, e)| e.dirty)
216            .map(|(&c, _)| c)
217            .collect()
218    }
219
220    /// Update the global SDF hash. Returns the list of chunks that were
221    /// invalidated (those whose stored hash differs from `new_hash`).
222    #[allow(clippy::significant_drop_tightening)]
223    pub fn update_sdf_hash(&self, new_hash: u64) -> Vec<ChunkCoord> {
224        {
225            let mut h = self.current_sdf_hash.write()
226                .expect("ChunkedMeshCache: RwLock poisoned on current_sdf_hash.write() in update_sdf_hash()");
227            *h = new_hash;
228        }
229
230        let mut invalidated = Vec::new();
231        let mut chunks = self
232            .chunks
233            .write()
234            .expect("ChunkedMeshCache: RwLock poisoned on chunks.write() in update_sdf_hash()");
235        for (&coord, entry) in chunks.entries.iter_mut() {
236            if entry.sdf_hash != new_hash {
237                entry.dirty = true;
238                invalidated.push(coord);
239            }
240        }
241        invalidated
242    }
243
244    // -- Enumeration ---------------------------------------------------------
245
246    /// Get all cached chunk coordinates.
247    pub fn cached_chunks(&self) -> Vec<ChunkCoord> {
248        let chunks = self
249            .chunks
250            .read()
251            .expect("ChunkedMeshCache: RwLock poisoned on chunks.read() in cached_chunks()");
252        chunks.entries.keys().copied().collect()
253    }
254
255    /// Number of cached chunks.
256    #[inline]
257    pub fn chunk_count(&self) -> usize {
258        let chunks = self
259            .chunks
260            .read()
261            .expect("ChunkedMeshCache: RwLock poisoned on chunks.read() in chunk_count()");
262        chunks.entries.len()
263    }
264
265    /// Total approximate memory usage of all cached chunk meshes (bytes).
266    pub fn memory_usage(&self) -> usize {
267        let chunks = self
268            .chunks
269            .read()
270            .expect("ChunkedMeshCache: RwLock poisoned on chunks.read() in memory_usage()");
271        chunks
272            .entries
273            .values()
274            .map(|e| estimate_mesh_bytes(&e.mesh))
275            .sum()
276    }
277
278    // -- Coordinate helpers --------------------------------------------------
279
280    /// Convert a world-space position to its containing chunk coordinate.
281    #[inline]
282    pub fn world_to_chunk(&self, pos: Vec3) -> ChunkCoord {
283        let s = self.config.chunk_size;
284        ChunkCoord {
285            x: pos.x.div_euclid(s).floor() as i32,
286            y: pos.y.div_euclid(s).floor() as i32,
287            z: pos.z.div_euclid(s).floor() as i32,
288        }
289    }
290
291    /// Get the axis-aligned bounding box `(min, max)` for a chunk coordinate.
292    #[inline]
293    pub fn chunk_bounds(&self, coord: &ChunkCoord) -> (Vec3, Vec3) {
294        let s = self.config.chunk_size;
295        let min = Vec3::new(coord.x as f32 * s, coord.y as f32 * s, coord.z as f32 * s);
296        let max = min + Vec3::splat(s);
297        (min, max)
298    }
299
300    /// Compute all chunk coordinates required to cover the given bounding box.
301    pub fn chunks_in_bounds(&self, min: Vec3, max: Vec3) -> Vec<ChunkCoord> {
302        let lo = self.world_to_chunk(min);
303        let hi = self.world_to_chunk(max);
304        let mut coords = Vec::new();
305        for x in lo.x..=hi.x {
306            for y in lo.y..=hi.y {
307                for z in lo.z..=hi.z {
308                    coords.push(ChunkCoord::new(x, y, z));
309                }
310            }
311        }
312        coords
313    }
314
315    // -- Persistence ---------------------------------------------------------
316
317    /// Persist all dirty chunks to disk as individual `.abm` files.
318    ///
319    /// Requires `cache_dir` to be set in the configuration. Returns the
320    /// number of chunks written.
321    #[allow(clippy::significant_drop_tightening)]
322    pub fn persist_dirty(&self) -> Result<usize, IoError> {
323        let dir = self.config.cache_dir.as_ref().ok_or_else(|| {
324            IoError::Io(std::io::Error::new(
325                std::io::ErrorKind::NotFound,
326                "cache_dir not configured",
327            ))
328        })?;
329        std::fs::create_dir_all(dir)?;
330
331        let mut chunks = self
332            .chunks
333            .write()
334            .expect("ChunkedMeshCache: RwLock poisoned on chunks.write() in persist_dirty()");
335        let mut written = 0usize;
336        for (&coord, entry) in chunks.entries.iter_mut() {
337            if entry.dirty {
338                let path = dir.join(format!("{}_{}_{}_.abm", coord.x, coord.y, coord.z));
339                save_abm(&entry.mesh, &path)?;
340                entry.dirty = false;
341                written += 1;
342            }
343        }
344        Ok(written)
345    }
346
347    /// Load a single chunk from the disk cache. Returns `None` if the file
348    /// does not exist. A chunk that is not cached yet is added at the back of
349    /// the FIFO order, evicting the oldest chunk when the cache is full.
350    #[allow(clippy::significant_drop_tightening)]
351    pub fn load_chunk(&self, coord: &ChunkCoord) -> Result<Option<Arc<Mesh>>, IoError> {
352        let dir = self.config.cache_dir.as_ref().ok_or_else(|| {
353            IoError::Io(std::io::Error::new(
354                std::io::ErrorKind::NotFound,
355                "cache_dir not configured",
356            ))
357        })?;
358        let path = dir.join(format!("{}_{}_{}_.abm", coord.x, coord.y, coord.z));
359        if !path.exists() {
360            return Ok(None);
361        }
362        let mesh = load_abm(&path)?;
363        let sdf_hash = *self
364            .current_sdf_hash
365            .read()
366            .expect("ChunkedMeshCache: RwLock poisoned on current_sdf_hash.read() in load_chunk()");
367        let arc = Arc::new(mesh);
368
369        let mut chunks = self
370            .chunks
371            .write()
372            .expect("ChunkedMeshCache: RwLock poisoned on chunks.write() in load_chunk()");
373        let is_new = !chunks.entries.contains_key(coord);
374        // Same FIFO eviction as `set_chunk`: a reloaded chunk must not push the
375        // cache past `max_cached_chunks`.
376        if is_new && chunks.entries.len() >= self.config.max_cached_chunks {
377            if let Some(evict_key) = chunks.order.first().copied() {
378                chunks.entries.remove(&evict_key);
379                chunks.order.remove(0);
380            }
381        }
382        chunks.entries.insert(
383            *coord,
384            ChunkEntry {
385                mesh: Arc::clone(&arc),
386                sdf_hash,
387                dirty: false,
388            },
389        );
390        if is_new {
391            chunks.order.push(*coord);
392        }
393        Ok(Some(arc))
394    }
395
396    // -- Bulk operations -----------------------------------------------------
397
398    /// Clear all cached chunks.
399    pub fn clear(&self) {
400        let mut chunks = self
401            .chunks
402            .write()
403            .expect("ChunkedMeshCache: RwLock poisoned on chunks.write() in clear()");
404        chunks.entries.clear();
405        chunks.order.clear();
406    }
407
408    /// Merge all cached chunk meshes into a single combined [`Mesh`].
409    ///
410    /// Vertex positions are preserved in world space. Index offsets are
411    /// adjusted so the resulting mesh is self-consistent.
412    ///
413    /// Chunks are concatenated in ascending `(x, y, z)` order of their
414    /// [`ChunkCoord`], so the same set of chunks always gives the same vertex
415    /// and index order, whatever the insertion order and from one run to the
416    /// next (until 5.0.0 the order was that of the internal `HashMap`, which
417    /// changes between runs).
418    #[allow(clippy::significant_drop_tightening)]
419    pub fn merge_all(&self) -> Mesh {
420        let chunks = self
421            .chunks
422            .read()
423            .expect("ChunkedMeshCache: RwLock poisoned on chunks.read() in merge_all()");
424        let mut ordered: Vec<(&ChunkCoord, &ChunkEntry)> = chunks.entries.iter().collect();
425        ordered.sort_unstable_by_key(|(c, _)| (c.x, c.y, c.z));
426        let mut vertices: Vec<Vertex> = Vec::new();
427        let mut indices: Vec<u32> = Vec::new();
428        for (_, entry) in ordered {
429            let base = vertices.len() as u32;
430            vertices.extend_from_slice(&entry.mesh.vertices);
431            indices.extend(entry.mesh.indices.iter().map(|&i| i + base));
432        }
433        Mesh { vertices, indices }
434    }
435}
436
437// ---------------------------------------------------------------------------
438// Helpers
439// ---------------------------------------------------------------------------
440
441/// Estimate mesh memory footprint in bytes.
442#[inline(always)]
443fn estimate_mesh_bytes(mesh: &Mesh) -> usize {
444    let vertex_size = std::mem::size_of::<Vertex>();
445    mesh.vertices.len() * vertex_size + mesh.indices.len() * std::mem::size_of::<u32>()
446}
447
448// ---------------------------------------------------------------------------
449// Tests
450// ---------------------------------------------------------------------------
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::mesh::Vertex;
456    use glam::Vec3;
457
458    /// Helper: create a simple test mesh.
459    fn make_mesh(num_verts: usize, num_tris: usize) -> Mesh {
460        let vertices: Vec<Vertex> = (0..num_verts)
461            .map(|i| {
462                let f = i as f32;
463                Vertex::new(Vec3::new(f, f, f), Vec3::Y)
464            })
465            .collect();
466        let indices: Vec<u32> = (0..num_tris * 3).map(|i| (i % num_verts) as u32).collect();
467        Mesh { vertices, indices }
468    }
469
470    #[test]
471    fn test_world_to_chunk_positive() {
472        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
473            chunk_size: 2.0,
474            ..Default::default()
475        });
476        let c = cache.world_to_chunk(Vec3::new(3.5, 1.0, 5.9));
477        assert_eq!(c, ChunkCoord::new(1, 0, 2));
478    }
479
480    #[test]
481    fn test_world_to_chunk_negative() {
482        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
483            chunk_size: 1.0,
484            ..Default::default()
485        });
486        let c = cache.world_to_chunk(Vec3::new(-0.5, -1.5, -0.01));
487        assert_eq!(c, ChunkCoord::new(-1, -2, -1));
488    }
489
490    #[test]
491    fn test_world_to_chunk_on_boundary() {
492        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
493            chunk_size: 1.0,
494            ..Default::default()
495        });
496        // Exactly on a boundary should map to the lower chunk.
497        let c = cache.world_to_chunk(Vec3::new(1.0, 0.0, -1.0));
498        assert_eq!(c, ChunkCoord::new(1, 0, -1));
499    }
500
501    #[test]
502    fn test_chunk_bounds() {
503        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
504            chunk_size: 2.0,
505            ..Default::default()
506        });
507        let (min, max) = cache.chunk_bounds(&ChunkCoord::new(1, -1, 0));
508        assert!((min.x - 2.0).abs() < 1e-6);
509        assert!((min.y - (-2.0)).abs() < 1e-6);
510        assert!((min.z - 0.0).abs() < 1e-6);
511        assert!((max.x - 4.0).abs() < 1e-6);
512        assert!((max.y - 0.0).abs() < 1e-6);
513        assert!((max.z - 2.0).abs() < 1e-6);
514    }
515
516    #[test]
517    fn test_chunks_in_bounds() {
518        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
519            chunk_size: 1.0,
520            ..Default::default()
521        });
522        let coords = cache.chunks_in_bounds(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.5, 0.5, 0.5));
523        // Should cover (0,0,0) and (1,0,0)
524        assert!(coords.contains(&ChunkCoord::new(0, 0, 0)));
525        assert!(coords.contains(&ChunkCoord::new(1, 0, 0)));
526    }
527
528    #[test]
529    fn test_insert_and_get() {
530        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
531        let coord = ChunkCoord::new(0, 0, 0);
532        let mesh = make_mesh(4, 2);
533
534        cache.set_chunk(coord, mesh, 42);
535        let retrieved = cache.get_chunk(&coord);
536        assert!(retrieved.is_some());
537        assert_eq!(retrieved.unwrap().vertex_count(), 4);
538    }
539
540    #[test]
541    fn test_get_missing_returns_none() {
542        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
543        assert!(cache.get_chunk(&ChunkCoord::new(99, 99, 99)).is_none());
544    }
545
546    #[test]
547    fn test_invalidate_region() {
548        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
549            chunk_size: 1.0,
550            ..Default::default()
551        });
552
553        // Insert several chunks and mark them clean.
554        for x in 0..3 {
555            for z in 0..3 {
556                cache.set_chunk(ChunkCoord::new(x, 0, z), make_mesh(4, 2), 1);
557            }
558        }
559        // Manually mark all clean first.
560        {
561            let mut chunks = cache.chunks.write().unwrap();
562            for e in chunks.entries.values_mut() {
563                e.dirty = false;
564            }
565        }
566
567        // Invalidate a region covering chunk (1,0,1) only.
568        cache.invalidate_region(Vec3::new(1.1, 0.0, 1.1), Vec3::new(1.9, 0.9, 1.9));
569
570        let dirty = cache.dirty_chunks();
571        assert_eq!(dirty.len(), 1);
572        assert_eq!(dirty[0], ChunkCoord::new(1, 0, 1));
573    }
574
575    #[test]
576    fn test_invalidate_all() {
577        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
578        cache.set_chunk(ChunkCoord::new(0, 0, 0), make_mesh(4, 2), 1);
579        cache.set_chunk(ChunkCoord::new(1, 0, 0), make_mesh(4, 2), 1);
580        // Mark clean.
581        {
582            let mut chunks = cache.chunks.write().unwrap();
583            for e in chunks.entries.values_mut() {
584                e.dirty = false;
585            }
586        }
587        assert_eq!(cache.dirty_chunks().len(), 0);
588
589        cache.invalidate_all();
590        assert_eq!(cache.dirty_chunks().len(), 2);
591    }
592
593    #[test]
594    fn test_merge_all() {
595        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
596        // Chunk A: 4 verts, indices [0,1,2, 1,2,3]
597        let mut mesh_a = make_mesh(4, 0);
598        mesh_a.indices = vec![0, 1, 2, 1, 2, 3];
599        cache.set_chunk(ChunkCoord::new(0, 0, 0), mesh_a, 1);
600
601        // Chunk B: 3 verts, indices [0,1,2]
602        let mut mesh_b = make_mesh(3, 0);
603        mesh_b.indices = vec![0, 1, 2];
604        cache.set_chunk(ChunkCoord::new(1, 0, 0), mesh_b, 1);
605
606        let merged = cache.merge_all();
607        assert_eq!(merged.vertices.len(), 7); // 4 + 3
608        assert_eq!(merged.indices.len(), 9); // 6 + 3
609
610        // Indices from chunk B should be offset by 4 (or by the vertex count
611        // of whatever chunk was iterated first -- HashMap order is unspecified).
612        // Just verify all indices are in-bounds.
613        for &idx in &merged.indices {
614            assert!((idx as usize) < merged.vertices.len());
615        }
616    }
617
618    #[test]
619    fn test_memory_usage() {
620        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
621        assert_eq!(cache.memory_usage(), 0);
622
623        let mesh = make_mesh(10, 4);
624        let expected = 10 * std::mem::size_of::<Vertex>() + 12 * std::mem::size_of::<u32>();
625        cache.set_chunk(ChunkCoord::new(0, 0, 0), mesh, 1);
626        assert_eq!(cache.memory_usage(), expected);
627    }
628
629    #[test]
630    fn test_update_sdf_hash_invalidation() {
631        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
632        cache.set_chunk(ChunkCoord::new(0, 0, 0), make_mesh(4, 2), 10);
633        cache.set_chunk(ChunkCoord::new(1, 0, 0), make_mesh(4, 2), 10);
634        cache.set_chunk(ChunkCoord::new(2, 0, 0), make_mesh(4, 2), 20);
635        // Mark clean.
636        {
637            let mut chunks = cache.chunks.write().unwrap();
638            for e in chunks.entries.values_mut() {
639                e.dirty = false;
640            }
641        }
642
643        // Update hash to 20 — chunks with hash 10 should be invalidated.
644        let invalidated = cache.update_sdf_hash(20);
645        assert_eq!(invalidated.len(), 2);
646        // Chunk (2,0,0) already matches hash 20, so NOT invalidated.
647        assert!(!invalidated.contains(&ChunkCoord::new(2, 0, 0)));
648    }
649
650    #[test]
651    fn test_clear() {
652        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
653        cache.set_chunk(ChunkCoord::new(0, 0, 0), make_mesh(4, 2), 1);
654        cache.set_chunk(ChunkCoord::new(1, 0, 0), make_mesh(4, 2), 1);
655        assert_eq!(cache.chunk_count(), 2);
656
657        cache.clear();
658        assert_eq!(cache.chunk_count(), 0);
659        assert!(cache.get_chunk(&ChunkCoord::new(0, 0, 0)).is_none());
660    }
661
662    #[test]
663    fn test_cached_chunks() {
664        let cache = ChunkedMeshCache::new(ChunkedCacheConfig::default());
665        cache.set_chunk(ChunkCoord::new(0, 0, 0), make_mesh(4, 2), 1);
666        cache.set_chunk(ChunkCoord::new(1, 2, 3), make_mesh(4, 2), 1);
667
668        let cached = cache.cached_chunks();
669        assert_eq!(cached.len(), 2);
670        assert!(cached.contains(&ChunkCoord::new(0, 0, 0)));
671        assert!(cached.contains(&ChunkCoord::new(1, 2, 3)));
672    }
673
674    #[test]
675    fn test_persist_dirty_and_load_chunk() {
676        let dir = std::env::temp_dir().join("alice_sdf_chunked_test");
677        let _ = std::fs::remove_dir_all(&dir);
678
679        let config = ChunkedCacheConfig {
680            cache_dir: Some(dir.clone()),
681            ..Default::default()
682        };
683        let cache = ChunkedMeshCache::new(config);
684
685        // Insert a mesh and persist.
686        let coord = ChunkCoord::new(1, -2, 3);
687        cache.set_chunk(coord, make_mesh(6, 3), 42);
688        let written = cache.persist_dirty().unwrap();
689        assert_eq!(written, 1);
690
691        // Dirty should now be empty.
692        assert_eq!(cache.dirty_chunks().len(), 0);
693
694        // Create a fresh cache and load from disk.
695        let config2 = ChunkedCacheConfig {
696            cache_dir: Some(dir.clone()),
697            ..Default::default()
698        };
699        let cache2 = ChunkedMeshCache::new(config2);
700        let loaded = cache2.load_chunk(&coord).unwrap();
701        assert!(loaded.is_some());
702        assert_eq!(loaded.unwrap().vertex_count(), 6);
703
704        // Cleanup.
705        let _ = std::fs::remove_dir_all(&dir);
706    }
707
708    #[test]
709    fn test_persist_without_cache_dir_errors() {
710        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
711            cache_dir: None,
712            ..Default::default()
713        });
714        cache.set_chunk(ChunkCoord::new(0, 0, 0), make_mesh(4, 2), 1);
715        assert!(cache.persist_dirty().is_err());
716    }
717
718    #[test]
719    fn test_max_cached_chunks_eviction() {
720        let cache = ChunkedMeshCache::new(ChunkedCacheConfig {
721            max_cached_chunks: 3,
722            ..Default::default()
723        });
724        for i in 0..5 {
725            cache.set_chunk(ChunkCoord::new(i, 0, 0), make_mesh(4, 2), 1);
726        }
727        // Should never exceed max.
728        assert!(cache.chunk_count() <= 3);
729    }
730}