u_nesting_d2/
spatial_index.rs

1//! Spatial indexing for 2D collision detection using R*-tree.
2//!
3//! This module provides efficient broad-phase collision detection for the nesting
4//! algorithm by using an R*-tree spatial index to quickly identify potentially
5//! overlapping geometries.
6
7use crate::geometry::Geometry2D;
8use rstar::{primitives::Rectangle, RTree, RTreeObject, AABB};
9use u_nesting_core::geometry::Geometry;
10
11/// An entry in the 2D spatial index representing a placed geometry.
12#[derive(Debug, Clone)]
13pub struct SpatialEntry2D {
14    /// Index of the geometry in the placed list
15    pub index: usize,
16    /// Geometry ID
17    pub id: String,
18    /// Axis-aligned bounding box (min_x, min_y, max_x, max_y)
19    pub aabb: [f64; 4],
20}
21
22impl SpatialEntry2D {
23    /// Creates a new spatial entry from a placed geometry.
24    pub fn new(index: usize, id: String, aabb: [f64; 4]) -> Self {
25        Self { index, id, aabb }
26    }
27
28    /// Creates a spatial entry from a geometry at a given position with rotation.
29    pub fn from_placed(
30        index: usize,
31        geometry: &Geometry2D,
32        position: (f64, f64),
33        rotation: f64,
34    ) -> Self {
35        let aabb = compute_transformed_aabb(geometry, position, rotation);
36        Self {
37            index,
38            id: geometry.id().clone(),
39            aabb,
40        }
41    }
42}
43
44impl RTreeObject for SpatialEntry2D {
45    type Envelope = AABB<[f64; 2]>;
46
47    fn envelope(&self) -> Self::Envelope {
48        AABB::from_corners([self.aabb[0], self.aabb[1]], [self.aabb[2], self.aabb[3]])
49    }
50}
51
52/// 2D spatial index using R*-tree for efficient collision queries.
53#[derive(Debug)]
54pub struct SpatialIndex2D {
55    tree: RTree<SpatialEntry2D>,
56}
57
58impl SpatialIndex2D {
59    /// Creates a new empty spatial index.
60    pub fn new() -> Self {
61        Self { tree: RTree::new() }
62    }
63
64    /// Creates a spatial index with the given entries.
65    pub fn with_entries(entries: Vec<SpatialEntry2D>) -> Self {
66        Self {
67            tree: RTree::bulk_load(entries),
68        }
69    }
70
71    /// Inserts a new entry into the spatial index.
72    pub fn insert(&mut self, entry: SpatialEntry2D) {
73        self.tree.insert(entry);
74    }
75
76    /// Inserts a placed geometry into the spatial index.
77    pub fn insert_geometry(
78        &mut self,
79        index: usize,
80        geometry: &Geometry2D,
81        position: (f64, f64),
82        rotation: f64,
83    ) {
84        let entry = SpatialEntry2D::from_placed(index, geometry, position, rotation);
85        self.insert(entry);
86    }
87
88    /// Returns the number of entries in the index.
89    pub fn len(&self) -> usize {
90        self.tree.size()
91    }
92
93    /// Returns true if the index is empty.
94    pub fn is_empty(&self) -> bool {
95        self.tree.size() == 0
96    }
97
98    /// Clears all entries from the index.
99    pub fn clear(&mut self) {
100        self.tree = RTree::new();
101    }
102
103    /// Finds all entries whose bounding boxes intersect with the given AABB.
104    ///
105    /// This is the primary broad-phase collision detection method.
106    pub fn query_aabb(&self, min: [f64; 2], max: [f64; 2]) -> Vec<&SpatialEntry2D> {
107        let envelope = AABB::from_corners(min, max);
108        self.tree
109            .locate_in_envelope_intersecting(&envelope)
110            .collect()
111    }
112
113    /// Finds all entries that potentially intersect with a geometry at the given position.
114    pub fn query_geometry(
115        &self,
116        geometry: &Geometry2D,
117        position: (f64, f64),
118        rotation: f64,
119    ) -> Vec<&SpatialEntry2D> {
120        let aabb = compute_transformed_aabb(geometry, position, rotation);
121        self.query_aabb([aabb[0], aabb[1]], [aabb[2], aabb[3]])
122    }
123
124    /// Finds all entries that potentially intersect with the given geometry AABB
125    /// expanded by a margin (for spacing).
126    pub fn query_with_margin(
127        &self,
128        geometry: &Geometry2D,
129        position: (f64, f64),
130        rotation: f64,
131        margin: f64,
132    ) -> Vec<&SpatialEntry2D> {
133        let aabb = compute_transformed_aabb(geometry, position, rotation);
134        self.query_aabb(
135            [aabb[0] - margin, aabb[1] - margin],
136            [aabb[2] + margin, aabb[3] + margin],
137        )
138    }
139
140    /// Returns an iterator over all entries in the index.
141    pub fn iter(&self) -> impl Iterator<Item = &SpatialEntry2D> {
142        self.tree.iter()
143    }
144
145    /// Returns the indices of potentially colliding geometries for a query geometry.
146    pub fn get_potential_collisions(
147        &self,
148        geometry: &Geometry2D,
149        position: (f64, f64),
150        rotation: f64,
151        spacing: f64,
152    ) -> Vec<usize> {
153        self.query_with_margin(geometry, position, rotation, spacing)
154            .iter()
155            .map(|entry| entry.index)
156            .collect()
157    }
158}
159
160impl Default for SpatialIndex2D {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166/// Computes the transformed AABB of a geometry at a given position with rotation.
167pub fn compute_transformed_aabb(
168    geometry: &Geometry2D,
169    position: (f64, f64),
170    rotation: f64,
171) -> [f64; 4] {
172    if rotation.abs() < 1e-10 {
173        // No rotation - simple translation
174        let (g_min, g_max) = geometry.aabb();
175        [
176            g_min[0] + position.0,
177            g_min[1] + position.1,
178            g_max[0] + position.0,
179            g_max[1] + position.1,
180        ]
181    } else {
182        // Apply rotation then translation
183        let exterior = geometry.exterior();
184        let cos_r = rotation.cos();
185        let sin_r = rotation.sin();
186
187        let mut min_x = f64::INFINITY;
188        let mut min_y = f64::INFINITY;
189        let mut max_x = f64::NEG_INFINITY;
190        let mut max_y = f64::NEG_INFINITY;
191
192        for (x, y) in exterior {
193            // Rotate around origin
194            let rx = x * cos_r - y * sin_r;
195            let ry = x * sin_r + y * cos_r;
196            // Translate
197            let tx = rx + position.0;
198            let ty = ry + position.1;
199
200            min_x = min_x.min(tx);
201            min_y = min_y.min(ty);
202            max_x = max_x.max(tx);
203            max_y = max_y.max(ty);
204        }
205
206        [min_x, min_y, max_x, max_y]
207    }
208}
209
210/// A wrapper around Rectangle for R*-tree that stores additional metadata.
211#[derive(Debug, Clone)]
212pub struct IndexedRectangle {
213    rectangle: Rectangle<[f64; 2]>,
214    pub index: usize,
215}
216
217impl IndexedRectangle {
218    /// Creates a new indexed rectangle.
219    pub fn new(min: [f64; 2], max: [f64; 2], index: usize) -> Self {
220        Self {
221            rectangle: Rectangle::from_corners(min, max),
222            index,
223        }
224    }
225}
226
227impl RTreeObject for IndexedRectangle {
228    type Envelope = AABB<[f64; 2]>;
229
230    fn envelope(&self) -> Self::Envelope {
231        self.rectangle.envelope()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_spatial_index_new() {
241        let index = SpatialIndex2D::new();
242        assert!(index.is_empty());
243        assert_eq!(index.len(), 0);
244    }
245
246    #[test]
247    fn test_spatial_index_insert() {
248        let mut index = SpatialIndex2D::new();
249        let entry = SpatialEntry2D::new(0, "test".to_string(), [0.0, 0.0, 10.0, 10.0]);
250        index.insert(entry);
251
252        assert!(!index.is_empty());
253        assert_eq!(index.len(), 1);
254    }
255
256    #[test]
257    fn test_spatial_index_query_aabb() {
258        let mut index = SpatialIndex2D::new();
259
260        // Insert three non-overlapping rectangles
261        index.insert(SpatialEntry2D::new(
262            0,
263            "r1".to_string(),
264            [0.0, 0.0, 10.0, 10.0],
265        ));
266        index.insert(SpatialEntry2D::new(
267            1,
268            "r2".to_string(),
269            [20.0, 0.0, 30.0, 10.0],
270        ));
271        index.insert(SpatialEntry2D::new(
272            2,
273            "r3".to_string(),
274            [0.0, 20.0, 10.0, 30.0],
275        ));
276
277        // Query overlapping with r1 only
278        let results = index.query_aabb([5.0, 5.0], [15.0, 15.0]);
279        assert_eq!(results.len(), 1);
280        assert_eq!(results[0].index, 0);
281
282        // Query overlapping with r1 and r2
283        let results = index.query_aabb([5.0, 0.0], [25.0, 10.0]);
284        assert_eq!(results.len(), 2);
285
286        // Query overlapping with nothing
287        let results = index.query_aabb([50.0, 50.0], [60.0, 60.0]);
288        assert!(results.is_empty());
289
290        // Query overlapping with all
291        let results = index.query_aabb([-10.0, -10.0], [40.0, 40.0]);
292        assert_eq!(results.len(), 3);
293    }
294
295    #[test]
296    fn test_spatial_index_with_geometry() {
297        let mut index = SpatialIndex2D::new();
298
299        let geom1 = Geometry2D::rectangle("R1", 10.0, 10.0);
300        let geom2 = Geometry2D::rectangle("R2", 10.0, 10.0);
301
302        index.insert_geometry(0, &geom1, (0.0, 0.0), 0.0);
303        index.insert_geometry(1, &geom2, (20.0, 0.0), 0.0);
304
305        assert_eq!(index.len(), 2);
306
307        // Query for potential collisions with a new geometry at (5, 0)
308        let query_geom = Geometry2D::rectangle("Q", 10.0, 10.0);
309        let results = index.query_geometry(&query_geom, (5.0, 0.0), 0.0);
310
311        // Should intersect with first geometry only
312        assert_eq!(results.len(), 1);
313        assert_eq!(results[0].index, 0);
314    }
315
316    #[test]
317    fn test_transformed_aabb_no_rotation() {
318        let geom = Geometry2D::rectangle("R", 10.0, 10.0);
319        let aabb = compute_transformed_aabb(&geom, (5.0, 5.0), 0.0);
320
321        assert!((aabb[0] - 5.0).abs() < 1e-10);
322        assert!((aabb[1] - 5.0).abs() < 1e-10);
323        assert!((aabb[2] - 15.0).abs() < 1e-10);
324        assert!((aabb[3] - 15.0).abs() < 1e-10);
325    }
326
327    #[test]
328    fn test_transformed_aabb_with_rotation() {
329        let geom = Geometry2D::rectangle("R", 10.0, 10.0);
330        let rotation = std::f64::consts::FRAC_PI_4; // 45 degrees
331        let aabb = compute_transformed_aabb(&geom, (0.0, 0.0), rotation);
332
333        // 10x10 square at origin, rotated 45 degrees:
334        // Original vertices: (0,0), (10,0), (10,10), (0,10)
335        // After rotation:
336        // (0, 0) -> (0, 0)
337        // (10, 0) -> (7.07, 7.07)
338        // (10, 10) -> (0, 14.14)
339        // (0, 10) -> (-7.07, 7.07)
340        // Resulting AABB: min_x ~ -7.07, min_y ~ 0, max_x ~ 7.07, max_y ~ 14.14
341        let half_diag = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
342        assert!(aabb[0] < -half_diag + 0.1); // min_x should be around -7.07
343        assert!(aabb[1].abs() < 0.1); // min_y should be close to 0
344        assert!(aabb[2] > half_diag - 0.1); // max_x should be around 7.07
345        assert!((aabb[3] - 10.0 * std::f64::consts::SQRT_2).abs() < 0.1); // max_y ~ 14.14
346    }
347
348    #[test]
349    fn test_query_with_margin() {
350        let mut index = SpatialIndex2D::new();
351
352        // Two rectangles with a gap
353        index.insert(SpatialEntry2D::new(
354            0,
355            "r1".to_string(),
356            [0.0, 0.0, 10.0, 10.0],
357        ));
358        index.insert(SpatialEntry2D::new(
359            1,
360            "r2".to_string(),
361            [15.0, 0.0, 25.0, 10.0],
362        ));
363
364        let query_geom = Geometry2D::rectangle("Q", 5.0, 5.0);
365
366        // Without margin, positioned at (10.5, 0) should not intersect
367        let results = index.query_geometry(&query_geom, (10.5, 0.0), 0.0);
368        // Actually at (10.5, 0) with width 5, AABB is [10.5, 0, 15.5, 5] - intersects r2
369        assert_eq!(results.len(), 1);
370
371        // With margin of 3.0, should intersect both
372        let results = index.query_with_margin(&query_geom, (10.5, 0.0), 0.0, 3.0);
373        assert_eq!(results.len(), 2);
374    }
375
376    #[test]
377    fn test_get_potential_collisions() {
378        let mut index = SpatialIndex2D::new();
379
380        let geom1 = Geometry2D::rectangle("R1", 10.0, 10.0);
381        let geom2 = Geometry2D::rectangle("R2", 10.0, 10.0);
382        let geom3 = Geometry2D::rectangle("R3", 10.0, 10.0);
383
384        index.insert_geometry(0, &geom1, (0.0, 0.0), 0.0);
385        index.insert_geometry(1, &geom2, (50.0, 0.0), 0.0);
386        index.insert_geometry(2, &geom3, (0.0, 50.0), 0.0);
387
388        let query_geom = Geometry2D::rectangle("Q", 5.0, 5.0);
389
390        // Query at (5, 5) with spacing 2 should only collide with index 0
391        let collisions = index.get_potential_collisions(&query_geom, (5.0, 5.0), 0.0, 2.0);
392        assert_eq!(collisions.len(), 1);
393        assert_eq!(collisions[0], 0);
394    }
395
396    #[test]
397    fn test_bulk_load() {
398        let entries = vec![
399            SpatialEntry2D::new(0, "r1".to_string(), [0.0, 0.0, 10.0, 10.0]),
400            SpatialEntry2D::new(1, "r2".to_string(), [20.0, 0.0, 30.0, 10.0]),
401            SpatialEntry2D::new(2, "r3".to_string(), [0.0, 20.0, 10.0, 30.0]),
402        ];
403
404        let index = SpatialIndex2D::with_entries(entries);
405        assert_eq!(index.len(), 3);
406
407        let results = index.query_aabb([0.0, 0.0], [15.0, 15.0]);
408        assert_eq!(results.len(), 1);
409    }
410}