Skip to main content

nms_graph/
edges.rs

1//! Graph edge construction strategies.
2//!
3//! Edges connect systems in the petgraph. Edge weights are distances in
4//! light-years. Two strategies are provided:
5//!
6//! - **KNN**: connect each system to its k nearest neighbors
7//! - **Warp range**: connect systems within a maximum warp distance
8
9use rstar::PointDistance;
10
11use crate::model::GalaxyModel;
12use crate::spatial::SystemId;
13
14/// Strategy for generating graph edges.
15#[derive(Debug, Clone, Copy)]
16pub enum EdgeStrategy {
17    /// Connect each system to its k nearest neighbors.
18    Knn { k: usize },
19    /// Connect all systems within warp range (light-years).
20    WarpRange { max_ly: f64 },
21}
22
23impl Default for EdgeStrategy {
24    fn default() -> Self {
25        EdgeStrategy::Knn { k: 10 }
26    }
27}
28
29impl GalaxyModel {
30    /// Build edges using the given strategy. Clears existing edges first.
31    pub fn build_edges(&mut self, strategy: EdgeStrategy) {
32        // Remove all existing edges
33        let edge_ids: Vec<_> = self.graph.edge_indices().collect();
34        for edge_id in edge_ids {
35            self.graph.remove_edge(edge_id);
36        }
37
38        match strategy {
39            EdgeStrategy::Knn { k } => self.build_knn_edges(k),
40            EdgeStrategy::WarpRange { max_ly } => self.build_warp_range_edges(max_ly),
41        }
42    }
43
44    /// Connect each system to its k nearest neighbors.
45    ///
46    /// For each system, queries the R-tree for the k+1 nearest points
47    /// (including itself), skips self, and adds undirected edges.
48    /// Duplicate edges (A-B when B-A already exists) are avoided.
49    fn build_knn_edges(&mut self, k: usize) {
50        let system_ids: Vec<SystemId> = self.systems.keys().copied().collect();
51
52        for &sys_id in &system_ids {
53            let system = match self.systems.get(&sys_id) {
54                Some(s) => s,
55                None => continue,
56            };
57
58            let query_point = [
59                system.address.voxel_x() as f64,
60                system.address.voxel_y() as f64,
61                system.address.voxel_z() as f64,
62            ];
63
64            // Get k+1 nearest (first may be self)
65            let neighbors: Vec<_> = self
66                .spatial
67                .nearest_neighbor_iter(&query_point)
68                .filter(|sp| sp.id != sys_id)
69                .take(k)
70                .map(|sp| {
71                    let dist_ly = sp.distance_2(&query_point).sqrt() * 400.0;
72                    (sp.id, dist_ly)
73                })
74                .collect();
75
76            let from_node = match self.node_map.get(&sys_id) {
77                Some(&n) => n,
78                None => continue,
79            };
80
81            for (neighbor_id, dist_ly) in neighbors {
82                let to_node = match self.node_map.get(&neighbor_id) {
83                    Some(&n) => n,
84                    None => continue,
85                };
86
87                // Avoid duplicate edges (check both directions)
88                if self.graph.find_edge(from_node, to_node).is_none() {
89                    self.graph.add_edge(from_node, to_node, dist_ly);
90                }
91            }
92        }
93    }
94
95    /// Connect all pairs of systems within a warp range (in light-years).
96    ///
97    /// For each system, queries the R-tree for all neighbors within
98    /// `max_ly / 400.0` voxel units and adds edges.
99    fn build_warp_range_edges(&mut self, max_ly: f64) {
100        let system_ids: Vec<SystemId> = self.systems.keys().copied().collect();
101
102        for &sys_id in &system_ids {
103            let system = match self.systems.get(&sys_id) {
104                Some(s) => s,
105                None => continue,
106            };
107
108            let query_point = [
109                system.address.voxel_x() as f64,
110                system.address.voxel_y() as f64,
111                system.address.voxel_z() as f64,
112            ];
113
114            let voxel_radius = max_ly / 400.0;
115            let voxel_radius_sq = voxel_radius * voxel_radius;
116
117            let neighbors: Vec<_> = self
118                .spatial
119                .nearest_neighbor_iter(&query_point)
120                .take_while(|sp| sp.distance_2(&query_point) <= voxel_radius_sq)
121                .filter(|sp| sp.id != sys_id)
122                .map(|sp| {
123                    let dist_ly = sp.distance_2(&query_point).sqrt() * 400.0;
124                    (sp.id, dist_ly)
125                })
126                .collect();
127
128            let from_node = match self.node_map.get(&sys_id) {
129                Some(&n) => n,
130                None => continue,
131            };
132
133            for (neighbor_id, dist_ly) in neighbors {
134                let to_node = match self.node_map.get(&neighbor_id) {
135                    Some(&n) => n,
136                    None => continue,
137                };
138
139                if self.graph.find_edge(from_node, to_node).is_none() {
140                    self.graph.add_edge(from_node, to_node, dist_ly);
141                }
142            }
143        }
144    }
145
146    /// Add KNN edges for a single newly inserted system.
147    ///
148    /// Call this after `insert_system()` to connect the new node to
149    /// its neighbors without rebuilding the entire graph.
150    pub fn connect_new_system(&mut self, sys_id: SystemId, k: usize) {
151        let system = match self.systems.get(&sys_id) {
152            Some(s) => s,
153            None => return,
154        };
155
156        let query_point = [
157            system.address.voxel_x() as f64,
158            system.address.voxel_y() as f64,
159            system.address.voxel_z() as f64,
160        ];
161
162        let neighbors: Vec<_> = self
163            .spatial
164            .nearest_neighbor_iter(&query_point)
165            .filter(|sp| sp.id != sys_id)
166            .take(k)
167            .map(|sp| {
168                let dist_ly = sp.distance_2(&query_point).sqrt() * 400.0;
169                (sp.id, dist_ly)
170            })
171            .collect();
172
173        let from_node = match self.node_map.get(&sys_id) {
174            Some(&n) => n,
175            None => return,
176        };
177
178        for (neighbor_id, dist_ly) in neighbors {
179            let to_node = match self.node_map.get(&neighbor_id) {
180                Some(&n) => n,
181                None => continue,
182            };
183
184            if self.graph.find_edge(from_node, to_node).is_none() {
185                self.graph.add_edge(from_node, to_node, dist_ly);
186            }
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use nms_core::address::GalacticAddress;
195    use nms_core::biome::Biome;
196    use nms_core::system::{Planet, System};
197
198    /// Build a model with N systems in a line along the X axis.
199    fn line_model(n: usize, spacing: i16) -> GalaxyModel {
200        let json = r#"{
201            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
202            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
203            "BaseContext": {"GameMode": 1, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
204            "ExpeditionContext": {"GameMode": 6, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
205            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
206        }"#;
207        let save = nms_save::parse_save(json.as_bytes()).unwrap();
208        let mut model = GalaxyModel::from_save(&save);
209
210        for i in 0..n {
211            let x = (i as i16) * spacing;
212            let ssi = (i + 1) as u16;
213            let addr = GalacticAddress::new(x, 0, 0, ssi, 0, 0);
214            let planet = Planet::new(0, Some(Biome::Barren), None, false, None, None);
215            let system = System::new(addr, None, None, None, vec![planet]);
216            model.insert_system(system);
217        }
218
219        model
220    }
221
222    #[test]
223    fn test_knn_edges_created() {
224        let mut model = line_model(5, 10);
225        model.build_edges(EdgeStrategy::Knn { k: 2 });
226        assert!(model.graph.edge_count() > 0);
227        assert!(model.graph.edge_count() <= 10);
228    }
229
230    #[test]
231    fn test_knn_edges_all_nodes_connected() {
232        let mut model = line_model(5, 10);
233        model.build_edges(EdgeStrategy::Knn { k: 2 });
234        for (_, &node_idx) in &model.node_map {
235            let degree = model.graph.edges(node_idx).count();
236            assert!(degree >= 1, "Node with 0 edges found");
237        }
238    }
239
240    #[test]
241    fn test_warp_range_edges_respects_distance() {
242        let mut model = line_model(5, 10);
243        // 10 voxels * 400 = 4000 ly. Range 5000 should connect adjacent only.
244        model.build_edges(EdgeStrategy::WarpRange { max_ly: 5000.0 });
245        for edge in model.graph.edge_indices() {
246            let weight = model.graph[edge];
247            assert!(weight <= 5000.0, "Edge weight {weight} exceeds warp range");
248        }
249    }
250
251    #[test]
252    fn test_warp_range_zero_no_edges() {
253        let mut model = line_model(5, 10);
254        model.build_edges(EdgeStrategy::WarpRange { max_ly: 0.0 });
255        assert_eq!(model.graph.edge_count(), 0);
256    }
257
258    #[test]
259    fn test_build_edges_clears_previous() {
260        let mut model = line_model(5, 10);
261        model.build_edges(EdgeStrategy::Knn { k: 4 });
262        let count1 = model.graph.edge_count();
263        model.build_edges(EdgeStrategy::Knn { k: 1 });
264        let count2 = model.graph.edge_count();
265        assert!(count2 < count1);
266    }
267
268    #[test]
269    fn test_connect_new_system_adds_edges() {
270        let mut model = line_model(3, 10);
271        model.build_edges(EdgeStrategy::Knn { k: 2 });
272        let edges_before = model.graph.edge_count();
273
274        // Insert a new system
275        let addr = GalacticAddress::new(5, 0, 0, 0xFFF, 0, 0);
276        let system = System::new(addr, None, None, None, vec![]);
277        let sys_id = crate::spatial::SystemId::from_address(&addr);
278        model.insert_system(system);
279        model.connect_new_system(sys_id, 2);
280
281        assert!(model.graph.edge_count() > edges_before);
282    }
283
284    #[test]
285    fn test_no_self_loops() {
286        let mut model = line_model(5, 10);
287        model.build_edges(EdgeStrategy::Knn { k: 4 });
288        for edge in model.graph.edge_indices() {
289            let (a, b) = model.graph.edge_endpoints(edge).unwrap();
290            assert_ne!(a, b, "Self-loop detected");
291        }
292    }
293
294    #[test]
295    fn test_no_duplicate_edges() {
296        let mut model = line_model(5, 10);
297        model.build_edges(EdgeStrategy::Knn { k: 4 });
298        let mut seen = std::collections::HashSet::new();
299        for edge in model.graph.edge_indices() {
300            let (a, b) = model.graph.edge_endpoints(edge).unwrap();
301            let key = if a < b { (a, b) } else { (b, a) };
302            assert!(seen.insert(key), "Duplicate edge found: {key:?}");
303        }
304    }
305
306    #[test]
307    fn test_edge_strategy_default_is_knn_10() {
308        let strategy = EdgeStrategy::default();
309        match strategy {
310            EdgeStrategy::Knn { k } => assert_eq!(k, 10),
311            _ => panic!("Default should be Knn"),
312        }
313    }
314
315    #[test]
316    fn test_warp_range_large_connects_all() {
317        let mut model = line_model(3, 10);
318        // 3 systems, spacing 10 voxels = 4000 ly each. Range 100000 ly covers all.
319        model.build_edges(EdgeStrategy::WarpRange { max_ly: 100_000.0 });
320        // 3 systems fully connected = 3 edges
321        assert_eq!(model.graph.edge_count(), 3);
322    }
323
324    #[test]
325    fn test_connect_new_system_nonexistent_is_noop() {
326        let mut model = line_model(3, 10);
327        model.build_edges(EdgeStrategy::Knn { k: 2 });
328        let edges_before = model.graph.edge_count();
329
330        // Try connecting a system that doesn't exist
331        model.connect_new_system(SystemId(0xDEADBEEF), 2);
332
333        assert_eq!(model.graph.edge_count(), edges_before);
334    }
335
336    #[test]
337    fn test_edge_weights_are_positive() {
338        let mut model = line_model(5, 10);
339        model.build_edges(EdgeStrategy::Knn { k: 2 });
340        for edge in model.graph.edge_indices() {
341            let weight = model.graph[edge];
342            assert!(weight > 0.0, "Edge weight should be positive, got {weight}");
343        }
344    }
345}