Skip to main content

oxigdal_algorithms/vector/
spatial_join.rs

1//! Spatial join operations with spatial indexing
2//!
3//! Efficient spatial joins using R-tree and other spatial indices.
4
5use crate::error::{AlgorithmError, Result};
6use oxigdal_core::vector::Point;
7use rstar::{AABB, PointDistance, RTree, RTreeObject};
8
9/// Spatial join predicate
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SpatialJoinPredicate {
12    /// Features intersect
13    Intersects,
14    /// First feature contains second
15    Contains,
16    /// First feature is within second
17    Within,
18    /// Features touch (share boundary)
19    Touches,
20    /// Features are within distance
21    WithinDistance,
22}
23
24/// Options for spatial join
25#[derive(Debug, Clone)]
26pub struct SpatialJoinOptions {
27    /// Spatial predicate
28    pub predicate: SpatialJoinPredicate,
29    /// Distance threshold (for WithinDistance predicate)
30    pub distance: f64,
31    /// Whether to build spatial index
32    pub use_index: bool,
33}
34
35impl Default for SpatialJoinOptions {
36    fn default() -> Self {
37        Self {
38            predicate: SpatialJoinPredicate::Intersects,
39            distance: 0.0,
40            use_index: true,
41        }
42    }
43}
44
45/// Result of spatial join
46#[derive(Debug, Clone)]
47pub struct SpatialJoinResult {
48    /// Pairs of matching indices (left_idx, right_idx)
49    pub matches: Vec<(usize, usize)>,
50    /// Number of matches
51    pub num_matches: usize,
52}
53
54/// Indexed point for R-tree
55#[derive(Debug, Clone)]
56struct IndexedPoint {
57    point: Point,
58    index: usize,
59}
60
61impl RTreeObject for IndexedPoint {
62    type Envelope = AABB<[f64; 2]>;
63
64    fn envelope(&self) -> Self::Envelope {
65        AABB::from_point([self.point.coord.x, self.point.coord.y])
66    }
67}
68
69impl PointDistance for IndexedPoint {
70    fn distance_2(&self, point: &[f64; 2]) -> f64 {
71        let dx = self.point.coord.x - point[0];
72        let dy = self.point.coord.y - point[1];
73        dx * dx + dy * dy
74    }
75}
76
77/// Perform spatial join between two point sets
78///
79/// # Arguments
80///
81/// * `left_points` - First set of points
82/// * `right_points` - Second set of points
83/// * `options` - Spatial join options
84///
85/// # Returns
86///
87/// Join result with matching pairs
88///
89/// # Errors
90///
91/// Returns [`AlgorithmError::UnsupportedOperation`] if `options.predicate` is
92/// `Contains`, `Within`, or `Touches` — only `Intersects` and `WithinDistance`
93/// are meaningful for point-only joins.
94///
95/// # Examples
96///
97/// ```
98/// use oxigdal_algorithms::vector::spatial_join::{spatial_join_points, SpatialJoinOptions, SpatialJoinPredicate};
99/// use oxigdal_algorithms::Point;
100/// # use oxigdal_algorithms::error::Result;
101///
102/// # fn main() -> Result<()> {
103/// let left = vec![
104///     Point::new(0.0, 0.0),
105///     Point::new(1.0, 1.0),
106/// ];
107///
108/// let right = vec![
109///     Point::new(0.1, 0.1),
110///     Point::new(10.0, 10.0),
111/// ];
112///
113/// let options = SpatialJoinOptions {
114///     predicate: SpatialJoinPredicate::WithinDistance,
115///     distance: 0.5,
116///     use_index: true,
117/// };
118///
119/// let result = spatial_join_points(&left, &right, &options)?;
120/// assert!(result.num_matches >= 1);
121/// # Ok(())
122/// # }
123/// ```
124pub fn spatial_join_points(
125    left_points: &[Point],
126    right_points: &[Point],
127    options: &SpatialJoinOptions,
128) -> Result<SpatialJoinResult> {
129    // Only `Intersects` and `WithinDistance` are meaningful for point-only
130    // joins. Previously the other predicates silently produced zero matches
131    // (indistinguishable from a legitimate "no matches" result), masking
132    // predicate misconfiguration. Reject them explicitly instead.
133    match options.predicate {
134        SpatialJoinPredicate::Intersects | SpatialJoinPredicate::WithinDistance => {}
135        SpatialJoinPredicate::Contains
136        | SpatialJoinPredicate::Within
137        | SpatialJoinPredicate::Touches => {
138            return Err(AlgorithmError::UnsupportedOperation {
139                operation: format!(
140                    "{:?} predicate on point geometries (only Intersects and WithinDistance are supported)",
141                    options.predicate
142                ),
143            });
144        }
145    }
146
147    if left_points.is_empty() || right_points.is_empty() {
148        return Ok(SpatialJoinResult {
149            matches: Vec::new(),
150            num_matches: 0,
151        });
152    }
153
154    let matches = if options.use_index {
155        // Build R-tree for right points
156        let indexed_points: Vec<IndexedPoint> = right_points
157            .iter()
158            .enumerate()
159            .map(|(idx, point)| IndexedPoint {
160                point: point.clone(),
161                index: idx,
162            })
163            .collect();
164
165        let rtree = RTree::bulk_load(indexed_points);
166
167        // Query R-tree for each left point
168        let mut all_matches = Vec::new();
169
170        for (left_idx, left_point) in left_points.iter().enumerate() {
171            let nearby = match options.predicate {
172                SpatialJoinPredicate::WithinDistance => {
173                    // Query points within distance
174                    let envelope = AABB::from_corners(
175                        [
176                            left_point.coord.x - options.distance,
177                            left_point.coord.y - options.distance,
178                        ],
179                        [
180                            left_point.coord.x + options.distance,
181                            left_point.coord.y + options.distance,
182                        ],
183                    );
184
185                    rtree
186                        .locate_in_envelope(envelope)
187                        .filter(|indexed| {
188                            point_distance(left_point, &indexed.point) <= options.distance
189                        })
190                        .map(|indexed| indexed.index)
191                        .collect::<Vec<_>>()
192                }
193                SpatialJoinPredicate::Intersects => {
194                    // For points, intersects means exactly coincident
195                    let mut matches = Vec::new();
196                    for indexed in rtree.locate_at_point([left_point.coord.x, left_point.coord.y]) {
197                        matches.push(indexed.index);
198                    }
199                    matches
200                }
201                _ => {
202                    // Other predicates not applicable to points
203                    Vec::new()
204                }
205            };
206
207            for right_idx in nearby {
208                all_matches.push((left_idx, right_idx));
209            }
210        }
211
212        all_matches
213    } else {
214        // Brute force comparison
215        let mut all_matches = Vec::new();
216
217        for (left_idx, left_point) in left_points.iter().enumerate() {
218            for (right_idx, right_point) in right_points.iter().enumerate() {
219                if matches_predicate(left_point, right_point, options) {
220                    all_matches.push((left_idx, right_idx));
221                }
222            }
223        }
224
225        all_matches
226    };
227
228    Ok(SpatialJoinResult {
229        num_matches: matches.len(),
230        matches,
231    })
232}
233
234/// Check if two points match the join predicate
235fn matches_predicate(left: &Point, right: &Point, options: &SpatialJoinOptions) -> bool {
236    match options.predicate {
237        SpatialJoinPredicate::Intersects => {
238            (left.coord.x - right.coord.x).abs() < 1e-10
239                && (left.coord.y - right.coord.y).abs() < 1e-10
240        }
241        SpatialJoinPredicate::WithinDistance => point_distance(left, right) <= options.distance,
242        _ => false,
243    }
244}
245
246/// Calculate Euclidean distance between points
247fn point_distance(p1: &Point, p2: &Point) -> f64 {
248    let dx = p1.coord.x - p2.coord.x;
249    let dy = p1.coord.y - p2.coord.y;
250    (dx * dx + dy * dy).sqrt()
251}
252
253/// Nearest neighbor search
254pub fn nearest_neighbor(query: &Point, points: &[Point]) -> Option<(usize, f64)> {
255    let indexed_points: Vec<IndexedPoint> = points
256        .iter()
257        .enumerate()
258        .map(|(idx, point)| IndexedPoint {
259            point: point.clone(),
260            index: idx,
261        })
262        .collect();
263
264    if indexed_points.is_empty() {
265        return None;
266    }
267
268    let rtree = RTree::bulk_load(indexed_points);
269    let nearest = rtree.nearest_neighbor([query.coord.x, query.coord.y])?;
270
271    let distance = point_distance(query, &nearest.point);
272
273    Some((nearest.index, distance))
274}
275
276/// K-nearest neighbors search
277pub fn k_nearest_neighbors(query: &Point, points: &[Point], k: usize) -> Vec<(usize, f64)> {
278    let indexed_points: Vec<IndexedPoint> = points
279        .iter()
280        .enumerate()
281        .map(|(idx, point)| IndexedPoint {
282            point: point.clone(),
283            index: idx,
284        })
285        .collect();
286
287    if indexed_points.is_empty() {
288        return Vec::new();
289    }
290
291    let rtree = RTree::bulk_load(indexed_points);
292
293    rtree
294        .nearest_neighbor_iter([query.coord.x, query.coord.y])
295        .take(k)
296        .map(|indexed| {
297            let dist = point_distance(query, &indexed.point);
298            (indexed.index, dist)
299        })
300        .collect()
301}
302
303/// Range query (all points within distance)
304pub fn range_query(query: &Point, points: &[Point], distance: f64) -> Vec<usize> {
305    let options = SpatialJoinOptions {
306        predicate: SpatialJoinPredicate::WithinDistance,
307        distance,
308        use_index: true,
309    };
310
311    let result = spatial_join_points(std::slice::from_ref(query), points, &options);
312
313    result
314        .map(|r| {
315            r.matches
316                .into_iter()
317                .map(|(_, right_idx)| right_idx)
318                .collect()
319        })
320        .unwrap_or_else(|_| Vec::new())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_spatial_join_within_distance() {
329        let left = vec![Point::new(0.0, 0.0), Point::new(10.0, 10.0)];
330
331        let right = vec![Point::new(0.1, 0.1), Point::new(5.0, 5.0)];
332
333        let options = SpatialJoinOptions {
334            predicate: SpatialJoinPredicate::WithinDistance,
335            distance: 0.5,
336            use_index: true,
337        };
338
339        let result = spatial_join_points(&left, &right, &options);
340        assert!(result.is_ok());
341
342        let join_result = result.expect("Join failed");
343        assert!(join_result.num_matches >= 1);
344    }
345
346    #[test]
347    fn test_nearest_neighbor() {
348        let points = vec![
349            Point::new(0.0, 0.0),
350            Point::new(5.0, 5.0),
351            Point::new(10.0, 10.0),
352        ];
353
354        let query = Point::new(0.1, 0.1);
355        let result = nearest_neighbor(&query, &points);
356
357        assert!(result.is_some());
358
359        let (idx, dist) = result.expect("Nearest neighbor failed");
360        assert_eq!(idx, 0);
361        assert!(dist < 0.2);
362    }
363
364    #[test]
365    fn test_k_nearest_neighbors() {
366        let points = vec![
367            Point::new(0.0, 0.0),
368            Point::new(1.0, 1.0),
369            Point::new(2.0, 2.0),
370            Point::new(10.0, 10.0),
371        ];
372
373        let query = Point::new(0.0, 0.0);
374        let result = k_nearest_neighbors(&query, &points, 2);
375
376        assert_eq!(result.len(), 2);
377        assert_eq!(result[0].0, 0); // First is the query point itself
378    }
379
380    #[test]
381    fn test_range_query() {
382        let points = vec![
383            Point::new(0.0, 0.0),
384            Point::new(0.5, 0.5),
385            Point::new(10.0, 10.0),
386        ];
387
388        let query = Point::new(0.0, 0.0);
389        let result = range_query(&query, &points, 1.0);
390
391        assert!(result.len() >= 2); // Should find points at 0.0 and 0.5
392    }
393
394    #[test]
395    fn test_point_distance() {
396        let p1 = Point::new(0.0, 0.0);
397        let p2 = Point::new(3.0, 4.0);
398
399        let dist = point_distance(&p1, &p2);
400        assert!((dist - 5.0).abs() < 1e-6);
401    }
402
403    #[test]
404    fn test_unsupported_predicates_error_not_silent_empty() {
405        let left = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)];
406        let right = vec![Point::new(0.0, 0.0), Point::new(2.0, 2.0)];
407
408        for predicate in [
409            SpatialJoinPredicate::Contains,
410            SpatialJoinPredicate::Within,
411            SpatialJoinPredicate::Touches,
412        ] {
413            for use_index in [true, false] {
414                let options = SpatialJoinOptions {
415                    predicate,
416                    distance: 0.0,
417                    use_index,
418                };
419                let result = spatial_join_points(&left, &right, &options);
420                assert!(
421                    matches!(result, Err(AlgorithmError::UnsupportedOperation { .. })),
422                    "predicate {predicate:?} (use_index={use_index}) must return \
423                     UnsupportedOperation, got {result:?}"
424                );
425            }
426        }
427    }
428
429    #[test]
430    fn test_supported_predicates_still_ok() {
431        let left = vec![Point::new(0.0, 0.0)];
432        let right = vec![Point::new(0.0, 0.0)];
433
434        for predicate in [
435            SpatialJoinPredicate::Intersects,
436            SpatialJoinPredicate::WithinDistance,
437        ] {
438            for use_index in [true, false] {
439                let options = SpatialJoinOptions {
440                    predicate,
441                    distance: 1.0,
442                    use_index,
443                };
444                let result = spatial_join_points(&left, &right, &options);
445                assert!(
446                    result.is_ok(),
447                    "predicate {predicate:?} (use_index={use_index}) must succeed"
448                );
449            }
450        }
451    }
452}