all_is_cubes/
chunking.rs

1//! Algorithms for grouping cubes into cubical batches (chunks).
2
3use all_is_cubes_base::math::{GridSize, Octant};
4use alloc::collections::BTreeMap;
5use alloc::sync::Arc;
6use alloc::vec::Vec;
7use core::fmt;
8use core::iter::FusedIterator;
9use core::ops::RangeTo;
10
11#[cfg(feature = "std")]
12use std::sync::Mutex;
13
14use euclid::{Point3D, Vector3D};
15
16/// Acts as polyfill for float methods
17#[cfg(not(any(feature = "std", test)))]
18#[allow(
19    unused_imports,
20    reason = "unclear why this warns even though it is needed"
21)]
22use num_traits::float::FloatCore as _;
23
24#[cfg(not(any(feature = "std", test)))]
25#[allow(
26    unused_imports,
27    reason = "unclear why this warns even though it is needed"
28)]
29use crate::math::Euclid as _;
30use crate::math::{
31    Cube, FreeCoordinate, FreePoint, GridAab, GridCoordinate, GridPoint, GridSizeCoord, OctantMask,
32};
33
34/// Unit-of-measure type for chunk positions *not* tracking the chunk size in the type.
35#[derive(Debug)]
36enum WholeChunk {}
37
38/// Unit-of-measure/coordinate-system type for cubes within a chunk (range `0..CHUNK_SIZE`)
39#[expect(clippy::exhaustive_enums)]
40#[derive(Debug)]
41pub enum ChunkRelative {}
42
43/// Relative chunk position (coordinates in units of whole chunks)
44type Ccv = Vector3D<i32, WholeChunk>;
45
46/// Type to distinguish chunk coordinates from cube coordinates.
47///
48/// Chunk math is generally just like cube math (hence the type of the field), but we
49/// don't want to confuse the two and forget to multiply or divide.
50/// A `ChunkPos([x, y, z])` identifies the chunk which contains the cubes with `x` coordinates
51/// in the half-open range `x * CHUNK_SIZE..(x + 1) * CHUNK_SIZE`, and similarly for the
52/// `y` and `z` axes.
53///
54/// The consequences are unspecified if `CHUNK_SIZE` is not positive.
55#[derive(Clone, Copy, Eq, Hash, PartialEq)]
56#[expect(clippy::exhaustive_structs)]
57pub struct ChunkPos<const CHUNK_SIZE: GridCoordinate>(pub Cube);
58
59impl<const CHUNK_SIZE: GridCoordinate> fmt::Debug for ChunkPos<CHUNK_SIZE> {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        let Self(Cube { x, y, z }) = *self;
62        write!(f, "ChunkPos<{CHUNK_SIZE}>({x}, {y}, {z})")
63    }
64}
65
66impl<const CHUNK_SIZE: GridCoordinate> ChunkPos<CHUNK_SIZE> {
67    /// Construct a [`ChunkPos`] from chunk coordinates
68    /// (i.e. successive numbers indicate adjacent chunks).
69    pub const fn new(x: GridCoordinate, y: GridCoordinate, z: GridCoordinate) -> Self {
70        Self(Cube::new(x, y, z))
71    }
72
73    /// Returns the bounds of this chunk as a [`GridAab`].
74    ///
75    /// TODO: specify what happens if the result would overflow.
76    pub fn bounds(self) -> GridAab {
77        GridAab::from_lower_size(
78            self.0.lower_bounds() * CHUNK_SIZE,
79            GridSize::splat(CHUNK_SIZE.cast_unsigned()),
80        )
81    }
82
83    /// Returns the distance between the two given chunks. See the [`Distance`] for an
84    /// explanation of what that means.
85    pub fn distance(self, other: Self) -> Distance {
86        chunk_distance_squared_for_view((self.0 - other.0).cast_unit())
87    }
88
89    /// Returns the squared distance along the shortest line from `origin_chunk`'s bounds
90    /// to this chunk's bounds.
91    ///
92    /// This is the same criterion that [`ChunkChart`] uses for
93    /// deciding whether a chunk is included in the chart or not.
94    pub fn min_distance_squared_from(self, origin_chunk: Self) -> GridCoordinate {
95        // TODO: change this to return the `Distance` instead of a value derived from it.
96        // That'll be less exactly-one-use-case.
97        self.distance(origin_chunk).nearest_approach_squared.cast_signed() * CHUNK_SIZE.pow(2)
98    }
99}
100
101/// Scale a cube position to obtain the containing chunk and the cube position within it.
102//---
103// Design note: these operations are combined to allow skipping some numeric overflow cases.
104pub fn cube_to_chunk<const CHUNK_SIZE: GridCoordinate>(
105    cube: Cube,
106) -> (ChunkPos<CHUNK_SIZE>, Point3D<GridCoordinate, ChunkRelative>) {
107    let pos = cube.lower_bounds();
108    (
109        ChunkPos(Cube::from(pos.map(|c| c.div_euclid(CHUNK_SIZE)))),
110        pos.map(|c| c.rem_euclid(CHUNK_SIZE)).cast_unit(),
111    )
112}
113
114/// Scale an arbitrary point to obtain the containing chunk.
115pub fn point_to_chunk<const CHUNK_SIZE: GridCoordinate>(point: FreePoint) -> ChunkPos<CHUNK_SIZE> {
116    ChunkPos(
117        Cube::containing(
118        point.map(|c| c.div_euclid(FreeCoordinate::from(CHUNK_SIZE))),
119    ).unwrap(/* TODO */),
120    )
121}
122
123/// A distance between two chunks, taking into consideration their entire volume.
124///
125/// Implements [`Ord`] to be comparable as a distance value, with the following properties:
126///
127/// * It matches [`ChunkChart`]'s concept of view distance: the minimum Euclidean distance
128///   from any point of two chunks, so that if nothing farther away than D can be seen
129///   then this chunk cannot be seen from any point within the origin chunk.
130/// * It is usable as a depth sort: chunks sorted by this distance from the chunk containing
131///   the eye position will be sorted in back-to-front or front-to-back order.
132#[derive(Copy, Clone, Eq, Ord, PartialEq, PartialOrd)]
133pub struct Distance {
134    /// The squared Euclidean distance between the nearest two chunk corners.
135    ///
136    /// As a concrete example, the distance value between any two chunks which touch on a
137    /// face, edge, or corner has zero in this field; if they have one chunk separating
138    /// them along one axis, then this field would be 1.
139    nearest_approach_squared: u32,
140    /// The number of coordinate axes along which the two chunks have coordinates differing
141    /// by more than zero.
142    ///
143    /// This field, being second, acts as an [`Ord`] tie-breaker after
144    /// [`Self::nearest_approach_squared`], counteracting the effect of having subtracted 1
145    /// such that the chunks which lie in the coordinate planes are counted as nearer than
146    /// the ones which don't.
147    off_plane_count: u8,
148}
149
150impl fmt::Debug for Distance {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        let Distance {
153            nearest_approach_squared,
154            off_plane_count,
155        } = self;
156        write!(f, "{nearest_approach_squared}+{off_plane_count}")
157    }
158}
159
160/// Precomputed information about the spherical pattern of chunks within view distance.
161///
162/// In order to use the same pattern for all possible view positions, the view position is
163/// rounded to enclosing chunk position.
164#[derive(Clone, Debug, Eq, PartialEq)] // TODO: customize Debug and PartialEq
165pub struct ChunkChart<const CHUNK_SIZE: GridCoordinate> {
166    /// The maximum view distance which this chart is designed for,
167    /// squared, in multiples of a whole chunk.
168    view_distance_in_squared_chunks: GridSizeCoord,
169
170    /// One octant of chunk positions (scaled down by `CHUNK_SIZE`) sorted by distance.
171    /// (It could be further reduced to a 64th by mirroring across the diagonal,
172    /// but then the indexing gets more complicated.)
173    ///
174    /// The full sphere can be constructed by mirroring this, minus the central plane.
175    /// That is, looking at a 2D slice we'd be storing the "#" and mirroring the "+" in:
176    ///
177    /// ```text
178    ///  +##
179    /// ++###
180    /// ++###
181    /// +++++
182    ///  +++
183    /// ```
184    ///
185    /// This vector may contain more than the desired chunks; this is done so that a small
186    /// chart can reuse the work to construct a large one.
187    /// TODO: That is not actually implemented.
188    octant_chunks: Arc<[Ccv]>,
189
190    /// Range of elements of `octant_chunks` to actually use.
191    octant_range: RangeTo<usize>,
192}
193
194impl<const CHUNK_SIZE: GridCoordinate> ChunkChart<CHUNK_SIZE> {
195    /// Constructs a new chart with the given view radius (in blocks).
196    ///
197    /// This function may reuse the calculations from previous calls.
198    pub fn new(view_distance: FreeCoordinate) -> Self {
199        let view_distance_in_squared_chunks = Self::sanitize_and_square_distance(view_distance);
200        let octant_chunks = get_or_compute_chart_octant(view_distance_in_squared_chunks);
201        Self {
202            view_distance_in_squared_chunks,
203            octant_range: ..octant_chunks.len(),
204            octant_chunks,
205        }
206    }
207
208    fn sanitize_and_square_distance(view_distance: FreeCoordinate) -> GridSizeCoord {
209        let sanitized = if view_distance.is_finite() {
210            view_distance.max(0.)
211        } else {
212            0.
213        };
214        let view_distance_in_chunks = sanitized / FreeCoordinate::from(CHUNK_SIZE);
215
216        view_distance_in_chunks.powi(2).ceil() as GridSizeCoord
217    }
218
219    /// Recalculate the chart if the provided distance is different.
220    pub fn resize_if_needed(&mut self, view_distance: FreeCoordinate) {
221        if Self::sanitize_and_square_distance(view_distance) != self.view_distance_in_squared_chunks
222        {
223            // TODO: If shrinking the chart, just shrink octant_range instead of
224            // recomputing
225            *self = Self::new(view_distance);
226        }
227    }
228
229    /// Returns an iterator over the chunks in this chart — i.e. those intersecting a sphere
230    /// (or more precisely, the Minkowski sum of a sphere and the chunk) around the given
231    /// origin chunk.
232    ///
233    /// The chunks are ordered from nearest to farthest in Euclidean distance; the iterator is a
234    /// [`DoubleEndedIterator`] so that [`Iterator::rev`] may be used to iterate from
235    /// farthest to nearest.
236    ///
237    /// `mask` specifies which directions are in view, in terms of octants of direction vectors.
238    /// Chunks only visible in directions not in the mask will be culled.
239    pub fn chunks(
240        &self,
241        origin: ChunkPos<CHUNK_SIZE>,
242        mask: OctantMask,
243    ) -> impl DoubleEndedIterator<Item = ChunkPos<CHUNK_SIZE>> + FusedIterator + '_ {
244        self.octant_chunks[self.octant_range]
245            .iter()
246            .copied()
247            .flat_map(move |v| AxisMirrorIter::new(v, mask))
248            .map(move |v| ChunkPos(origin.0 + v.cast_unit()))
249    }
250
251    /// Convert to a `Space` so it can be directly viewed; for tests.
252    #[doc(hidden)]
253    #[mutants::skip]
254    pub fn visualization(&self) -> crate::space::Space {
255        use crate::block::Block;
256        use crate::math::Rgba;
257
258        let mut max = GridPoint::origin();
259        for chunk in self.octant_chunks[self.octant_range].iter().copied() {
260            max = max.max(chunk.to_point().cast_unit());
261        }
262        let extent = GridAab::from_lower_upper(max.map(|c| -c - 1), max.map(|c| c + 2));
263
264        // TODO: use wireframe blocks instead, or something that will highlight the counts better
265        let base_octant_chunk = Block::builder()
266            .display_name("Base octant chunk")
267            .color(Rgba::new(0.75, 0.4, 0.4, 1.0))
268            .build();
269        let other_octant_chunk_1 = Block::builder()
270            .display_name("Mirrored octant chunk")
271            .color(Rgba::new(0.5, 0.5, 0.5, 1.0))
272            .build();
273        let other_octant_chunk_2 = Block::builder()
274            .display_name("Mirrored octant chunk")
275            .color(Rgba::new(0.6, 0.6, 0.6, 1.0))
276            .build();
277
278        crate::space::Space::builder(extent)
279            .build_and_mutate(|m| {
280                for ChunkPos(pos) in self.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL) {
281                    let px = pos.x >= 0;
282                    let py = pos.y >= 0;
283                    let pz = pos.z >= 0;
284                    let block = if px && py && pz {
285                        &base_octant_chunk
286                    } else if px ^ py ^ pz {
287                        &other_octant_chunk_1
288                    } else {
289                        &other_octant_chunk_2
290                    };
291                    m.set(pos, block).unwrap();
292                }
293                Ok(())
294            })
295            .unwrap()
296    }
297
298    /// Returns the total number of chunks in this chart.
299    /// Currently overestimates.
300    #[doc(hidden)] // TODO: unclear if good api
301    pub fn count_all(&self) -> usize {
302        // TODO: this is an overcount because it doesn't allow for axis-plane chunks that aren't mirrored
303        self.octant_range.end * 8
304    }
305}
306
307fn get_or_compute_chart_octant(view_distance_in_squared_chunks: GridSizeCoord) -> Arc<[Ccv]> {
308    #[cfg(feature = "std")]
309    let mut cache = match CHUNK_CHART_CACHE.lock() {
310        Ok(cache) => cache,
311        Err(p) => {
312            // If poisoned, clear the cache.
313            let mut cache = p.into_inner();
314            *cache = BTreeMap::new();
315            cache
316        }
317    };
318
319    // If not on std, don't use a global cache. TODO: improve on  this
320    #[cfg(not(feature = "std"))]
321    let mut cache = BTreeMap::new();
322
323    let len = cache.len();
324
325    use alloc::collections::btree_map::Entry;
326    match cache.entry(view_distance_in_squared_chunks) {
327        Entry::Occupied(e) => {
328            // eprintln!("cache hit {view_distance_in_squared_chunks}");
329            Arc::clone(e.get())
330        }
331        Entry::Vacant(e) => {
332            // eprintln!("cache miss {view_distance_in_squared_chunks} ({len} in cache)");
333            let value = compute_chart_octant(view_distance_in_squared_chunks);
334
335            // We don't expect this to happen, but don't let the cache grow too big.
336            // TODO: LRU policy instead
337            if len < 20 {
338                e.insert(Arc::clone(&value));
339            }
340
341            value
342        }
343    }
344}
345
346#[doc(hidden)] // used for benchmarks
347pub fn reset_chunk_chart_cache() {
348    #[cfg(feature = "std")]
349    match CHUNK_CHART_CACHE.lock() {
350        Ok(mut cache) => cache.clear(),
351        Err(_) => {
352            // No action needed; will be cleared on next use
353        }
354    }
355}
356
357fn compute_chart_octant(view_distance_in_squared_chunks: GridSizeCoord) -> Arc<[Ccv]> {
358    // We're going to compute in the zero-or-positive octant, which means that the chunk origin
359    // coordinates we work with are (conveniently) the coordinates for the _nearest corner_ of
360    // each chunk.
361
362    let candidates = GridAab::from_lower_size(
363        [0, 0, 0],
364        GridSize::splat(view_distance_in_squared_chunks.saturating_add(1)),
365    )
366    .to_vol()
367    .unwrap();
368    let mut octant_chunks: Vec<Ccv> = Vec::with_capacity(candidates.volume());
369    // (This for loop has been measured as slightly faster than a .filter().collect().)
370    for chunk_cube in candidates.iter_cubes() {
371        let chunk = chunk_cube.lower_bounds().to_vector().cast_unit();
372        if chunk_distance_squared_for_view(chunk).nearest_approach_squared
373            <= view_distance_in_squared_chunks
374        {
375            octant_chunks.push(chunk);
376        }
377    }
378
379    octant_chunks.sort_unstable_by_key(depth_sort_key);
380    octant_chunks.into()
381}
382
383/// Builds on [`chunk_distance_squared_for_view`] by breaking ties so the result is
384/// a stable ordering. This is the ordering that `ChunkChart::octant_chunks` contains.
385fn depth_sort_key(&chunk: &Ccv) -> (Distance, [i32; 3]) {
386    (chunk_distance_squared_for_view(chunk), chunk.into())
387}
388
389fn chunk_distance_squared_for_view(chunk: Ccv) -> Distance {
390    let chunk = chunk.map(i32::unsigned_abs);
391    // By subtracting 1 from all coordinates, we include the chunks intersecting
392    // the view sphere centered on the _farthest corner point_ of the
393    // viewpoint-containing chunk. The shape formed (after mirroring) is the
394    // Minkowski sum of the view sphere and the chunk cube.
395    // The max(0) includes the axis-aligned span of chunks that form the
396    // Minkowski-sum-expanded cube faces.
397    Distance {
398        nearest_approach_squared: chunk
399            .map(
400                #[inline(always)]
401                |s| s.saturating_sub(1),
402            )
403            .square_length(),
404        off_plane_count: u8::from(chunk.x > 0) + u8::from(chunk.y > 0) + u8::from(chunk.z > 0),
405    }
406}
407
408/// A cache for [`get_or_compute_chart_octant()`].
409///
410/// Keys are `view_distance_in_squared_chunks` and values are `octant_chunks`.
411#[cfg(feature = "std")]
412static CHUNK_CHART_CACHE: Mutex<BTreeMap<GridSizeCoord, Arc<[Ccv]>>> = Mutex::new(BTreeMap::new());
413
414/// An iterator that returns a vector and its opposite in the specified axis,
415///
416/// Part of the implementation of [`ChunkChart`].
417struct AxisMirrorIter {
418    v: Ccv,
419    /// Which copies are yet to be emitted.
420    todo: OctantMask,
421}
422impl AxisMirrorIter {
423    #[inline]
424    fn new(v: Ccv, mask: OctantMask) -> Self {
425        // For each axis whose value is zero, collapse the mask into being one-sided on that axis
426        // to avoid duplicating the vector.
427        let todo = mask.collapse_to_negative(v.x == 0, v.y == 0, v.z == 0);
428        Self { v, todo }
429    }
430
431    fn generate_and_clear(&mut self, octant: Octant) -> Ccv {
432        self.todo.clear(octant);
433        octant.reflect(self.v)
434    }
435}
436impl Iterator for AxisMirrorIter {
437    type Item = Ccv;
438    #[inline]
439    fn next(&mut self) -> Option<Ccv> {
440        self.todo.first().map(|index| self.generate_and_clear(index))
441    }
442
443    // Not useful because in the one place AxisMirrorIter is used, use of `flat_map()`
444    // defeats it.
445    // #[inline]
446    // fn size_hint(&self) -> (usize, Option<usize>) {
447    //     let count = self.todo.count();
448    //     (count, Some(count))
449    // }
450}
451impl DoubleEndedIterator for AxisMirrorIter {
452    #[inline]
453    fn next_back(&mut self) -> Option<Self::Item> {
454        self.todo.last().map(|index| self.generate_and_clear(index))
455    }
456}
457impl ExactSizeIterator for AxisMirrorIter {}
458impl FusedIterator for AxisMirrorIter {}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::raytracer::print_space;
464    use pretty_assertions::assert_eq;
465    use rand::{Rng, SeedableRng};
466    use std::collections::HashSet;
467
468    #[test]
469    fn chunk_consistency() {
470        // TODO: this is overkill; sampling the edge cases would be sufficient
471        for cube in GridAab::from_lower_size([-1, -1, -1], [32, 32, 32]).interior_iter() {
472            let (chunk, rel) = cube_to_chunk::<16>(cube);
473            assert!(chunk.bounds().contains_cube(cube));
474            assert_eq!(
475                cube,
476                Cube::from(rel.cast_unit::<Cube>()) + chunk.bounds().lower_bounds().to_vector()
477            );
478        }
479    }
480
481    #[test]
482    fn min_distance_squared_cases() {
483        fn test(pos: [GridCoordinate; 3]) -> GridCoordinate {
484            // Arbitrary offset to exercise the subtraction
485            let origin_cube = Cube::new(100, 200, 300);
486            // Arbitrary chunk size
487            const CS: GridCoordinate = 32;
488            let grid_distance = ChunkPos::<CS>(origin_cube + Vector3D::from(pos))
489                .min_distance_squared_from(ChunkPos(origin_cube));
490            assert_eq!(grid_distance.rem_euclid(CS.pow(2)), 0);
491            grid_distance / CS.pow(2)
492        }
493        // Origin and all adjacent chunks are zero distance apart
494        assert_eq!(0, test([0, 0, 0]));
495        assert_eq!(0, test([1, 0, 0]));
496        assert_eq!(0, test([-1, 0, 0]));
497        assert_eq!(0, test([1, 1, 1]));
498        // Separated by one chunk, answer is 1
499        assert_eq!(1, test([2, 0, 0]));
500        // Separated by one diagonal, answer is 1+1+1 (diagonal squared)
501        assert_eq!(3, test([2, 2, 2]));
502        // Try negative numbers too
503        assert_eq!(3, test([-2, 2, 2]));
504        assert_eq!(3, test([-2, -2, 2]));
505    }
506
507    #[test]
508    #[ignore = "unimplemented"]
509    fn min_distance_squared_consistent_with_chart() {
510        todo!("implement check that min_distance_squared_from matches ChunkChart");
511    }
512
513    // TODO: test for point_to_chunk
514
515    /// Zero distance means only the origin chunk.
516    /// This also tests that the origin position is added in.
517    #[test]
518    fn chunk_chart_zero_size() {
519        let chart = ChunkChart::<16>::new(0.0);
520        let chunk = ChunkPos::new(1, 2, 3);
521        assert_eq!(
522            chart.chunks(chunk, OctantMask::ALL).collect::<Vec<_>>(),
523            vec![chunk]
524        );
525        assert!(dbg!(chart.count_all()) >= 1);
526    }
527
528    /// If we look a tiny bit outside the origin chunk, there are 9³ - 1 neighbors.
529    #[test]
530    fn chunk_chart_epsilon_size() {
531        let chart = ChunkChart::<16>::new(0.00001);
532        assert_eq!(
533            chart.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL).collect::<Vec<_>>(),
534            vec![
535                ChunkPos::new(0, 0, 0),
536                // Face meetings.
537                ChunkPos::new(0, 0, -1),
538                ChunkPos::new(0, 0, 1),
539                ChunkPos::new(0, -1, 0),
540                ChunkPos::new(0, 1, 0),
541                ChunkPos::new(-1, 0, 0),
542                ChunkPos::new(1, 0, 0),
543                // Edge meetings.
544                ChunkPos::new(0, -1, -1),
545                ChunkPos::new(0, -1, 1),
546                ChunkPos::new(0, 1, -1),
547                ChunkPos::new(0, 1, 1),
548                ChunkPos::new(-1, 0, -1),
549                ChunkPos::new(-1, 0, 1),
550                ChunkPos::new(1, 0, -1),
551                ChunkPos::new(1, 0, 1),
552                ChunkPos::new(-1, -1, 0),
553                ChunkPos::new(-1, 1, 0),
554                ChunkPos::new(1, -1, 0),
555                ChunkPos::new(1, 1, 0),
556                // Corner meetings.
557                ChunkPos::new(-1, -1, -1),
558                ChunkPos::new(-1, -1, 1),
559                ChunkPos::new(-1, 1, -1),
560                ChunkPos::new(-1, 1, 1),
561                ChunkPos::new(1, -1, -1),
562                ChunkPos::new(1, -1, 1),
563                ChunkPos::new(1, 1, -1),
564                ChunkPos::new(1, 1, 1),
565            ]
566        );
567        assert!(dbg!(chart.count_all()) >= 29);
568    }
569
570    /// As [`chunk_chart_epsilon_size()`], but exercise the octant mask.
571    #[test]
572    fn chunk_chart_masked() {
573        let chart = ChunkChart::<16>::new(0.00001);
574        assert_eq!(
575            chart
576                .chunks(
577                    ChunkPos::new(0, 0, 0),
578                    // Include three octants: [+x +y +z], [+x, +y, -z], and [+x, -y, -z]
579                    OctantMask::from_iter([Octant::Ppp, Octant::Ppn, Octant::Pnn])
580                )
581                .collect::<Vec<_>>(),
582            vec![
583                ChunkPos::new(0, 0, 0),
584                // Face meetings. No -X for this mask.
585                ChunkPos::new(0, 0, -1),
586                ChunkPos::new(0, 0, 1),
587                ChunkPos::new(0, -1, 0),
588                ChunkPos::new(0, 1, 0),
589                ChunkPos::new(1, 0, 0),
590                // Edge meetings.
591                ChunkPos::new(0, -1, -1),
592                //ChunkPos::new(0, -1, 1),
593                ChunkPos::new(0, 1, -1),
594                ChunkPos::new(0, 1, 1),
595                //ChunkPos::new(-1, 0, -1),
596                //ChunkPos::new(-1, 0, 1),
597                ChunkPos::new(1, 0, -1),
598                ChunkPos::new(1, 0, 1),
599                //ChunkPos::new(-1, -1, 0),
600                //ChunkPos::new(-1, 1, 0),
601                ChunkPos::new(1, -1, 0),
602                ChunkPos::new(1, 1, 0),
603                // Corner meetings. This includes only the chosen octants.
604                ChunkPos::new(1, -1, -1),
605                ChunkPos::new(1, 1, -1),
606                ChunkPos::new(1, 1, 1),
607            ]
608        )
609    }
610    #[test]
611    fn chunk_chart_radius_break_points() {
612        fn assert_count(distance_in_chunks: FreeCoordinate, count: usize) {
613            let chart = ChunkChart::<16>::new(distance_in_chunks * 16.);
614
615            println!("distance {distance_in_chunks}, expected count {count}");
616            print_space(&chart.visualization().read(), [1., 1., 1.]);
617
618            let chunks: Vec<_> = chart.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL).collect();
619            assert_eq!(
620                chunks.len(),
621                count,
622                "distance = {}, octant_chunks = {:#?}, results = {:#?}",
623                distance_in_chunks,
624                chart.octant_chunks,
625                chunks
626            );
627        }
628        assert_count(0.00, 1);
629        // All neighbor chunks
630        assert_count(0.01, 3 * 3 * 3);
631        assert_count(0.99, 3 * 3 * 3);
632        assert_count(1.00, 3 * 3 * 3);
633        // Add more distant neighbors
634        // TODO: I would think that the math would work out to add the 3×3
635        // face neighbors before any additional edge neighbors appear.
636        // Dig into the math some more...?
637        assert_count(1.01, 3 * 3 * 3 + 3 * 3 * 6 + 3 * 12);
638    }
639
640    /// [`ChunkChart`]'s iterator should be consistent when reversed.
641    #[test]
642    fn chunk_chart_reverse_iteration() {
643        let chart = ChunkChart::<16>::new(7. * 16.);
644        let p = ChunkPos::new(10, 3, 100);
645        let forward = chart.chunks(p, OctantMask::ALL).collect::<Vec<_>>();
646        let mut reverse = chart.chunks(p, OctantMask::ALL).rev().collect::<Vec<_>>();
647        reverse.reverse();
648        assert_eq!(forward, reverse);
649    }
650
651    #[test]
652    fn chunk_chart_sorting() {
653        // Caution: O(n^6) in the chart radius...
654        let chart = ChunkChart::<16>::new(4.0 * 16.);
655        println!("{chart:?}");
656
657        let mut seen: HashSet<Cube> = HashSet::new();
658        for ChunkPos(p) in chart.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL) {
659            for &q in &seen {
660                assert!(
661                    // Either it's the same point mirrored,
662                    p.lower_bounds().map(|s| s.abs()) == q.lower_bounds().map(|s| s.abs())
663                        // or it has at least one greater coordinate.
664                        || p.x.abs() > q.x.abs()
665                        || p.y.abs() > q.y.abs()
666                        || p.z.abs() > q.z.abs(),
667                    "{p:?} should be before {q:?}"
668                );
669            }
670            seen.insert(p);
671        }
672    }
673
674    /// Test a large chart shrunk, against a small chart enlarged.
675    /// They should give the same answer.
676    #[test]
677    fn chunk_chart_resize() {
678        let chart1 = ChunkChart::<16>::new(200.0);
679        let mut chart2 = ChunkChart::new(300.0);
680        chart2.resize_if_needed(200.0);
681        assert_eq!(
682            chart1.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL).collect::<Vec<_>>(),
683            chart2.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL).collect::<Vec<_>>()
684        );
685    }
686
687    /// As `chunk_chart_resize` but randomized for more coverage.
688    #[test]
689    #[ignore = "TODO: enable this when we have cleverer resizing that might be wrong"]
690    fn chunk_chart_resize_rand() {
691        let mut rng = rand_xoshiro::Xoshiro256Plus::seed_from_u64(0);
692        for _ in 0..50 {
693            let target_size = rng.random_range(0.0..200.0);
694            let small_size = rng.random_range(0.0..target_size);
695            let big_size = rng.random_range(target_size..200.0);
696            print!("{small_size:?} -> {target_size:?} <- {big_size:?} | ");
697
698            let exact = ChunkChart::<16>::new(target_size);
699            let mut enlarged = ChunkChart::<16>::new(small_size);
700            enlarged.resize_if_needed(target_size);
701            let mut shrunk = ChunkChart::new(big_size);
702            shrunk.resize_if_needed(target_size);
703
704            println!(
705                "enlarged {} exact {} shrunk {}",
706                enlarged.octant_range.end, exact.octant_range.end, shrunk.octant_range.end
707            );
708
709            // Check the internal data, because if that's wrong then it's easier to debug
710            assert_eq!(
711                &enlarged.octant_chunks[enlarged.octant_range],
712                &exact.octant_chunks[exact.octant_range]
713            );
714            assert_eq!(
715                &shrunk.octant_chunks[shrunk.octant_range],
716                &exact.octant_chunks[exact.octant_range]
717            );
718
719            // Check the public interface
720            assert_eq!(
721                enlarged.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL).collect::<Vec<_>>(),
722                shrunk.chunks(ChunkPos::new(0, 0, 0), OctantMask::ALL).collect::<Vec<_>>()
723            );
724        }
725    }
726}