Skip to main content

rustial_engine/
query.rs

1//! Interaction and query API primitives.
2//!
3//! This module provides a small engine-owned query surface for source-feature
4//! lookup, rendered-feature picking, and feature-state storage keyed by style
5//! sources / feature ids.
6
7use crate::geometry::{Feature, Geometry, PropertyValue};
8use rustial_math::{GeoCoord, TileId, WebMercator};
9use std::collections::HashMap;
10
11/// Mutable per-feature state payload.
12pub type FeatureState = HashMap<String, PropertyValue>;
13
14/// Stable feature-state identifier.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct FeatureStateId {
17    /// Style source id owning the feature.
18    pub source_id: String,
19    /// Stable feature id within that source.
20    pub feature_id: String,
21}
22
23impl FeatureStateId {
24    /// Create a new feature-state id.
25    pub fn new(source_id: impl Into<String>, feature_id: impl Into<String>) -> Self {
26        Self {
27            source_id: source_id.into(),
28            feature_id: feature_id.into(),
29        }
30    }
31}
32
33/// Query filtering options.
34#[derive(Debug, Clone, Default)]
35pub struct QueryOptions {
36    /// Restrict results to specific style layer ids / runtime layer names.
37    pub layers: Vec<String>,
38    /// Restrict results to specific source ids.
39    pub sources: Vec<String>,
40    /// Hit tolerance in meters for point/line picking.
41    pub tolerance_meters: f64,
42    /// Whether placed-symbol collision boxes participate in rendered queries.
43    pub include_symbols: bool,
44}
45
46impl QueryOptions {
47    /// Create default query options.
48    pub fn new() -> Self {
49        Self {
50            layers: Vec::new(),
51            sources: Vec::new(),
52            tolerance_meters: 16.0,
53            include_symbols: true,
54        }
55    }
56
57}
58
59/// A feature returned by source or rendered query APIs.
60#[derive(Debug, Clone)]
61pub struct QueriedFeature {
62    /// Style layer id or runtime layer name that produced the feature.
63    pub layer_id: Option<String>,
64    /// Style source id, when known.
65    pub source_id: Option<String>,
66    /// Style source-layer id, when known.
67    pub source_layer: Option<String>,
68    /// Tile that supplied the feature, when known.
69    pub source_tile: Option<TileId>,
70    /// Stable feature id.
71    pub feature_id: String,
72    /// Source-local feature index.
73    pub feature_index: usize,
74    /// Feature geometry.
75    pub geometry: Geometry,
76    /// Feature properties.
77    pub properties: HashMap<String, PropertyValue>,
78    /// Mutable feature-state snapshot.
79    pub state: FeatureState,
80    /// Distance from the query position in meters.
81    pub distance_meters: f64,
82    /// Whether the hit came from the rendered symbol representation.
83    pub from_symbol: bool,
84}
85
86/// Derive a stable feature id from properties or source-local index.
87pub fn feature_id_for_feature(feature: &Feature, feature_index: usize) -> String {
88    if let Some(value) = feature.property("id") {
89        return property_value_as_feature_id(value).unwrap_or_else(|| feature_index.to_string());
90    }
91    if let Some(value) = feature.property("feature_id") {
92        return property_value_as_feature_id(value).unwrap_or_else(|| feature_index.to_string());
93    }
94    feature_index.to_string()
95}
96
97/// Geometric hit-testing against a feature geometry.
98pub fn geometry_hit_distance(
99    geometry: &Geometry,
100    coord: &GeoCoord,
101    tolerance_meters: f64,
102) -> Option<f64> {
103    match geometry {
104        Geometry::Point(point) => {
105            let dist = world_distance(coord, &point.coord);
106            (dist <= tolerance_meters).then_some(dist)
107        }
108        Geometry::LineString(line) => line_distance(&line.coords, coord)
109            .filter(|distance| *distance <= tolerance_meters),
110        Geometry::Polygon(polygon) => polygon_hit_distance(polygon, coord, tolerance_meters),
111        Geometry::MultiPoint(points) => points
112            .points
113            .iter()
114            .filter_map(|point| geometry_hit_distance(&Geometry::Point(point.clone()), coord, tolerance_meters))
115            .min_by(f64::total_cmp),
116        Geometry::MultiLineString(lines) => lines
117            .lines
118            .iter()
119            .filter_map(|line| geometry_hit_distance(&Geometry::LineString(line.clone()), coord, tolerance_meters))
120            .min_by(f64::total_cmp),
121        Geometry::MultiPolygon(polygons) => polygons
122            .polygons
123            .iter()
124            .filter_map(|polygon| geometry_hit_distance(&Geometry::Polygon(polygon.clone()), coord, tolerance_meters))
125            .min_by(f64::total_cmp),
126        Geometry::GeometryCollection(geometries) => geometries
127            .iter()
128            .filter_map(|geometry| geometry_hit_distance(geometry, coord, tolerance_meters))
129            .min_by(f64::total_cmp),
130    }
131}
132
133fn property_value_as_feature_id(value: &PropertyValue) -> Option<String> {
134    match value {
135        PropertyValue::Null => None,
136        PropertyValue::Bool(value) => Some(value.to_string()),
137        PropertyValue::Number(value) => Some(value.to_string()),
138        PropertyValue::String(value) => Some(value.clone()),
139    }
140}
141
142fn polygon_hit_distance(
143    polygon: &crate::geometry::Polygon,
144    coord: &GeoCoord,
145    tolerance_meters: f64,
146) -> Option<f64> {
147    if point_in_ring(&polygon.exterior, coord)
148        && !polygon.interiors.iter().any(|hole| point_in_ring(hole, coord))
149    {
150        return Some(0.0);
151    }
152
153    let mut min_distance = line_distance(&polygon.exterior, coord);
154    for hole in &polygon.interiors {
155        let hole_distance = line_distance(hole, coord);
156        min_distance = match (min_distance, hole_distance) {
157            (Some(a), Some(b)) => Some(a.min(b)),
158            (Some(a), None) => Some(a),
159            (None, Some(b)) => Some(b),
160            (None, None) => None,
161        };
162    }
163    min_distance.filter(|distance| *distance <= tolerance_meters)
164}
165
166fn line_distance(coords: &[GeoCoord], coord: &GeoCoord) -> Option<f64> {
167    if coords.len() < 2 {
168        return None;
169    }
170    let p = world_xy(coord);
171    coords
172        .windows(2)
173        .map(|segment| point_to_segment_distance(p, world_xy(&segment[0]), world_xy(&segment[1])))
174        .min_by(f64::total_cmp)
175}
176
177fn point_in_ring(ring: &[GeoCoord], coord: &GeoCoord) -> bool {
178    if ring.len() < 3 {
179        return false;
180    }
181    let p = world_xy(coord);
182    let mut inside = false;
183    let mut prev = world_xy(ring.last().expect("ring last"));
184    for current_coord in ring {
185        let current = world_xy(current_coord);
186        let intersects = ((current[1] > p[1]) != (prev[1] > p[1]))
187            && (p[0] < (prev[0] - current[0]) * (p[1] - current[1]) / (prev[1] - current[1]).max(f64::EPSILON) + current[0]);
188        if intersects {
189            inside = !inside;
190        }
191        prev = current;
192    }
193    inside
194}
195
196fn point_to_segment_distance(point: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
197    let ab = [b[0] - a[0], b[1] - a[1]];
198    let ap = [point[0] - a[0], point[1] - a[1]];
199    let len2 = ab[0] * ab[0] + ab[1] * ab[1];
200    if len2 <= f64::EPSILON {
201        return ((point[0] - a[0]).powi(2) + (point[1] - a[1]).powi(2)).sqrt();
202    }
203    let t = ((ap[0] * ab[0] + ap[1] * ab[1]) / len2).clamp(0.0, 1.0);
204    let closest = [a[0] + ab[0] * t, a[1] + ab[1] * t];
205    ((point[0] - closest[0]).powi(2) + (point[1] - closest[1]).powi(2)).sqrt()
206}
207
208fn world_distance(a: &GeoCoord, b: &GeoCoord) -> f64 {
209    let a = world_xy(a);
210    let b = world_xy(b);
211    ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
212}
213
214fn world_xy(coord: &GeoCoord) -> [f64; 2] {
215    let projected = WebMercator::project(coord);
216    [projected.position.x, projected.position.y]
217}
218
219// ---------------------------------------------------------------------------
220// Bounding-box intersection for box queries
221// ---------------------------------------------------------------------------
222
223/// Axis-aligned bounding box in Web Mercator world-space, used by box/rectangle
224/// rendered-feature queries.
225///
226/// Coordinates are in the same projected space as [`world_xy`], i.e. Web
227/// Mercator metres. `min` is the south-west corner, `max` is the north-east
228/// corner.
229#[derive(Debug, Clone, Copy)]
230pub struct GeoBBox {
231    /// Minimum (south-west) corner in projected world space.
232    pub min: [f64; 2],
233    /// Maximum (north-east) corner in projected world space.
234    pub max: [f64; 2],
235}
236
237impl GeoBBox {
238    /// Build a bounding box from two geographic coordinates.
239    ///
240    /// The coordinates need not be ordered; the constructor normalises them so
241    /// `min <= max` on both axes.
242    pub fn from_geo_coords(a: &GeoCoord, b: &GeoCoord) -> Self {
243        let pa = world_xy(a);
244        let pb = world_xy(b);
245        Self {
246            min: [pa[0].min(pb[0]), pa[1].min(pb[1])],
247            max: [pa[0].max(pb[0]), pa[1].max(pb[1])],
248        }
249    }
250
251    /// Whether a projected point lies inside this bounding box.
252    fn contains_point(&self, p: [f64; 2]) -> bool {
253        p[0] >= self.min[0] && p[0] <= self.max[0] && p[1] >= self.min[1] && p[1] <= self.max[1]
254    }
255
256    /// Whether another bounding box overlaps this one.
257    fn overlaps(&self, other: &GeoBBox) -> bool {
258        self.min[0] <= other.max[0]
259            && self.max[0] >= other.min[0]
260            && self.min[1] <= other.max[1]
261            && self.max[1] >= other.min[1]
262    }
263}
264
265/// Test whether a feature geometry intersects a geographic bounding box.
266///
267/// Returns `true` when any part of the geometry touches or overlaps the box.
268/// The test is performed in Web Mercator projected world space.
269///
270/// This is the geometric predicate underlying
271/// [`MapState::query_rendered_features_in_box`](crate::MapState::query_rendered_features_in_box).
272pub fn geometry_intersects_bbox(geometry: &Geometry, bbox: &GeoBBox) -> bool {
273    match geometry {
274        Geometry::Point(point) => bbox.contains_point(world_xy(&point.coord)),
275        Geometry::LineString(line) => linestring_intersects_bbox(&line.coords, bbox),
276        Geometry::Polygon(polygon) => polygon_intersects_bbox(polygon, bbox),
277        Geometry::MultiPoint(points) => points
278            .points
279            .iter()
280            .any(|p| bbox.contains_point(world_xy(&p.coord))),
281        Geometry::MultiLineString(lines) => lines
282            .lines
283            .iter()
284            .any(|line| linestring_intersects_bbox(&line.coords, bbox)),
285        Geometry::MultiPolygon(polygons) => polygons
286            .polygons
287            .iter()
288            .any(|poly| polygon_intersects_bbox(poly, bbox)),
289        Geometry::GeometryCollection(geometries) => geometries
290            .iter()
291            .any(|g| geometry_intersects_bbox(g, bbox)),
292    }
293}
294
295/// Test whether a line string intersects a bounding box.
296///
297/// A line intersects the box if any vertex lies inside or any segment crosses
298/// one of the four box edges.
299fn linestring_intersects_bbox(coords: &[GeoCoord], bbox: &GeoBBox) -> bool {
300    // Fast check: any vertex inside the box?
301    for coord in coords {
302        if bbox.contains_point(world_xy(coord)) {
303            return true;
304        }
305    }
306    // Segment-edge crossing check.
307    for pair in coords.windows(2) {
308        let a = world_xy(&pair[0]);
309        let b = world_xy(&pair[1]);
310        if segment_intersects_bbox(a, b, bbox) {
311            return true;
312        }
313    }
314    false
315}
316
317/// Test whether a polygon intersects a bounding box.
318///
319/// A polygon intersects the box if:
320/// 1. any vertex of the exterior ring lies inside the box, or
321/// 2. any exterior-ring segment crosses a box edge, or
322/// 3. the centre of the box lies inside the polygon (polygon fully contains box).
323fn polygon_intersects_bbox(polygon: &crate::geometry::Polygon, bbox: &GeoBBox) -> bool {
324    // Quick AABB overlap check for the polygon exterior.
325    let poly_bbox = ring_bbox(&polygon.exterior);
326    if !bbox.overlaps(&poly_bbox) {
327        return false;
328    }
329    // Any exterior vertex inside the box?
330    for coord in &polygon.exterior {
331        if bbox.contains_point(world_xy(coord)) {
332            return true;
333        }
334    }
335    // Any exterior segment crossing a box edge?
336    for pair in polygon.exterior.windows(2) {
337        let a = world_xy(&pair[0]);
338        let b = world_xy(&pair[1]);
339        if segment_intersects_bbox(a, b, bbox) {
340            return true;
341        }
342    }
343    // Polygon fully encloses the box? Test the box centre.
344    let centre_world = rustial_math::WorldCoord::new(
345        (bbox.min[0] + bbox.max[0]) * 0.5,
346        (bbox.min[1] + bbox.max[1]) * 0.5,
347        0.0,
348    );
349    let centre_geo = WebMercator::unproject(&centre_world);
350    if point_in_ring(&polygon.exterior, &centre_geo)
351        && !polygon
352            .interiors
353            .iter()
354            .any(|hole| point_in_ring(hole, &centre_geo))
355    {
356        return true;
357    }
358    false
359}
360
361/// Compute an axis-aligned bounding box for a coordinate ring.
362fn ring_bbox(ring: &[GeoCoord]) -> GeoBBox {
363    let mut min = [f64::MAX, f64::MAX];
364    let mut max = [f64::MIN, f64::MIN];
365    for coord in ring {
366        let p = world_xy(coord);
367        min[0] = min[0].min(p[0]);
368        min[1] = min[1].min(p[1]);
369        max[0] = max[0].max(p[0]);
370        max[1] = max[1].max(p[1]);
371    }
372    GeoBBox { min, max }
373}
374
375/// Test whether a segment (a->b) intersects an axis-aligned bounding box.
376///
377/// Uses the Liang-Barsky segment-AABB intersection algorithm.
378fn segment_intersects_bbox(a: [f64; 2], b: [f64; 2], bbox: &GeoBBox) -> bool {
379    let dx = b[0] - a[0];
380    let dy = b[1] - a[1];
381    let mut t_min = 0.0_f64;
382    let mut t_max = 1.0_f64;
383
384    let clips = [
385        (-dx, a[0] - bbox.min[0]),
386        (dx, bbox.max[0] - a[0]),
387        (-dy, a[1] - bbox.min[1]),
388        (dy, bbox.max[1] - a[1]),
389    ];
390
391    for &(p, q) in &clips {
392        if p.abs() < f64::EPSILON {
393            // Segment is parallel to this slab.
394            if q < 0.0 {
395                return false;
396            }
397        } else {
398            let t = q / p;
399            if p < 0.0 {
400                t_min = t_min.max(t);
401            } else {
402                t_max = t_max.min(t);
403            }
404            if t_min > t_max {
405                return false;
406            }
407        }
408    }
409    true
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::geometry::{Feature, LineString, Point, Polygon};
416    use std::collections::HashMap;
417
418    #[test]
419    fn feature_id_prefers_id_property() {
420        let mut properties = HashMap::new();
421        properties.insert("id".into(), PropertyValue::String("abc".into()));
422        let feature = Feature {
423            geometry: Geometry::Point(Point {
424                coord: GeoCoord::from_lat_lon(0.0, 0.0),
425            }),
426            properties,
427        };
428        assert_eq!(feature_id_for_feature(&feature, 3), "abc");
429    }
430
431    #[test]
432    fn point_query_uses_tolerance() {
433        let geometry = Geometry::Point(Point {
434            coord: GeoCoord::from_lat_lon(0.0, 0.0),
435        });
436        assert!(geometry_hit_distance(&geometry, &GeoCoord::from_lat_lon(0.0, 0.0), 1.0).is_some());
437    }
438
439    #[test]
440    fn line_query_measures_distance() {
441        let geometry = Geometry::LineString(LineString {
442            coords: vec![GeoCoord::from_lat_lon(0.0, 0.0), GeoCoord::from_lat_lon(0.0, 0.001)],
443        });
444        assert!(geometry_hit_distance(&geometry, &GeoCoord::from_lat_lon(0.00001, 0.0005), 32.0).is_some());
445    }
446
447    #[test]
448    fn polygon_query_hits_inside() {
449        let geometry = Geometry::Polygon(Polygon {
450            exterior: vec![
451                GeoCoord::from_lat_lon(0.0, 0.0),
452                GeoCoord::from_lat_lon(0.0, 0.01),
453                GeoCoord::from_lat_lon(0.01, 0.01),
454                GeoCoord::from_lat_lon(0.01, 0.0),
455                GeoCoord::from_lat_lon(0.0, 0.0),
456            ],
457            interiors: vec![],
458        });
459        assert_eq!(geometry_hit_distance(&geometry, &GeoCoord::from_lat_lon(0.005, 0.005), 1.0), Some(0.0));
460    }
461
462    // -----------------------------------------------------------------------
463    // Bounding-box intersection tests
464    // -----------------------------------------------------------------------
465
466    /// Helper: build a bbox from two lat/lon corners.
467    fn bbox(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> GeoBBox {
468        GeoBBox::from_geo_coords(
469            &GeoCoord::from_lat_lon(lat1, lon1),
470            &GeoCoord::from_lat_lon(lat2, lon2),
471        )
472    }
473
474    #[test]
475    fn point_inside_bbox() {
476        let geom = Geometry::Point(Point {
477            coord: GeoCoord::from_lat_lon(0.005, 0.005),
478        });
479        let b = bbox(0.0, 0.0, 0.01, 0.01);
480        assert!(geometry_intersects_bbox(&geom, &b));
481    }
482
483    #[test]
484    fn point_outside_bbox() {
485        let geom = Geometry::Point(Point {
486            coord: GeoCoord::from_lat_lon(1.0, 1.0),
487        });
488        let b = bbox(0.0, 0.0, 0.01, 0.01);
489        assert!(!geometry_intersects_bbox(&geom, &b));
490    }
491
492    #[test]
493    fn line_crossing_bbox() {
494        // A line that starts outside the box on one side and ends outside
495        // on the other side, passing through the box.
496        let geom = Geometry::LineString(LineString {
497            coords: vec![
498                GeoCoord::from_lat_lon(-0.01, 0.005),
499                GeoCoord::from_lat_lon(0.02, 0.005),
500            ],
501        });
502        let b = bbox(0.0, 0.0, 0.01, 0.01);
503        assert!(geometry_intersects_bbox(&geom, &b));
504    }
505
506    #[test]
507    fn line_fully_outside_bbox() {
508        let geom = Geometry::LineString(LineString {
509            coords: vec![
510                GeoCoord::from_lat_lon(1.0, 1.0),
511                GeoCoord::from_lat_lon(1.0, 1.01),
512            ],
513        });
514        let b = bbox(0.0, 0.0, 0.01, 0.01);
515        assert!(!geometry_intersects_bbox(&geom, &b));
516    }
517
518    #[test]
519    fn polygon_overlapping_bbox() {
520        let geom = Geometry::Polygon(Polygon {
521            exterior: vec![
522                GeoCoord::from_lat_lon(0.0, 0.0),
523                GeoCoord::from_lat_lon(0.0, 0.01),
524                GeoCoord::from_lat_lon(0.01, 0.01),
525                GeoCoord::from_lat_lon(0.01, 0.0),
526                GeoCoord::from_lat_lon(0.0, 0.0),
527            ],
528            interiors: vec![],
529        });
530        let b = bbox(0.003, 0.003, 0.007, 0.007);
531        assert!(geometry_intersects_bbox(&geom, &b));
532    }
533
534    #[test]
535    fn polygon_enclosing_bbox() {
536        // Large polygon fully enclosing a small box -- should still intersect.
537        let geom = Geometry::Polygon(Polygon {
538            exterior: vec![
539                GeoCoord::from_lat_lon(-1.0, -1.0),
540                GeoCoord::from_lat_lon(-1.0, 1.0),
541                GeoCoord::from_lat_lon(1.0, 1.0),
542                GeoCoord::from_lat_lon(1.0, -1.0),
543                GeoCoord::from_lat_lon(-1.0, -1.0),
544            ],
545            interiors: vec![],
546        });
547        let b = bbox(0.0, 0.0, 0.01, 0.01);
548        assert!(geometry_intersects_bbox(&geom, &b));
549    }
550
551    #[test]
552    fn polygon_disjoint_from_bbox() {
553        let geom = Geometry::Polygon(Polygon {
554            exterior: vec![
555                GeoCoord::from_lat_lon(10.0, 10.0),
556                GeoCoord::from_lat_lon(10.0, 10.01),
557                GeoCoord::from_lat_lon(10.01, 10.01),
558                GeoCoord::from_lat_lon(10.01, 10.0),
559                GeoCoord::from_lat_lon(10.0, 10.0),
560            ],
561            interiors: vec![],
562        });
563        let b = bbox(0.0, 0.0, 0.01, 0.01);
564        assert!(!geometry_intersects_bbox(&geom, &b));
565    }
566}