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