Skip to main content

rustial_engine/
picking.rs

1//! Canonical picking contract for Rustial `v1.0`.
2//!
3//! This module defines the stable engine-owned picking model that host
4//! applications should depend on.  It exposes:
5//!
6//! - **Pick query types** -- [`PickQuery`] (screen, geo, or ray) with filtering options.
7//! - **Hit result types** -- [`PickHit`] with provenance, category, and priority.
8//! - **Layer queryability** -- [`PickableLayerKind`] documents which native layer
9//!   families participate in normal feature picking.
10//! - **Hit provenance** -- [`HitProvenance`] distinguishes geometric approximation
11//!   from renderer-assisted exact results.
12//!
13//! # Supported picking contract for `v1.0`
14//!
15//! - **Terrain** -- exact or near-exact surface recovery via screen-to-geo.
16//! - **Vector fill / line / circle / heatmap / fill-extrusion** -- CPU geometric
17//!   hit-testing with tolerance.
18//! - **Symbols** -- collision-box-based hit-testing.
19//! - **Models** -- bounding-radius-based hit-testing with terrain-aware altitude.
20//! - **Raster / background / hillshade** -- explicitly **not** normal feature-pick
21//!   targets.  A future raster-value API may be introduced separately.
22//!
23//! # Result sorting rules
24//!
25//! Pick results ([`PickResult`]) are sorted in two stages:
26//!
27//! 1. **`layer_priority`** (descending) -- every [`PickHit`] carries a
28//!    `layer_priority` derived from the layer's position in the layer stack.
29//!    Top-most layers receive the highest priority so the visually front-most
30//!    feature appears first.
31//!
32//! 2. **`distance_meters`** (ascending, within the same priority) -- when two
33//!    hits share the same layer priority, the one closest to the query point
34//!    wins.  For point and line geometries this is the Euclidean distance in
35//!    Web Mercator metres; for polygons that fully contain the query point the
36//!    distance is `0.0`.
37//!
38//! Query results from
39//! [`query_rendered_features`](crate::MapState::query_rendered_features) and
40//! the `_at_screen` / `_at_geo` / `_along_ray` convenience wrappers apply the
41//! same sort but only by `distance_meters` (ascending), because all features
42//! already come from a single layer-stack traversal in render order.
43//!
44//! Box queries
45//! ([`query_rendered_features_in_box`](crate::MapState::query_rendered_features_in_box))
46//! return features in layer-stack order without distance-based sorting because
47//! every intersecting feature is equally "hit" by the rectangle.
48//!
49//! # Duplicate-suppression rules
50//!
51//! Both point queries and box queries de-duplicate features that appear in
52//! multiple streamed-vector tile payloads (overlapping tile edges).
53//! De-duplication is keyed on the triple
54//! `(source_id, source_layer, geometry_debug_string)` so the same logical
55//! feature carried by two adjacent tiles is returned only once.
56//!
57//! Symbol queries de-duplicate on `(source_id, source_layer, feature_id)` so
58//! a symbol that is placed on multiple tiles (due to label avoidance retries)
59//! is returned only once.
60//!
61//! These rules guarantee stable, reproducible query results regardless of
62//! which tiles are currently loaded.
63//!
64//! # Cross-renderer parity
65//!
66//! Both `rustial-renderer-wgpu` and `rustial-renderer-bevy` consume the same
67//! engine-owned picking surface via [`MapState`](crate::MapState).  Both
68//! renderers expose identical `pick()`, `pick_at_screen()`, `pick_at_geo()`,
69//! and `pick_along_ray()` convenience methods that delegate to the canonical
70//! engine pipeline.  The public API, result format, and prioritization rules
71//! are engine-defined and renderer-agnostic.
72//!
73//! Regression coverage for the stable `v1.0` picking surface now includes
74//! explicit tests across both `CameraMode::Perspective` and
75//! `CameraMode::Orthographic` and across both stable projections
76//! (`WebMercator` and `Equirectangular`), for both screen-coordinate and
77//! world-space-ray entry points.
78
79use crate::camera_projection::CameraProjection;
80use crate::geometry::PropertyValue;
81use crate::query::FeatureState;
82#[cfg(test)]
83use crate::query::QueryOptions;
84use rustial_math::{GeoCoord, TileId};
85use std::collections::HashMap;
86
87// ---------------------------------------------------------------------------
88// PickableLayerKind
89// ---------------------------------------------------------------------------
90
91/// Native layer families that participate in normal feature picking.
92///
93/// This enum explicitly encodes the `v1.0` picking contract so that
94/// unsupported layer kinds are never silently included.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum PickableLayerKind {
97    /// Vector fill polygons.
98    Fill,
99    /// Vector line features.
100    Line,
101    /// Vector circle / point features.
102    Circle,
103    /// Heatmap source-point features.
104    ///
105    /// Queries hit the underlying source points, not the blended
106    /// heatmap visual.
107    Heatmap,
108    /// Extruded polygon features.
109    ///
110    /// Hit-testing uses the 2D footprint in `v1.0` (not height-aware
111    /// geometric or renderer-exact).
112    FillExtrusion,
113    /// Placed symbol features (text / icon).
114    ///
115    /// Hit-testing uses the placed collision box.
116    Symbol,
117    /// 3D model instances.
118    ///
119    /// Hit-testing uses a bounding-radius approximation around the
120    /// model anchor, with terrain-aware altitude resolution.
121    Model,
122}
123
124impl PickableLayerKind {
125    /// Whether this layer kind is queryable in `v1.0`.
126    #[inline]
127    pub fn is_queryable(&self) -> bool {
128        true
129    }
130}
131
132/// Native layer families that are explicitly **not** normal feature-pick
133/// targets in `v1.0`.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub enum NonPickableLayerKind {
136    /// Raster tile imagery.
137    Raster,
138    /// Background clear / fill layer.
139    Background,
140    /// Hillshade overlay.
141    Hillshade,
142}
143
144// ---------------------------------------------------------------------------
145// HitProvenance
146// ---------------------------------------------------------------------------
147
148/// How a pick hit was resolved.
149///
150/// This allows callers to understand the precision class of each hit
151/// without forcing them to care about internal implementation details.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
153pub enum HitProvenance {
154    /// CPU-side feature geometry test (point-in-polygon, segment
155    /// distance, bounding-radius, collision-box intersection).
156    GeometricApproximation,
157    /// Terrain surface recovery from cached elevation data (ray-march
158    /// or bilinear interpolation).
159    TerrainSurface,
160    /// Renderer-owned depth / coordinate / object buffer readback
161    /// (future; not yet available in `v1.0`).
162    RendererExact,
163}
164
165// ---------------------------------------------------------------------------
166// HitCategory
167// ---------------------------------------------------------------------------
168
169/// Broad category of a pick result.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum HitCategory {
172    /// A terrain surface hit (screen -> geo recovery).
173    Terrain,
174    /// A vector feature hit (fill, line, circle, heatmap, extrusion).
175    Feature,
176    /// A placed symbol hit (text / icon collision box).
177    Symbol,
178    /// A 3D model instance hit.
179    Model,
180}
181
182// ---------------------------------------------------------------------------
183// PickQuery
184// ---------------------------------------------------------------------------
185
186/// Input for a pick operation.
187///
188/// Callers specify one of three entry-point types:
189///
190/// - **Screen** -- pixel coordinates relative to the viewport origin.
191/// - **Geo** -- a geographic coordinate (lat/lon/alt).
192/// - **Ray** -- a world-space ray in the active scene projection.
193///
194/// All three are resolved through the same engine-owned query pipeline.
195#[derive(Debug, Clone)]
196pub enum PickQuery {
197    /// Pick at a screen-space pixel coordinate.
198    Screen {
199        /// X coordinate in logical pixels (0 = left).
200        x: f64,
201        /// Y coordinate in logical pixels (0 = top).
202        y: f64,
203    },
204    /// Pick at a geographic coordinate.
205    Geo {
206        /// Geographic coordinate to query.
207        coord: GeoCoord,
208    },
209    /// Pick along a world-space ray in the active scene projection.
210    Ray {
211        /// Ray origin in world-space meters.
212        origin: glam::DVec3,
213        /// Ray direction (will be normalized internally).
214        direction: glam::DVec3,
215    },
216}
217
218impl PickQuery {
219    /// Create a screen-space pick query.
220    pub fn screen(x: f64, y: f64) -> Self {
221        Self::Screen { x, y }
222    }
223
224    /// Create a geographic pick query.
225    pub fn geo(coord: GeoCoord) -> Self {
226        Self::Geo { coord }
227    }
228
229    /// Create a ray-based pick query.
230    pub fn ray(origin: glam::DVec3, direction: glam::DVec3) -> Self {
231        Self::Ray { origin, direction }
232    }
233}
234
235// ---------------------------------------------------------------------------
236// PickOptions
237// ---------------------------------------------------------------------------
238
239/// Filtering and behavior options for a pick operation.
240#[derive(Debug, Clone)]
241pub struct PickOptions {
242    /// Restrict results to specific layer ids.
243    pub layers: Vec<String>,
244    /// Restrict results to specific source ids.
245    pub sources: Vec<String>,
246    /// Hit tolerance in meters for point and line features.
247    pub tolerance_meters: f64,
248    /// Whether placed-symbol collision boxes participate in the query.
249    pub include_symbols: bool,
250    /// Whether 3D model instances participate in the query.
251    pub include_models: bool,
252    /// Whether terrain surface recovery is included as a hit.
253    pub include_terrain_surface: bool,
254    /// Maximum number of results to return (0 = unlimited).
255    pub limit: usize,
256}
257
258impl Default for PickOptions {
259    fn default() -> Self {
260        Self {
261            layers: Vec::new(),
262            sources: Vec::new(),
263            tolerance_meters: 16.0,
264            include_symbols: true,
265            include_models: true,
266            include_terrain_surface: false,
267            limit: 0,
268        }
269    }
270}
271
272impl PickOptions {
273    /// Create default pick options.
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// Include terrain surface recovery as a result.
279    pub fn with_terrain_surface(mut self) -> Self {
280        self.include_terrain_surface = true;
281        self
282    }
283
284    /// Set the result limit.
285    pub fn with_limit(mut self, limit: usize) -> Self {
286        self.limit = limit;
287        self
288    }
289
290    /// Restrict to specific layer ids.
291    pub fn with_layers(mut self, layers: Vec<String>) -> Self {
292        self.layers = layers;
293        self
294    }
295
296    /// Restrict to specific source ids.
297    pub fn with_sources(mut self, sources: Vec<String>) -> Self {
298        self.sources = sources;
299        self
300    }
301
302    #[cfg(test)]
303    /// Convert to the existing QueryOptions type for backward compat.
304    pub(crate) fn to_query_options(&self) -> QueryOptions {
305        QueryOptions {
306            layers: self.layers.clone(),
307            sources: self.sources.clone(),
308            tolerance_meters: self.tolerance_meters,
309            include_symbols: self.include_symbols,
310        }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// PickHit
316// ---------------------------------------------------------------------------
317
318/// A single hit from a pick operation.
319///
320/// This is the canonical result type consumed by host applications.
321/// It provides identity, geometry, properties, state, distance,
322/// provenance, and category metadata for every hit.
323#[derive(Debug, Clone)]
324pub struct PickHit {
325    /// Broad category of this hit.
326    pub category: HitCategory,
327    /// How this hit was resolved.
328    pub provenance: HitProvenance,
329    /// Style layer id or runtime layer name that produced the hit.
330    pub layer_id: Option<String>,
331    /// Style source id, when known.
332    pub source_id: Option<String>,
333    /// Style source-layer id, when known.
334    pub source_layer: Option<String>,
335    /// Tile that supplied the feature, when known.
336    pub source_tile: Option<TileId>,
337    /// Stable feature id within the source.
338    pub feature_id: Option<String>,
339    /// Source-local feature index.
340    pub feature_index: Option<usize>,
341    /// Feature geometry, when available.
342    pub geometry: Option<crate::geometry::Geometry>,
343    /// Feature properties, when available.
344    pub properties: HashMap<String, PropertyValue>,
345    /// Mutable feature-state snapshot at query time.
346    pub state: FeatureState,
347    /// Distance from the query position in meters.
348    pub distance_meters: f64,
349    /// Geographic coordinate of the hit point.
350    pub hit_coord: Option<GeoCoord>,
351    /// Layer-order priority (lower = rendered on top / higher priority).
352    ///
353    /// This is derived from the reverse layer-stack index so that
354    /// top-most rendered layers have the lowest priority value.
355    pub layer_priority: u32,
356    /// Whether the hit came from a placed symbol collision box.
357    pub from_symbol: bool,
358}
359
360impl PickHit {
361    /// Create a terrain-surface hit.
362    pub fn terrain_surface(coord: GeoCoord, elevation: Option<f64>) -> Self {
363        Self {
364            category: HitCategory::Terrain,
365            provenance: HitProvenance::TerrainSurface,
366            layer_id: None,
367            source_id: None,
368            source_layer: None,
369            source_tile: None,
370            feature_id: None,
371            feature_index: None,
372            geometry: Some(crate::geometry::Geometry::Point(crate::geometry::Point {
373                coord: GeoCoord::new(coord.lat, coord.lon, elevation.unwrap_or(coord.alt)),
374            })),
375            properties: HashMap::new(),
376            state: HashMap::new(),
377            distance_meters: 0.0,
378            hit_coord: Some(coord),
379            layer_priority: u32::MAX,
380            from_symbol: false,
381        }
382    }
383}
384
385// ---------------------------------------------------------------------------
386// PickResult
387// ---------------------------------------------------------------------------
388
389/// Complete result of a pick operation.
390///
391/// Contains zero or more [`PickHit`]s sorted in priority and distance order,
392/// plus metadata about the query itself.
393#[derive(Debug, Clone, Default)]
394pub struct PickResult {
395    /// Ordered hits (highest priority first, then by distance).
396    pub hits: Vec<PickHit>,
397    /// The resolved geographic coordinate of the query point, if available.
398    pub query_coord: Option<GeoCoord>,
399    /// The camera projection active at query time.
400    pub projection: Option<CameraProjection>,
401}
402
403impl PickResult {
404    /// Whether the pick produced any hits.
405    #[inline]
406    pub fn is_empty(&self) -> bool {
407        self.hits.is_empty()
408    }
409
410    /// Number of hits.
411    #[inline]
412    pub fn len(&self) -> usize {
413        self.hits.len()
414    }
415
416    /// Iterate hits in priority order.
417    pub fn iter(&self) -> impl Iterator<Item = &PickHit> {
418        self.hits.iter()
419    }
420
421    /// The top-priority hit, if any.
422    pub fn first(&self) -> Option<&PickHit> {
423        self.hits.first()
424    }
425
426    /// Filter hits by category.
427    pub fn by_category(&self, category: HitCategory) -> Vec<&PickHit> {
428        self.hits
429            .iter()
430            .filter(|h| h.category == category)
431            .collect()
432    }
433
434    /// Filter hits to only terrain surface results.
435    pub fn terrain_hits(&self) -> Vec<&PickHit> {
436        self.by_category(HitCategory::Terrain)
437    }
438
439    /// Filter hits to only feature results.
440    pub fn feature_hits(&self) -> Vec<&PickHit> {
441        self.by_category(HitCategory::Feature)
442    }
443
444    /// Filter hits to only symbol results.
445    pub fn symbol_hits(&self) -> Vec<&PickHit> {
446        self.by_category(HitCategory::Symbol)
447    }
448
449    /// Filter hits to only model results.
450    pub fn model_hits(&self) -> Vec<&PickHit> {
451        self.by_category(HitCategory::Model)
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn pickable_layer_kinds_are_all_queryable() {
461        assert!(PickableLayerKind::Fill.is_queryable());
462        assert!(PickableLayerKind::Line.is_queryable());
463        assert!(PickableLayerKind::Circle.is_queryable());
464        assert!(PickableLayerKind::Heatmap.is_queryable());
465        assert!(PickableLayerKind::FillExtrusion.is_queryable());
466        assert!(PickableLayerKind::Symbol.is_queryable());
467        assert!(PickableLayerKind::Model.is_queryable());
468    }
469
470    #[test]
471    fn pick_query_constructors() {
472        let screen = PickQuery::screen(400.0, 300.0);
473        assert!(matches!(screen, PickQuery::Screen { x: 400.0, y: 300.0 }));
474
475        let geo = PickQuery::geo(GeoCoord::from_lat_lon(51.1, 17.0));
476        assert!(matches!(geo, PickQuery::Geo { .. }));
477
478        let ray = PickQuery::ray(glam::DVec3::ZERO, -glam::DVec3::Z);
479        assert!(matches!(ray, PickQuery::Ray { .. }));
480    }
481
482    #[test]
483    fn pick_options_defaults() {
484        let opts = PickOptions::default();
485        assert!(opts.layers.is_empty());
486        assert!(opts.sources.is_empty());
487        assert!(opts.include_symbols);
488        assert!(opts.include_models);
489        assert!(!opts.include_terrain_surface);
490        assert_eq!(opts.limit, 0);
491    }
492
493    #[test]
494    fn pick_options_builder() {
495        let opts = PickOptions::new()
496            .with_terrain_surface()
497            .with_limit(5)
498            .with_layers(vec!["layer-a".into()]);
499        assert!(opts.include_terrain_surface);
500        assert_eq!(opts.limit, 5);
501        assert_eq!(opts.layers, vec!["layer-a"]);
502    }
503
504    #[test]
505    fn pick_result_filtering() {
506        let mut result = PickResult::default();
507        result.hits.push(PickHit::terrain_surface(
508            GeoCoord::from_lat_lon(10.0, 20.0),
509            Some(100.0),
510        ));
511        result.hits.push(PickHit {
512            category: HitCategory::Feature,
513            provenance: HitProvenance::GeometricApproximation,
514            layer_id: Some("fills".into()),
515            source_id: None,
516            source_layer: None,
517            source_tile: None,
518            feature_id: Some("42".into()),
519            feature_index: Some(42),
520            geometry: None,
521            properties: HashMap::new(),
522            state: HashMap::new(),
523            distance_meters: 5.0,
524            hit_coord: None,
525            layer_priority: 0,
526            from_symbol: false,
527        });
528
529        assert_eq!(result.len(), 2);
530        assert_eq!(result.terrain_hits().len(), 1);
531        assert_eq!(result.feature_hits().len(), 1);
532        assert_eq!(result.symbol_hits().len(), 0);
533        assert_eq!(result.model_hits().len(), 0);
534    }
535
536    #[test]
537    fn terrain_surface_hit_has_correct_metadata() {
538        let hit = PickHit::terrain_surface(GeoCoord::new(10.0, 20.0, 50.0), Some(100.0));
539        assert_eq!(hit.category, HitCategory::Terrain);
540        assert_eq!(hit.provenance, HitProvenance::TerrainSurface);
541        assert!(hit.layer_id.is_none());
542        assert!(hit.feature_id.is_none());
543        assert_eq!(hit.layer_priority, u32::MAX);
544        if let Some(GeoCoord { lat, lon, alt: _ }) = hit.hit_coord {
545            assert!((lat - 10.0).abs() < 1e-9);
546            assert!((lon - 20.0).abs() < 1e-9);
547        }
548    }
549
550    #[test]
551    fn hit_provenance_distinguishes_methods() {
552        assert_ne!(
553            HitProvenance::GeometricApproximation,
554            HitProvenance::TerrainSurface
555        );
556        assert_ne!(HitProvenance::TerrainSurface, HitProvenance::RendererExact);
557    }
558
559    #[test]
560    fn to_query_options_preserves_fields() {
561        let opts = PickOptions {
562            layers: vec!["a".into()],
563            sources: vec!["b".into()],
564            tolerance_meters: 32.0,
565            include_symbols: false,
566            ..Default::default()
567        };
568        let qo = opts.to_query_options();
569        assert_eq!(qo.layers, vec!["a"]);
570        assert_eq!(qo.sources, vec!["b"]);
571        assert!((qo.tolerance_meters - 32.0).abs() < 1e-9);
572        assert!(!qo.include_symbols);
573    }
574}