Skip to main content

projective_grid/hex/
graph.rs

1//! 6-connected hex grid graph construction via KD-tree spatial search.
2
3use crate::graph::{GridGraphParams, NeighborCandidate};
4use crate::hex::direction::{HexDirection, HexNodeNeighbor};
5use crate::Float;
6use kiddo::{KdTree, SquaredEuclidean};
7use nalgebra::{Point2, Vector2};
8
9/// Extension point for hex-pattern-specific neighbor validation.
10///
11/// Implementors decide whether a spatially close point is a valid hex grid
12/// neighbor, and if so, assign it a direction and quality score.
13pub trait HexNeighborValidator<F: Float = f32> {
14    /// Per-point data beyond position (e.g., orientation angle).
15    /// Use `()` if no extra data is needed.
16    type PointData;
17
18    /// Validate whether `candidate` is a valid hex grid neighbor of the point
19    /// at `source_index`. Returns `(direction, score)` where lower score
20    /// is better, or `None` to reject.
21    fn validate(
22        &self,
23        source_index: usize,
24        source_data: &Self::PointData,
25        candidate: &NeighborCandidate<F>,
26        candidate_data: &Self::PointData,
27    ) -> Option<(HexDirection, F)>;
28}
29
30/// A 6-connected hex grid graph over 2D points.
31///
32/// Each node has at most one neighbor per hex direction,
33/// selected as the best-scoring candidate from spatial proximity search.
34pub struct HexGridGraph<F: Float = f32> {
35    /// Per-node adjacency list. `neighbors[i]` contains up to 6 validated neighbors.
36    pub neighbors: Vec<Vec<HexNodeNeighbor<F>>>,
37}
38
39impl<F: Float + kiddo::float::kdtree::Axis> HexGridGraph<F> {
40    /// Build a hex grid graph from 2D points using a caller-supplied validator.
41    ///
42    /// - `positions`: 2D point positions for spatial search.
43    /// - `point_data`: per-point data passed to the validator (same length as `positions`).
44    /// - `validator`: determines which spatial neighbors are valid hex grid neighbors.
45    /// - `params`: controls KD-tree search parameters.
46    pub fn build<V: HexNeighborValidator<F>>(
47        positions: &[Point2<F>],
48        point_data: &[V::PointData],
49        validator: &V,
50        params: &GridGraphParams<F>,
51    ) -> Self {
52        assert_eq!(
53            positions.len(),
54            point_data.len(),
55            "positions and point_data must have the same length"
56        );
57
58        let coords: Vec<[F; 2]> = positions.iter().map(|p| [p.x, p.y]).collect();
59        let tree: KdTree<F, 2> = (&coords).into();
60        let max_dist_sq = params.max_distance * params.max_distance;
61
62        let mut neighbors = Vec::with_capacity(positions.len());
63
64        for (i, pos) in positions.iter().enumerate() {
65            let query = [pos.x, pos.y];
66            let results = tree.nearest_n::<SquaredEuclidean>(&query, params.k_neighbors);
67
68            let mut candidates = Vec::new();
69
70            for nn in results {
71                let j = nn.item as usize;
72                if j == i {
73                    continue;
74                }
75
76                let dist_sq = nn.distance;
77                if dist_sq > max_dist_sq {
78                    continue;
79                }
80
81                let neighbor_pos = positions[j];
82                let offset = Vector2::new(neighbor_pos.x - pos.x, neighbor_pos.y - pos.y);
83                let distance = dist_sq.sqrt();
84
85                let candidate = NeighborCandidate {
86                    index: j,
87                    offset,
88                    distance,
89                };
90
91                if let Some((direction, score)) =
92                    validator.validate(i, &point_data[i], &candidate, &point_data[j])
93                {
94                    candidates.push(HexNodeNeighbor {
95                        direction,
96                        index: j,
97                        distance,
98                        score,
99                    });
100                }
101            }
102
103            neighbors.push(select_hex_neighbors(candidates));
104        }
105
106        Self { neighbors }
107    }
108}
109
110/// Keep at most one neighbor per direction, choosing the lowest-score candidate.
111fn select_hex_neighbors<F: Float>(candidates: Vec<HexNodeNeighbor<F>>) -> Vec<HexNodeNeighbor<F>> {
112    let mut best: [Option<HexNodeNeighbor<F>>; 6] = [None, None, None, None, None, None];
113
114    for candidate in candidates {
115        let slot = &mut best[candidate.direction.slot_index()];
116
117        let replace = match slot {
118            None => true,
119            Some(current) => {
120                candidate.score < current.score
121                    || (candidate.score == current.score && candidate.distance < current.distance)
122            }
123        };
124
125        if replace {
126            *slot = Some(candidate);
127        }
128    }
129
130    best.into_iter().flatten().collect()
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// Trivial validator that assigns direction by sextant angle.
138    struct AngleValidator;
139
140    impl HexNeighborValidator for AngleValidator {
141        type PointData = ();
142
143        fn validate(
144            &self,
145            _source_index: usize,
146            _source_data: &(),
147            candidate: &NeighborCandidate,
148            _candidate_data: &(),
149        ) -> Option<(HexDirection, f32)> {
150            let angle = candidate.offset.y.atan2(candidate.offset.x);
151            let deg = angle.to_degrees();
152
153            // Map angle to nearest hex direction (pointy-top, 60° sectors)
154            let dir = if (-30.0..30.0).contains(&deg) {
155                HexDirection::East
156            } else if (30.0..90.0).contains(&deg) {
157                HexDirection::SouthEast
158            } else if (90.0..150.0).contains(&deg) {
159                HexDirection::SouthWest
160            } else if !(-150.0..150.0).contains(&deg) {
161                HexDirection::West
162            } else if (-150.0..-90.0).contains(&deg) {
163                HexDirection::NorthWest
164            } else {
165                HexDirection::NorthEast
166            };
167
168            Some((dir, candidate.distance))
169        }
170    }
171
172    /// Generate a regular hex lattice (pointy-top) with given radius.
173    fn hex_lattice(radius: i32, spacing: f32) -> Vec<Point2<f32>> {
174        let mut points = Vec::new();
175        let sqrt3 = 3.0f32.sqrt();
176        for q in -radius..=radius {
177            for r in -radius..=radius {
178                if (q + r).abs() > radius {
179                    continue;
180                }
181                let x = spacing * (q as f32 + r as f32 * 0.5);
182                let y = spacing * (r as f32 * sqrt3 / 2.0);
183                points.push(Point2::new(x, y));
184            }
185        }
186        points
187    }
188
189    #[test]
190    fn center_node_has_six_neighbors() {
191        let spacing = 50.0;
192        let points = hex_lattice(2, spacing);
193        let data = vec![(); points.len()];
194
195        let params = GridGraphParams {
196            k_neighbors: 12,
197            max_distance: spacing * 1.5,
198        };
199
200        let graph = HexGridGraph::build(&points, &data, &AngleValidator, &params);
201
202        // Find the center node (0, 0) -> (x=0, y=0)
203        let center = points
204            .iter()
205            .position(|p| p.x.abs() < 0.01 && p.y.abs() < 0.01)
206            .unwrap();
207
208        assert_eq!(graph.neighbors[center].len(), 6);
209    }
210
211    #[test]
212    fn edge_nodes_have_fewer_neighbors() {
213        let spacing = 50.0;
214        let points = hex_lattice(1, spacing);
215        let data = vec![(); points.len()];
216
217        let params = GridGraphParams {
218            k_neighbors: 12,
219            max_distance: spacing * 1.5,
220        };
221
222        let graph = HexGridGraph::build(&points, &data, &AngleValidator, &params);
223
224        // All non-center nodes in radius-1 hex have exactly 3 neighbors
225        for (i, p) in points.iter().enumerate() {
226            if p.x.abs() < 0.01 && p.y.abs() < 0.01 {
227                assert_eq!(graph.neighbors[i].len(), 6);
228            } else {
229                assert_eq!(
230                    graph.neighbors[i].len(),
231                    3,
232                    "edge node {i} at ({}, {}) has {} neighbors",
233                    p.x,
234                    p.y,
235                    graph.neighbors[i].len()
236                );
237            }
238        }
239    }
240
241    #[test]
242    fn select_keeps_best_per_direction() {
243        let candidates = vec![
244            HexNodeNeighbor {
245                direction: HexDirection::East,
246                index: 1,
247                distance: 50.0,
248                score: 0.9,
249            },
250            HexNodeNeighbor {
251                direction: HexDirection::East,
252                index: 2,
253                distance: 55.0,
254                score: 0.5,
255            },
256            HexNodeNeighbor {
257                direction: HexDirection::West,
258                index: 3,
259                distance: 50.0,
260                score: 0.3,
261            },
262        ];
263
264        let selected = select_hex_neighbors(candidates);
265        assert_eq!(selected.len(), 2);
266
267        let east = selected
268            .iter()
269            .find(|n| n.direction == HexDirection::East)
270            .unwrap();
271        assert_eq!(east.index, 2); // lower score wins
272    }
273}