Skip to main content

luci/spatial/
query.rs

1//! Geospatial query implementations: geo_distance, geo_bounding_box, geo_shape.
2//!
3//! See [[geospatial]] and [[feature-geo-shape]].
4
5use crate::core::{DocId, NO_MORE_DOCS, Result, ScoreMode, Scorer, TwoPhaseIterator};
6
7use super::geo::{GeoPoint, GeoPointStore, haversine_km};
8use super::shape::GeoShapeStore;
9use crate::query::ast::SpatialRelation;
10use crate::query::{BoundQuery, Query, ScorerSupplier};
11use crate::search::searcher::Searcher;
12use crate::segment::reader::SegmentReader;
13
14/// Match documents within a distance from a center point.
15pub struct GeoDistanceQuery {
16    pub field: String,
17    pub center: GeoPoint,
18    pub distance_km: f64,
19}
20
21impl Query for GeoDistanceQuery {
22    fn bind(&self, _: &Searcher, _: ScoreMode) -> Result<Box<dyn BoundQuery>> {
23        Ok(Box::new(BoundGeoDistanceQuery {
24            field: self.field.clone(),
25            center: self.center,
26            distance_km: self.distance_km,
27        }))
28    }
29}
30
31struct BoundGeoDistanceQuery {
32    field: String,
33    center: GeoPoint,
34    distance_km: f64,
35}
36
37impl BoundQuery for BoundGeoDistanceQuery {
38    fn scorer_supplier(&self, reader: &SegmentReader) -> Result<Option<Box<dyn ScorerSupplier>>> {
39        let field_id = match reader
40            .header()
41            .fields
42            .iter()
43            .find(|f| f.field_name == self.field)
44            .map(|f| f.field_id)
45        {
46            Some(id) => id,
47            None => return Ok(None),
48        };
49
50        let store = match reader.geo_points(field_id) {
51            Some(s) => s,
52            None => return Ok(None),
53        };
54
55        Ok(Some(Box::new(GeoDistanceScorerSupplier {
56            center: self.center,
57            distance_km: self.distance_km,
58            store,
59        })))
60    }
61}
62
63struct GeoDistanceScorerSupplier {
64    center: GeoPoint,
65    distance_km: f64,
66    store: GeoPointStore,
67}
68
69impl ScorerSupplier for GeoDistanceScorerSupplier {
70    fn cost(&self) -> u64 {
71        self.store.len() as u64
72    }
73
74    fn scorer(self: Box<Self>) -> Result<Box<dyn Scorer>> {
75        // Compute latitude bounds: 1 degree of latitude ≈ 111 km
76        const KM_PER_DEG_LAT: f64 = 111.0;
77        let lat_delta = self.distance_km / KM_PER_DEG_LAT;
78        let min_lat = self.center.lat - lat_delta;
79        let max_lat = self.center.lat + lat_delta;
80
81        // Binary search for lat-bounded candidates, then exact haversine
82        let candidates = self.store.docs_in_lat_range(min_lat, max_lat);
83        let mut matches = Vec::new();
84        for doc_id in candidates {
85            if let Some(point) = self.store.get(doc_id) {
86                let d = haversine_km(self.center, point);
87                if d <= self.distance_km {
88                    matches.push((doc_id, d));
89                }
90            }
91        }
92
93        Ok(Box::new(GeoScorer { matches, pos: 0 }))
94    }
95}
96
97/// Match documents within a bounding box.
98pub struct GeoBoundingBoxQuery {
99    pub field: String,
100    pub top_left: GeoPoint,
101    pub bottom_right: GeoPoint,
102}
103
104impl Query for GeoBoundingBoxQuery {
105    fn bind(&self, _: &Searcher, _: ScoreMode) -> Result<Box<dyn BoundQuery>> {
106        Ok(Box::new(BoundGeoBBoxQuery {
107            field: self.field.clone(),
108            top_left: self.top_left,
109            bottom_right: self.bottom_right,
110        }))
111    }
112}
113
114struct BoundGeoBBoxQuery {
115    field: String,
116    top_left: GeoPoint,
117    bottom_right: GeoPoint,
118}
119
120impl BoundQuery for BoundGeoBBoxQuery {
121    fn scorer_supplier(&self, reader: &SegmentReader) -> Result<Option<Box<dyn ScorerSupplier>>> {
122        let field_id = match reader
123            .header()
124            .fields
125            .iter()
126            .find(|f| f.field_name == self.field)
127            .map(|f| f.field_id)
128        {
129            Some(id) => id,
130            None => return Ok(None),
131        };
132
133        let store = match reader.geo_points(field_id) {
134            Some(s) => s,
135            None => return Ok(None),
136        };
137
138        Ok(Some(Box::new(GeoBBoxScorerSupplier {
139            top_left: self.top_left,
140            bottom_right: self.bottom_right,
141            store,
142        })))
143    }
144}
145
146struct GeoBBoxScorerSupplier {
147    top_left: GeoPoint,
148    bottom_right: GeoPoint,
149    store: GeoPointStore,
150}
151
152impl ScorerSupplier for GeoBBoxScorerSupplier {
153    fn cost(&self) -> u64 {
154        self.store.len() as u64
155    }
156
157    fn scorer(self: Box<Self>) -> Result<Box<dyn Scorer>> {
158        // Lat range is already defined by the bounding box — only check lon
159        let candidates = self
160            .store
161            .docs_in_lat_range(self.bottom_right.lat, self.top_left.lat);
162        let mut matches = Vec::new();
163        for doc_id in candidates {
164            if let Some(point) = self.store.get(doc_id) {
165                if point.lon >= self.top_left.lon && point.lon <= self.bottom_right.lon {
166                    matches.push((doc_id, 0.0));
167                }
168            }
169        }
170
171        Ok(Box::new(GeoScorer { matches, pos: 0 }))
172    }
173}
174
175/// Simple scorer over pre-materialized geo matches.
176struct GeoScorer {
177    matches: Vec<(u32, f64)>, // (doc_id, distance_km)
178    pos: usize,
179}
180
181impl Scorer for GeoScorer {
182    fn doc_id(&self) -> DocId {
183        if self.pos < self.matches.len() {
184            DocId::new(self.matches[self.pos].0)
185        } else {
186            NO_MORE_DOCS
187        }
188    }
189    fn next(&mut self) -> DocId {
190        if self.pos < self.matches.len() {
191            self.pos += 1;
192        }
193        self.doc_id()
194    }
195    fn advance(&mut self, target: DocId) -> DocId {
196        while self.pos < self.matches.len() && self.matches[self.pos].0 < target.as_u32() {
197            self.pos += 1;
198        }
199        self.doc_id()
200    }
201    fn score(&mut self) -> f32 {
202        1.0
203    }
204    fn two_phase(&mut self) -> Option<&mut dyn TwoPhaseIterator> {
205        None
206    }
207}
208
209// ---------------------------------------------------------------------------
210// geo_shape query
211// ---------------------------------------------------------------------------
212
213/// Check if a geometry is an axis-aligned rectangle (4 corners + closing point).
214fn is_axis_aligned_rect(geom: &::geo::Geometry<f64>) -> bool {
215    if let ::geo::Geometry::Polygon(p) = geom {
216        if !p.interiors().is_empty() {
217            return false;
218        }
219        let coords = &p.exterior().0;
220        if coords.len() != 5 {
221            return false;
222        }
223        // Check that all x values are one of two distinct values, same for y
224        let xs: std::collections::BTreeSet<u64> =
225            coords[..4].iter().map(|c| c.x.to_bits()).collect();
226        let ys: std::collections::BTreeSet<u64> =
227            coords[..4].iter().map(|c| c.y.to_bits()).collect();
228        xs.len() == 2 && ys.len() == 2
229    } else {
230        false
231    }
232}
233
234/// Match documents whose geo_shape field satisfies a spatial relation
235/// with a query shape. Uses R-tree for candidate selection and the `geo`
236/// crate for exact [[de-9im]] predicate evaluation.
237///
238/// See [[feature-geo-shape]].
239pub struct GeoShapeQuery {
240    pub field: String,
241    pub query_shape: ::geo::Geometry<f64>,
242    pub query_bbox: (f64, f64, f64, f64),
243    pub relation: SpatialRelation,
244}
245
246impl Query for GeoShapeQuery {
247    fn bind(&self, _: &Searcher, _: ScoreMode) -> Result<Box<dyn BoundQuery>> {
248        Ok(Box::new(BoundGeoShapeQuery {
249            field: self.field.clone(),
250            query_shape: self.query_shape.clone(),
251            query_bbox: self.query_bbox,
252            relation: self.relation.clone(),
253        }))
254    }
255}
256
257struct BoundGeoShapeQuery {
258    field: String,
259    query_shape: ::geo::Geometry<f64>,
260    query_bbox: (f64, f64, f64, f64),
261    relation: SpatialRelation,
262}
263
264impl BoundQuery for BoundGeoShapeQuery {
265    fn scorer_supplier(&self, reader: &SegmentReader) -> Result<Option<Box<dyn ScorerSupplier>>> {
266        let field_id = match reader
267            .header()
268            .fields
269            .iter()
270            .find(|f| f.field_name == self.field)
271            .map(|f| f.field_id)
272        {
273            Some(id) => id,
274            None => return Ok(None),
275        };
276
277        let store = match reader.geo_shapes(field_id) {
278            Some(s) => s,
279            None => return Ok(None),
280        };
281
282        Ok(Some(Box::new(GeoShapeScorerSupplier {
283            query_shape: self.query_shape.clone(),
284            query_bbox: self.query_bbox,
285            relation: self.relation.clone(),
286            store,
287        })))
288    }
289}
290
291struct GeoShapeScorerSupplier {
292    query_shape: ::geo::Geometry<f64>,
293    query_bbox: (f64, f64, f64, f64),
294    relation: SpatialRelation,
295    store: GeoShapeStore,
296}
297
298impl GeoShapeScorerSupplier {
299    /// Optimized disjoint: find the small set of docs that DO intersect
300    /// via R-tree, verify with exact predicate, then return everything else.
301    fn score_disjoint(self, rtree_data: &[u8]) -> Result<Box<dyn Scorer>> {
302        use ::geo::algorithm::Intersects;
303
304        let intersecting_candidates = GeoShapeStore::search_rtree(
305            rtree_data,
306            self.query_bbox,
307            self.store.shape_offsets_ref(),
308        );
309
310        // Verify which candidates truly intersect (bbox overlap ≠ shape overlap)
311        let mut intersecting = std::collections::HashSet::new();
312        for doc_id in intersecting_candidates {
313            if let Some(doc_shape) = self.store.get_shape(doc_id) {
314                if self.query_shape.intersects(&doc_shape) {
315                    intersecting.insert(doc_id);
316                }
317            }
318        }
319
320        // Return all non-null docs NOT in the intersecting set
321        let mut matches = Vec::new();
322        for doc_id in 0..self.store.len() as u32 {
323            if self.store.get_bbox(doc_id).is_some() && !intersecting.contains(&doc_id) {
324                matches.push((doc_id, 0.0));
325            }
326        }
327
328        Ok(Box::new(GeoScorer { matches, pos: 0 }))
329    }
330}
331
332impl ScorerSupplier for GeoShapeScorerSupplier {
333    fn cost(&self) -> u64 {
334        self.store.len() as u64
335    }
336
337    fn scorer(self: Box<Self>) -> Result<Box<dyn Scorer>> {
338        use ::geo::algorithm::{Contains, Intersects, Relate};
339
340        // Use pre-built R-tree from segment, fall back to building if empty
341        let rtree_data_owned;
342        let rtree_data = if !self.store.rtree_data().is_empty() {
343            self.store.rtree_data()
344        } else {
345            rtree_data_owned = self.store.build_rtree();
346            &rtree_data_owned
347        };
348        // For disjoint: find docs that DO intersect via R-tree, then return
349        // the complement. Much faster than iterating all docs.
350        if self.relation == SpatialRelation::Disjoint {
351            let rtree_owned = if self.store.rtree_data().is_empty() {
352                self.store.build_rtree()
353            } else {
354                self.store.rtree_data().to_vec()
355            };
356            return self.score_disjoint(&rtree_owned);
357        }
358
359        let candidates = GeoShapeStore::search_rtree(
360            rtree_data,
361            self.query_bbox,
362            self.store.shape_offsets_ref(),
363        );
364
365        let (q_min_x, q_min_y, q_max_x, q_max_y) = self.query_bbox;
366
367        // Check if query shape is axis-aligned rect (bbox == exact geometry).
368        // When true, bbox containment proves exact containment.
369        let query_is_rect = matches!(self.query_shape, ::geo::Geometry::Rect(_))
370            || is_axis_aligned_rect(&self.query_shape);
371
372        let mut matches = Vec::new();
373        for doc_id in candidates {
374            let doc_bbox = match self.store.get_bbox(doc_id) {
375                Some(bb) => bb,
376                None => continue,
377            };
378            let (d_min_x, d_min_y, d_max_x, d_max_y) = doc_bbox;
379
380            // Bbox pre-filter: skip docs that can't possibly match
381            match self.relation {
382                SpatialRelation::Within | SpatialRelation::CoveredBy => {
383                    if d_min_x < q_min_x
384                        || d_min_y < q_min_y
385                        || d_max_x > q_max_x
386                        || d_max_y > q_max_y
387                    {
388                        continue;
389                    }
390                }
391                SpatialRelation::Contains
392                | SpatialRelation::Covers
393                | SpatialRelation::ContainsProperly => {
394                    if q_min_x < d_min_x
395                        || q_min_y < d_min_y
396                        || q_max_x > d_max_x
397                        || q_max_y > d_max_y
398                    {
399                        continue;
400                    }
401                }
402                _ => {}
403            }
404
405            // Fast path 1: rect query — if doc bbox is fully inside the
406            // query bbox, then doc shape ⊆ doc bbox ⊆ query rect.
407            if query_is_rect {
408                match self.relation {
409                    SpatialRelation::Within | SpatialRelation::CoveredBy => {
410                        matches.push((doc_id, 0.0));
411                        continue;
412                    }
413                    _ => {}
414                }
415            }
416
417            // Fast path 2: rect doc — if query bbox is fully inside the
418            // doc bbox and the doc is a rect, doc contains query.
419            if self.store.is_rect_shape(doc_id) {
420                let bbox_proves_contains = q_min_x >= d_min_x
421                    && q_min_y >= d_min_y
422                    && q_max_x <= d_max_x
423                    && q_max_y <= d_max_y;
424                if bbox_proves_contains {
425                    match self.relation {
426                        SpatialRelation::Contains | SpatialRelation::Covers => {
427                            matches.push((doc_id, 0.0));
428                            continue;
429                        }
430                        _ => {}
431                    }
432                }
433            }
434
435            // Fast path 3: non-rect query but all 4 doc bbox corners are
436            // inside the query shape → doc shape ⊆ doc bbox ⊆ query shape.
437            // Uses point-in-polygon which is much cheaper than shape-contains-shape.
438            match self.relation {
439                SpatialRelation::Within | SpatialRelation::CoveredBy => {
440                    let corners = [
441                        ::geo::Coord {
442                            x: d_min_x,
443                            y: d_min_y,
444                        },
445                        ::geo::Coord {
446                            x: d_max_x,
447                            y: d_min_y,
448                        },
449                        ::geo::Coord {
450                            x: d_max_x,
451                            y: d_max_y,
452                        },
453                        ::geo::Coord {
454                            x: d_min_x,
455                            y: d_max_y,
456                        },
457                    ];
458                    let all_inside = corners
459                        .iter()
460                        .all(|c| self.query_shape.contains(&::geo::Point(*c)));
461                    if all_inside {
462                        matches.push((doc_id, 0.0));
463                        continue;
464                    }
465                }
466                _ => {}
467            }
468
469            let doc_shape = match self.store.get_shape(doc_id) {
470                Some(s) => s,
471                None => continue,
472            };
473
474            // Use fast-path traits for common predicates, fall back to
475            // full DE-9IM Relate only when needed.
476            let matched = match self.relation {
477                SpatialRelation::Intersects => self.query_shape.intersects(&doc_shape),
478                SpatialRelation::Disjoint => !self.query_shape.intersects(&doc_shape),
479                SpatialRelation::Contains | SpatialRelation::Covers => {
480                    doc_shape.contains(&self.query_shape)
481                }
482                SpatialRelation::Within | SpatialRelation::CoveredBy => {
483                    self.query_shape.contains(&doc_shape)
484                }
485                // Full DE-9IM for predicates that need the intersection matrix
486                _ => {
487                    let im = doc_shape.relate(&self.query_shape);
488                    match self.relation {
489                        SpatialRelation::Touches => im.is_touches(),
490                        SpatialRelation::Crosses => im.is_crosses(),
491                        SpatialRelation::Overlaps => im.is_overlaps(),
492                        SpatialRelation::Equals => im.is_equal_topo(),
493                        SpatialRelation::Covers => im.is_covers(),
494                        SpatialRelation::CoveredBy => im.is_coveredby(),
495                        SpatialRelation::ContainsProperly => im.is_contains_properly(),
496                        _ => unreachable!(),
497                    }
498                }
499            };
500
501            if matched {
502                matches.push((doc_id, 0.0));
503            }
504        }
505
506        matches.sort_by_key(|m| m.0);
507        Ok(Box::new(GeoScorer { matches, pos: 0 }))
508    }
509}