Skip to main content

steel_utils/climate/
parameter_list.rs

1//! R-Tree backed parameter list for climate-based biome lookup.
2//!
3//! Implements vanilla's `Climate.ParameterList` with a flattened R-Tree for
4//! cache-efficient nearest-neighbor search. The tree is built once at startup
5//! using vanilla's algorithm, then flattened into a BFS-ordered contiguous
6//! array where children of the same parent occupy adjacent indices.
7
8// Vanilla uses `(a + b) / 2` (integer division, truncates toward zero) for midpoints.
9// `i64::midpoint` rounds differently for negative odd sums, so we can't use it.
10#![expect(
11    clippy::manual_midpoint,
12    reason = "must match vanilla's truncate-toward-zero behavior"
13)]
14
15use std::cmp::Ordering;
16
17use super::PARAMETER_COUNT;
18use super::types::{Parameter, ParameterPoint, TargetPoint};
19use rayon::iter::{IntoParallelIterator, ParallelIterator};
20
21/// Maximum children per tree node.
22const CHILDREN_PER_NODE: usize = 19;
23
24/// R-Tree node used during construction only. After building, the tree is
25/// flattened into a `Vec<FlatNode>` for search.
26enum RTreeNode {
27    /// Leaf node containing a single biome entry.
28    Leaf {
29        parameter_space: [Parameter; PARAMETER_COUNT],
30        value_index: usize,
31    },
32    /// Internal node with children and a bounding box.
33    SubTree {
34        parameter_space: [Parameter; PARAMETER_COUNT],
35        children: Vec<RTreeNode>,
36    },
37}
38
39impl RTreeNode {
40    /// Get the parameter space (bounding box) of this node.
41    const fn parameter_space(&self) -> &[Parameter; PARAMETER_COUNT] {
42        match self {
43            Self::Leaf {
44                parameter_space, ..
45            }
46            | Self::SubTree {
47                parameter_space, ..
48            } => parameter_space,
49        }
50    }
51}
52
53/// Build data used during tree construction.
54#[derive(Clone)]
55struct BuildEntry {
56    parameter_space: [Parameter; PARAMETER_COUNT],
57    index: usize,
58}
59
60/// Build the bounding box for a set of child nodes.
61fn build_parameter_space(children: &[RTreeNode]) -> [Parameter; PARAMETER_COUNT] {
62    let mut bounds: [Option<Parameter>; PARAMETER_COUNT] = [None; PARAMETER_COUNT];
63    for child in children {
64        let ps = child.parameter_space();
65        for d in 0..PARAMETER_COUNT {
66            bounds[d] = Some(ps[d].span_with(bounds[d].as_ref()));
67        }
68    }
69    bounds.map(|b| b.expect("bounds should be initialized"))
70}
71
72/// Calculate the cost of a bounding box (sum of range widths).
73fn cost(parameter_space: &[Parameter; PARAMETER_COUNT]) -> i64 {
74    let mut result = 0i64;
75    for p in parameter_space {
76        result += (p.max - p.min).abs();
77    }
78    result
79}
80
81/// Build an R-Tree from a list of entries, matching vanilla's algorithm.
82fn build_tree(entries: &mut [BuildEntry]) -> RTreeNode {
83    assert!(!entries.is_empty());
84
85    if entries.len() == 1 {
86        return RTreeNode::Leaf {
87            parameter_space: entries[0].parameter_space,
88            value_index: entries[0].index,
89        };
90    }
91
92    if entries.len() <= CHILDREN_PER_NODE {
93        // Sort by total magnitude of centers across all dimensions
94        entries.sort_by_key(|e| {
95            e.parameter_space
96                .iter()
97                .map(|p| ((p.min + p.max) / 2).abs())
98                .sum::<i64>()
99        });
100
101        let children: Vec<RTreeNode> = entries
102            .iter()
103            .map(|e| RTreeNode::Leaf {
104                parameter_space: e.parameter_space,
105                value_index: e.index,
106            })
107            .collect();
108        let ps = build_parameter_space(&children);
109        return RTreeNode::SubTree {
110            parameter_space: ps,
111            children,
112        };
113    }
114
115    // Try splitting along each dimension, choose minimum cost.
116    // Like vanilla, save the bucketized entries when we find a better dimension.
117    // This is critical because vanilla sorts `children` in-place for each dimension
118    // and `bucketize()` captures a snapshot. Later sorts don't affect saved buckets.
119    // Re-sorting after the loop would produce different stable-sort tie-breaking.
120    let mut min_cost = i64::MAX;
121    let mut best_dim = 0;
122    let mut best_buckets: Option<Vec<Vec<BuildEntry>>> = None;
123
124    for d in 0..PARAMETER_COUNT {
125        sort_entries(entries, d);
126        let (bucket_cost, buckets) = snapshot_buckets(entries);
127        if min_cost > bucket_cost {
128            min_cost = bucket_cost;
129            best_dim = d;
130            best_buckets = Some(buckets);
131        }
132    }
133
134    // Build subtrees from the saved buckets (entries in the order sorted by best_dim)
135    let buckets = best_buckets.expect("should have found at least one dimension");
136
137    // Compute bounding box for each bucket, pair with entries for sorting
138    let mut bucket_subtrees: Vec<([Parameter; PARAMETER_COUNT], Vec<BuildEntry>)> = buckets
139        .into_iter()
140        .map(|bucket_entries| {
141            let mut bounds: [Option<Parameter>; PARAMETER_COUNT] = [None; PARAMETER_COUNT];
142            for e in &bucket_entries {
143                for dim in 0..PARAMETER_COUNT {
144                    bounds[dim] = Some(e.parameter_space[dim].span_with(bounds[dim].as_ref()));
145                }
146            }
147            let ps = bounds.map(|b| b.expect("bounds should be initialized"));
148            (ps, bucket_entries)
149        })
150        .collect();
151
152    // Sort the bucket subtrees by the best dimension (absolute=true)
153    sort_bucket_subtrees(&mut bucket_subtrees, best_dim);
154    // For each bucket, recursively build
155    let final_children: Vec<RTreeNode> = bucket_subtrees
156        .into_par_iter()
157        .map(|(_, mut child_entries)| build_tree(&mut child_entries))
158        .collect();
159
160    let ps = build_parameter_space(&final_children);
161    RTreeNode::SubTree {
162        parameter_space: ps,
163        children: final_children,
164    }
165}
166
167/// Sort entries by a dimension, with tiebreaking by subsequent dimensions.
168fn sort_entries(entries: &mut [BuildEntry], dimension: usize) {
169    entries.sort_by(|a, b| {
170        for offset in 0..PARAMETER_COUNT {
171            let d = (dimension + offset) % PARAMETER_COUNT;
172            let center_a = (a.parameter_space[d].min + a.parameter_space[d].max) / 2;
173            let center_b = (b.parameter_space[d].min + b.parameter_space[d].max) / 2;
174            let cmp = center_a.cmp(&center_b);
175            if cmp != Ordering::Equal {
176                return cmp;
177            }
178        }
179        Ordering::Equal
180    });
181}
182
183/// Sort bucket subtrees by a dimension (absolute=true), matching vanilla's
184/// `sort(minBuckets, dimensions, minDimension, true)`.
185fn sort_bucket_subtrees(
186    subtrees: &mut [([Parameter; PARAMETER_COUNT], Vec<BuildEntry>)],
187    dimension: usize,
188) {
189    subtrees.sort_by(|a, b| {
190        for offset in 0..PARAMETER_COUNT {
191            let d = (dimension + offset) % PARAMETER_COUNT;
192            let center_a = (a.0[d].min + a.0[d].max) / 2;
193            let center_b = (b.0[d].min + b.0[d].max) / 2;
194            let cmp = center_a.abs().cmp(&center_b.abs());
195            if cmp != Ordering::Equal {
196                return cmp;
197            }
198        }
199        Ordering::Equal
200    });
201}
202
203/// Compute the expected bucket size from vanilla's formula.
204fn expected_children_count(total: usize) -> usize {
205    let log_base_children = ((total as f64) - 0.01).ln() / (CHILDREN_PER_NODE as f64).ln();
206    (CHILDREN_PER_NODE as f64).powf(log_base_children.floor()) as usize
207}
208
209/// Snapshot the current entry order into buckets and compute total cost.
210///
211/// This matches vanilla's `bucketize()` which creates `SubTree` objects that
212/// capture the children's current sorted order. We return cloned entries so
213/// that later sorts of the original slice don't affect the saved buckets.
214fn snapshot_buckets(entries: &[BuildEntry]) -> (i64, Vec<Vec<BuildEntry>>) {
215    let expected = expected_children_count(entries.len());
216    let mut buckets = Vec::new();
217    let mut total_cost = 0i64;
218    let mut start = 0;
219    while start < entries.len() {
220        let end = (start + expected).min(entries.len());
221        let bucket = entries[start..end].to_vec();
222        // Compute bounding box cost for this bucket
223        let mut bounds: [Option<Parameter>; PARAMETER_COUNT] = [None; PARAMETER_COUNT];
224        for e in &bucket {
225            for d in 0..PARAMETER_COUNT {
226                bounds[d] = Some(e.parameter_space[d].span_with(bounds[d].as_ref()));
227            }
228        }
229        let ps = bounds.map(|b| b.expect("bounds should be initialized"));
230        total_cost += cost(&ps);
231        buckets.push(bucket);
232        start = end;
233    }
234    (total_cost, buckets)
235}
236
237/// Compact node for the flattened R-Tree.
238///
239/// Children of the same parent are stored at contiguous indices in a single
240/// `Vec<FlatNode>`, enabling cache-efficient iteration during search.
241/// The BFS-order layout also means that nodes accessed together during a
242/// search tend to be near each other in memory.
243struct FlatNode {
244    /// Bounding box minimum values for each parameter dimension.
245    mins: [i64; PARAMETER_COUNT],
246    /// Bounding box maximum values for each parameter dimension.
247    maxs: [i64; PARAMETER_COUNT],
248    /// For leaf nodes: index into the values array.
249    /// For subtree nodes: `u32::MAX` (sentinel).
250    value_index: u32,
251    /// Start index of children in the nodes array (subtree only).
252    children_start: u32,
253    /// Number of children (0 = leaf, 1..=6 = subtree).
254    children_count: u8,
255}
256
257impl FlatNode {
258    /// Compute the squared distance from a target point to this node's bounding box.
259    ///
260    /// Uses branchless `max` operations (compiles to `cmov`) to avoid branch
261    /// mispredictions in the hot inner loop.
262    #[inline]
263    fn distance(&self, target: &[i64; PARAMETER_COUNT]) -> i64 {
264        let mut d = 0i64;
265        for i in 0..PARAMETER_COUNT {
266            let di = (target[i] - self.maxs[i])
267                .max(self.mins[i] - target[i])
268                .max(0);
269            d += di * di;
270        }
271        d
272    }
273
274    #[inline]
275    const fn is_leaf(&self) -> bool {
276        self.children_count == 0
277    }
278}
279
280/// Flatten an R-Tree into a contiguous `Vec` using BFS ordering.
281///
282/// BFS guarantees that all children of the same parent occupy contiguous
283/// indices, which is the key property for cache-efficient search.
284fn flatten_tree(root: RTreeNode) -> Vec<FlatNode> {
285    use std::collections::VecDeque;
286
287    let mut nodes: Vec<FlatNode> = Vec::new();
288    // Queue of (children_batch, parent_flat_index).
289    // Each batch is a Vec of siblings that will be laid out contiguously.
290    let mut queue: VecDeque<(Vec<RTreeNode>, Option<u32>)> = VecDeque::new();
291    queue.push_back((vec![root], None));
292
293    while let Some((batch, parent_idx)) = queue.pop_front() {
294        let batch_start = nodes.len() as u32;
295
296        // Fix up parent's children_start to point to this batch
297        if let Some(pidx) = parent_idx {
298            nodes[pidx as usize].children_start = batch_start;
299        }
300
301        for node in batch {
302            let flat_idx = nodes.len() as u32;
303            match node {
304                RTreeNode::Leaf {
305                    parameter_space,
306                    value_index,
307                } => {
308                    nodes.push(FlatNode {
309                        mins: parameter_space.map(|p| p.min),
310                        maxs: parameter_space.map(|p| p.max),
311                        value_index: value_index as u32,
312                        children_start: 0,
313                        children_count: 0,
314                    });
315                }
316                RTreeNode::SubTree {
317                    parameter_space,
318                    children,
319                } => {
320                    let children_count = children.len() as u8;
321                    nodes.push(FlatNode {
322                        mins: parameter_space.map(|p| p.min),
323                        maxs: parameter_space.map(|p| p.max),
324                        value_index: u32::MAX,
325                        children_start: 0, // fixed up when children batch is processed
326                        children_count,
327                    });
328                    queue.push_back((children, Some(flat_idx)));
329                }
330            }
331        }
332    }
333
334    nodes
335}
336
337/// Search the flat R-Tree for the nearest leaf to the target.
338///
339/// Matches vanilla's `SubTree.search()` which passes the candidate through
340/// recursion and checks the returned leaf distance against the local best.
341fn search_nearest(
342    nodes: &[FlatNode],
343    node: &FlatNode,
344    target: &[i64; PARAMETER_COUNT],
345    best_dist: &mut i64,
346    best_idx: &mut Option<u32>,
347) {
348    let start = node.children_start as usize;
349    let end = start + node.children_count as usize;
350    let children = &nodes[start..end];
351
352    for child in children {
353        let child_dist = child.distance(target);
354        // Vanilla uses strict > for pruning (skips equal distance)
355        if *best_dist > child_dist {
356            if child.is_leaf() {
357                // Leaf: child_dist IS the exact distance — no recursion needed
358                *best_dist = child_dist;
359                *best_idx = Some(child.value_index);
360            } else {
361                // Subtree: recurse into children
362                search_nearest(nodes, child, target, best_dist, best_idx);
363            }
364        }
365    }
366}
367
368/// A list of biome parameter points with their associated values.
369///
370/// Uses an R-Tree for lookup matching vanilla's `Climate.ParameterList`.
371pub struct ParameterList<T> {
372    /// The biome entries (parameter point, value pairs)
373    values: Vec<(ParameterPoint, T)>,
374    /// Cached parameter spaces for each value (for distance computation in lastResult)
375    param_spaces: Vec<[Parameter; PARAMETER_COUNT]>,
376    /// Flat R-Tree nodes in BFS order. Root is at index 0.
377    nodes: Vec<FlatNode>,
378}
379
380impl<T> ParameterList<T> {
381    /// Create a new parameter list from values, building an R-Tree index.
382    ///
383    /// # Panics
384    ///
385    /// Panics if `values` is empty.
386    #[must_use]
387    pub fn new(values: Vec<(ParameterPoint, T)>) -> Self {
388        assert!(!values.is_empty(), "Need at least one value");
389
390        let param_spaces: Vec<[Parameter; PARAMETER_COUNT]> =
391            values.iter().map(|(pp, _)| pp.parameter_space()).collect();
392
393        // Build R-Tree from the parameter points
394        let mut entries: Vec<BuildEntry> = values
395            .iter()
396            .enumerate()
397            .map(|(i, (pp, _))| BuildEntry {
398                parameter_space: pp.parameter_space(),
399                index: i,
400            })
401            .collect();
402
403        let root = build_tree(&mut entries);
404        let nodes = flatten_tree(root);
405
406        Self {
407            values,
408            param_spaces,
409            nodes,
410        }
411    }
412
413    /// Get the underlying values.
414    #[must_use]
415    pub fn values(&self) -> &[(ParameterPoint, T)] {
416        &self.values
417    }
418
419    /// Find the best matching value for a target point (no caching).
420    ///
421    /// Uses R-Tree search matching vanilla's `Climate.ParameterList.findValue()`.
422    ///
423    /// Note: Vanilla warm-starts with `lastResult` via `ThreadLocal`, which can
424    /// affect tie-breaking on equal-distance candidates. This version starts
425    /// from `i64::MAX` (no warm-start). Use `find_value_cached` for the hot
426    /// path to match vanilla's tie-breaking behavior.
427    ///
428    /// # Panics
429    ///
430    /// Panics if the R-Tree search fails to find any matching value.
431    #[must_use]
432    pub fn find_value(&self, target: &TargetPoint) -> &T {
433        let target_array = target.to_parameter_array();
434        let root = &self.nodes[0];
435        if root.is_leaf() {
436            return &self.values[root.value_index as usize].1;
437        }
438        let mut best_dist = i64::MAX;
439        let mut best_idx = None;
440        search_nearest(
441            &self.nodes,
442            root,
443            &target_array,
444            &mut best_dist,
445            &mut best_idx,
446        );
447        &self.values[best_idx.expect("R-Tree search should always find a value") as usize].1
448    }
449
450    /// Find the best matching value with lastResult caching.
451    ///
452    /// Matches vanilla's `Climate.ParameterList.findValue()` with `ThreadLocal`
453    /// `lastNode` warm-starting. The cache stores the index of the last result,
454    /// which is used as the initial candidate for the next search, improving
455    /// both performance and tie-breaking behavior.
456    ///
457    /// # Panics
458    ///
459    /// Panics if the R-Tree search fails to find any matching value.
460    #[must_use]
461    pub fn find_value_cached(&self, target: &TargetPoint, cache: &mut Option<usize>) -> &T {
462        let target_array = target.to_parameter_array();
463
464        let root = &self.nodes[0];
465        if root.is_leaf() {
466            let idx = root.value_index as usize;
467            *cache = Some(idx);
468            return &self.values[idx].1;
469        }
470
471        // Compute initial distance from cached last result
472        let (mut best_dist, init_idx) = match *cache {
473            Some(idx) => {
474                let ps = &self.param_spaces[idx];
475                let mut d = 0i64;
476                for i in 0..PARAMETER_COUNT {
477                    let di = ps[i].distance(target_array[i]);
478                    d += di * di;
479                }
480                (d, Some(idx as u32))
481            }
482            None => (i64::MAX, None),
483        };
484
485        let mut best_idx = init_idx;
486        search_nearest(
487            &self.nodes,
488            root,
489            &target_array,
490            &mut best_dist,
491            &mut best_idx,
492        );
493        let result_idx = best_idx.expect("R-Tree search should always find a value") as usize;
494
495        *cache = Some(result_idx);
496        &self.values[result_idx].1
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn test_parameter_list_find_value() {
506        let values = vec![
507            (
508                ParameterPoint::new(
509                    Parameter::new(-10000, 0),
510                    Parameter::new(0, 0),
511                    Parameter::new(0, 0),
512                    Parameter::new(0, 0),
513                    Parameter::new(0, 0),
514                    Parameter::new(0, 0),
515                    0,
516                ),
517                "cold",
518            ),
519            (
520                ParameterPoint::new(
521                    Parameter::new(0, 10000),
522                    Parameter::new(0, 0),
523                    Parameter::new(0, 0),
524                    Parameter::new(0, 0),
525                    Parameter::new(0, 0),
526                    Parameter::new(0, 0),
527                    0,
528                ),
529                "hot",
530            ),
531        ];
532
533        let list = ParameterList::new(values);
534
535        // Cold biome should match negative temperature
536        let cold_target = TargetPoint::new(-5000, 0, 0, 0, 0, 0);
537        assert_eq!(*list.find_value(&cold_target), "cold");
538
539        // Hot biome should match positive temperature
540        let hot_target = TargetPoint::new(5000, 0, 0, 0, 0, 0);
541        assert_eq!(*list.find_value(&hot_target), "hot");
542    }
543}