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.iter().filter(|h| h.category == category).collect()
429 }
430
431 /// Filter hits to only terrain surface results.
432 pub fn terrain_hits(&self) -> Vec<&PickHit> {
433 self.by_category(HitCategory::Terrain)
434 }
435
436 /// Filter hits to only feature results.
437 pub fn feature_hits(&self) -> Vec<&PickHit> {
438 self.by_category(HitCategory::Feature)
439 }
440
441 /// Filter hits to only symbol results.
442 pub fn symbol_hits(&self) -> Vec<&PickHit> {
443 self.by_category(HitCategory::Symbol)
444 }
445
446 /// Filter hits to only model results.
447 pub fn model_hits(&self) -> Vec<&PickHit> {
448 self.by_category(HitCategory::Model)
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn pickable_layer_kinds_are_all_queryable() {
458 assert!(PickableLayerKind::Fill.is_queryable());
459 assert!(PickableLayerKind::Line.is_queryable());
460 assert!(PickableLayerKind::Circle.is_queryable());
461 assert!(PickableLayerKind::Heatmap.is_queryable());
462 assert!(PickableLayerKind::FillExtrusion.is_queryable());
463 assert!(PickableLayerKind::Symbol.is_queryable());
464 assert!(PickableLayerKind::Model.is_queryable());
465 }
466
467 #[test]
468 fn pick_query_constructors() {
469 let screen = PickQuery::screen(400.0, 300.0);
470 assert!(matches!(screen, PickQuery::Screen { x: 400.0, y: 300.0 }));
471
472 let geo = PickQuery::geo(GeoCoord::from_lat_lon(51.1, 17.0));
473 assert!(matches!(geo, PickQuery::Geo { .. }));
474
475 let ray = PickQuery::ray(glam::DVec3::ZERO, -glam::DVec3::Z);
476 assert!(matches!(ray, PickQuery::Ray { .. }));
477 }
478
479 #[test]
480 fn pick_options_defaults() {
481 let opts = PickOptions::default();
482 assert!(opts.layers.is_empty());
483 assert!(opts.sources.is_empty());
484 assert!(opts.include_symbols);
485 assert!(opts.include_models);
486 assert!(!opts.include_terrain_surface);
487 assert_eq!(opts.limit, 0);
488 }
489
490 #[test]
491 fn pick_options_builder() {
492 let opts = PickOptions::new()
493 .with_terrain_surface()
494 .with_limit(5)
495 .with_layers(vec!["layer-a".into()]);
496 assert!(opts.include_terrain_surface);
497 assert_eq!(opts.limit, 5);
498 assert_eq!(opts.layers, vec!["layer-a"]);
499 }
500
501 #[test]
502 fn pick_result_filtering() {
503 let mut result = PickResult::default();
504 result.hits.push(PickHit::terrain_surface(
505 GeoCoord::from_lat_lon(10.0, 20.0),
506 Some(100.0),
507 ));
508 result.hits.push(PickHit {
509 category: HitCategory::Feature,
510 provenance: HitProvenance::GeometricApproximation,
511 layer_id: Some("fills".into()),
512 source_id: None,
513 source_layer: None,
514 source_tile: None,
515 feature_id: Some("42".into()),
516 feature_index: Some(42),
517 geometry: None,
518 properties: HashMap::new(),
519 state: HashMap::new(),
520 distance_meters: 5.0,
521 hit_coord: None,
522 layer_priority: 0,
523 from_symbol: false,
524 });
525
526 assert_eq!(result.len(), 2);
527 assert_eq!(result.terrain_hits().len(), 1);
528 assert_eq!(result.feature_hits().len(), 1);
529 assert_eq!(result.symbol_hits().len(), 0);
530 assert_eq!(result.model_hits().len(), 0);
531 }
532
533 #[test]
534 fn terrain_surface_hit_has_correct_metadata() {
535 let hit = PickHit::terrain_surface(GeoCoord::new(10.0, 20.0, 50.0), Some(100.0));
536 assert_eq!(hit.category, HitCategory::Terrain);
537 assert_eq!(hit.provenance, HitProvenance::TerrainSurface);
538 assert!(hit.layer_id.is_none());
539 assert!(hit.feature_id.is_none());
540 assert_eq!(hit.layer_priority, u32::MAX);
541 if let Some(GeoCoord { lat, lon, alt: _ }) = hit.hit_coord {
542 assert!((lat - 10.0).abs() < 1e-9);
543 assert!((lon - 20.0).abs() < 1e-9);
544 }
545 }
546
547 #[test]
548 fn hit_provenance_distinguishes_methods() {
549 assert_ne!(HitProvenance::GeometricApproximation, HitProvenance::TerrainSurface);
550 assert_ne!(HitProvenance::TerrainSurface, HitProvenance::RendererExact);
551 }
552
553 #[test]
554 fn to_query_options_preserves_fields() {
555 let opts = PickOptions {
556 layers: vec!["a".into()],
557 sources: vec!["b".into()],
558 tolerance_meters: 32.0,
559 include_symbols: false,
560 ..Default::default()
561 };
562 let qo = opts.to_query_options();
563 assert_eq!(qo.layers, vec!["a"]);
564 assert_eq!(qo.sources, vec!["b"]);
565 assert!((qo.tolerance_meters - 32.0).abs() < 1e-9);
566 assert!(!qo.include_symbols);
567 }
568}