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