Skip to main content

rustial_engine/
style.rs

1//! Style/runtime model for style-document-driven map construction.
2//!
3//! This module provides a small engine-owned style runtime inspired by
4//! MapLibre's style system. It covers Rustial's current source families and
5//! a broader style-layer taxonomy, while lowering geometry-centric layers onto
6//! the existing engine primitives (`BackgroundLayer`, `HillshadeLayer`,
7//! `TileLayer`, `VectorLayer`, and `ModelLayer`).
8
9use crate::camera_projection::CameraProjection;
10use crate::cluster::{ClusterOptions, PointCluster};
11use crate::geometry::{FeatureCollection, PropertyValue};
12use crate::layer::Layer;
13use crate::layers::{
14    BackgroundLayer, DynamicImageOverlayLayer, FrameProviderFactory, HillshadeLayer, LineCap,
15    LineJoin, ModelLayer, TileLayer, VectorLayer, VectorRenderMode, VectorStyle,
16};
17use crate::models::ModelInstance;
18use crate::query::FeatureState;
19use crate::symbols::{
20    SymbolAnchor, SymbolIconTextFit, SymbolPlacement, SymbolTextJustify, SymbolTextTransform,
21    SymbolWritingMode,
22};
23use crate::terrain::{ElevationSource, TerrainConfig};
24use crate::tile_manager::TileSelectionConfig;
25use crate::tile_source::TileSource;
26use rustial_math::GeoCoord;
27use std::borrow::Cow;
28use std::collections::HashMap;
29use std::fmt;
30use std::sync::Arc;
31
32/// Style/runtime errors.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum StyleError {
35    /// Attempted to add a source whose id already exists.
36    DuplicateSourceId(String),
37    /// Attempted to add a layer whose id already exists.
38    DuplicateLayerId(String),
39    /// A style layer referenced a source that does not exist.
40    MissingSource(String),
41    /// A style layer referenced a source of the wrong kind.
42    SourceKindMismatch {
43        /// Layer id being evaluated.
44        layer_id: String,
45        /// Source id being referenced.
46        source_id: String,
47        /// Expected source kind name.
48        expected: &'static str,
49        /// Actual source kind name.
50        actual: &'static str,
51    },
52    /// A vector style layer referenced a source layer that does not exist.
53    MissingSourceLayer {
54        /// Layer id being evaluated.
55        layer_id: String,
56        /// Source id being referenced.
57        source_id: String,
58        /// Requested source-layer name.
59        source_layer: String,
60    },
61}
62
63impl fmt::Display for StyleError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            StyleError::DuplicateSourceId(id) => write!(f, "duplicate style source id `{id}`"),
67            StyleError::DuplicateLayerId(id) => write!(f, "duplicate style layer id `{id}`"),
68            StyleError::MissingSource(id) => write!(f, "missing style source `{id}`"),
69            StyleError::SourceKindMismatch {
70                layer_id,
71                source_id,
72                expected,
73                actual,
74            } => write!(
75                f,
76                "style layer `{layer_id}` expected source `{source_id}` of kind `{expected}`, got `{actual}`"
77            ),
78            StyleError::MissingSourceLayer {
79                layer_id,
80                source_id,
81                source_layer,
82            } => write!(
83                f,
84                "style layer `{layer_id}` referenced missing source-layer `{source_layer}` on source `{source_id}`"
85            ),
86        }
87    }
88}
89
90impl std::error::Error for StyleError {}
91
92/// A style source identifier.
93pub type StyleSourceId = String;
94
95/// A style layer identifier.
96pub type StyleLayerId = String;
97
98/// Runtime evaluation context for paint/layout values.
99///
100/// This lightweight struct carries zoom-only state and is `Copy` so it can be
101/// passed cheaply through the style-evaluation pipeline. For feature-state-aware
102/// evaluation, use [`StyleEvalContextFull`] which borrows a [`FeatureState`]
103/// map alongside the zoom level.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct StyleEvalContext {
106    /// Current map zoom level.
107    pub zoom: f32,
108}
109
110impl StyleEvalContext {
111    /// Create a new evaluation context.
112    pub fn new(zoom: f32) -> Self {
113        Self { zoom }
114    }
115
116    /// Promote to a full evaluation context that includes per-feature state.
117    ///
118    /// This is the bridge between the fast zoom-only path and the richer
119    /// feature-state-aware path used during hover/selection restyling.
120    pub fn with_feature_state(self, feature_state: &FeatureState) -> StyleEvalContextFull<'_> {
121        StyleEvalContextFull {
122            zoom: self.zoom,
123            feature_state,
124        }
125    }
126}
127
128impl Default for StyleEvalContext {
129    fn default() -> Self {
130        Self { zoom: 0.0 }
131    }
132}
133
134/// Extended evaluation context that carries per-feature mutable state alongside
135/// the current zoom level.
136///
137/// This is used when paint/layout values may depend on feature-state keys such
138/// as `"hover"` or `"selected"`. The lifetime `'a` borrows the feature-state
139/// map owned by [`MapState`](crate::MapState).
140///
141/// # Example
142///
143/// ```ignore
144/// let ctx = StyleEvalContext::new(14.0)
145///     .with_feature_state(&feature_state);
146/// let color = fill_color.evaluate_with_full_context(&ctx);
147/// ```
148#[derive(Debug, Clone, Copy, PartialEq)]
149pub struct StyleEvalContextFull<'a> {
150    /// Current map zoom level.
151    pub zoom: f32,
152    /// Per-feature mutable state (e.g. `{"hover": true, "selected": false}`).
153    pub feature_state: &'a FeatureState,
154}
155
156impl<'a> StyleEvalContextFull<'a> {
157    /// Create a full evaluation context.
158    pub fn new(zoom: f32, feature_state: &'a FeatureState) -> Self {
159        Self {
160            zoom,
161            feature_state,
162        }
163    }
164
165    /// Downgrade to a zoom-only context (discards feature-state).
166    pub fn to_base(&self) -> StyleEvalContext {
167        StyleEvalContext { zoom: self.zoom }
168    }
169
170    /// Look up a feature-state key, returning `None` if the key is absent.
171    pub fn get_feature_state(&self, key: &str) -> Option<&PropertyValue> {
172        self.feature_state.get(key)
173    }
174
175    /// Look up a feature-state key as a boolean, defaulting to `false`.
176    pub fn feature_state_bool(&self, key: &str) -> bool {
177        self.feature_state
178            .get(key)
179            .and_then(|v| v.as_bool())
180            .unwrap_or(false)
181    }
182
183    /// Look up a feature-state key as an f64, returning a default when absent.
184    pub fn feature_state_f64(&self, key: &str, default: f64) -> f64 {
185        self.feature_state
186            .get(key)
187            .and_then(|v| v.as_f64())
188            .unwrap_or(default)
189    }
190}
191
192/// Top-level style-owned projection selection.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194pub enum StyleProjection {
195    /// Default Web Mercator map projection.
196    #[default]
197    Mercator,
198    /// Equirectangular / Plate Carree planar projection.
199    Equirectangular,
200    /// Globe / geocentric projection.
201    Globe,
202    /// Near-sided vertical perspective projection.
203    VerticalPerspective,
204}
205
206impl StyleProjection {
207    /// Convert the style-owned projection to the camera-facing projection.
208    pub fn to_camera_projection(self) -> CameraProjection {
209        match self {
210            StyleProjection::Mercator => CameraProjection::WebMercator,
211            StyleProjection::Equirectangular => CameraProjection::Equirectangular,
212            StyleProjection::Globe => CameraProjection::Globe,
213            StyleProjection::VerticalPerspective => {
214                CameraProjection::vertical_perspective(GeoCoord::default(), 10_000_000.0)
215            }
216        }
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Lighting configuration
222// ---------------------------------------------------------------------------
223
224/// Lighting mode controlling how lit shaders compute surface illumination.
225///
226/// Mirrors Mapbox GL JS v3's `"lights"` style-spec model which supports
227/// three light types:  ambient, directional, and flat.
228///
229/// Rustial always evaluates **one** lighting mode per frame.  The mode is
230/// selected by [`LightConfig::mode`] (default: [`LightingMode::Default`]
231/// which combines ambient + directional).
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
233pub enum LightingMode {
234    /// Ambient + directional (default).  This is the standard 3-D mode
235    /// used by Mapbox GL JS when both ambient and directional lights are
236    /// present in the `"lights"` array.
237    #[default]
238    Default,
239    /// Flat / unlit.  Surfaces are rendered with no shading, using only
240    /// the base colour.  Equivalent to Mapbox's `"flat"` light type.
241    Flat,
242}
243
244/// Ambient light parameters.
245///
246/// Emulates sky-hemisphere illumination.  All surfaces receive at least
247/// this much light regardless of their orientation.
248#[derive(Debug, Clone, PartialEq)]
249pub struct AmbientLight {
250    /// Ambient light colour (linear RGB, 0–1).  Default: white.
251    pub color: [f32; 3],
252    /// Ambient intensity multiplier.  Default: `0.5`.
253    pub intensity: f32,
254}
255
256impl Default for AmbientLight {
257    fn default() -> Self {
258        Self {
259            color: [1.0, 1.0, 1.0],
260            intensity: 0.5,
261        }
262    }
263}
264
265/// Directional (sun) light parameters.
266///
267/// A single infinitely-distant directional light source, equivalent to
268/// Mapbox's `"directional"` light type.
269#[derive(Debug, Clone, PartialEq)]
270pub struct DirectionalLight {
271    /// Light direction **toward** the light source, in world space.
272    ///
273    /// Encoded as `[azimuth_deg, altitude_deg]`:
274    /// - `azimuth_deg`: compass bearing of the sun (0 = north, 90 = east).
275    ///   Default: `210.0` (south-west, matching Mapbox's default).
276    /// - `altitude_deg`: elevation angle above the horizon.
277    ///   Default: `45.0`.
278    pub direction: [f32; 2],
279    /// Directional light colour (linear RGB, 0–1).  Default: white.
280    pub color: [f32; 3],
281    /// Directional intensity multiplier.  Default: `0.5`.
282    pub intensity: f32,
283    /// Whether this light should cast shadows (reserved for Phase 7
284    /// cascaded shadow maps).  Default: `false`.
285    pub cast_shadows: bool,
286}
287
288impl Default for DirectionalLight {
289    fn default() -> Self {
290        Self {
291            direction: [210.0, 45.0],
292            color: [1.0, 1.0, 1.0],
293            intensity: 0.5,
294            cast_shadows: false,
295        }
296    }
297}
298
299/// Top-level lighting configuration.
300///
301/// Combines an ambient and directional light into a single config that
302/// can be set via the API or parsed from a style document's `"lights"`
303/// array.
304///
305/// Default values produce the same visual result as the hardcoded sun
306/// direction that was previously baked into the fill-extrusion and model
307/// shaders.
308#[derive(Debug, Clone, PartialEq, Default)]
309pub struct LightConfig {
310    /// Lighting mode.  Default: [`LightingMode::Default`].
311    pub mode: LightingMode,
312    /// Ambient light parameters.
313    pub ambient: AmbientLight,
314    /// Directional light parameters.
315    pub directional: DirectionalLight,
316    /// Shadow map configuration (used when [`DirectionalLight::cast_shadows`]
317    /// is `true`).
318    pub shadow: ShadowConfig,
319}
320
321/// Pre-computed lighting parameters ready for GPU uniform upload.
322///
323/// Computed once per frame from the [`LightConfig`] stored on
324/// [`MapState`].  Both WGPU and Bevy renderers consume this struct
325/// directly.
326#[derive(Debug, Clone, Copy, PartialEq)]
327pub struct ComputedLighting {
328    /// Ambient light colour scaled by intensity (linear RGB).
329    pub ambient_color: [f32; 3],
330    /// Unit direction vector **toward** the light source in world space.
331    pub directional_dir: [f32; 3],
332    /// Directional light colour scaled by intensity (linear RGB).
333    pub directional_color: [f32; 3],
334    /// `1.0` when using default (ambient+directional) mode, `0.0` for flat.
335    pub lighting_enabled: f32,
336    /// Whether shadow mapping is enabled for this frame.
337    pub shadows_enabled: bool,
338}
339
340impl Default for ComputedLighting {
341    fn default() -> Self {
342        compute_lighting(&LightConfig::default())
343    }
344}
345
346/// Compute GPU-ready lighting parameters from the user config.
347pub fn compute_lighting(config: &LightConfig) -> ComputedLighting {
348    let enabled = match config.mode {
349        LightingMode::Default => 1.0,
350        LightingMode::Flat => 0.0,
351    };
352
353    let ambient_color = [
354        config.ambient.color[0] * config.ambient.intensity,
355        config.ambient.color[1] * config.ambient.intensity,
356        config.ambient.color[2] * config.ambient.intensity,
357    ];
358
359    let [azimuth_deg, altitude_deg] = config.directional.direction;
360    let azimuth = azimuth_deg.to_radians();
361    let altitude = altitude_deg.to_radians();
362    let cos_alt = altitude.cos();
363    // Convert compass bearing + altitude to a unit direction vector.
364    // Mapbox convention: azimuth 0 = north (+Y), 90 = east (+X).
365    let dir = [
366        azimuth.sin() * cos_alt,
367        azimuth.cos() * cos_alt,
368        altitude.sin(),
369    ];
370    let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
371    let directional_dir = if len > 1e-6 {
372        [dir[0] / len, dir[1] / len, dir[2] / len]
373    } else {
374        [0.0, 0.0, 1.0]
375    };
376
377    let directional_color = [
378        config.directional.color[0] * config.directional.intensity,
379        config.directional.color[1] * config.directional.intensity,
380        config.directional.color[2] * config.directional.intensity,
381    ];
382
383    ComputedLighting {
384        ambient_color,
385        directional_dir,
386        directional_color,
387        lighting_enabled: enabled,
388        shadows_enabled: enabled > 0.5 && config.directional.cast_shadows,
389    }
390}
391
392// ---------------------------------------------------------------------------
393// Cascaded shadow map configuration
394// ---------------------------------------------------------------------------
395
396/// Shadow map configuration parameters.
397///
398/// Controls cascaded shadow map (CSM) generation when
399/// [`DirectionalLight::cast_shadows`] is `true`.
400#[derive(Debug, Clone, Copy, PartialEq)]
401pub struct ShadowConfig {
402    /// Number of shadow cascades (1–4).  Default: `2`.
403    pub cascade_count: u32,
404    /// Shadow map resolution per cascade (pixels).  Default: `2048`.
405    pub map_resolution: u32,
406    /// Shadow intensity in `[0, 1]`.  `0` = no shadow, `1` = fully dark.
407    /// Default: `0.8`.
408    pub intensity: f32,
409    /// Normal-offset scale to reduce shadow acne (meters).  Default: `3.0`.
410    pub normal_offset: f32,
411}
412
413impl Default for ShadowConfig {
414    fn default() -> Self {
415        Self {
416            cascade_count: 2,
417            map_resolution: 2048,
418            intensity: 0.8,
419            normal_offset: 3.0,
420        }
421    }
422}
423
424/// Pre-computed shadow parameters for the current frame.
425///
426/// Computed by the engine each frame when shadows are enabled, then
427/// consumed by the WGPU renderer to build depth passes and upload
428/// uniforms.
429#[derive(Debug, Clone, Copy, PartialEq)]
430pub struct ComputedShadow {
431    /// Whether shadows are enabled this frame.
432    pub enabled: bool,
433    /// Light-space view-projection matrices, one per cascade.
434    /// Each transforms world-space positions into `[-1, 1]` NDC for
435    /// the corresponding cascade depth texture.
436    pub light_matrices: [[[f32; 4]; 4]; 4],
437    /// Number of active cascades (1–4).
438    pub cascade_count: u32,
439    /// Shadow map resolution per cascade.
440    pub map_resolution: u32,
441    /// Shadow intensity `[0, 1]`.
442    pub intensity: f32,
443    /// Texel size = `1.0 / map_resolution`.
444    pub texel_size: f32,
445    /// Normal offset scale in world units.
446    pub normal_offset: f32,
447    /// Far distance of cascade 0 (used for cascade selection in shaders).
448    pub cascade_split: f32,
449}
450
451impl Default for ComputedShadow {
452    fn default() -> Self {
453        Self {
454            enabled: false,
455            light_matrices: [[[0.0; 4]; 4]; 4],
456            cascade_count: 2,
457            map_resolution: 2048,
458            intensity: 0.0,
459            texel_size: 1.0 / 2048.0,
460            normal_offset: 3.0,
461            cascade_split: 0.0,
462        }
463    }
464}
465
466/// Compute cascade light-space matrices from the camera frustum and light
467/// direction.
468///
469/// This is the core CSM algorithm: the camera frustum is split into
470/// `cascade_count` slices, and for each slice an orthographic projection
471/// from the light's point of view is computed that tightly encloses the
472/// slice.
473///
474/// # Arguments
475///
476/// - `view_proj` — camera view-projection matrix.
477/// - `light_dir` — unit direction **toward** the light source.
478/// - `camera_distance` — distance from camera to the target point (meters).
479/// - `config` — shadow configuration.
480///
481/// Returns a [`ComputedShadow`] with populated light matrices.
482pub fn compute_shadow_cascades(
483    view_proj: &glam::DMat4,
484    light_dir: [f32; 3],
485    camera_distance: f64,
486    config: &ShadowConfig,
487) -> ComputedShadow {
488    use glam::{DMat4, DVec3};
489
490    let cascade_count = config.cascade_count.clamp(1, 4) as usize;
491    let resolution = config.map_resolution.max(256);
492
493    // Cascade split distances.  Mapbox uses 1.5× camera distance for
494    // cascade 0, 3× for cascade 1.
495    let split0 = camera_distance * 1.5;
496    let splits: [f64; 4] = [split0, split0 * 2.0, split0 * 4.0, split0 * 8.0];
497
498    // Inverse VP to unproject frustum corners.
499    let inv_vp = view_proj.inverse();
500
501    // Light direction (toward light, world-space).
502    let light = DVec3::new(
503        light_dir[0] as f64,
504        light_dir[1] as f64,
505        light_dir[2] as f64,
506    )
507    .normalize_or_zero();
508    if light.length_squared() < 0.5 {
509        return ComputedShadow::default();
510    }
511
512    // Build a light-space basis.
513    let up_hint = if light.z.abs() > 0.99 {
514        DVec3::new(0.0, 1.0, 0.0)
515    } else {
516        DVec3::new(0.0, 0.0, 1.0)
517    };
518    let light_right = light.cross(up_hint).normalize();
519    let light_up = light_right.cross(light).normalize();
520
521    // Light view matrix (no translation yet — per-cascade centering).
522    let light_view_rot = DMat4::from_cols(
523        glam::DVec4::new(light_right.x, light_up.x, light.x, 0.0),
524        glam::DVec4::new(light_right.y, light_up.y, light.y, 0.0),
525        glam::DVec4::new(light_right.z, light_up.z, light.z, 0.0),
526        glam::DVec4::new(0.0, 0.0, 0.0, 1.0),
527    );
528
529    let mut result = ComputedShadow {
530        enabled: true,
531        light_matrices: [[[0.0; 4]; 4]; 4],
532        cascade_count: cascade_count as u32,
533        map_resolution: resolution,
534        intensity: config.intensity.clamp(0.0, 1.0),
535        texel_size: 1.0 / resolution as f32,
536        normal_offset: config.normal_offset,
537        cascade_split: splits[0] as f32,
538    };
539
540    // NDC cube corners (near z = 0 in WGPU's [0,1] depth convention).
541    let ndc_corners: [[f64; 3]; 8] = [
542        [-1.0, -1.0, 0.0],
543        [1.0, -1.0, 0.0],
544        [-1.0, 1.0, 0.0],
545        [1.0, 1.0, 0.0],
546        [-1.0, -1.0, 1.0],
547        [1.0, -1.0, 1.0],
548        [-1.0, 1.0, 1.0],
549        [1.0, 1.0, 1.0],
550    ];
551
552    for cascade in 0..cascade_count {
553        // Depth range for this cascade (in NDC Z).
554        let near_frac = if cascade == 0 {
555            0.0
556        } else {
557            splits[cascade - 1] / splits[cascade_count - 1]
558        };
559        let far_frac = splits[cascade] / splits[cascade_count - 1];
560
561        // Unproject 8 frustum corners for this cascade slice.
562        let mut world_corners = [DVec3::ZERO; 8];
563        for (i, ndc) in ndc_corners.iter().enumerate() {
564            // Lerp NDC Z by the cascade fraction.
565            let z = if i < 4 { near_frac } else { far_frac };
566            let ndc_pos = glam::DVec4::new(ndc[0], ndc[1], z, 1.0);
567            let world_h = inv_vp * ndc_pos;
568            world_corners[i] = DVec3::new(
569                world_h.x / world_h.w,
570                world_h.y / world_h.w,
571                world_h.z / world_h.w,
572            );
573        }
574
575        // Find bounding sphere center.
576        let center: DVec3 = world_corners.iter().copied().sum::<DVec3>() / 8.0;
577
578        // Find radius.
579        let radius = world_corners
580            .iter()
581            .map(|c| (*c - center).length())
582            .fold(0.0_f64, f64::max);
583
584        // Snap to texel grid to reduce shadow shimmer.
585        let texel_size = (radius * 2.0) / resolution as f64;
586        let light_center = light_view_rot * glam::DVec4::new(center.x, center.y, center.z, 1.0);
587        let snapped_x = (light_center.x / texel_size).floor() * texel_size;
588        let snapped_y = (light_center.y / texel_size).floor() * texel_size;
589
590        // Apply snapping offset to center (counter the sub-texel drift).
591        let snap_offset_x = snapped_x - light_center.x;
592        let snap_offset_y = snapped_y - light_center.y;
593        let snapped_center = center + DVec3::new(snap_offset_x, snap_offset_y, 0.0);
594
595        // Translate light view to cascade center.
596        let light_pos = snapped_center - light * radius * 2.0;
597        let light_view = DMat4::look_at_rh(light_pos, snapped_center, light_up);
598
599        // Re-snap after look_at.
600        let view_center = light_view * glam::DVec4::new(center.x, center.y, center.z, 1.0);
601        let snap_offset_x = (view_center.x / texel_size).fract() * texel_size;
602        let snap_offset_y = (view_center.y / texel_size).fract() * texel_size;
603        let snap = DMat4::from_translation(DVec3::new(-snap_offset_x, -snap_offset_y, 0.0));
604        let snapped_view = snap * light_view;
605
606        // Orthographic projection covering the bounding sphere.
607        // WGPU uses [0, 1] depth range, so near=0, far = 4*radius.
608        let ortho = DMat4::orthographic_rh(-radius, radius, -radius, radius, 0.0, radius * 4.0);
609
610        let light_vp = ortho * snapped_view;
611
612        // Store as f32 column-major.
613        let m32 = light_vp.as_mat4();
614        result.light_matrices[cascade] = m32.to_cols_array_2d();
615    }
616
617    result
618}
619
620// ---------------------------------------------------------------------------
621// Sky / atmosphere configuration
622// ---------------------------------------------------------------------------
623
624/// Rendering mode for the sky background.
625///
626/// Mirrors Mapbox GL JS v3's `sky-type` style property.
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
628pub enum SkyType {
629    /// Physically-based Rayleigh + Mie atmospheric scattering (default).
630    #[default]
631    Atmosphere,
632    /// Simple radial gradient (cheaper, stylised look).
633    Gradient,
634}
635
636/// User-facing sky / atmosphere configuration.
637///
638/// When attached to a [`StyleDocument`] or set directly on [`MapState`],
639/// this enables a procedural sky background rendered behind all scene
640/// geometry.
641///
642/// Default values produce a physically-plausible Earth-like atmosphere
643/// with the sun matching the directional light position from
644/// [`LightConfig`].
645#[derive(Debug, Clone, PartialEq)]
646pub struct SkyConfig {
647    /// Rendering mode.  Default: [`SkyType::Atmosphere`].
648    pub sky_type: SkyType,
649
650    /// Sun position as `[azimuth_deg, altitude_deg]`.
651    ///
652    /// - `azimuth_deg`: compass bearing (0 = north, 90 = east).
653    /// - `altitude_deg`: elevation above the horizon (0 = horizon, 90 = zenith).
654    ///
655    /// When `None`, inherits from the directional light direction in
656    /// [`LightConfig`].
657    pub sun_position: Option<[f32; 2]>,
658
659    /// Sun brightness multiplier (0–100).  Default: `10.0`.
660    pub sun_intensity: f32,
661
662    /// Tint for Rayleigh scattering (linear RGB, 0–1).  Default: white.
663    pub atmosphere_color: [f32; 3],
664
665    /// Tint for Mie scattering / halo (linear RGB, 0–1).  Default: white.
666    pub halo_color: [f32; 3],
667
668    /// Overall sky opacity (0–1).  Default: `1.0`.
669    ///
670    /// At `0.0` the sky pass is skipped entirely (clear-colour only).
671    pub opacity: f32,
672}
673
674impl Default for SkyConfig {
675    fn default() -> Self {
676        Self {
677            sky_type: SkyType::default(),
678            sun_position: None,
679            sun_intensity: 10.0,
680            atmosphere_color: [1.0, 1.0, 1.0],
681            halo_color: [1.0, 1.0, 1.0],
682            opacity: 1.0,
683        }
684    }
685}
686
687/// Pre-computed sky parameters ready for GPU uniform upload.
688///
689/// Computed once per frame from the [`SkyConfig`] stored on
690/// [`MapState`].  Both WGPU and Bevy renderers consume this struct.
691#[derive(Debug, Clone, Copy, PartialEq)]
692pub struct ComputedSky {
693    /// Unit direction vector **toward** the sun in world space.
694    pub sun_direction: [f32; 3],
695    /// Sun brightness multiplier.
696    pub sun_intensity: f32,
697    /// Rayleigh scattering tint (linear RGB).
698    pub rayleigh_color: [f32; 3],
699    /// Mie scattering tint (linear RGB).
700    pub mie_color: [f32; 3],
701    /// `1.0` when sky rendering is enabled, `0.0` when disabled.
702    pub sky_enabled: f32,
703}
704
705impl Default for ComputedSky {
706    fn default() -> Self {
707        Self {
708            sun_direction: [0.0, 0.0, 1.0],
709            sun_intensity: 0.0,
710            rayleigh_color: [1.0, 1.0, 1.0],
711            mie_color: [1.0, 1.0, 1.0],
712            sky_enabled: 0.0, // disabled by default (no SkyConfig set)
713        }
714    }
715}
716
717/// Compute GPU-ready sky parameters from the user config.
718///
719/// `fallback_sun` is the directional light direction `[azimuth_deg, altitude_deg]`
720/// used when the sky config does not specify an explicit sun position.
721pub fn compute_sky(config: &SkyConfig, fallback_sun: [f32; 2]) -> ComputedSky {
722    let enabled = if config.opacity > 0.0 { 1.0 } else { 0.0 };
723
724    let [azimuth_deg, altitude_deg] = config.sun_position.unwrap_or(fallback_sun);
725    let azimuth = azimuth_deg.to_radians();
726    let altitude = altitude_deg.to_radians();
727    let cos_alt = altitude.cos();
728    let dir = [
729        azimuth.sin() * cos_alt,
730        azimuth.cos() * cos_alt,
731        altitude.sin(),
732    ];
733    let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
734    let sun_direction = if len > 1e-6 {
735        [dir[0] / len, dir[1] / len, dir[2] / len]
736    } else {
737        [0.0, 0.0, 1.0]
738    };
739
740    ComputedSky {
741        sun_direction,
742        sun_intensity: config.sun_intensity,
743        rayleigh_color: config.atmosphere_color,
744        mie_color: config.halo_color,
745        sky_enabled: enabled,
746    }
747}
748
749// ---------------------------------------------------------------------------
750// Fog / atmosphere configuration
751// ---------------------------------------------------------------------------
752
753/// User-facing fog/atmosphere configuration.
754///
755/// Mirrors MapLibre's `sky` / Mapbox's `fog` style properties.
756/// When attached to a [`StyleDocument`] or set directly on [`MapState`],
757/// these values override the default pitch-based atmospheric fog.
758///
759/// Omitted fields (`None`) fall back to the automatic camera-derived
760/// defaults so users can override only the aspects they care about.
761#[derive(Debug, Clone, PartialEq, Default)]
762pub struct FogConfig {
763    /// Fog tint colour (RGBA, linear).
764    ///
765    /// When `None`, derived automatically from the background colour and
766    /// camera pitch (the existing atmospheric-clear-colour behaviour).
767    pub color: Option<[f32; 4]>,
768
769    /// Fog range as `[start, end]` — fractions of the camera visible range.
770    ///
771    /// `0.0` = at the camera eye, `1.0` = at the computed visible-range
772    /// horizon.  Default: `[0.55, 1.05]`.
773    pub range: Option<[f32; 2]>,
774
775    /// Peak fog density `[0.0, 1.0]`.
776    ///
777    /// When `None`, the density is computed from camera pitch (0 at
778    /// top-down, ramping to 0.9 near the horizon).
779    pub density: Option<f32>,
780
781    /// Horizon / sky colour (RGBA, linear).
782    ///
783    /// Used as the clear-colour background when the camera is pitched
784    /// toward the horizon.  When `None`, derived from the base
785    /// background colour.
786    pub horizon_color: Option<[f32; 4]>,
787
788    /// Horizon blend factor `[0.0, 1.0]`.
789    ///
790    /// Controls how strongly the horizon colour mixes into the
791    /// background as pitch increases.  When `None`, automatic.
792    pub horizon_blend: Option<f32>,
793}
794
795/// Pre-computed fog parameters ready for GPU uniform upload.
796///
797/// Computed by the engine each frame from camera state, the optional
798/// [`FogConfig`], and the background colour.  Both WGPU and Bevy
799/// renderers consume this struct directly instead of duplicating the
800/// fog math.
801#[derive(Debug, Clone, Copy, PartialEq)]
802pub struct ComputedFog {
803    /// Fog / horizon tint colour (RGBA, linear).
804    pub fog_color: [f32; 4],
805    /// Distance from the camera eye at which fog begins (meters).
806    pub fog_start: f32,
807    /// Distance from the camera eye at which fog reaches full density (meters).
808    pub fog_end: f32,
809    /// Peak fog density `[0.0, 1.0]`.
810    pub fog_density: f32,
811    /// Background clear colour after atmospheric tinting.
812    pub clear_color: [f32; 4],
813}
814
815impl Default for ComputedFog {
816    fn default() -> Self {
817        Self {
818            fog_color: [1.0; 4],
819            fog_start: 10_000.0,
820            fog_end: 20_000.0,
821            fog_density: 0.0,
822            clear_color: [1.0; 4],
823        }
824    }
825}
826
827/// Blend `base` toward a slightly lifted "horizon" colour as `pitch`
828/// increases past 0.25 rad.
829///
830/// This is the canonical implementation used by both WGPU and Bevy
831/// renderers.
832pub fn atmospheric_clear_color(base: [f32; 4], pitch: f64) -> [f32; 4] {
833    let t = (((pitch - 0.25) / 1.0).clamp(0.0, 1.0)) as f32;
834    let horizon = [
835        (base[0] * 0.92 + 0.05).clamp(0.0, 1.0),
836        (base[1] * 0.95 + 0.06).clamp(0.0, 1.0),
837        (base[2] * 0.98 + 0.08).clamp(0.0, 1.0),
838        base[3],
839    ];
840    [
841        base[0] * (1.0 - t) + horizon[0] * t,
842        base[1] * (1.0 - t) + horizon[1] * t,
843        base[2] * (1.0 - t) + horizon[2] * t,
844        base[3],
845    ]
846}
847
848/// Compute fog parameters from camera state and optional user config.
849///
850/// This centralises the fog math that was previously duplicated in the
851/// WGPU and Bevy renderers.
852pub fn compute_fog(
853    pitch: f64,
854    camera_distance: f64,
855    background_color: [f32; 4],
856    config: Option<&FogConfig>,
857) -> ComputedFog {
858    let auto_clear = atmospheric_clear_color(background_color, pitch);
859
860    // Fog density: ramps from 0 (top-down) to 0.9 near the horizon.
861    let auto_density = (((pitch - 0.70) / 0.55).clamp(0.0, 1.0) as f32) * 0.9;
862
863    // Visible ground range when pitched.
864    let visible_range = camera_distance / pitch.cos().max(0.05);
865    let auto_start = (visible_range * 0.55) as f32;
866    let auto_end = (visible_range * 1.05) as f32;
867
868    let (fog_start, fog_end) = match config.and_then(|c| c.range) {
869        Some([s, e]) => ((visible_range as f32) * s, (visible_range as f32) * e),
870        None => (auto_start, auto_end),
871    };
872
873    let fog_density = config.and_then(|c| c.density).unwrap_or(auto_density);
874
875    let fog_color = config.and_then(|c| c.color).unwrap_or(auto_clear);
876
877    let clear_color = match config.and_then(|c| c.horizon_color) {
878        Some(horizon) => {
879            let blend = config
880                .and_then(|c| c.horizon_blend)
881                .unwrap_or_else(|| ((pitch - 0.25) / 1.0).clamp(0.0, 1.0) as f32);
882            [
883                background_color[0] * (1.0 - blend) + horizon[0] * blend,
884                background_color[1] * (1.0 - blend) + horizon[1] * blend,
885                background_color[2] * (1.0 - blend) + horizon[2] * blend,
886                background_color[3],
887            ]
888        }
889        None => auto_clear,
890    };
891
892    ComputedFog {
893        fog_color,
894        fog_start,
895        fog_end,
896        fog_density,
897        clear_color,
898    }
899}
900
901/// Interpolation behaviour for style values.
902///
903/// Implementors must also provide [`FromFeatureStateProperty`] so that
904/// feature-state-driven [`StyleValue::FeatureState`] variants can attempt
905/// conversion from [`PropertyValue`] during evaluation.
906pub trait StyleInterpolatable: Clone + FromFeatureStateProperty {
907    /// Sample between two stop values.
908    fn interpolate(a: &Self, b: &Self, t: f32) -> Self;
909}
910
911impl StyleInterpolatable for f32 {
912    fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
913        *a + (*b - *a) * t.clamp(0.0, 1.0)
914    }
915}
916
917impl StyleInterpolatable for [f32; 4] {
918    fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
919        let t = t.clamp(0.0, 1.0);
920        [
921            a[0] + (b[0] - a[0]) * t,
922            a[1] + (b[1] - a[1]) * t,
923            a[2] + (b[2] - a[2]) * t,
924            a[3] + (b[3] - a[3]) * t,
925        ]
926    }
927}
928
929impl StyleInterpolatable for bool {
930    fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
931        if t < 1.0 {
932            *a
933        } else {
934            *b
935        }
936    }
937}
938
939impl StyleInterpolatable for String {
940    fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
941        if t < 1.0 {
942            a.clone()
943        } else {
944            b.clone()
945        }
946    }
947}
948
949impl StyleInterpolatable for SymbolTextJustify {
950    fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
951        if t < 1.0 {
952            *a
953        } else {
954            *b
955        }
956    }
957}
958
959impl StyleInterpolatable for SymbolTextTransform {
960    fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
961        if t < 1.0 {
962            *a
963        } else {
964            *b
965        }
966    }
967}
968
969impl StyleInterpolatable for SymbolIconTextFit {
970    fn interpolate(a: &Self, b: &Self, t: f32) -> Self {
971        if t < 1.0 {
972            *a
973        } else {
974            *b
975        }
976    }
977}
978
979// ---------------------------------------------------------------------------
980// Style transitions
981// ---------------------------------------------------------------------------
982
983/// Transition timing specification.
984///
985/// Mirrors Mapbox GL JS `TransitionSpecification`:
986/// ```json
987/// { "duration": 300, "delay": 0 }
988/// ```
989///
990/// Both values are in **seconds** (Mapbox uses milliseconds — we convert
991/// at the JSON-parsing boundary).
992#[derive(Debug, Clone, Copy, PartialEq)]
993pub struct TransitionSpec {
994    /// Transition duration in seconds.  Default: `0.3` (300 ms).
995    pub duration: f32,
996    /// Delay before the transition begins, in seconds.  Default: `0.0`.
997    pub delay: f32,
998}
999
1000impl Default for TransitionSpec {
1001    fn default() -> Self {
1002        Self {
1003            duration: 0.3,
1004            delay: 0.0,
1005        }
1006    }
1007}
1008
1009impl TransitionSpec {
1010    /// A zero-duration transition (instant snap).
1011    pub const INSTANT: Self = Self {
1012        duration: 0.0,
1013        delay: 0.0,
1014    };
1015
1016    /// Returns `true` when `duration + delay > 0`.
1017    pub fn is_active(&self) -> bool {
1018        self.duration > 0.0 || self.delay > 0.0
1019    }
1020}
1021
1022/// Cubic ease-in-out curve matching Mapbox's `easeCubicInOut`.
1023///
1024/// Maps `t ∈ [0, 1]` to a smoothly accelerating/decelerating value.
1025fn ease_cubic_in_out(t: f32) -> f32 {
1026    let t = t.clamp(0.0, 1.0);
1027    if t < 0.5 {
1028        4.0 * t * t * t
1029    } else {
1030        let f = 2.0 * t - 2.0;
1031        0.5 * f * f * f + 1.0
1032    }
1033}
1034
1035/// A single property undergoing a timed transition.
1036///
1037/// Holds the prior value, the target value, and the timing window.
1038/// Call [`resolve`](Self::resolve) each frame to get the interpolated
1039/// output.
1040#[derive(Debug, Clone)]
1041pub struct Transitioning<T: StyleInterpolatable> {
1042    /// Value we are transitioning **from** (captured at the moment the
1043    /// property changed).
1044    prior: T,
1045    /// Value we are transitioning **to**.
1046    target: T,
1047    /// Monotonic time (seconds) at which the transition begins (after delay).
1048    begin: f64,
1049    /// Monotonic time (seconds) at which the transition ends.
1050    end: f64,
1051}
1052
1053impl<T: StyleInterpolatable> Transitioning<T> {
1054    /// Create a new transition.
1055    ///
1056    /// - `prior`: the value before the change.
1057    /// - `target`: the new value.
1058    /// - `now`: current monotonic time (seconds).
1059    /// - `spec`: transition timing.
1060    pub fn new(prior: T, target: T, now: f64, spec: &TransitionSpec) -> Self {
1061        let begin = now + spec.delay as f64;
1062        let end = begin + spec.duration as f64;
1063        Self {
1064            prior,
1065            target,
1066            begin,
1067            end,
1068        }
1069    }
1070
1071    /// Create a completed (instant) transition.
1072    pub fn settled(value: T) -> Self {
1073        Self {
1074            prior: value.clone(),
1075            target: value,
1076            begin: 0.0,
1077            end: 0.0,
1078        }
1079    }
1080
1081    /// Resolve the current interpolated value.
1082    pub fn resolve(&self, now: f64) -> T {
1083        if now >= self.end {
1084            return self.target.clone();
1085        }
1086        if now < self.begin {
1087            return self.prior.clone();
1088        }
1089        let duration = self.end - self.begin;
1090        if duration <= 0.0 {
1091            return self.target.clone();
1092        }
1093        let t = ((now - self.begin) / duration) as f32;
1094        T::interpolate(&self.prior, &self.target, ease_cubic_in_out(t))
1095    }
1096
1097    /// Returns `true` when the transition is still in progress.
1098    pub fn is_active(&self, now: f64) -> bool {
1099        now < self.end
1100    }
1101
1102    /// The target value (what we're transitioning toward).
1103    pub fn target(&self) -> &T {
1104        &self.target
1105    }
1106
1107    /// Update the target, restarting the transition from the current
1108    /// interpolated value.
1109    pub fn retarget(&mut self, new_target: T, now: f64, spec: &TransitionSpec) {
1110        let current = self.resolve(now);
1111        self.prior = current;
1112        self.target = new_target;
1113        self.begin = now + spec.delay as f64;
1114        self.end = self.begin + spec.duration as f64;
1115    }
1116}
1117
1118/// Per-layer transition state for all transitionable paint properties.
1119///
1120/// Stores a [`Transitioning`] wrapper for each property that supports
1121/// transitions.  The engine updates this state each frame in the
1122/// style-evaluation loop.
1123#[derive(Debug, Clone)]
1124pub struct LayerTransitionState {
1125    /// The transition spec to use for this layer's properties.
1126    pub spec: TransitionSpec,
1127    /// Layer opacity transition.
1128    pub opacity: Transitioning<f32>,
1129    /// Primary fill/stroke/circle colour transition.
1130    pub color: Transitioning<[f32; 4]>,
1131    /// Secondary colour (outline, stroke).
1132    pub secondary_color: Transitioning<[f32; 4]>,
1133    /// Primary width (line width, circle radius, outline width).
1134    pub width: Transitioning<f32>,
1135    /// Fill-extrusion height.
1136    pub height: Transitioning<f32>,
1137    /// Fill-extrusion base.
1138    pub base: Transitioning<f32>,
1139}
1140
1141impl LayerTransitionState {
1142    /// Create initial (settled) state from evaluated property values.
1143    pub fn from_initial(
1144        spec: TransitionSpec,
1145        opacity: f32,
1146        color: [f32; 4],
1147        secondary_color: [f32; 4],
1148        width: f32,
1149        height: f32,
1150        base: f32,
1151    ) -> Self {
1152        Self {
1153            spec,
1154            opacity: Transitioning::settled(opacity),
1155            color: Transitioning::settled(color),
1156            secondary_color: Transitioning::settled(secondary_color),
1157            width: Transitioning::settled(width),
1158            height: Transitioning::settled(height),
1159            base: Transitioning::settled(base),
1160        }
1161    }
1162
1163    /// Update a property, starting a transition if the value changed.
1164    fn update_f32(trans: &mut Transitioning<f32>, new_val: f32, now: f64, spec: &TransitionSpec) {
1165        if (trans.target() - new_val).abs() > 1e-6 {
1166            trans.retarget(new_val, now, spec);
1167        }
1168    }
1169
1170    fn update_color(
1171        trans: &mut Transitioning<[f32; 4]>,
1172        new_val: [f32; 4],
1173        now: f64,
1174        spec: &TransitionSpec,
1175    ) {
1176        let old = trans.target();
1177        let diff = (old[0] - new_val[0]).abs()
1178            + (old[1] - new_val[1]).abs()
1179            + (old[2] - new_val[2]).abs()
1180            + (old[3] - new_val[3]).abs();
1181        if diff > 1e-5 {
1182            trans.retarget(new_val, now, spec);
1183        }
1184    }
1185
1186    /// Update all properties from freshly evaluated values.
1187    #[allow(clippy::too_many_arguments)]
1188    pub fn update(
1189        &mut self,
1190        now: f64,
1191        opacity: f32,
1192        color: [f32; 4],
1193        secondary_color: [f32; 4],
1194        width: f32,
1195        height: f32,
1196        base: f32,
1197    ) {
1198        let spec = self.spec;
1199        Self::update_f32(&mut self.opacity, opacity, now, &spec);
1200        Self::update_color(&mut self.color, color, now, &spec);
1201        Self::update_color(&mut self.secondary_color, secondary_color, now, &spec);
1202        Self::update_f32(&mut self.width, width, now, &spec);
1203        Self::update_f32(&mut self.height, height, now, &spec);
1204        Self::update_f32(&mut self.base, base, now, &spec);
1205    }
1206
1207    /// Resolve all transitioned values for the current time.
1208    pub fn resolve(&self, now: f64) -> ResolvedTransitions {
1209        ResolvedTransitions {
1210            opacity: self.opacity.resolve(now),
1211            color: self.color.resolve(now),
1212            secondary_color: self.secondary_color.resolve(now),
1213            width: self.width.resolve(now),
1214            height: self.height.resolve(now),
1215            base: self.base.resolve(now),
1216        }
1217    }
1218
1219    /// Returns `true` if any property is still mid-transition.
1220    pub fn has_active_transitions(&self, now: f64) -> bool {
1221        self.opacity.is_active(now)
1222            || self.color.is_active(now)
1223            || self.secondary_color.is_active(now)
1224            || self.width.is_active(now)
1225            || self.height.is_active(now)
1226            || self.base.is_active(now)
1227    }
1228}
1229
1230/// Snapshot of all resolved transition values for a single layer.
1231#[derive(Debug, Clone, Copy, PartialEq)]
1232pub struct ResolvedTransitions {
1233    /// Resolved opacity.
1234    pub opacity: f32,
1235    /// Resolved primary colour.
1236    pub color: [f32; 4],
1237    /// Resolved secondary colour (e.g. outline).
1238    pub secondary_color: [f32; 4],
1239    /// Resolved width.
1240    pub width: f32,
1241    /// Resolved height.
1242    pub height: f32,
1243    /// Resolved base height.
1244    pub base: f32,
1245}
1246
1247/// Factory for constructing a fresh raster tile source when a style document is applied.
1248pub type RasterSourceFactory = Arc<dyn Fn() -> Box<dyn TileSource> + Send + Sync>;
1249
1250/// Factory for constructing a fresh streamed vector tile source when a style document is applied.
1251pub type VectorTileSourceFactory = Arc<dyn Fn() -> Box<dyn TileSource> + Send + Sync>;
1252
1253/// Factory for constructing a fresh terrain elevation source when a style document is applied.
1254pub type TerrainSourceFactory = Arc<dyn Fn() -> Box<dyn ElevationSource> + Send + Sync>;
1255
1256/// Enumerates style/runtime source families.
1257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1258pub enum StyleSourceKind {
1259    /// Raster tile source.
1260    Raster,
1261    /// Terrain / raster-dem source.
1262    Terrain,
1263    /// In-memory GeoJSON-like feature source.
1264    GeoJson,
1265    /// Vector-tile-like feature source resolved into vector features by the host.
1266    VectorTile,
1267    /// Image source lowered onto raster rendering.
1268    Image,
1269    /// Video source — georeferenced dynamic overlay.
1270    Video,
1271    /// Canvas source — georeferenced dynamic overlay.
1272    Canvas,
1273    /// Model instance source.
1274    Model,
1275}
1276
1277impl StyleSourceKind {
1278    /// Stable string name for diagnostics and JSON interop.
1279    pub fn as_str(self) -> &'static str {
1280        match self {
1281            StyleSourceKind::Raster => "raster",
1282            StyleSourceKind::Terrain => "terrain",
1283            StyleSourceKind::GeoJson => "geojson",
1284            StyleSourceKind::VectorTile => "vector",
1285            StyleSourceKind::Image => "image",
1286            StyleSourceKind::Video => "video",
1287            StyleSourceKind::Canvas => "canvas",
1288            StyleSourceKind::Model => "model",
1289        }
1290    }
1291}
1292
1293/// Source registry entry.
1294#[derive(Clone)]
1295pub enum StyleSource {
1296    /// Raster tile source.
1297    Raster(RasterSource),
1298    /// Terrain/elevation source.
1299    Terrain(TerrainSource),
1300    /// In-memory vector feature source.
1301    GeoJson(GeoJsonSource),
1302    /// Vector-tile-like source represented as resolved features.
1303    VectorTile(VectorTileSource),
1304    /// Image source lowered onto raster rendering.
1305    Image(ImageSource),
1306    /// Video source — georeferenced dynamic overlay.
1307    Video(VideoSource),
1308    /// Canvas source — georeferenced dynamic overlay.
1309    Canvas(CanvasSource),
1310    /// In-memory model instance source.
1311    Model(ModelSource),
1312}
1313
1314impl StyleSource {
1315    /// Human-readable source kind.
1316    pub fn kind_name(&self) -> &'static str {
1317        self.kind().as_str()
1318    }
1319
1320    /// Enumerated source kind.
1321    pub fn kind(&self) -> StyleSourceKind {
1322        match self {
1323            StyleSource::Raster(_) => StyleSourceKind::Raster,
1324            StyleSource::Terrain(_) => StyleSourceKind::Terrain,
1325            StyleSource::GeoJson(_) => StyleSourceKind::GeoJson,
1326            StyleSource::VectorTile(_) => StyleSourceKind::VectorTile,
1327            StyleSource::Image(_) => StyleSourceKind::Image,
1328            StyleSource::Video(_) => StyleSourceKind::Video,
1329            StyleSource::Canvas(_) => StyleSourceKind::Canvas,
1330            StyleSource::Model(_) => StyleSourceKind::Model,
1331        }
1332    }
1333}
1334
1335impl fmt::Debug for StyleSource {
1336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1337        match self {
1338            StyleSource::Raster(src) => f.debug_tuple("Raster").field(src).finish(),
1339            StyleSource::Terrain(src) => f.debug_tuple("Terrain").field(src).finish(),
1340            StyleSource::GeoJson(src) => f.debug_tuple("GeoJson").field(src).finish(),
1341            StyleSource::VectorTile(src) => f.debug_tuple("VectorTile").field(src).finish(),
1342            StyleSource::Image(src) => f.debug_tuple("Image").field(src).finish(),
1343            StyleSource::Video(src) => f.debug_tuple("Video").field(src).finish(),
1344            StyleSource::Canvas(src) => f.debug_tuple("Canvas").field(src).finish(),
1345            StyleSource::Model(src) => f.debug_tuple("Model").field(src).finish(),
1346        }
1347    }
1348}
1349
1350/// Raster tile source entry.
1351#[derive(Clone)]
1352pub struct RasterSource {
1353    /// Maximum tile-cache capacity used by the created tile layer.
1354    pub cache_capacity: usize,
1355    /// Tile-selection policy for the created tile layer.
1356    pub selection: TileSelectionConfig,
1357    /// Factory used to build a fresh tile source each time the style is applied.
1358    pub factory: RasterSourceFactory,
1359}
1360
1361impl RasterSource {
1362    /// Create a raster source from a tile-source factory.
1363    pub fn new(factory: impl Fn() -> Box<dyn TileSource> + Send + Sync + 'static) -> Self {
1364        Self {
1365            cache_capacity: 256,
1366            selection: TileSelectionConfig::default(),
1367            factory: Arc::new(factory),
1368        }
1369    }
1370
1371    /// Set tile cache capacity.
1372    pub fn with_cache_capacity(mut self, cache_capacity: usize) -> Self {
1373        self.cache_capacity = cache_capacity;
1374        self
1375    }
1376
1377    /// Set tile-selection policy.
1378    pub fn with_selection(mut self, selection: TileSelectionConfig) -> Self {
1379        self.selection = selection;
1380        self
1381    }
1382}
1383
1384impl fmt::Debug for RasterSource {
1385    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1386        f.debug_struct("RasterSource")
1387            .field("cache_capacity", &self.cache_capacity)
1388            .field("selection", &self.selection)
1389            .finish_non_exhaustive()
1390    }
1391}
1392
1393/// Terrain source entry.
1394#[derive(Clone)]
1395pub struct TerrainSource {
1396    /// Whether terrain is enabled.
1397    pub enabled: bool,
1398    /// Terrain vertical exaggeration.
1399    pub vertical_exaggeration: f64,
1400    /// Terrain mesh resolution.
1401    pub mesh_resolution: u16,
1402    /// Terrain skirt depth in meters.
1403    pub skirt_depth: f64,
1404    /// Terrain cache capacity.
1405    pub cache_capacity: usize,
1406    /// Factory used to build a fresh elevation source each time the style is applied.
1407    pub factory: TerrainSourceFactory,
1408}
1409
1410impl TerrainSource {
1411    /// Create a terrain source from an elevation-source factory.
1412    pub fn new(factory: impl Fn() -> Box<dyn ElevationSource> + Send + Sync + 'static) -> Self {
1413        Self {
1414            enabled: true,
1415            vertical_exaggeration: 1.0,
1416            mesh_resolution: 64,
1417            skirt_depth: 100.0,
1418            cache_capacity: 256,
1419            factory: Arc::new(factory),
1420        }
1421    }
1422
1423    /// Set terrain cache capacity.
1424    pub fn with_cache_capacity(mut self, cache_capacity: usize) -> Self {
1425        self.cache_capacity = cache_capacity;
1426        self
1427    }
1428
1429    /// Convert this style terrain source to a terrain config using a fresh source instance.
1430    pub fn to_terrain_config(&self) -> TerrainConfig {
1431        TerrainConfig {
1432            enabled: self.enabled,
1433            vertical_exaggeration: self.vertical_exaggeration,
1434            mesh_resolution: self.mesh_resolution,
1435            skirt_depth: self.skirt_depth,
1436            source_max_zoom: TerrainConfig::default().source_max_zoom,
1437            source: (self.factory)(),
1438        }
1439    }
1440}
1441
1442impl fmt::Debug for TerrainSource {
1443    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1444        f.debug_struct("TerrainSource")
1445            .field("enabled", &self.enabled)
1446            .field("vertical_exaggeration", &self.vertical_exaggeration)
1447            .field("mesh_resolution", &self.mesh_resolution)
1448            .field("skirt_depth", &self.skirt_depth)
1449            .field("cache_capacity", &self.cache_capacity)
1450            .finish_non_exhaustive()
1451    }
1452}
1453
1454/// In-memory vector feature source with optional point clustering.
1455#[derive(Clone)]
1456pub struct GeoJsonSource {
1457    /// Features exposed by the source.
1458    pub data: FeatureCollection,
1459    /// Pre-built cluster index.  When present, layers referencing this
1460    /// source receive clustered features based on the current zoom level.
1461    cluster_index: Option<Arc<PointCluster>>,
1462}
1463
1464impl fmt::Debug for GeoJsonSource {
1465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1466        f.debug_struct("GeoJsonSource")
1467            .field("features", &self.data.len())
1468            .field("clustered", &self.cluster_index.is_some())
1469            .finish()
1470    }
1471}
1472
1473impl GeoJsonSource {
1474    /// Create a new in-memory vector source.
1475    pub fn new(data: FeatureCollection) -> Self {
1476        Self {
1477            data,
1478            cluster_index: None,
1479        }
1480    }
1481
1482    /// Enable point clustering on this source.
1483    ///
1484    /// Builds the cluster index immediately from the current `data`.
1485    /// Layers that reference this source will receive clustered features
1486    /// at the appropriate zoom level instead of the raw point data.
1487    pub fn with_clustering(mut self, options: ClusterOptions) -> Self {
1488        let mut cluster = PointCluster::new(options);
1489        cluster.load(&self.data);
1490        self.cluster_index = Some(Arc::new(cluster));
1491        self
1492    }
1493
1494    /// Returns `true` if this source has clustering enabled.
1495    pub fn is_clustered(&self) -> bool {
1496        self.cluster_index.is_some()
1497    }
1498
1499    /// Resolve features for a given zoom level.
1500    ///
1501    /// If clustering is enabled, returns zoom-appropriate clustered features.
1502    /// Otherwise returns a borrowed reference to the raw data.
1503    pub fn features_at_zoom(&self, zoom: u8) -> Cow<'_, FeatureCollection> {
1504        if let Some(ref cluster) = self.cluster_index {
1505            Cow::Owned(cluster.get_clusters_for_zoom(zoom))
1506        } else {
1507            Cow::Borrowed(&self.data)
1508        }
1509    }
1510}
1511
1512/// Vector-tile-like feature source.
1513#[derive(Clone)]
1514pub struct VectorTileSource {
1515    /// Flattened features exposed by the source.
1516    ///
1517    /// This remains for backward compatibility with earlier runtime code that
1518    /// treated vector sources as one resolved feature collection.
1519    pub data: FeatureCollection,
1520    /// Optional source-layer partitioning for style/runtime resolution.
1521    ///
1522    /// When present, vector style layers may select a specific source layer via
1523    /// their `source_layer` field, mirroring MapLibre's `source-layer`
1524    /// behavior. When absent, the flattened `data` collection is used.
1525    pub source_layers: HashMap<String, FeatureCollection>,
1526    /// Optional streamed tile source factory.
1527    ///
1528    /// When present, the style/runtime path builds a source-owned hidden tile
1529    /// manager that fetches binary vector tiles at runtime. The in-memory
1530    /// `data` and `source_layers` remain available for tests, fallbacks, and
1531    /// backward-compatible resolved-feature workflows.
1532    pub factory: Option<VectorTileSourceFactory>,
1533    /// Maximum tile-cache capacity for the streamed vector source runtime.
1534    pub cache_capacity: usize,
1535    /// Tile-selection policy for the streamed vector source runtime.
1536    pub selection: TileSelectionConfig,
1537}
1538
1539impl fmt::Debug for VectorTileSource {
1540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1541        f.debug_struct("VectorTileSource")
1542            .field("feature_count", &self.data.len())
1543            .field("source_layer_count", &self.source_layers.len())
1544            .field("streamed", &self.factory.is_some())
1545            .field("cache_capacity", &self.cache_capacity)
1546            .field("selection", &self.selection)
1547            .finish()
1548    }
1549}
1550
1551impl VectorTileSource {
1552    /// Create a new vector-tile-like source from resolved features.
1553    pub fn new(data: FeatureCollection) -> Self {
1554        Self {
1555            data,
1556            source_layers: HashMap::new(),
1557            factory: None,
1558            cache_capacity: 256,
1559            selection: TileSelectionConfig::default(),
1560        }
1561    }
1562
1563    /// Create a new streamed vector tile source from a tile-source factory.
1564    pub fn streamed(factory: impl Fn() -> Box<dyn TileSource> + Send + Sync + 'static) -> Self {
1565        Self {
1566            data: FeatureCollection::default(),
1567            source_layers: HashMap::new(),
1568            factory: Some(Arc::new(factory)),
1569            cache_capacity: 256,
1570            selection: TileSelectionConfig::default(),
1571        }
1572    }
1573
1574    /// Create a new vector-tile-like source from named source-layer feature sets.
1575    pub fn from_source_layers(source_layers: HashMap<String, FeatureCollection>) -> Self {
1576        let mut data = FeatureCollection::default();
1577        for features in source_layers.values() {
1578            data.features.extend(features.features.iter().cloned());
1579        }
1580        Self {
1581            data,
1582            source_layers,
1583            factory: None,
1584            cache_capacity: 256,
1585            selection: TileSelectionConfig::default(),
1586        }
1587    }
1588
1589    /// Attach a named source layer to this source.
1590    pub fn with_source_layer(mut self, name: impl Into<String>, data: FeatureCollection) -> Self {
1591        self.source_layers.insert(name.into(), data);
1592        self.rebuild_flattened_data();
1593        self
1594    }
1595
1596    /// Borrow a named source layer if present.
1597    pub fn source_layer(&self, name: &str) -> Option<&FeatureCollection> {
1598        self.source_layers.get(name)
1599    }
1600
1601    /// Return `true` if this source has explicit source-layer partitioning.
1602    pub fn has_source_layers(&self) -> bool {
1603        !self.source_layers.is_empty()
1604    }
1605
1606    /// Return `true` if this source should fetch vector tiles at runtime.
1607    pub fn is_streamed(&self) -> bool {
1608        self.factory.is_some()
1609    }
1610
1611    /// Set streamed tile cache capacity.
1612    pub fn with_cache_capacity(mut self, cache_capacity: usize) -> Self {
1613        self.cache_capacity = cache_capacity;
1614        self
1615    }
1616
1617    /// Set streamed tile-selection policy.
1618    pub fn with_selection(mut self, selection: TileSelectionConfig) -> Self {
1619        self.selection = selection;
1620        self
1621    }
1622
1623    /// Build a fresh runtime tile source if this vector source is streamed.
1624    pub fn make_tile_source(&self) -> Option<Box<dyn TileSource>> {
1625        self.factory.as_ref().map(|factory| (factory)())
1626    }
1627
1628    fn rebuild_flattened_data(&mut self) {
1629        let mut data = FeatureCollection::default();
1630        for features in self.source_layers.values() {
1631            data.features.extend(features.features.iter().cloned());
1632        }
1633        self.data = data;
1634    }
1635}
1636
1637/// Image source lowered onto raster rendering.
1638#[derive(Clone)]
1639pub struct ImageSource {
1640    /// Maximum tile-cache capacity used by the created raster layer.
1641    pub cache_capacity: usize,
1642    /// Tile-selection policy for the created raster layer.
1643    pub selection: TileSelectionConfig,
1644    /// Factory used to build a fresh tile source each time the style is applied.
1645    pub factory: RasterSourceFactory,
1646}
1647
1648impl ImageSource {
1649    /// Create an image source from a tile-source factory.
1650    pub fn new(factory: impl Fn() -> Box<dyn TileSource> + Send + Sync + 'static) -> Self {
1651        Self {
1652            cache_capacity: 16,
1653            selection: TileSelectionConfig::default(),
1654            factory: Arc::new(factory),
1655        }
1656    }
1657
1658    /// Set tile cache capacity.
1659    pub fn with_cache_capacity(mut self, cache_capacity: usize) -> Self {
1660        self.cache_capacity = cache_capacity;
1661        self
1662    }
1663
1664    /// Set tile-selection policy.
1665    pub fn with_selection(mut self, selection: TileSelectionConfig) -> Self {
1666        self.selection = selection;
1667        self
1668    }
1669}
1670
1671impl fmt::Debug for ImageSource {
1672    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1673        f.debug_struct("ImageSource")
1674            .field("cache_capacity", &self.cache_capacity)
1675            .field("selection", &self.selection)
1676            .finish_non_exhaustive()
1677    }
1678}
1679
1680/// Video source — georeferenced dynamic overlay driven by a
1681/// [`FrameProvider`](crate::layers::FrameProvider).
1682///
1683/// This is the Rustial equivalent of MapLibre / Mapbox `video` source.
1684/// In the browser, a `<video>` element supplies frames; in Rustial the
1685/// user supplies a [`FrameProviderFactory`] that creates a
1686/// [`FrameProvider`](crate::layers::FrameProvider) for each style
1687/// application.
1688///
1689/// When a raster style layer references a video source, the style
1690/// evaluator produces a [`DynamicImageOverlayLayer`] instead of a
1691/// raster tile layer.
1692#[derive(Clone)]
1693pub struct VideoSource {
1694    /// Geographic corner coordinates (TL, TR, BR, BL).
1695    pub coordinates: [GeoCoord; 4],
1696    /// Factory used to build a fresh frame provider when the style is applied.
1697    pub factory: FrameProviderFactory,
1698}
1699
1700impl VideoSource {
1701    /// Create a video source.
1702    ///
1703    /// `coordinates` must be in TL → TR → BR → BL order.
1704    pub fn new(
1705        coordinates: [GeoCoord; 4],
1706        factory: impl Fn() -> Box<dyn crate::layers::FrameProvider> + Send + Sync + 'static,
1707    ) -> Self {
1708        Self {
1709            coordinates,
1710            factory: Arc::new(factory),
1711        }
1712    }
1713}
1714
1715impl fmt::Debug for VideoSource {
1716    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1717        f.debug_struct("VideoSource")
1718            .field("coordinates", &self.coordinates)
1719            .finish_non_exhaustive()
1720    }
1721}
1722
1723/// Canvas source — georeferenced dynamic overlay driven by a
1724/// [`FrameProvider`](crate::layers::FrameProvider).
1725///
1726/// This is the Rustial equivalent of MapLibre / Mapbox `canvas` source.
1727/// In the browser, a `<canvas>` element supplies frames; in Rustial the
1728/// user supplies a [`FrameProviderFactory`] that creates a
1729/// [`FrameProvider`](crate::layers::FrameProvider) for each style
1730/// application.
1731///
1732/// The `animate` flag controls whether the source re-reads its provider
1733/// each frame.  Set it to `false` for static canvas content to avoid
1734/// unnecessary GPU re-uploads.
1735#[derive(Clone)]
1736pub struct CanvasSource {
1737    /// Geographic corner coordinates (TL, TR, BR, BL).
1738    pub coordinates: [GeoCoord; 4],
1739    /// Factory used to build a fresh frame provider when the style is applied.
1740    pub factory: FrameProviderFactory,
1741    /// Whether this canvas source animates (polls each frame).
1742    /// Defaults to `true`.
1743    pub animate: bool,
1744}
1745
1746impl CanvasSource {
1747    /// Create a canvas source.
1748    ///
1749    /// `coordinates` must be in TL → TR → BR → BL order.
1750    pub fn new(
1751        coordinates: [GeoCoord; 4],
1752        factory: impl Fn() -> Box<dyn crate::layers::FrameProvider> + Send + Sync + 'static,
1753    ) -> Self {
1754        Self {
1755            coordinates,
1756            factory: Arc::new(factory),
1757            animate: true,
1758        }
1759    }
1760
1761    /// Set the `animate` flag.
1762    pub fn with_animate(mut self, animate: bool) -> Self {
1763        self.animate = animate;
1764        self
1765    }
1766}
1767
1768impl fmt::Debug for CanvasSource {
1769    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1770        f.debug_struct("CanvasSource")
1771            .field("coordinates", &self.coordinates)
1772            .field("animate", &self.animate)
1773            .finish_non_exhaustive()
1774    }
1775}
1776
1777/// In-memory model source.
1778#[derive(Debug, Clone, Default)]
1779pub struct ModelSource {
1780    /// Model instances exposed by the source.
1781    pub instances: Vec<ModelInstance>,
1782}
1783
1784impl ModelSource {
1785    /// Create a new in-memory model source.
1786    pub fn new(instances: Vec<ModelInstance>) -> Self {
1787        Self { instances }
1788    }
1789}
1790
1791/// A style document containing source registry, ordered layers, and optional terrain source.
1792#[derive(Debug, Default)]
1793pub struct StyleDocument {
1794    sources: HashMap<StyleSourceId, StyleSource>,
1795    layers: Vec<StyleLayer>,
1796    terrain_source: Option<StyleSourceId>,
1797    projection: StyleProjection,
1798    fog: Option<FogConfig>,
1799    lights: Option<LightConfig>,
1800    sky: Option<SkyConfig>,
1801    /// Global default transition timing.
1802    ///
1803    /// Per-layer `StyleLayerMeta::transition` overrides this.
1804    /// Matches Mapbox GL JS root `"transition"` object.
1805    transition: TransitionSpec,
1806}
1807
1808/// Type alias for backward compatibility.
1809///
1810/// [`StyleValue<T>`] is now an alias for [`Expression<T>`], the typed
1811/// expression engine. All existing `StyleValue::Constant(...)`,
1812/// `StyleValue::ZoomStops(...)`, and `StyleValue::FeatureState { .. }`
1813/// constructions continue to work unchanged.
1814///
1815/// New code should prefer importing [`Expression`] directly and using
1816/// the richer expression variants (`GetProperty`, `Interpolate`, `Step`,
1817/// `Match`, `Case`, etc.) for data-driven styling.
1818pub type StyleValue<T> = crate::expression::Expression<T>;
1819
1820/// Conversion from a [`PropertyValue`] stored in feature-state to a concrete
1821/// style value type.
1822///
1823/// Types that have a natural representation in `PropertyValue` (f32 from
1824/// Number, bool from Bool, String from String) implement this directly.
1825/// Types without a mapping (e.g. `[f32; 4]` colour tuples, enum variants)
1826/// return `None` so the fallback value is used instead.
1827///
1828/// This trait is intentionally sealed to [`StyleInterpolatable`] implementors
1829/// and does not need to be implemented by downstream code.
1830pub trait FromFeatureStateProperty: Sized {
1831    /// Attempt to convert a property value. Returns `None` when the
1832    /// `PropertyValue` variant does not map to `Self`.
1833    fn from_feature_state_property(prop: &PropertyValue) -> Option<Self>;
1834}
1835
1836impl FromFeatureStateProperty for f32 {
1837    fn from_feature_state_property(prop: &PropertyValue) -> Option<Self> {
1838        prop.as_f64().map(|v| v as f32)
1839    }
1840}
1841
1842impl FromFeatureStateProperty for [f32; 4] {
1843    /// Colour tuples cannot be expressed in a single `PropertyValue` today.
1844    fn from_feature_state_property(_prop: &PropertyValue) -> Option<Self> {
1845        None
1846    }
1847}
1848
1849impl FromFeatureStateProperty for bool {
1850    fn from_feature_state_property(prop: &PropertyValue) -> Option<Self> {
1851        prop.as_bool()
1852    }
1853}
1854
1855impl FromFeatureStateProperty for String {
1856    fn from_feature_state_property(prop: &PropertyValue) -> Option<Self> {
1857        prop.as_str().map(|s| s.to_owned())
1858    }
1859}
1860
1861impl FromFeatureStateProperty for SymbolTextJustify {
1862    fn from_feature_state_property(_prop: &PropertyValue) -> Option<Self> {
1863        None
1864    }
1865}
1866
1867impl FromFeatureStateProperty for SymbolTextTransform {
1868    fn from_feature_state_property(_prop: &PropertyValue) -> Option<Self> {
1869        None
1870    }
1871}
1872
1873impl FromFeatureStateProperty for SymbolIconTextFit {
1874    fn from_feature_state_property(_prop: &PropertyValue) -> Option<Self> {
1875        None
1876    }
1877}
1878
1879/// Shared style-layer metadata.
1880#[derive(Debug, Clone)]
1881pub struct StyleLayerMeta {
1882    /// Stable style-layer id.
1883    pub id: StyleLayerId,
1884    /// Human-readable layer name.
1885    pub name: String,
1886    /// Whether the layer is visible.
1887    pub visible: StyleValue<bool>,
1888    /// Layer opacity in `[0, 1]`.
1889    pub opacity: StyleValue<f32>,
1890    /// Optional minimum zoom for visibility.
1891    pub min_zoom: Option<f32>,
1892    /// Optional maximum zoom for visibility.
1893    pub max_zoom: Option<f32>,
1894    /// Default transition timing for all paint properties in this layer.
1895    pub transition: TransitionSpec,
1896}
1897
1898impl StyleLayerMeta {
1899    /// Create metadata with defaults.
1900    pub fn new(id: impl Into<String>) -> Self {
1901        let id = id.into();
1902        Self {
1903            name: id.clone(),
1904            id,
1905            visible: StyleValue::Constant(true),
1906            opacity: StyleValue::Constant(1.0),
1907            min_zoom: None,
1908            max_zoom: None,
1909            transition: TransitionSpec::default(),
1910        }
1911    }
1912
1913    fn visible_in_context(&self, ctx: StyleEvalContext) -> bool {
1914        if let Some(min_zoom) = self.min_zoom {
1915            if ctx.zoom < min_zoom {
1916                return false;
1917            }
1918        }
1919        if let Some(max_zoom) = self.max_zoom {
1920            if ctx.zoom > max_zoom {
1921                return false;
1922            }
1923        }
1924        self.visible.evaluate_with_context(ctx)
1925    }
1926}
1927
1928/// Background layer style spec.
1929#[allow(missing_docs)]
1930#[derive(Debug, Clone)]
1931pub struct BackgroundStyleLayer {
1932    pub meta: StyleLayerMeta,
1933    pub color: StyleValue<[f32; 4]>,
1934}
1935
1936impl BackgroundStyleLayer {
1937    /// Create a background style layer with the given id and fill colour.
1938    pub fn new(id: impl Into<String>, color: impl Into<StyleValue<[f32; 4]>>) -> Self {
1939        Self {
1940            meta: StyleLayerMeta::new(id),
1941            color: color.into(),
1942        }
1943    }
1944}
1945
1946/// Hillshade layer style spec.
1947#[allow(missing_docs)]
1948#[derive(Debug, Clone)]
1949pub struct HillshadeStyleLayer {
1950    pub meta: StyleLayerMeta,
1951    pub highlight_color: StyleValue<[f32; 4]>,
1952    pub shadow_color: StyleValue<[f32; 4]>,
1953    pub accent_color: StyleValue<[f32; 4]>,
1954    pub illumination_direction_deg: StyleValue<f32>,
1955    pub illumination_altitude_deg: StyleValue<f32>,
1956    pub exaggeration: StyleValue<f32>,
1957}
1958
1959impl HillshadeStyleLayer {
1960    /// Create a hillshade style layer with default hillshade parameters.
1961    pub fn new(id: impl Into<String>) -> Self {
1962        Self {
1963            meta: StyleLayerMeta::new(id),
1964            highlight_color: [1.0, 1.0, 1.0, 1.0].into(),
1965            shadow_color: [0.0, 0.0, 0.0, 1.0].into(),
1966            accent_color: [0.42, 0.48, 0.42, 1.0].into(),
1967            illumination_direction_deg: 335.0.into(),
1968            illumination_altitude_deg: 45.0.into(),
1969            exaggeration: 1.0.into(),
1970        }
1971    }
1972}
1973
1974/// Raster/tile layer style spec.
1975#[allow(missing_docs)]
1976#[derive(Debug, Clone)]
1977pub struct RasterStyleLayer {
1978    pub meta: StyleLayerMeta,
1979    pub source: StyleSourceId,
1980}
1981
1982impl RasterStyleLayer {
1983    /// Create a raster style layer bound to the given style source id.
1984    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
1985        Self {
1986            meta: StyleLayerMeta::new(id),
1987            source: source.into(),
1988        }
1989    }
1990}
1991
1992/// Legacy generic vector style spec.
1993#[allow(missing_docs)]
1994#[derive(Debug, Clone)]
1995pub struct VectorStyleLayer {
1996    pub meta: StyleLayerMeta,
1997    pub source: StyleSourceId,
1998    /// Optional source-layer name for vector-tile-like sources.
1999    pub source_layer: Option<String>,
2000    pub fill_color: StyleValue<[f32; 4]>,
2001    pub stroke_color: StyleValue<[f32; 4]>,
2002    pub stroke_width: StyleValue<f32>,
2003}
2004
2005impl VectorStyleLayer {
2006    /// Create a generic vector style layer bound to the given style source id.
2007    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2008        Self {
2009            meta: StyleLayerMeta::new(id),
2010            source: source.into(),
2011            source_layer: None,
2012            fill_color: VectorStyle::default().fill_color.into(),
2013            stroke_color: VectorStyle::default().stroke_color.into(),
2014            stroke_width: VectorStyle::default().stroke_width.into(),
2015        }
2016    }
2017}
2018
2019/// Fill layer style spec.
2020#[allow(missing_docs)]
2021#[derive(Debug, Clone)]
2022pub struct FillStyleLayer {
2023    pub meta: StyleLayerMeta,
2024    pub source: StyleSourceId,
2025    /// Optional source-layer name for vector-tile-like sources.
2026    pub source_layer: Option<String>,
2027    pub fill_color: StyleValue<[f32; 4]>,
2028    pub outline_color: StyleValue<[f32; 4]>,
2029    pub outline_width: StyleValue<f32>,
2030    /// Optional repeating pattern image for fill-pattern rendering.
2031    ///
2032    /// When set, the fill is textured with the given pattern instead
2033    /// of a solid colour.  Matches MapLibre / Mapbox `fill-pattern`.
2034    pub fill_pattern: Option<std::sync::Arc<crate::PatternImage>>,
2035}
2036
2037impl FillStyleLayer {
2038    /// Create a fill style layer bound to the given style source id.
2039    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2040        let style = VectorStyle::default();
2041        Self {
2042            meta: StyleLayerMeta::new(id),
2043            source: source.into(),
2044            source_layer: None,
2045            fill_color: style.fill_color.into(),
2046            outline_color: style.stroke_color.into(),
2047            outline_width: style.stroke_width.into(),
2048            fill_pattern: None,
2049        }
2050    }
2051}
2052
2053/// Line layer style spec.
2054#[allow(missing_docs)]
2055#[derive(Debug, Clone)]
2056pub struct LineStyleLayer {
2057    pub meta: StyleLayerMeta,
2058    pub source: StyleSourceId,
2059    /// Optional source-layer name for vector-tile-like sources.
2060    pub source_layer: Option<String>,
2061    pub color: StyleValue<[f32; 4]>,
2062    pub width: StyleValue<f32>,
2063    /// Line cap style (default: butt).
2064    pub line_cap: LineCap,
2065    /// Line join style (default: miter).
2066    pub line_join: LineJoin,
2067    /// Miter limit ratio (default: 2.0).
2068    pub miter_limit: f32,
2069    /// Optional dash pattern `[dash, gap, …]` in pixels.
2070    pub dash_array: Option<Vec<f32>>,
2071    /// Optional colour ramp evaluated along the line's length.
2072    ///
2073    /// When set, per-vertex colours are overridden by the gradient
2074    /// evaluated at each vertex's normalized distance `[0, 1]` along the
2075    /// polyline.  Replaces `line-color` (matching MapLibre / Mapbox
2076    /// `line-gradient` semantics).
2077    pub line_gradient: Option<crate::visualization::ColorRamp>,
2078    /// Optional repeating pattern image for textured line rendering.
2079    ///
2080    /// When set, the line is rendered with a repeating pattern texture
2081    /// instead of a solid colour, matching MapLibre / Mapbox
2082    /// `line-pattern` semantics.
2083    pub line_pattern: Option<std::sync::Arc<crate::PatternImage>>,
2084}
2085
2086impl LineStyleLayer {
2087    /// Create a line style layer bound to the given style source id.
2088    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2089        let style = VectorStyle::default();
2090        Self {
2091            meta: StyleLayerMeta::new(id),
2092            source: source.into(),
2093            source_layer: None,
2094            color: style.stroke_color.into(),
2095            width: style.stroke_width.into(),
2096            line_cap: LineCap::default(),
2097            line_join: LineJoin::default(),
2098            miter_limit: 2.0,
2099            dash_array: None,
2100            line_gradient: None,
2101            line_pattern: None,
2102        }
2103    }
2104}
2105
2106/// Circle layer style spec.
2107#[allow(missing_docs)]
2108#[derive(Debug, Clone)]
2109pub struct CircleStyleLayer {
2110    pub meta: StyleLayerMeta,
2111    pub source: StyleSourceId,
2112    /// Optional source-layer name for vector-tile-like sources.
2113    pub source_layer: Option<String>,
2114    pub color: StyleValue<[f32; 4]>,
2115    pub radius: StyleValue<f32>,
2116    pub stroke_color: StyleValue<[f32; 4]>,
2117    pub stroke_width: StyleValue<f32>,
2118}
2119
2120impl CircleStyleLayer {
2121    /// Create a circle style layer bound to the given style source id.
2122    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2123        let style = VectorStyle::default();
2124        Self {
2125            meta: StyleLayerMeta::new(id),
2126            source: source.into(),
2127            source_layer: None,
2128            color: style.fill_color.into(),
2129            radius: style.point_radius.into(),
2130            stroke_color: style.stroke_color.into(),
2131            stroke_width: style.stroke_width.into(),
2132        }
2133    }
2134}
2135
2136/// Heatmap layer style spec.
2137#[allow(missing_docs)]
2138#[derive(Debug, Clone)]
2139pub struct HeatmapStyleLayer {
2140    pub meta: StyleLayerMeta,
2141    pub source: StyleSourceId,
2142    /// Optional source-layer name for vector-tile-like sources.
2143    pub source_layer: Option<String>,
2144    pub color: StyleValue<[f32; 4]>,
2145    pub radius: StyleValue<f32>,
2146    pub intensity: StyleValue<f32>,
2147}
2148
2149impl HeatmapStyleLayer {
2150    /// Create a heatmap style layer bound to the given style source id.
2151    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2152        let style = VectorStyle::default();
2153        Self {
2154            meta: StyleLayerMeta::new(id),
2155            source: source.into(),
2156            source_layer: None,
2157            color: style.fill_color.into(),
2158            radius: style.heatmap_radius.into(),
2159            intensity: style.heatmap_intensity.into(),
2160        }
2161    }
2162}
2163
2164/// Fill extrusion layer style spec.
2165#[allow(missing_docs)]
2166#[derive(Debug, Clone)]
2167pub struct FillExtrusionStyleLayer {
2168    pub meta: StyleLayerMeta,
2169    pub source: StyleSourceId,
2170    /// Optional source-layer name for vector-tile-like sources.
2171    pub source_layer: Option<String>,
2172    pub color: StyleValue<[f32; 4]>,
2173    pub base: StyleValue<f32>,
2174    pub height: StyleValue<f32>,
2175}
2176
2177impl FillExtrusionStyleLayer {
2178    /// Create a fill-extrusion style layer bound to the given style source id.
2179    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2180        let style = VectorStyle::default();
2181        Self {
2182            meta: StyleLayerMeta::new(id),
2183            source: source.into(),
2184            source_layer: None,
2185            color: style.fill_color.into(),
2186            base: style.extrusion_base.into(),
2187            height: style.extrusion_height.into(),
2188        }
2189    }
2190}
2191
2192/// Symbol layer style spec.
2193#[allow(missing_docs)]
2194#[derive(Debug, Clone)]
2195pub struct SymbolStyleLayer {
2196    pub meta: StyleLayerMeta,
2197    pub source: StyleSourceId,
2198    /// Optional source-layer name for vector-tile-like sources.
2199    pub source_layer: Option<String>,
2200    pub color: StyleValue<[f32; 4]>,
2201    pub halo_color: StyleValue<[f32; 4]>,
2202    pub size: StyleValue<f32>,
2203    pub text_field: Option<StyleValue<String>>,
2204    pub icon_image: Option<StyleValue<String>>,
2205    pub font_stack: StyleValue<String>,
2206    pub padding: StyleValue<f32>,
2207    /// Shared overlap fallback for simplified callers.
2208    pub allow_overlap: StyleValue<bool>,
2209    /// Explicit text overlap control from the style specification.
2210    pub text_allow_overlap: Option<StyleValue<bool>>,
2211    /// Explicit icon overlap control from the style specification.
2212    pub icon_allow_overlap: Option<StyleValue<bool>>,
2213    /// Whether text may be dropped while keeping the icon.
2214    pub text_optional: Option<StyleValue<bool>>,
2215    /// Whether the icon may be dropped while keeping the text.
2216    pub icon_optional: Option<StyleValue<bool>>,
2217    /// Whether text may be placed without blocking later symbols.
2218    pub text_ignore_placement: Option<StyleValue<bool>>,
2219    /// Whether the icon may be placed without blocking later symbols.
2220    pub icon_ignore_placement: Option<StyleValue<bool>>,
2221    /// Radial text offset measured in text-size units.
2222    pub radial_offset: Option<StyleValue<f32>>,
2223    /// Explicit per-anchor offsets for variable anchor placement.
2224    pub variable_anchor_offsets: Option<Vec<(SymbolAnchor, [f32; 2])>>,
2225    /// Default anchor when variable anchors are not in use.
2226    pub anchor: SymbolAnchor,
2227    /// Requested text justification.
2228    pub justify: StyleValue<SymbolTextJustify>,
2229    /// Text transformation applied before shaping and measurement.
2230    pub transform: StyleValue<SymbolTextTransform>,
2231    /// Maximum point-label width before wrapping.
2232    pub max_width: Option<StyleValue<f32>>,
2233    /// Preferred wrapped text line height.
2234    pub line_height: Option<StyleValue<f32>>,
2235    /// Extra spacing between adjacent glyphs.
2236    pub letter_spacing: Option<StyleValue<f32>>,
2237    /// Icon sizing mode relative to text.
2238    pub icon_text_fit: StyleValue<SymbolIconTextFit>,
2239    /// Padding applied when fitting an icon around text.
2240    pub icon_text_fit_padding: [f32; 4],
2241    /// Placement priority for symbol ordering.
2242    pub sort_key: Option<StyleValue<f32>>,
2243    /// Whether symbols are placed at points or anchored along lines.
2244    pub placement: SymbolPlacement,
2245    /// Preferred spacing for repeated line-placed symbols.
2246    pub spacing: StyleValue<f32>,
2247    /// Maximum cumulative turn angle tolerated for a line-placed label.
2248    pub max_angle: StyleValue<f32>,
2249    /// Whether line-placed text should be flipped to remain upright.
2250    pub keep_upright: StyleValue<bool>,
2251    pub variable_anchors: Vec<SymbolAnchor>,
2252    pub writing_mode: SymbolWritingMode,
2253    pub offset: [f32; 2],
2254}
2255
2256impl SymbolStyleLayer {
2257    /// Create a symbol style layer bound to the given style source id.
2258    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2259        let style = VectorStyle::default();
2260        Self {
2261            meta: StyleLayerMeta::new(id),
2262            source: source.into(),
2263            source_layer: None,
2264            color: style.fill_color.into(),
2265            halo_color: style.symbol_halo_color.into(),
2266            size: style.symbol_size.into(),
2267            text_field: None,
2268            icon_image: None,
2269            font_stack: style.symbol_font_stack.into(),
2270            padding: style.symbol_padding.into(),
2271            allow_overlap: style.symbol_allow_overlap.into(),
2272            text_allow_overlap: None,
2273            icon_allow_overlap: None,
2274            text_optional: None,
2275            icon_optional: None,
2276            text_ignore_placement: None,
2277            icon_ignore_placement: None,
2278            radial_offset: None,
2279            variable_anchor_offsets: None,
2280            anchor: style.symbol_text_anchor,
2281            justify: style.symbol_text_justify.into(),
2282            transform: style.symbol_text_transform.into(),
2283            max_width: None,
2284            line_height: None,
2285            letter_spacing: None,
2286            icon_text_fit: style.symbol_icon_text_fit.into(),
2287            icon_text_fit_padding: style.symbol_icon_text_fit_padding,
2288            sort_key: None,
2289            placement: style.symbol_placement,
2290            spacing: style.symbol_spacing.into(),
2291            max_angle: style.symbol_max_angle.into(),
2292            keep_upright: style.symbol_keep_upright.into(),
2293            variable_anchors: style.symbol_anchors.clone(),
2294            writing_mode: style.symbol_writing_mode,
2295            offset: style.symbol_offset,
2296        }
2297    }
2298}
2299
2300/// Model layer style spec.
2301#[allow(missing_docs)]
2302#[derive(Debug, Clone)]
2303pub struct ModelStyleLayer {
2304    pub meta: StyleLayerMeta,
2305    pub source: StyleSourceId,
2306}
2307
2308impl ModelStyleLayer {
2309    /// Create a model style layer bound to the given style source id.
2310    pub fn new(id: impl Into<String>, source: impl Into<String>) -> Self {
2311        Self {
2312            meta: StyleLayerMeta::new(id),
2313            source: source.into(),
2314        }
2315    }
2316}
2317
2318/// Supported style-layer variants.
2319#[derive(Debug, Clone)]
2320#[allow(clippy::large_enum_variant)]
2321pub enum StyleLayer {
2322    /// Solid background style layer.
2323    Background(BackgroundStyleLayer),
2324    /// Terrain hillshade style layer.
2325    Hillshade(HillshadeStyleLayer),
2326    /// Raster imagery style layer.
2327    Raster(RasterStyleLayer),
2328    /// Generic vector style layer.
2329    Vector(VectorStyleLayer),
2330    /// Polygon fill style layer.
2331    Fill(FillStyleLayer),
2332    /// Polyline stroke style layer.
2333    Line(LineStyleLayer),
2334    /// Point circle style layer.
2335    Circle(CircleStyleLayer),
2336    /// Heatmap style layer.
2337    Heatmap(HeatmapStyleLayer),
2338    /// Extruded polygon fill style layer.
2339    FillExtrusion(FillExtrusionStyleLayer),
2340    /// Text and icon symbol style layer.
2341    Symbol(SymbolStyleLayer),
2342    /// 3D model placement style layer.
2343    Model(ModelStyleLayer),
2344}
2345
2346impl StyleLayer {
2347    /// Layer id.
2348    pub fn id(&self) -> &str {
2349        self.meta().id.as_str()
2350    }
2351
2352    /// Shared metadata.
2353    pub fn meta(&self) -> &StyleLayerMeta {
2354        match self {
2355            StyleLayer::Background(layer) => &layer.meta,
2356            StyleLayer::Hillshade(layer) => &layer.meta,
2357            StyleLayer::Raster(layer) => &layer.meta,
2358            StyleLayer::Vector(layer) => &layer.meta,
2359            StyleLayer::Fill(layer) => &layer.meta,
2360            StyleLayer::Line(layer) => &layer.meta,
2361            StyleLayer::Circle(layer) => &layer.meta,
2362            StyleLayer::Heatmap(layer) => &layer.meta,
2363            StyleLayer::FillExtrusion(layer) => &layer.meta,
2364            StyleLayer::Symbol(layer) => &layer.meta,
2365            StyleLayer::Model(layer) => &layer.meta,
2366        }
2367    }
2368
2369    /// Shared metadata, mutable.
2370    pub fn meta_mut(&mut self) -> &mut StyleLayerMeta {
2371        match self {
2372            StyleLayer::Background(layer) => &mut layer.meta,
2373            StyleLayer::Hillshade(layer) => &mut layer.meta,
2374            StyleLayer::Raster(layer) => &mut layer.meta,
2375            StyleLayer::Vector(layer) => &mut layer.meta,
2376            StyleLayer::Fill(layer) => &mut layer.meta,
2377            StyleLayer::Line(layer) => &mut layer.meta,
2378            StyleLayer::Circle(layer) => &mut layer.meta,
2379            StyleLayer::Heatmap(layer) => &mut layer.meta,
2380            StyleLayer::FillExtrusion(layer) => &mut layer.meta,
2381            StyleLayer::Symbol(layer) => &mut layer.meta,
2382            StyleLayer::Model(layer) => &mut layer.meta,
2383        }
2384    }
2385
2386    /// Evaluate into a concrete runtime layer with a context.
2387    pub fn to_runtime_layer_with_context(
2388        &self,
2389        sources: &HashMap<StyleSourceId, StyleSource>,
2390        ctx: StyleEvalContext,
2391    ) -> Result<Box<dyn Layer>, StyleError> {
2392        match self {
2393            StyleLayer::Background(layer) => Ok(Box::new(evaluate_background_layer(layer, ctx))),
2394            StyleLayer::Hillshade(layer) => Ok(Box::new(evaluate_hillshade_layer(layer, ctx))),
2395            StyleLayer::Raster(layer) => evaluate_raster_layer(layer, sources, ctx),
2396            StyleLayer::Vector(layer) => evaluate_vector_layer(layer, sources, ctx),
2397            StyleLayer::Fill(layer) => evaluate_fill_layer(layer, sources, ctx),
2398            StyleLayer::Line(layer) => evaluate_line_layer(layer, sources, ctx),
2399            StyleLayer::Circle(layer) => evaluate_circle_layer(layer, sources, ctx),
2400            StyleLayer::Heatmap(layer) => evaluate_heatmap_layer(layer, sources, ctx),
2401            StyleLayer::FillExtrusion(layer) => evaluate_fill_extrusion_layer(layer, sources, ctx),
2402            StyleLayer::Symbol(layer) => evaluate_symbol_layer(layer, sources, ctx),
2403            StyleLayer::Model(layer) => evaluate_model_layer(layer, sources, ctx),
2404        }
2405    }
2406
2407    /// Apply evaluated paint/layout state onto an existing runtime layer.
2408    pub fn apply_to_runtime_layer_with_context(
2409        &self,
2410        runtime: &mut dyn Layer,
2411        sources: &HashMap<StyleSourceId, StyleSource>,
2412        ctx: StyleEvalContext,
2413    ) -> Result<(), StyleError> {
2414        match self {
2415            StyleLayer::Background(layer) => apply_background_to_runtime(layer, runtime, ctx),
2416            StyleLayer::Hillshade(layer) => apply_hillshade_to_runtime(layer, runtime, ctx),
2417            StyleLayer::Raster(layer) => apply_raster_to_runtime(layer, runtime, sources, ctx),
2418            StyleLayer::Vector(layer) => apply_vector_to_runtime(layer, runtime, sources, ctx),
2419            StyleLayer::Fill(layer) => apply_fill_to_runtime(layer, runtime, sources, ctx),
2420            StyleLayer::Line(layer) => apply_line_to_runtime(layer, runtime, sources, ctx),
2421            StyleLayer::Circle(layer) => apply_circle_to_runtime(layer, runtime, sources, ctx),
2422            StyleLayer::Heatmap(layer) => apply_heatmap_to_runtime(layer, runtime, sources, ctx),
2423            StyleLayer::FillExtrusion(layer) => {
2424                apply_fill_extrusion_to_runtime(layer, runtime, sources, ctx)
2425            }
2426            StyleLayer::Symbol(layer) => apply_symbol_to_runtime(layer, runtime, sources, ctx),
2427            StyleLayer::Model(layer) => apply_model_to_runtime(layer, runtime, sources, ctx),
2428        }
2429    }
2430
2431    /// Return the referenced source id, if this layer is source-backed.
2432    pub fn source_id(&self) -> Option<&str> {
2433        match self {
2434            StyleLayer::Background(_) | StyleLayer::Hillshade(_) => None,
2435            StyleLayer::Raster(layer) => Some(layer.source.as_str()),
2436            StyleLayer::Vector(layer) => Some(layer.source.as_str()),
2437            StyleLayer::Fill(layer) => Some(layer.source.as_str()),
2438            StyleLayer::Line(layer) => Some(layer.source.as_str()),
2439            StyleLayer::Circle(layer) => Some(layer.source.as_str()),
2440            StyleLayer::Heatmap(layer) => Some(layer.source.as_str()),
2441            StyleLayer::FillExtrusion(layer) => Some(layer.source.as_str()),
2442            StyleLayer::Symbol(layer) => Some(layer.source.as_str()),
2443            StyleLayer::Model(layer) => Some(layer.source.as_str()),
2444        }
2445    }
2446
2447    /// Return the referenced source-layer name, if this layer targets a
2448    /// named source layer within a vector-tile-like source.
2449    pub fn source_layer(&self) -> Option<&str> {
2450        match self {
2451            StyleLayer::Vector(layer) => layer.source_layer.as_deref(),
2452            StyleLayer::Fill(layer) => layer.source_layer.as_deref(),
2453            StyleLayer::Line(layer) => layer.source_layer.as_deref(),
2454            StyleLayer::Circle(layer) => layer.source_layer.as_deref(),
2455            StyleLayer::Heatmap(layer) => layer.source_layer.as_deref(),
2456            StyleLayer::FillExtrusion(layer) => layer.source_layer.as_deref(),
2457            StyleLayer::Symbol(layer) => layer.source_layer.as_deref(),
2458            _ => None,
2459        }
2460    }
2461
2462    /// Return `true` if this layer references the given source id.
2463    pub fn uses_source(&self, source_id: &str) -> bool {
2464        self.source_id() == Some(source_id)
2465    }
2466
2467    /// Whether any paint property on this layer uses a `FeatureState` value.
2468    ///
2469    /// This lets callers skip per-feature re-evaluation for layers that only
2470    /// use constant or zoom-keyed paint properties.
2471    pub fn has_feature_state_driven_paint(&self) -> bool {
2472        match self {
2473            StyleLayer::Fill(l) => {
2474                l.fill_color.is_feature_state_driven()
2475                    || l.outline_color.is_feature_state_driven()
2476                    || l.outline_width.is_feature_state_driven()
2477            }
2478            StyleLayer::Line(l) => {
2479                l.color.is_feature_state_driven() || l.width.is_feature_state_driven()
2480            }
2481            StyleLayer::Circle(l) => {
2482                l.color.is_feature_state_driven()
2483                    || l.radius.is_feature_state_driven()
2484                    || l.stroke_color.is_feature_state_driven()
2485                    || l.stroke_width.is_feature_state_driven()
2486            }
2487            StyleLayer::Heatmap(l) => {
2488                l.color.is_feature_state_driven()
2489                    || l.radius.is_feature_state_driven()
2490                    || l.intensity.is_feature_state_driven()
2491            }
2492            StyleLayer::FillExtrusion(l) => {
2493                l.color.is_feature_state_driven()
2494                    || l.base.is_feature_state_driven()
2495                    || l.height.is_feature_state_driven()
2496            }
2497            StyleLayer::Symbol(l) => {
2498                l.color.is_feature_state_driven()
2499                    || l.halo_color.is_feature_state_driven()
2500                    || l.size.is_feature_state_driven()
2501            }
2502            StyleLayer::Vector(l) => {
2503                l.fill_color.is_feature_state_driven()
2504                    || l.stroke_color.is_feature_state_driven()
2505                    || l.stroke_width.is_feature_state_driven()
2506            }
2507            // Non-vector layers do not support feature-state-driven paint.
2508            StyleLayer::Background(_)
2509            | StyleLayer::Hillshade(_)
2510            | StyleLayer::Raster(_)
2511            | StyleLayer::Model(_) => false,
2512        }
2513    }
2514
2515    /// Resolve the layer's paint properties against a full evaluation context
2516    /// that includes per-feature mutable state.
2517    ///
2518    /// Returns `Some(VectorStyle)` for vector-backed layer families, or `None`
2519    /// for layer types that do not produce a `VectorStyle` (background,
2520    /// hillshade, raster, model).
2521    ///
2522    /// This is the primary entry point for hover/selection-driven restyling:
2523    /// the caller builds a [`StyleEvalContextFull`] with the target feature's
2524    /// current state, then receives a resolved `VectorStyle` that can be
2525    /// compared or applied to the feature's visual representation.
2526    pub fn resolve_style_with_feature_state(
2527        &self,
2528        ctx: &StyleEvalContextFull<'_>,
2529    ) -> Option<VectorStyle> {
2530        match self {
2531            StyleLayer::Fill(l) => Some(fill_style_with_state(l, ctx)),
2532            StyleLayer::Line(l) => Some(line_style_with_state(l, ctx)),
2533            StyleLayer::Circle(l) => Some(circle_style_with_state(l, ctx)),
2534            StyleLayer::Heatmap(l) => Some(heatmap_style_with_state(l, ctx)),
2535            StyleLayer::FillExtrusion(l) => Some(fill_extrusion_style_with_state(l, ctx)),
2536            StyleLayer::Symbol(l) => Some(symbol_style_with_state(l, ctx)),
2537            StyleLayer::Vector(l) => Some(vector_style_with_state(l, ctx)),
2538            // Non-vector layers do not produce a VectorStyle.
2539            StyleLayer::Background(_)
2540            | StyleLayer::Hillshade(_)
2541            | StyleLayer::Raster(_)
2542            | StyleLayer::Model(_) => None,
2543        }
2544    }
2545}
2546
2547impl StyleDocument {
2548    /// Create an empty style document.
2549    pub fn new() -> Self {
2550        Self::default()
2551    }
2552
2553    /// Register a named source.
2554    pub fn add_source(
2555        &mut self,
2556        id: impl Into<String>,
2557        source: StyleSource,
2558    ) -> Result<(), StyleError> {
2559        let id = id.into();
2560        if self.sources.contains_key(&id) {
2561            return Err(StyleError::DuplicateSourceId(id));
2562        }
2563        self.sources.insert(id, source);
2564        Ok(())
2565    }
2566
2567    /// Upsert a named source.
2568    pub fn set_source(&mut self, id: impl Into<String>, source: StyleSource) {
2569        self.sources.insert(id.into(), source);
2570    }
2571
2572    /// Remove a source, returning it if present.
2573    pub fn remove_source(&mut self, id: &str) -> Option<StyleSource> {
2574        if self.terrain_source.as_deref() == Some(id) {
2575            self.terrain_source = None;
2576        }
2577        self.sources.remove(id)
2578    }
2579
2580    /// Look up a source by id.
2581    pub fn source(&self, id: &str) -> Option<&StyleSource> {
2582        self.sources.get(id)
2583    }
2584
2585    /// Iterate registered sources.
2586    pub fn sources(&self) -> impl Iterator<Item = (&str, &StyleSource)> {
2587        self.sources
2588            .iter()
2589            .map(|(id, source)| (id.as_str(), source))
2590    }
2591
2592    /// Set the terrain source id used to configure `MapState` terrain.
2593    pub fn set_terrain_source(&mut self, source_id: Option<impl Into<String>>) {
2594        self.terrain_source = source_id.map(Into::into);
2595    }
2596
2597    /// Return the configured terrain source id, if any.
2598    pub fn terrain_source(&self) -> Option<&str> {
2599        self.terrain_source.as_deref()
2600    }
2601
2602    /// Set the top-level style projection.
2603    pub fn set_projection(&mut self, projection: StyleProjection) {
2604        self.projection = projection;
2605    }
2606
2607    /// Return the top-level style projection.
2608    pub fn projection(&self) -> StyleProjection {
2609        self.projection
2610    }
2611
2612    /// Set the fog/atmosphere configuration.
2613    pub fn set_fog(&mut self, fog: Option<FogConfig>) {
2614        self.fog = fog;
2615    }
2616
2617    /// Return the fog/atmosphere configuration, if any.
2618    pub fn fog(&self) -> Option<&FogConfig> {
2619        self.fog.as_ref()
2620    }
2621
2622    /// Set the lighting configuration.
2623    pub fn set_lights(&mut self, lights: Option<LightConfig>) {
2624        self.lights = lights;
2625    }
2626
2627    /// Return the lighting configuration, if any.
2628    pub fn lights(&self) -> Option<&LightConfig> {
2629        self.lights.as_ref()
2630    }
2631
2632    /// Set the sky / atmosphere configuration.
2633    pub fn set_sky(&mut self, sky: Option<SkyConfig>) {
2634        self.sky = sky;
2635    }
2636
2637    /// Return the sky / atmosphere configuration, if any.
2638    pub fn sky(&self) -> Option<&SkyConfig> {
2639        self.sky.as_ref()
2640    }
2641
2642    /// Set the global default transition timing.
2643    pub fn set_transition(&mut self, spec: TransitionSpec) {
2644        self.transition = spec;
2645    }
2646
2647    /// Return the global default transition timing.
2648    pub fn transition(&self) -> TransitionSpec {
2649        self.transition
2650    }
2651
2652    /// Append a style layer to the ordered layer stack.
2653    pub fn add_layer(&mut self, layer: StyleLayer) -> Result<(), StyleError> {
2654        if self
2655            .layers
2656            .iter()
2657            .any(|existing| existing.id() == layer.id())
2658        {
2659            return Err(StyleError::DuplicateLayerId(layer.id().to_owned()));
2660        }
2661        self.layers.push(layer);
2662        Ok(())
2663    }
2664
2665    /// Insert a style layer before another style layer id.
2666    pub fn insert_layer_before(
2667        &mut self,
2668        before_id: &str,
2669        layer: StyleLayer,
2670    ) -> Result<(), StyleError> {
2671        if self
2672            .layers
2673            .iter()
2674            .any(|existing| existing.id() == layer.id())
2675        {
2676            return Err(StyleError::DuplicateLayerId(layer.id().to_owned()));
2677        }
2678        if let Some(index) = self.layer_index(before_id) {
2679            self.layers.insert(index, layer);
2680        } else {
2681            self.layers.push(layer);
2682        }
2683        Ok(())
2684    }
2685
2686    /// Move an existing style layer before another style layer.
2687    pub fn move_layer_before(&mut self, layer_id: &str, before_id: &str) -> bool {
2688        let Some(from) = self.layer_index(layer_id) else {
2689            return false;
2690        };
2691        let layer = self.layers.remove(from);
2692        let to = self.layer_index(before_id).unwrap_or(self.layers.len());
2693        self.layers.insert(to, layer);
2694        true
2695    }
2696
2697    /// Remove a style layer by id.
2698    pub fn remove_layer(&mut self, layer_id: &str) -> Option<StyleLayer> {
2699        self.layer_index(layer_id)
2700            .map(|index| self.layers.remove(index))
2701    }
2702
2703    /// Get a style layer by id.
2704    pub fn layer(&self, layer_id: &str) -> Option<&StyleLayer> {
2705        self.layers.iter().find(|layer| layer.id() == layer_id)
2706    }
2707
2708    /// Get a mutable style layer by id.
2709    pub fn layer_mut(&mut self, layer_id: &str) -> Option<&mut StyleLayer> {
2710        self.layers.iter_mut().find(|layer| layer.id() == layer_id)
2711    }
2712
2713    /// Iterate style layers in render order.
2714    pub fn layers(&self) -> &[StyleLayer] {
2715        &self.layers
2716    }
2717
2718    /// Evaluate the style document to a concrete runtime layer stack.
2719    pub fn to_runtime_layers(&self) -> Result<Vec<Box<dyn Layer>>, StyleError> {
2720        self.to_runtime_layers_with_context(StyleEvalContext::default())
2721    }
2722
2723    /// Evaluate the style document to a concrete runtime layer stack using a context.
2724    pub fn to_runtime_layers_with_context(
2725        &self,
2726        ctx: StyleEvalContext,
2727    ) -> Result<Vec<Box<dyn Layer>>, StyleError> {
2728        self.layers
2729            .iter()
2730            .map(|layer| layer.to_runtime_layer_with_context(&self.sources, ctx))
2731            .collect()
2732    }
2733
2734    /// Evaluate the configured terrain source to a concrete terrain config.
2735    pub fn to_terrain_config(&self) -> Result<Option<(TerrainConfig, usize)>, StyleError> {
2736        let Some(source_id) = self.terrain_source.as_deref() else {
2737            return Ok(None);
2738        };
2739        let Some(source) = self.sources.get(source_id) else {
2740            return Err(StyleError::MissingSource(source_id.to_owned()));
2741        };
2742        match source {
2743            StyleSource::Terrain(terrain) => {
2744                Ok(Some((terrain.to_terrain_config(), terrain.cache_capacity)))
2745            }
2746            other => Err(StyleError::SourceKindMismatch {
2747                layer_id: "<terrain>".to_owned(),
2748                source_id: source_id.to_owned(),
2749                expected: "terrain",
2750                actual: other.kind_name(),
2751            }),
2752        }
2753    }
2754
2755    #[allow(dead_code)]
2756    pub(crate) fn apply_runtime_layers_with_context(
2757        &self,
2758        layers: &mut crate::layers::LayerStack,
2759        ctx: StyleEvalContext,
2760    ) -> Result<(), StyleError> {
2761        if layers.len() != self.layers.len() {
2762            return Ok(());
2763        }
2764        for (style_layer, runtime_layer) in self.layers.iter().zip(layers.iter_mut()) {
2765            style_layer.apply_to_runtime_layer_with_context(
2766                runtime_layer.as_mut(),
2767                &self.sources,
2768                ctx,
2769            )?;
2770        }
2771        Ok(())
2772    }
2773
2774    fn layer_index(&self, layer_id: &str) -> Option<usize> {
2775        self.layers.iter().position(|layer| layer.id() == layer_id)
2776    }
2777
2778    /// Return `true` if any style layer or terrain configuration uses the
2779    /// given source id.
2780    pub fn source_is_used(&self, source_id: &str) -> bool {
2781        self.terrain_source.as_deref() == Some(source_id)
2782            || self.layers.iter().any(|layer| layer.uses_source(source_id))
2783    }
2784
2785    /// Return the ordered list of style layer ids that reference the given
2786    /// source id.
2787    pub fn layer_ids_using_source(&self, source_id: &str) -> Vec<&str> {
2788        self.layers
2789            .iter()
2790            .filter(|layer| layer.uses_source(source_id))
2791            .map(|layer| layer.id())
2792            .collect()
2793    }
2794}
2795
2796fn evaluate_background_layer(
2797    layer: &BackgroundStyleLayer,
2798    ctx: StyleEvalContext,
2799) -> BackgroundLayer {
2800    let mut runtime = BackgroundLayer::new(
2801        layer.meta.name.clone(),
2802        layer.color.evaluate_with_context(ctx),
2803    );
2804    apply_shared_meta(&mut runtime, &layer.meta, ctx);
2805    runtime
2806}
2807
2808fn evaluate_hillshade_layer(layer: &HillshadeStyleLayer, ctx: StyleEvalContext) -> HillshadeLayer {
2809    let mut runtime = HillshadeLayer::new(layer.meta.name.clone());
2810    apply_shared_meta(&mut runtime, &layer.meta, ctx);
2811    runtime.set_highlight_color(layer.highlight_color.evaluate_with_context(ctx));
2812    runtime.set_shadow_color(layer.shadow_color.evaluate_with_context(ctx));
2813    runtime.set_accent_color(layer.accent_color.evaluate_with_context(ctx));
2814    runtime.set_illumination_direction_deg(
2815        layer.illumination_direction_deg.evaluate_with_context(ctx),
2816    );
2817    runtime
2818        .set_illumination_altitude_deg(layer.illumination_altitude_deg.evaluate_with_context(ctx));
2819    runtime.set_exaggeration(layer.exaggeration.evaluate_with_context(ctx));
2820    runtime
2821}
2822
2823fn evaluate_raster_layer(
2824    layer: &RasterStyleLayer,
2825    sources: &HashMap<StyleSourceId, StyleSource>,
2826    ctx: StyleEvalContext,
2827) -> Result<Box<dyn Layer>, StyleError> {
2828    // Video/canvas sources produce a DynamicImageOverlayLayer instead of
2829    // a raster tile layer.
2830    if let Some(result) =
2831        try_dynamic_overlay_from_source(&layer.meta.name, &layer.source, sources, ctx)
2832    {
2833        return result.map(|mut runtime| {
2834            apply_shared_meta(runtime.as_mut(), &layer.meta, ctx);
2835            runtime
2836        });
2837    }
2838
2839    let (factory, cache_capacity, selection) =
2840        require_raster_source(&layer.meta.id, &layer.source, sources)?;
2841    let mut runtime = TileLayer::new_with_selection_config(
2842        layer.meta.name.clone(),
2843        (factory)(),
2844        cache_capacity,
2845        selection.clone(),
2846    );
2847    apply_shared_meta(&mut runtime, &layer.meta, ctx);
2848    Ok(Box::new(runtime))
2849}
2850
2851fn evaluate_vector_runtime_layer(
2852    meta: &StyleLayerMeta,
2853    source_id: &str,
2854    source_layer: Option<&str>,
2855    layer_id: &str,
2856    style: VectorStyle,
2857    sources: &HashMap<StyleSourceId, StyleSource>,
2858    ctx: StyleEvalContext,
2859) -> Result<Box<dyn Layer>, StyleError> {
2860    let features = match sources.get(source_id) {
2861        Some(StyleSource::VectorTile(source)) if source.is_streamed() => {
2862            FeatureCollection::default()
2863        }
2864        _ => require_vector_source(layer_id, source_id, source_layer, sources, ctx.zoom as u8)?
2865            .into_owned(),
2866    };
2867    let mut runtime = VectorLayer::new(meta.name.clone(), features, style)
2868        .with_query_metadata(Some(layer_id.to_owned()), Some(source_id.to_owned()))
2869        .with_source_layer(source_layer.map(str::to_owned));
2870    apply_shared_meta(&mut runtime, meta, ctx);
2871    Ok(Box::new(runtime))
2872}
2873
2874fn evaluate_vector_layer(
2875    layer: &VectorStyleLayer,
2876    sources: &HashMap<StyleSourceId, StyleSource>,
2877    ctx: StyleEvalContext,
2878) -> Result<Box<dyn Layer>, StyleError> {
2879    evaluate_vector_runtime_layer(
2880        &layer.meta,
2881        &layer.source,
2882        layer.source_layer.as_deref(),
2883        &layer.meta.id,
2884        vector_style_from_vector_layer(layer, ctx),
2885        sources,
2886        ctx,
2887    )
2888}
2889
2890fn evaluate_fill_layer(
2891    layer: &FillStyleLayer,
2892    sources: &HashMap<StyleSourceId, StyleSource>,
2893    ctx: StyleEvalContext,
2894) -> Result<Box<dyn Layer>, StyleError> {
2895    evaluate_vector_runtime_layer(
2896        &layer.meta,
2897        &layer.source,
2898        layer.source_layer.as_deref(),
2899        &layer.meta.id,
2900        vector_style_from_fill_layer(layer, ctx),
2901        sources,
2902        ctx,
2903    )
2904}
2905
2906fn evaluate_line_layer(
2907    layer: &LineStyleLayer,
2908    sources: &HashMap<StyleSourceId, StyleSource>,
2909    ctx: StyleEvalContext,
2910) -> Result<Box<dyn Layer>, StyleError> {
2911    evaluate_vector_runtime_layer(
2912        &layer.meta,
2913        &layer.source,
2914        layer.source_layer.as_deref(),
2915        &layer.meta.id,
2916        vector_style_from_line_layer(layer, ctx),
2917        sources,
2918        ctx,
2919    )
2920}
2921
2922fn evaluate_circle_layer(
2923    layer: &CircleStyleLayer,
2924    sources: &HashMap<StyleSourceId, StyleSource>,
2925    ctx: StyleEvalContext,
2926) -> Result<Box<dyn Layer>, StyleError> {
2927    evaluate_vector_runtime_layer(
2928        &layer.meta,
2929        &layer.source,
2930        layer.source_layer.as_deref(),
2931        &layer.meta.id,
2932        vector_style_from_circle_layer(layer, ctx),
2933        sources,
2934        ctx,
2935    )
2936}
2937
2938fn evaluate_heatmap_layer(
2939    layer: &HeatmapStyleLayer,
2940    sources: &HashMap<StyleSourceId, StyleSource>,
2941    ctx: StyleEvalContext,
2942) -> Result<Box<dyn Layer>, StyleError> {
2943    evaluate_vector_runtime_layer(
2944        &layer.meta,
2945        &layer.source,
2946        layer.source_layer.as_deref(),
2947        &layer.meta.id,
2948        vector_style_from_heatmap_layer(layer, ctx),
2949        sources,
2950        ctx,
2951    )
2952}
2953
2954fn evaluate_fill_extrusion_layer(
2955    layer: &FillExtrusionStyleLayer,
2956    sources: &HashMap<StyleSourceId, StyleSource>,
2957    ctx: StyleEvalContext,
2958) -> Result<Box<dyn Layer>, StyleError> {
2959    evaluate_vector_runtime_layer(
2960        &layer.meta,
2961        &layer.source,
2962        layer.source_layer.as_deref(),
2963        &layer.meta.id,
2964        vector_style_from_fill_extrusion_layer(layer, ctx),
2965        sources,
2966        ctx,
2967    )
2968}
2969
2970fn evaluate_symbol_layer(
2971    layer: &SymbolStyleLayer,
2972    sources: &HashMap<StyleSourceId, StyleSource>,
2973    ctx: StyleEvalContext,
2974) -> Result<Box<dyn Layer>, StyleError> {
2975    evaluate_vector_runtime_layer(
2976        &layer.meta,
2977        &layer.source,
2978        layer.source_layer.as_deref(),
2979        &layer.meta.id,
2980        vector_style_from_symbol_layer(layer, ctx),
2981        sources,
2982        ctx,
2983    )
2984}
2985
2986fn evaluate_model_layer(
2987    layer: &ModelStyleLayer,
2988    sources: &HashMap<StyleSourceId, StyleSource>,
2989    ctx: StyleEvalContext,
2990) -> Result<Box<dyn Layer>, StyleError> {
2991    let model = require_model_source(&layer.meta.id, &layer.source, sources)?;
2992    let mut runtime = ModelLayer::new(layer.meta.name.clone())
2993        .with_query_metadata(Some(layer.meta.id.clone()), Some(layer.source.clone()));
2994    apply_shared_meta(&mut runtime, &layer.meta, ctx);
2995    runtime.instances.extend(model.instances.iter().cloned());
2996    Ok(Box::new(runtime))
2997}
2998
2999fn apply_background_to_runtime(
3000    layer: &BackgroundStyleLayer,
3001    runtime: &mut dyn Layer,
3002    ctx: StyleEvalContext,
3003) -> Result<(), StyleError> {
3004    let background = runtime
3005        .as_any_mut()
3006        .downcast_mut::<BackgroundLayer>()
3007        .expect("style/runtime layer mismatch: expected BackgroundLayer");
3008    apply_shared_meta(background, &layer.meta, ctx);
3009    background.set_color(layer.color.evaluate_with_context(ctx));
3010    Ok(())
3011}
3012
3013fn apply_hillshade_to_runtime(
3014    layer: &HillshadeStyleLayer,
3015    runtime: &mut dyn Layer,
3016    ctx: StyleEvalContext,
3017) -> Result<(), StyleError> {
3018    let hillshade = runtime
3019        .as_any_mut()
3020        .downcast_mut::<HillshadeLayer>()
3021        .expect("style/runtime layer mismatch: expected HillshadeLayer");
3022    apply_shared_meta(hillshade, &layer.meta, ctx);
3023    hillshade.set_highlight_color(layer.highlight_color.evaluate_with_context(ctx));
3024    hillshade.set_shadow_color(layer.shadow_color.evaluate_with_context(ctx));
3025    hillshade.set_accent_color(layer.accent_color.evaluate_with_context(ctx));
3026    hillshade.set_illumination_direction_deg(
3027        layer.illumination_direction_deg.evaluate_with_context(ctx),
3028    );
3029    hillshade
3030        .set_illumination_altitude_deg(layer.illumination_altitude_deg.evaluate_with_context(ctx));
3031    hillshade.set_exaggeration(layer.exaggeration.evaluate_with_context(ctx));
3032    Ok(())
3033}
3034
3035fn apply_raster_to_runtime(
3036    layer: &RasterStyleLayer,
3037    runtime: &mut dyn Layer,
3038    sources: &HashMap<StyleSourceId, StyleSource>,
3039    ctx: StyleEvalContext,
3040) -> Result<(), StyleError> {
3041    let _ = require_raster_source(&layer.meta.id, &layer.source, sources)?;
3042    let tile = runtime
3043        .as_any_mut()
3044        .downcast_mut::<TileLayer>()
3045        .expect("style/runtime layer mismatch: expected TileLayer");
3046    apply_shared_meta(tile, &layer.meta, ctx);
3047    Ok(())
3048}
3049
3050#[allow(clippy::too_many_arguments)]
3051fn apply_vector_style_to_runtime(
3052    runtime: &mut dyn Layer,
3053    meta: &StyleLayerMeta,
3054    source_id: &str,
3055    source_layer: Option<&str>,
3056    layer_id: &str,
3057    style: VectorStyle,
3058    sources: &HashMap<StyleSourceId, StyleSource>,
3059    ctx: StyleEvalContext,
3060) -> Result<(), StyleError> {
3061    let vector = require_vector_source(layer_id, source_id, source_layer, sources, ctx.zoom as u8)?;
3062    let layer = runtime
3063        .as_any_mut()
3064        .downcast_mut::<VectorLayer>()
3065        .expect("style/runtime layer mismatch: expected VectorLayer");
3066    apply_shared_meta(layer, meta, ctx);
3067    layer.style = style;
3068    layer.set_query_metadata(Some(layer_id.to_owned()), Some(source_id.to_owned()));
3069    if layer.features.len() != vector.len() {
3070        layer.features = vector.into_owned();
3071    }
3072    Ok(())
3073}
3074
3075fn apply_vector_to_runtime(
3076    layer: &VectorStyleLayer,
3077    runtime: &mut dyn Layer,
3078    sources: &HashMap<StyleSourceId, StyleSource>,
3079    ctx: StyleEvalContext,
3080) -> Result<(), StyleError> {
3081    apply_vector_style_to_runtime(
3082        runtime,
3083        &layer.meta,
3084        &layer.source,
3085        layer.source_layer.as_deref(),
3086        &layer.meta.id,
3087        vector_style_from_vector_layer(layer, ctx),
3088        sources,
3089        ctx,
3090    )
3091}
3092
3093fn apply_fill_to_runtime(
3094    layer: &FillStyleLayer,
3095    runtime: &mut dyn Layer,
3096    sources: &HashMap<StyleSourceId, StyleSource>,
3097    ctx: StyleEvalContext,
3098) -> Result<(), StyleError> {
3099    apply_vector_style_to_runtime(
3100        runtime,
3101        &layer.meta,
3102        &layer.source,
3103        layer.source_layer.as_deref(),
3104        &layer.meta.id,
3105        vector_style_from_fill_layer(layer, ctx),
3106        sources,
3107        ctx,
3108    )
3109}
3110
3111fn apply_line_to_runtime(
3112    layer: &LineStyleLayer,
3113    runtime: &mut dyn Layer,
3114    sources: &HashMap<StyleSourceId, StyleSource>,
3115    ctx: StyleEvalContext,
3116) -> Result<(), StyleError> {
3117    apply_vector_style_to_runtime(
3118        runtime,
3119        &layer.meta,
3120        &layer.source,
3121        layer.source_layer.as_deref(),
3122        &layer.meta.id,
3123        vector_style_from_line_layer(layer, ctx),
3124        sources,
3125        ctx,
3126    )
3127}
3128
3129fn apply_circle_to_runtime(
3130    layer: &CircleStyleLayer,
3131    runtime: &mut dyn Layer,
3132    sources: &HashMap<StyleSourceId, StyleSource>,
3133    ctx: StyleEvalContext,
3134) -> Result<(), StyleError> {
3135    apply_vector_style_to_runtime(
3136        runtime,
3137        &layer.meta,
3138        &layer.source,
3139        layer.source_layer.as_deref(),
3140        &layer.meta.id,
3141        vector_style_from_circle_layer(layer, ctx),
3142        sources,
3143        ctx,
3144    )
3145}
3146
3147fn apply_heatmap_to_runtime(
3148    layer: &HeatmapStyleLayer,
3149    runtime: &mut dyn Layer,
3150    sources: &HashMap<StyleSourceId, StyleSource>,
3151    ctx: StyleEvalContext,
3152) -> Result<(), StyleError> {
3153    apply_vector_style_to_runtime(
3154        runtime,
3155        &layer.meta,
3156        &layer.source,
3157        layer.source_layer.as_deref(),
3158        &layer.meta.id,
3159        vector_style_from_heatmap_layer(layer, ctx),
3160        sources,
3161        ctx,
3162    )
3163}
3164
3165fn apply_fill_extrusion_to_runtime(
3166    layer: &FillExtrusionStyleLayer,
3167    runtime: &mut dyn Layer,
3168    sources: &HashMap<StyleSourceId, StyleSource>,
3169    ctx: StyleEvalContext,
3170) -> Result<(), StyleError> {
3171    apply_vector_style_to_runtime(
3172        runtime,
3173        &layer.meta,
3174        &layer.source,
3175        layer.source_layer.as_deref(),
3176        &layer.meta.id,
3177        vector_style_from_fill_extrusion_layer(layer, ctx),
3178        sources,
3179        ctx,
3180    )
3181}
3182
3183fn apply_symbol_to_runtime(
3184    layer: &SymbolStyleLayer,
3185    runtime: &mut dyn Layer,
3186    sources: &HashMap<StyleSourceId, StyleSource>,
3187    ctx: StyleEvalContext,
3188) -> Result<(), StyleError> {
3189    apply_vector_style_to_runtime(
3190        runtime,
3191        &layer.meta,
3192        &layer.source,
3193        layer.source_layer.as_deref(),
3194        &layer.meta.id,
3195        vector_style_from_symbol_layer(layer, ctx),
3196        sources,
3197        ctx,
3198    )
3199}
3200
3201fn apply_model_to_runtime(
3202    layer: &ModelStyleLayer,
3203    runtime: &mut dyn Layer,
3204    sources: &HashMap<StyleSourceId, StyleSource>,
3205    ctx: StyleEvalContext,
3206) -> Result<(), StyleError> {
3207    let model = require_model_source(&layer.meta.id, &layer.source, sources)?;
3208    let runtime = runtime
3209        .as_any_mut()
3210        .downcast_mut::<ModelLayer>()
3211        .expect("style/runtime layer mismatch: expected ModelLayer");
3212    apply_shared_meta(runtime, &layer.meta, ctx);
3213    runtime.set_query_metadata(Some(layer.meta.id.clone()), Some(layer.source.clone()));
3214    runtime.instances.clear();
3215    runtime.instances.extend(model.instances.iter().cloned());
3216    Ok(())
3217}
3218
3219fn vector_style_from_vector_layer(layer: &VectorStyleLayer, ctx: StyleEvalContext) -> VectorStyle {
3220    VectorStyle {
3221        render_mode: VectorRenderMode::Generic,
3222        fill_color: layer.fill_color.evaluate_with_context(ctx),
3223        stroke_color: layer.stroke_color.evaluate_with_context(ctx),
3224        stroke_width: layer.stroke_width.evaluate_with_context(ctx),
3225        ..VectorStyle::default()
3226    }
3227}
3228
3229fn vector_style_from_fill_layer(layer: &FillStyleLayer, ctx: StyleEvalContext) -> VectorStyle {
3230    let mut style = VectorStyle::fill(
3231        layer.fill_color.evaluate_with_context(ctx),
3232        layer.outline_color.evaluate_with_context(ctx),
3233        layer.outline_width.evaluate_with_context(ctx),
3234    );
3235    style.fill_pattern = layer.fill_pattern.clone();
3236    style
3237}
3238
3239fn vector_style_from_line_layer(layer: &LineStyleLayer, ctx: StyleEvalContext) -> VectorStyle {
3240    let mut style = VectorStyle::line_styled(
3241        layer.color.evaluate_with_context(ctx),
3242        layer.width.evaluate_with_context(ctx),
3243        layer.line_cap,
3244        layer.line_join,
3245        layer.miter_limit,
3246        layer.dash_array.clone(),
3247    );
3248    // Carry the original expressions so tessellation can evaluate per feature.
3249    if layer.width.is_data_driven() {
3250        style.width_expr = Some(layer.width.clone());
3251    }
3252    if layer.color.is_data_driven() {
3253        style.stroke_color_expr = Some(layer.color.clone());
3254    }
3255    style.eval_zoom = ctx.zoom;
3256    style.line_gradient = layer.line_gradient.clone();
3257    style.line_pattern = layer.line_pattern.clone();
3258    style
3259}
3260
3261fn vector_style_from_circle_layer(layer: &CircleStyleLayer, ctx: StyleEvalContext) -> VectorStyle {
3262    VectorStyle::circle(
3263        layer.color.evaluate_with_context(ctx),
3264        layer.radius.evaluate_with_context(ctx),
3265        layer.stroke_color.evaluate_with_context(ctx),
3266        layer.stroke_width.evaluate_with_context(ctx),
3267    )
3268}
3269
3270fn vector_style_from_heatmap_layer(
3271    layer: &HeatmapStyleLayer,
3272    ctx: StyleEvalContext,
3273) -> VectorStyle {
3274    VectorStyle::heatmap(
3275        layer.color.evaluate_with_context(ctx),
3276        layer.radius.evaluate_with_context(ctx),
3277        layer.intensity.evaluate_with_context(ctx),
3278    )
3279}
3280
3281fn vector_style_from_fill_extrusion_layer(
3282    layer: &FillExtrusionStyleLayer,
3283    ctx: StyleEvalContext,
3284) -> VectorStyle {
3285    VectorStyle::fill_extrusion(
3286        layer.color.evaluate_with_context(ctx),
3287        layer.base.evaluate_with_context(ctx),
3288        layer.height.evaluate_with_context(ctx),
3289    )
3290}
3291
3292fn vector_style_from_symbol_layer(layer: &SymbolStyleLayer, ctx: StyleEvalContext) -> VectorStyle {
3293    let mut style = VectorStyle::symbol(
3294        layer.color.evaluate_with_context(ctx),
3295        layer.halo_color.evaluate_with_context(ctx),
3296        layer.size.evaluate_with_context(ctx),
3297    );
3298    style.symbol_text_field = layer
3299        .text_field
3300        .as_ref()
3301        .map(|value| value.evaluate_with_context(ctx));
3302    style.symbol_icon_image = layer
3303        .icon_image
3304        .as_ref()
3305        .map(|value| value.evaluate_with_context(ctx));
3306    style.symbol_font_stack = layer.font_stack.evaluate_with_context(ctx);
3307    style.symbol_padding = layer.padding.evaluate_with_context(ctx);
3308    let shared_overlap = layer.allow_overlap.evaluate_with_context(ctx);
3309    style.symbol_allow_overlap = shared_overlap;
3310    style.symbol_text_allow_overlap = layer
3311        .text_allow_overlap
3312        .as_ref()
3313        .map(|value| value.evaluate_with_context(ctx))
3314        .unwrap_or(shared_overlap);
3315    style.symbol_icon_allow_overlap = layer
3316        .icon_allow_overlap
3317        .as_ref()
3318        .map(|value| value.evaluate_with_context(ctx))
3319        .unwrap_or(shared_overlap);
3320    style.symbol_text_optional = layer
3321        .text_optional
3322        .as_ref()
3323        .map(|value| value.evaluate_with_context(ctx))
3324        .unwrap_or(false);
3325    style.symbol_icon_optional = layer
3326        .icon_optional
3327        .as_ref()
3328        .map(|value| value.evaluate_with_context(ctx))
3329        .unwrap_or(false);
3330    style.symbol_text_ignore_placement = layer
3331        .text_ignore_placement
3332        .as_ref()
3333        .map(|value| value.evaluate_with_context(ctx))
3334        .unwrap_or(false);
3335    style.symbol_icon_ignore_placement = layer
3336        .icon_ignore_placement
3337        .as_ref()
3338        .map(|value| value.evaluate_with_context(ctx))
3339        .unwrap_or(false);
3340    style.symbol_text_radial_offset = layer
3341        .radial_offset
3342        .as_ref()
3343        .map(|value| value.evaluate_with_context(ctx));
3344    style.symbol_variable_anchor_offsets = layer.variable_anchor_offsets.clone();
3345    style.symbol_text_anchor = layer.anchor;
3346    style.symbol_text_justify =
3347        effective_symbol_text_justify(layer.justify.evaluate_with_context(ctx), layer.anchor);
3348    style.symbol_text_transform = layer.transform.evaluate_with_context(ctx);
3349    style.symbol_text_max_width = layer
3350        .max_width
3351        .as_ref()
3352        .map(|value| value.evaluate_with_context(ctx));
3353    style.symbol_text_line_height = layer
3354        .line_height
3355        .as_ref()
3356        .map(|value| value.evaluate_with_context(ctx));
3357    style.symbol_text_letter_spacing = layer
3358        .letter_spacing
3359        .as_ref()
3360        .map(|value| value.evaluate_with_context(ctx));
3361    style.symbol_icon_text_fit = layer.icon_text_fit.evaluate_with_context(ctx);
3362    style.symbol_icon_text_fit_padding = layer.icon_text_fit_padding;
3363    style.symbol_sort_key = layer
3364        .sort_key
3365        .as_ref()
3366        .map(|value| value.evaluate_with_context(ctx));
3367    style.symbol_placement = layer.placement;
3368    style.symbol_spacing = layer.spacing.evaluate_with_context(ctx);
3369    style.symbol_max_angle = layer.max_angle.evaluate_with_context(ctx);
3370    style.symbol_keep_upright = layer.keep_upright.evaluate_with_context(ctx);
3371    style.symbol_anchors = effective_symbol_anchor_order(
3372        layer.anchor,
3373        &layer.variable_anchors,
3374        layer.variable_anchor_offsets.as_deref(),
3375    );
3376    style.symbol_writing_mode = layer.writing_mode;
3377    style.symbol_offset = layer.offset;
3378    style
3379}
3380
3381fn effective_symbol_anchor_order(
3382    anchor: SymbolAnchor,
3383    variable_anchors: &[SymbolAnchor],
3384    variable_anchor_offsets: Option<&[(SymbolAnchor, [f32; 2])]>,
3385) -> Vec<SymbolAnchor> {
3386    if let Some(anchor_offsets) = variable_anchor_offsets {
3387        return anchor_offsets.iter().map(|(anchor, _)| *anchor).collect();
3388    }
3389    if variable_anchors.is_empty() {
3390        vec![anchor]
3391    } else {
3392        variable_anchors.to_vec()
3393    }
3394}
3395
3396// -------------------------------------------------------------------------
3397// Full-context variants for feature-state-driven paint resolution.
3398//
3399// Each function mirrors its `vector_style_from_*` counterpart but accepts a
3400// `StyleEvalContextFull` and calls `evaluate_with_full_context` so that
3401// `StyleValue::FeatureState` variants resolve against the per-feature state
3402// map instead of always returning the fallback.
3403//
3404// These are used by the public `resolve_style_with_feature_state` entry
3405// point when the engine needs to re-evaluate a layer's paint properties for
3406// a single feature whose state changed (e.g. hover on / hover off).
3407// -------------------------------------------------------------------------
3408
3409/// Resolve a [`FillStyleLayer`]'s paint properties with per-feature state.
3410pub fn fill_style_with_state(
3411    layer: &FillStyleLayer,
3412    ctx: &StyleEvalContextFull<'_>,
3413) -> VectorStyle {
3414    let mut style = VectorStyle::fill(
3415        layer.fill_color.evaluate_with_full_context(ctx),
3416        layer.outline_color.evaluate_with_full_context(ctx),
3417        layer.outline_width.evaluate_with_full_context(ctx),
3418    );
3419    style.fill_pattern = layer.fill_pattern.clone();
3420    style
3421}
3422
3423/// Resolve a [`LineStyleLayer`]'s paint properties with per-feature state.
3424pub fn line_style_with_state(
3425    layer: &LineStyleLayer,
3426    ctx: &StyleEvalContextFull<'_>,
3427) -> VectorStyle {
3428    let mut style = VectorStyle::line_styled(
3429        layer.color.evaluate_with_full_context(ctx),
3430        layer.width.evaluate_with_full_context(ctx),
3431        layer.line_cap,
3432        layer.line_join,
3433        layer.miter_limit,
3434        layer.dash_array.clone(),
3435    );
3436    style.line_gradient = layer.line_gradient.clone();
3437    style.line_pattern = layer.line_pattern.clone();
3438    style
3439}
3440
3441/// Resolve a [`CircleStyleLayer`]'s paint properties with per-feature state.
3442pub fn circle_style_with_state(
3443    layer: &CircleStyleLayer,
3444    ctx: &StyleEvalContextFull<'_>,
3445) -> VectorStyle {
3446    VectorStyle::circle(
3447        layer.color.evaluate_with_full_context(ctx),
3448        layer.radius.evaluate_with_full_context(ctx),
3449        layer.stroke_color.evaluate_with_full_context(ctx),
3450        layer.stroke_width.evaluate_with_full_context(ctx),
3451    )
3452}
3453
3454/// Resolve a [`HeatmapStyleLayer`]'s paint properties with per-feature state.
3455pub fn heatmap_style_with_state(
3456    layer: &HeatmapStyleLayer,
3457    ctx: &StyleEvalContextFull<'_>,
3458) -> VectorStyle {
3459    VectorStyle::heatmap(
3460        layer.color.evaluate_with_full_context(ctx),
3461        layer.radius.evaluate_with_full_context(ctx),
3462        layer.intensity.evaluate_with_full_context(ctx),
3463    )
3464}
3465
3466/// Resolve a [`FillExtrusionStyleLayer`]'s paint properties with per-feature state.
3467pub fn fill_extrusion_style_with_state(
3468    layer: &FillExtrusionStyleLayer,
3469    ctx: &StyleEvalContextFull<'_>,
3470) -> VectorStyle {
3471    VectorStyle::fill_extrusion(
3472        layer.color.evaluate_with_full_context(ctx),
3473        layer.base.evaluate_with_full_context(ctx),
3474        layer.height.evaluate_with_full_context(ctx),
3475    )
3476}
3477
3478/// Resolve a [`VectorStyleLayer`]'s paint properties with per-feature state.
3479pub fn vector_style_with_state(
3480    layer: &VectorStyleLayer,
3481    ctx: &StyleEvalContextFull<'_>,
3482) -> VectorStyle {
3483    VectorStyle {
3484        render_mode: VectorRenderMode::Generic,
3485        fill_color: layer.fill_color.evaluate_with_full_context(ctx),
3486        stroke_color: layer.stroke_color.evaluate_with_full_context(ctx),
3487        stroke_width: layer.stroke_width.evaluate_with_full_context(ctx),
3488        ..VectorStyle::default()
3489    }
3490}
3491
3492/// Resolve a [`SymbolStyleLayer`]'s paint properties with per-feature state.
3493///
3494/// Layout properties that do not have `FeatureState` variants (anchors,
3495/// placement mode, writing mode, etc.) resolve identically to the zoom-only
3496/// path and are included for completeness.
3497pub fn symbol_style_with_state(
3498    layer: &SymbolStyleLayer,
3499    ctx: &StyleEvalContextFull<'_>,
3500) -> VectorStyle {
3501    let base = ctx.to_base();
3502    let mut style = VectorStyle::symbol(
3503        layer.color.evaluate_with_full_context(ctx),
3504        layer.halo_color.evaluate_with_full_context(ctx),
3505        layer.size.evaluate_with_full_context(ctx),
3506    );
3507    // Text / icon fields -- use full context so feature-state can toggle
3508    // text-field or icon-image dynamically if desired.
3509    style.symbol_text_field = layer
3510        .text_field
3511        .as_ref()
3512        .map(|v| v.evaluate_with_full_context(ctx));
3513    style.symbol_icon_image = layer
3514        .icon_image
3515        .as_ref()
3516        .map(|v| v.evaluate_with_full_context(ctx));
3517    style.symbol_font_stack = layer.font_stack.evaluate_with_full_context(ctx);
3518    style.symbol_padding = layer.padding.evaluate_with_full_context(ctx);
3519    let shared_overlap = layer.allow_overlap.evaluate_with_full_context(ctx);
3520    style.symbol_allow_overlap = shared_overlap;
3521    style.symbol_text_allow_overlap = layer
3522        .text_allow_overlap
3523        .as_ref()
3524        .map(|v| v.evaluate_with_full_context(ctx))
3525        .unwrap_or(shared_overlap);
3526    style.symbol_icon_allow_overlap = layer
3527        .icon_allow_overlap
3528        .as_ref()
3529        .map(|v| v.evaluate_with_full_context(ctx))
3530        .unwrap_or(shared_overlap);
3531    style.symbol_text_optional = layer
3532        .text_optional
3533        .as_ref()
3534        .map(|v| v.evaluate_with_full_context(ctx))
3535        .unwrap_or(false);
3536    style.symbol_icon_optional = layer
3537        .icon_optional
3538        .as_ref()
3539        .map(|v| v.evaluate_with_full_context(ctx))
3540        .unwrap_or(false);
3541    style.symbol_text_ignore_placement = layer
3542        .text_ignore_placement
3543        .as_ref()
3544        .map(|v| v.evaluate_with_full_context(ctx))
3545        .unwrap_or(false);
3546    style.symbol_icon_ignore_placement = layer
3547        .icon_ignore_placement
3548        .as_ref()
3549        .map(|v| v.evaluate_with_full_context(ctx))
3550        .unwrap_or(false);
3551    style.symbol_text_radial_offset = layer
3552        .radial_offset
3553        .as_ref()
3554        .map(|v| v.evaluate_with_full_context(ctx));
3555    style.symbol_variable_anchor_offsets = layer.variable_anchor_offsets.clone();
3556    style.symbol_text_anchor = layer.anchor;
3557    style.symbol_text_justify =
3558        effective_symbol_text_justify(layer.justify.evaluate_with_context(base), layer.anchor);
3559    style.symbol_text_transform = layer.transform.evaluate_with_full_context(ctx);
3560    style.symbol_text_max_width = layer
3561        .max_width
3562        .as_ref()
3563        .map(|v| v.evaluate_with_full_context(ctx));
3564    style.symbol_text_line_height = layer
3565        .line_height
3566        .as_ref()
3567        .map(|v| v.evaluate_with_full_context(ctx));
3568    style.symbol_text_letter_spacing = layer
3569        .letter_spacing
3570        .as_ref()
3571        .map(|v| v.evaluate_with_full_context(ctx));
3572    style.symbol_icon_text_fit = layer.icon_text_fit.evaluate_with_full_context(ctx);
3573    style.symbol_icon_text_fit_padding = layer.icon_text_fit_padding;
3574    style.symbol_sort_key = layer
3575        .sort_key
3576        .as_ref()
3577        .map(|v| v.evaluate_with_full_context(ctx));
3578    style.symbol_placement = layer.placement;
3579    style.symbol_spacing = layer.spacing.evaluate_with_full_context(ctx);
3580    style.symbol_max_angle = layer.max_angle.evaluate_with_full_context(ctx);
3581    style.symbol_keep_upright = layer.keep_upright.evaluate_with_full_context(ctx);
3582    style.symbol_anchors = effective_symbol_anchor_order(
3583        layer.anchor,
3584        &layer.variable_anchors,
3585        layer.variable_anchor_offsets.as_deref(),
3586    );
3587    style.symbol_writing_mode = layer.writing_mode;
3588    style.symbol_offset = layer.offset;
3589    style
3590}
3591
3592// MapLibre treats `text-justify: auto` as "match the effective anchor" for
3593// point labels. The engine does not yet run full text shaping, but deriving the
3594// effective justification here preserves the requested style state for future
3595// shaping work and keeps it consistent with the chosen anchor defaults.
3596fn effective_symbol_text_justify(
3597    justify: SymbolTextJustify,
3598    anchor: SymbolAnchor,
3599) -> SymbolTextJustify {
3600    match justify {
3601        SymbolTextJustify::Auto => anchor_justification(anchor),
3602        explicit => explicit,
3603    }
3604}
3605
3606fn anchor_justification(anchor: SymbolAnchor) -> SymbolTextJustify {
3607    match anchor {
3608        SymbolAnchor::Left | SymbolAnchor::TopLeft | SymbolAnchor::BottomLeft => {
3609            SymbolTextJustify::Left
3610        }
3611        SymbolAnchor::Right | SymbolAnchor::TopRight | SymbolAnchor::BottomRight => {
3612            SymbolTextJustify::Right
3613        }
3614        _ => SymbolTextJustify::Center,
3615    }
3616}
3617
3618/// Mutable runtime wrapper around a [`StyleDocument`].
3619#[derive(Debug, Default)]
3620pub struct MapStyle {
3621    document: StyleDocument,
3622}
3623
3624impl MapStyle {
3625    /// Create an empty map style.
3626    pub fn new() -> Self {
3627        Self::default()
3628    }
3629
3630    /// Wrap an existing style document.
3631    pub fn from_document(document: StyleDocument) -> Self {
3632        Self { document }
3633    }
3634
3635    /// Access the underlying document.
3636    pub fn document(&self) -> &StyleDocument {
3637        &self.document
3638    }
3639
3640    /// Mutable access to the underlying document.
3641    pub fn document_mut(&mut self) -> &mut StyleDocument {
3642        &mut self.document
3643    }
3644
3645    /// Consume and return the underlying document.
3646    pub fn into_document(self) -> StyleDocument {
3647        self.document
3648    }
3649}
3650
3651fn apply_shared_meta(layer: &mut dyn Layer, meta: &StyleLayerMeta, ctx: StyleEvalContext) {
3652    layer.set_visible(meta.visible_in_context(ctx));
3653    layer.set_opacity(meta.opacity.evaluate_with_context(ctx));
3654}
3655
3656fn require_raster_source<'a>(
3657    layer_id: &str,
3658    source_id: &str,
3659    sources: &'a HashMap<StyleSourceId, StyleSource>,
3660) -> Result<(&'a RasterSourceFactory, usize, &'a TileSelectionConfig), StyleError> {
3661    let Some(source) = sources.get(source_id) else {
3662        return Err(StyleError::MissingSource(source_id.to_owned()));
3663    };
3664    match source {
3665        StyleSource::Raster(raster) => {
3666            Ok((&raster.factory, raster.cache_capacity, &raster.selection))
3667        }
3668        StyleSource::Image(image) => Ok((&image.factory, image.cache_capacity, &image.selection)),
3669        other => Err(StyleError::SourceKindMismatch {
3670            layer_id: layer_id.to_owned(),
3671            source_id: source_id.to_owned(),
3672            expected: "raster|image",
3673            actual: other.kind_name(),
3674        }),
3675    }
3676}
3677
3678/// Try to resolve a video or canvas source for a raster-type style layer.
3679///
3680/// Returns `Some(DynamicImageOverlayLayer)` if the referenced source is a
3681/// `Video` or `Canvas` variant.  Returns `None` if the source is a normal
3682/// raster/image source (caller should fall through to `require_raster_source`).
3683fn try_dynamic_overlay_from_source(
3684    layer_name: &str,
3685    source_id: &str,
3686    sources: &HashMap<StyleSourceId, StyleSource>,
3687    ctx: StyleEvalContext,
3688) -> Option<Result<Box<dyn Layer>, StyleError>> {
3689    let source = sources.get(source_id)?;
3690    match source {
3691        StyleSource::Video(video) => {
3692            let provider = (video.factory)();
3693            let mut layer =
3694                DynamicImageOverlayLayer::new(layer_name.to_owned(), video.coordinates, provider);
3695            layer.set_opacity(ctx.zoom.fract()); // placeholder; real opacity comes from apply_shared_meta
3696            Some(Ok(Box::new(layer)))
3697        }
3698        StyleSource::Canvas(canvas) => {
3699            let provider = (canvas.factory)();
3700            let mut layer =
3701                DynamicImageOverlayLayer::new(layer_name.to_owned(), canvas.coordinates, provider);
3702            layer.set_opacity(ctx.zoom.fract());
3703            Some(Ok(Box::new(layer)))
3704        }
3705        _ => None,
3706    }
3707}
3708
3709fn require_vector_source<'a>(
3710    layer_id: &str,
3711    source_id: &str,
3712    source_layer: Option<&str>,
3713    sources: &'a HashMap<StyleSourceId, StyleSource>,
3714    zoom: u8,
3715) -> Result<Cow<'a, FeatureCollection>, StyleError> {
3716    let Some(source) = sources.get(source_id) else {
3717        return Err(StyleError::MissingSource(source_id.to_owned()));
3718    };
3719    match source {
3720        StyleSource::GeoJson(source) => Ok(source.features_at_zoom(zoom)),
3721        StyleSource::VectorTile(source) => {
3722            if let Some(source_layer) = source_layer {
3723                source
3724                    .source_layer(source_layer)
3725                    .map(Cow::Borrowed)
3726                    .ok_or_else(|| StyleError::MissingSourceLayer {
3727                        layer_id: layer_id.to_owned(),
3728                        source_id: source_id.to_owned(),
3729                        source_layer: source_layer.to_owned(),
3730                    })
3731            } else {
3732                Ok(Cow::Borrowed(&source.data))
3733            }
3734        }
3735        other => Err(StyleError::SourceKindMismatch {
3736            layer_id: layer_id.to_owned(),
3737            source_id: source_id.to_owned(),
3738            expected: "geojson|vector",
3739            actual: other.kind_name(),
3740        }),
3741    }
3742}
3743
3744fn require_model_source<'a>(
3745    layer_id: &str,
3746    source_id: &str,
3747    sources: &'a HashMap<StyleSourceId, StyleSource>,
3748) -> Result<&'a ModelSource, StyleError> {
3749    let Some(source) = sources.get(source_id) else {
3750        return Err(StyleError::MissingSource(source_id.to_owned()));
3751    };
3752    match source {
3753        StyleSource::Model(model) => Ok(model),
3754        other => Err(StyleError::SourceKindMismatch {
3755            layer_id: layer_id.to_owned(),
3756            source_id: source_id.to_owned(),
3757            expected: "model",
3758            actual: other.kind_name(),
3759        }),
3760    }
3761}
3762
3763#[cfg(test)]
3764mod tests {
3765    use super::*;
3766    use crate::geometry::{Feature, FeatureCollection, Geometry, Point};
3767    use crate::tile_source::{TileError, TileResponse, TileSource};
3768    use std::collections::HashMap;
3769
3770    struct EmptyTileSource;
3771
3772    impl TileSource for EmptyTileSource {
3773        fn request(&self, _id: rustial_math::TileId) {}
3774
3775        fn poll(&self) -> Vec<(rustial_math::TileId, Result<TileResponse, TileError>)> {
3776            Vec::new()
3777        }
3778    }
3779
3780    fn feature_at(lat: f64, lon: f64) -> Feature {
3781        Feature {
3782            geometry: Geometry::Point(Point {
3783                coord: GeoCoord::from_lat_lon(lat, lon),
3784            }),
3785            properties: HashMap::new(),
3786        }
3787    }
3788
3789    fn collection_with_point(lat: f64, lon: f64) -> FeatureCollection {
3790        FeatureCollection {
3791            features: vec![feature_at(lat, lon)],
3792        }
3793    }
3794
3795    #[test]
3796    fn vector_tile_source_can_be_partitioned_by_source_layer() {
3797        let mut source_layers = HashMap::new();
3798        source_layers.insert("roads".to_string(), collection_with_point(1.0, 2.0));
3799        source_layers.insert("water".to_string(), collection_with_point(3.0, 4.0));
3800
3801        let source = VectorTileSource::from_source_layers(source_layers);
3802        assert!(source.has_source_layers());
3803        assert_eq!(source.source_layer("roads").map(|fc| fc.len()), Some(1));
3804        assert_eq!(source.source_layer("water").map(|fc| fc.len()), Some(1));
3805        assert_eq!(source.data.len(), 2);
3806    }
3807
3808    #[test]
3809    fn vector_style_layer_resolves_requested_source_layer() {
3810        let mut document = StyleDocument::new();
3811        let mut source_layers = HashMap::new();
3812        source_layers.insert("roads".to_string(), collection_with_point(10.0, 20.0));
3813        source_layers.insert("water".to_string(), collection_with_point(30.0, 40.0));
3814        document
3815            .add_source(
3816                "vector",
3817                StyleSource::VectorTile(VectorTileSource::from_source_layers(source_layers)),
3818            )
3819            .expect("source added");
3820
3821        let mut layer = LineStyleLayer::new("roads-line", "vector");
3822        layer.source_layer = Some("roads".to_string());
3823        document
3824            .add_layer(StyleLayer::Line(layer))
3825            .expect("layer added");
3826
3827        let runtime = document.to_runtime_layers().expect("runtime layers");
3828        let vector = runtime[0]
3829            .as_any()
3830            .downcast_ref::<VectorLayer>()
3831            .expect("vector runtime layer");
3832        assert_eq!(vector.features.len(), 1);
3833        match &vector.features.features[0].geometry {
3834            Geometry::Point(point) => {
3835                assert!((point.coord.lat - 10.0).abs() < 1e-9);
3836                assert!((point.coord.lon - 20.0).abs() < 1e-9);
3837            }
3838            other => panic!("expected point geometry, got {other:?}"),
3839        }
3840    }
3841
3842    #[test]
3843    fn missing_source_layer_returns_style_error() {
3844        let mut document = StyleDocument::new();
3845        document
3846            .add_source(
3847                "vector",
3848                StyleSource::VectorTile(VectorTileSource::new(collection_with_point(0.0, 0.0))),
3849            )
3850            .expect("source added");
3851
3852        let mut layer = FillStyleLayer::new("water-fill", "vector");
3853        layer.source_layer = Some("water".to_string());
3854        document
3855            .add_layer(StyleLayer::Fill(layer))
3856            .expect("layer added");
3857
3858        let err = document
3859            .to_runtime_layers()
3860            .expect_err("missing source-layer should fail");
3861        assert!(matches!(err, StyleError::MissingSourceLayer { .. }));
3862    }
3863
3864    #[test]
3865    fn streamed_vector_source_allows_runtime_layer_creation_without_resolved_features() {
3866        let mut document = StyleDocument::new();
3867        document
3868            .add_source(
3869                "vector",
3870                StyleSource::VectorTile(
3871                    VectorTileSource::streamed(|| Box::new(EmptyTileSource)).with_cache_capacity(8),
3872                ),
3873            )
3874            .expect("source added");
3875
3876        let mut layer = CircleStyleLayer::new("labels", "vector");
3877        layer.source_layer = Some("poi".to_string());
3878        document
3879            .add_layer(StyleLayer::Circle(layer))
3880            .expect("layer added");
3881
3882        let runtime = document.to_runtime_layers().expect("runtime layers");
3883        let vector = runtime[0]
3884            .as_any()
3885            .downcast_ref::<VectorLayer>()
3886            .expect("vector runtime layer");
3887        assert!(vector.features.is_empty());
3888        assert_eq!(vector.query_source_layer.as_deref(), Some("poi"));
3889    }
3890
3891    #[test]
3892    fn style_document_reports_source_usage() {
3893        let mut document = StyleDocument::new();
3894        document
3895            .add_source(
3896                "places",
3897                StyleSource::GeoJson(GeoJsonSource::new(collection_with_point(0.0, 0.0))),
3898            )
3899            .expect("source added");
3900        document
3901            .add_source(
3902                "labels",
3903                StyleSource::VectorTile(VectorTileSource::new(collection_with_point(1.0, 1.0))),
3904            )
3905            .expect("source added");
3906        document.set_terrain_source(Some("labels"));
3907
3908        document
3909            .add_layer(StyleLayer::Fill(FillStyleLayer::new("fill", "places")))
3910            .expect("fill layer added");
3911        document
3912            .add_layer(StyleLayer::Line(LineStyleLayer::new("line", "labels")))
3913            .expect("line layer added");
3914
3915        assert!(document.source_is_used("places"));
3916        assert!(document.source_is_used("labels"));
3917        assert!(!document.source_is_used("missing"));
3918
3919        let layer_ids = document.layer_ids_using_source("labels");
3920        assert_eq!(layer_ids, vec!["line"]);
3921    }
3922
3923    // -----------------------------------------------------------------------
3924    // Feature-state-driven style evaluation tests
3925    // -----------------------------------------------------------------------
3926
3927    #[test]
3928    fn feature_state_value_returns_fallback_with_zoom_only_context() {
3929        // When evaluating a FeatureState value without a full context, the
3930        // fallback is returned because there is no feature-state map to query.
3931        let value = StyleValue::<f32>::feature_state_key("opacity", 0.5);
3932        let result = value.evaluate_with_context(StyleEvalContext::new(10.0));
3933        assert!((result - 0.5).abs() < f32::EPSILON);
3934    }
3935
3936    #[test]
3937    fn feature_state_value_resolves_with_full_context() {
3938        // When the feature-state map contains the requested key, the stored
3939        // PropertyValue is converted to the target type (f32 in this case).
3940        let mut state = HashMap::new();
3941        state.insert("opacity".to_string(), PropertyValue::Number(0.8));
3942
3943        let value = StyleValue::<f32>::feature_state_key("opacity", 0.5);
3944        let ctx = StyleEvalContext::new(10.0).with_feature_state(&state);
3945        let result = value.evaluate_with_full_context(&ctx);
3946        assert!((result - 0.8).abs() < f32::EPSILON);
3947    }
3948
3949    #[test]
3950    fn feature_state_value_falls_back_when_key_absent() {
3951        // When the feature-state map does not contain the requested key,
3952        // the fallback value is used.
3953        let state: FeatureState = HashMap::new();
3954
3955        let value = StyleValue::<f32>::feature_state_key("opacity", 0.5);
3956        let ctx = StyleEvalContext::new(10.0).with_feature_state(&state);
3957        let result = value.evaluate_with_full_context(&ctx);
3958        assert!((result - 0.5).abs() < f32::EPSILON);
3959    }
3960
3961    #[test]
3962    fn feature_state_bool_resolves_hover_flag() {
3963        // The most common Mapbox pattern: a boolean "hover" flag toggled
3964        // by the interaction manager.
3965        let mut state = HashMap::new();
3966        state.insert("hover".to_string(), PropertyValue::Bool(true));
3967
3968        let value = StyleValue::<bool>::feature_state_key("hover", false);
3969        let ctx = StyleEvalContext::new(14.0).with_feature_state(&state);
3970        assert!(value.evaluate_with_full_context(&ctx));
3971    }
3972
3973    #[test]
3974    fn feature_state_color_array_always_returns_fallback() {
3975        // Colour tuples ([f32; 4]) cannot be expressed in a single
3976        // PropertyValue today, so the fallback is always used.
3977        let mut state = HashMap::new();
3978        state.insert("color".to_string(), PropertyValue::Number(1.0));
3979
3980        let fallback = [0.1, 0.2, 0.3, 1.0];
3981        let value = StyleValue::<[f32; 4]>::feature_state_key("color", fallback);
3982        let ctx = StyleEvalContext::new(14.0).with_feature_state(&state);
3983        assert_eq!(value.evaluate_with_full_context(&ctx), fallback);
3984    }
3985
3986    #[test]
3987    fn is_feature_state_driven_flag() {
3988        let constant: StyleValue<f32> = StyleValue::Constant(1.0);
3989        assert!(!constant.is_feature_state_driven());
3990
3991        let driven: StyleValue<f32> = StyleValue::feature_state_key("opacity", 1.0);
3992        assert!(driven.is_feature_state_driven());
3993    }
3994
3995    #[test]
3996    fn constant_and_zoom_stops_unchanged_with_full_context() {
3997        // Existing Constant and ZoomStops variants must continue to work
3998        // identically when evaluated through the full-context path.
3999        let state: FeatureState = HashMap::new();
4000        let ctx = StyleEvalContext::new(5.0).with_feature_state(&state);
4001
4002        let constant = StyleValue::Constant(42.0_f32);
4003        assert!((constant.evaluate_with_full_context(&ctx) - 42.0).abs() < f32::EPSILON);
4004
4005        let stops = StyleValue::ZoomStops(vec![(0.0, 0.0_f32), (10.0, 100.0)]);
4006        let result = stops.evaluate_with_full_context(&ctx);
4007        assert!((result - 50.0).abs() < f32::EPSILON);
4008    }
4009
4010    #[test]
4011    fn full_context_helpers_return_expected_values() {
4012        let mut state = HashMap::new();
4013        state.insert("hover".to_string(), PropertyValue::Bool(true));
4014        state.insert("width".to_string(), PropertyValue::Number(3.5));
4015
4016        let ctx = StyleEvalContextFull::new(14.0, &state);
4017        assert!(ctx.feature_state_bool("hover"));
4018        assert!(!ctx.feature_state_bool("missing"));
4019        assert!((ctx.feature_state_f64("width", 1.0) - 3.5).abs() < f64::EPSILON);
4020        assert!((ctx.feature_state_f64("missing", 1.0) - 1.0).abs() < f64::EPSILON);
4021    }
4022
4023    // -----------------------------------------------------------------------
4024    // Full-context style resolution and has_feature_state_driven_paint tests
4025    // -----------------------------------------------------------------------
4026
4027    #[test]
4028    fn fill_layer_resolves_with_feature_state() {
4029        // A fill layer whose fill_color depends on feature-state("hover")
4030        // should resolve to the fallback when hover is false, and to the
4031        // state-provided value when hover triggers a numeric override.
4032        let mut layer = FillStyleLayer::new("buildings", "source");
4033        // Use a feature-state-driven outline width so we can verify resolution.
4034        layer.outline_width = StyleValue::feature_state_key("width", 1.0);
4035
4036        // Without hover state: fallback (1.0).
4037        let empty_state: FeatureState = HashMap::new();
4038        let ctx = StyleEvalContext::new(14.0).with_feature_state(&empty_state);
4039        let style = fill_style_with_state(&layer, &ctx);
4040        assert!((style.stroke_width - 1.0).abs() < f32::EPSILON);
4041
4042        // With hover state: overridden value (4.0).
4043        let mut hover_state = HashMap::new();
4044        hover_state.insert("width".to_string(), PropertyValue::Number(4.0));
4045        let ctx = StyleEvalContext::new(14.0).with_feature_state(&hover_state);
4046        let style = fill_style_with_state(&layer, &ctx);
4047        assert!((style.stroke_width - 4.0).abs() < f32::EPSILON);
4048    }
4049
4050    #[test]
4051    fn line_layer_resolves_with_feature_state() {
4052        let mut layer = LineStyleLayer::new("roads", "source");
4053        layer.width = StyleValue::feature_state_key("highlight_width", 2.0);
4054
4055        let mut state = HashMap::new();
4056        state.insert("highlight_width".to_string(), PropertyValue::Number(6.0));
4057        let ctx = StyleEvalContext::new(10.0).with_feature_state(&state);
4058        let style = line_style_with_state(&layer, &ctx);
4059        assert!((style.stroke_width - 6.0).abs() < f32::EPSILON);
4060    }
4061
4062    #[test]
4063    fn circle_layer_resolves_with_feature_state() {
4064        let mut layer = CircleStyleLayer::new("points", "source");
4065        layer.radius = StyleValue::feature_state_key("size", 5.0);
4066
4067        let mut state = HashMap::new();
4068        state.insert("size".to_string(), PropertyValue::Number(12.0));
4069        let ctx = StyleEvalContext::new(10.0).with_feature_state(&state);
4070        let style = circle_style_with_state(&layer, &ctx);
4071        assert!((style.point_radius - 12.0).abs() < f32::EPSILON);
4072    }
4073
4074    #[test]
4075    fn has_feature_state_driven_paint_detects_driven_fields() {
4076        let mut fill = FillStyleLayer::new("buildings", "source");
4077        let fill_layer = StyleLayer::Fill(fill.clone());
4078        assert!(!fill_layer.has_feature_state_driven_paint());
4079
4080        // Make outline_width feature-state-driven.
4081        fill.outline_width = StyleValue::feature_state_key("width", 1.0);
4082        let fill_layer = StyleLayer::Fill(fill);
4083        assert!(fill_layer.has_feature_state_driven_paint());
4084    }
4085
4086    #[test]
4087    fn has_feature_state_driven_paint_false_for_non_vector_layers() {
4088        let bg = BackgroundStyleLayer::new("bg", [0.0, 0.0, 0.0, 1.0]);
4089        assert!(!StyleLayer::Background(bg).has_feature_state_driven_paint());
4090    }
4091
4092    #[test]
4093    fn resolve_style_with_feature_state_returns_none_for_background() {
4094        let bg = BackgroundStyleLayer::new("bg", [0.0, 0.0, 0.0, 1.0]);
4095        let state: FeatureState = HashMap::new();
4096        let ctx = StyleEvalContext::new(10.0).with_feature_state(&state);
4097        assert!(StyleLayer::Background(bg)
4098            .resolve_style_with_feature_state(&ctx)
4099            .is_none());
4100    }
4101
4102    #[test]
4103    fn resolve_style_with_feature_state_dispatches_fill() {
4104        let mut fill = FillStyleLayer::new("buildings", "source");
4105        fill.outline_width = StyleValue::feature_state_key("width", 1.0);
4106
4107        let mut state = HashMap::new();
4108        state.insert("width".to_string(), PropertyValue::Number(5.0));
4109        let ctx = StyleEvalContext::new(14.0).with_feature_state(&state);
4110
4111        let style = StyleLayer::Fill(fill)
4112            .resolve_style_with_feature_state(&ctx)
4113            .expect("fill layer should produce VectorStyle");
4114        assert!((style.stroke_width - 5.0).abs() < f32::EPSILON);
4115    }
4116
4117    #[test]
4118    fn non_driven_fields_unchanged_through_full_context() {
4119        // When a fill layer has only constant paint values, the full-context
4120        // evaluation should produce the same results as the zoom-only path.
4121        let layer = FillStyleLayer::new("buildings", "source");
4122        let state: FeatureState = HashMap::new();
4123        let full_ctx = StyleEvalContext::new(14.0).with_feature_state(&state);
4124        let zoom_ctx = StyleEvalContext::new(14.0);
4125
4126        let via_full = fill_style_with_state(&layer, &full_ctx);
4127        let via_zoom = vector_style_from_fill_layer(&layer, zoom_ctx);
4128        assert_eq!(via_full.fill_color, via_zoom.fill_color);
4129        assert_eq!(via_full.stroke_color, via_zoom.stroke_color);
4130        assert!((via_full.stroke_width - via_zoom.stroke_width).abs() < f32::EPSILON);
4131    }
4132
4133    // -----------------------------------------------------------------------
4134    // GeoJSON source clustering integration tests
4135    // -----------------------------------------------------------------------
4136
4137    #[test]
4138    fn geojson_source_with_clustering_returns_clustered_features_at_low_zoom() {
4139        // Place 10 tightly-packed points — they should cluster at low zoom.
4140        let features = FeatureCollection {
4141            features: (0..10)
4142                .map(|i| feature_at(48.858 + i as f64 * 0.0001, 2.294))
4143                .collect(),
4144        };
4145        let source = GeoJsonSource::new(features).with_clustering(ClusterOptions {
4146            radius: 80.0,
4147            max_zoom: 16,
4148            min_points: 2,
4149            ..Default::default()
4150        });
4151        assert!(source.is_clustered());
4152
4153        // At zoom 2, all 10 points should merge into a single cluster.
4154        let clustered = source.features_at_zoom(2);
4155        assert!(
4156            clustered.len() < 10,
4157            "Expected fewer features at zoom 2 (got {})",
4158            clustered.len(),
4159        );
4160
4161        // At zoom 20 (above max_zoom), all originals should be returned.
4162        let unclustered = source.features_at_zoom(20);
4163        assert_eq!(unclustered.len(), 10);
4164    }
4165
4166    #[test]
4167    fn geojson_source_without_clustering_returns_raw_data() {
4168        let source = GeoJsonSource::new(FeatureCollection {
4169            features: vec![feature_at(51.5, -0.12), feature_at(51.51, -0.13)],
4170        });
4171        assert!(!source.is_clustered());
4172        let result = source.features_at_zoom(5);
4173        assert_eq!(result.len(), 2);
4174    }
4175
4176    #[test]
4177    fn clustered_geojson_circle_layer_resolves_to_runtime_layer() {
4178        let features = FeatureCollection {
4179            features: (0..20)
4180                .map(|i| feature_at(48.858 + i as f64 * 0.0001, 2.294))
4181                .collect(),
4182        };
4183        let source = GeoJsonSource::new(features).with_clustering(Default::default());
4184
4185        let mut doc = StyleDocument::new();
4186        doc.add_source("points", StyleSource::GeoJson(source))
4187            .expect("source added");
4188        doc.add_layer(StyleLayer::Circle(CircleStyleLayer::new("dots", "points")))
4189            .expect("layer added");
4190
4191        // Evaluate at zoom 3 — should produce a runtime layer with clustered features.
4192        let ctx = StyleEvalContext::new(3.0);
4193        let layers = doc.to_runtime_layers_with_context(ctx).expect("layers ok");
4194        assert_eq!(layers.len(), 1, "expected 1 circle layer");
4195    }
4196
4197    #[test]
4198    fn video_source_produces_dynamic_image_overlay_layer() {
4199        use crate::layers::{FrameData, FrameProvider};
4200
4201        struct TestProvider;
4202        impl FrameProvider for TestProvider {
4203            fn next_frame(&mut self) -> Option<FrameData> {
4204                Some(FrameData {
4205                    width: 4,
4206                    height: 4,
4207                    data: vec![255; 64],
4208                })
4209            }
4210        }
4211
4212        let corners = [
4213            GeoCoord::from_lat_lon(40.0, -74.0),
4214            GeoCoord::from_lat_lon(40.0, -73.0),
4215            GeoCoord::from_lat_lon(39.0, -73.0),
4216            GeoCoord::from_lat_lon(39.0, -74.0),
4217        ];
4218        let source = VideoSource::new(corners, || Box::new(TestProvider));
4219
4220        let mut doc = StyleDocument::new();
4221        doc.add_source("video", StyleSource::Video(source))
4222            .expect("source added");
4223        doc.add_layer(StyleLayer::Raster(RasterStyleLayer::new(
4224            "video-layer",
4225            "video",
4226        )))
4227        .expect("layer added");
4228
4229        let layers = doc.to_runtime_layers().expect("runtime layers");
4230        assert_eq!(layers.len(), 1);
4231        assert!(
4232            layers[0]
4233                .as_any()
4234                .downcast_ref::<DynamicImageOverlayLayer>()
4235                .is_some(),
4236            "video source should produce a DynamicImageOverlayLayer"
4237        );
4238    }
4239
4240    #[test]
4241    fn canvas_source_produces_dynamic_image_overlay_layer() {
4242        use crate::layers::{FrameData, FrameProvider};
4243
4244        struct StaticCanvas;
4245        impl FrameProvider for StaticCanvas {
4246            fn next_frame(&mut self) -> Option<FrameData> {
4247                Some(FrameData {
4248                    width: 8,
4249                    height: 8,
4250                    data: vec![128; 256],
4251                })
4252            }
4253            fn is_animating(&self) -> bool {
4254                false
4255            }
4256        }
4257
4258        let corners = [
4259            GeoCoord::from_lat_lon(51.0, -1.0),
4260            GeoCoord::from_lat_lon(51.0, 0.0),
4261            GeoCoord::from_lat_lon(50.0, 0.0),
4262            GeoCoord::from_lat_lon(50.0, -1.0),
4263        ];
4264        let source = CanvasSource::new(corners, || Box::new(StaticCanvas)).with_animate(false);
4265
4266        let mut doc = StyleDocument::new();
4267        doc.add_source("canvas", StyleSource::Canvas(source))
4268            .expect("source added");
4269        doc.add_layer(StyleLayer::Raster(RasterStyleLayer::new(
4270            "canvas-layer",
4271            "canvas",
4272        )))
4273        .expect("layer added");
4274
4275        let layers = doc.to_runtime_layers().expect("runtime layers");
4276        assert_eq!(layers.len(), 1);
4277        let dyn_layer = layers[0]
4278            .as_any()
4279            .downcast_ref::<DynamicImageOverlayLayer>()
4280            .expect("canvas source should produce a DynamicImageOverlayLayer");
4281        // Canvas was set to non-animating.
4282        assert!(!dyn_layer.provider().is_animating());
4283    }
4284
4285    // ---------------------------------------------------------------
4286    // Lighting
4287    // ---------------------------------------------------------------
4288
4289    #[test]
4290    fn compute_lighting_defaults() {
4291        let config = LightConfig::default();
4292        let lit = compute_lighting(&config);
4293        // Default mode is enabled
4294        assert!((lit.lighting_enabled - 1.0).abs() < f32::EPSILON);
4295        // Ambient colour should be white × 0.5
4296        assert!((lit.ambient_color[0] - 0.5).abs() < 0.01);
4297        assert!((lit.ambient_color[1] - 0.5).abs() < 0.01);
4298        assert!((lit.ambient_color[2] - 0.5).abs() < 0.01);
4299        // Directional colour should be white × 0.5
4300        assert!((lit.directional_color[0] - 0.5).abs() < 0.01);
4301        assert!((lit.directional_color[1] - 0.5).abs() < 0.01);
4302        assert!((lit.directional_color[2] - 0.5).abs() < 0.01);
4303        // Directional dir should be a unit vector
4304        let len = (lit.directional_dir[0].powi(2)
4305            + lit.directional_dir[1].powi(2)
4306            + lit.directional_dir[2].powi(2))
4307        .sqrt();
4308        assert!((len - 1.0).abs() < 0.01);
4309    }
4310
4311    #[test]
4312    fn compute_lighting_flat_mode() {
4313        let config = LightConfig {
4314            mode: LightingMode::Flat,
4315            ..Default::default()
4316        };
4317        let lit = compute_lighting(&config);
4318        assert!((lit.lighting_enabled - 0.0).abs() < f32::EPSILON);
4319    }
4320
4321    #[test]
4322    fn compute_lighting_custom_ambient() {
4323        let config = LightConfig {
4324            ambient: AmbientLight {
4325                color: [1.0, 0.0, 0.0],
4326                intensity: 0.8,
4327            },
4328            ..Default::default()
4329        };
4330        let lit = compute_lighting(&config);
4331        assert!((lit.ambient_color[0] - 0.8).abs() < 0.01);
4332        assert!((lit.ambient_color[1] - 0.0).abs() < 0.01);
4333        assert!((lit.ambient_color[2] - 0.0).abs() < 0.01);
4334    }
4335
4336    #[test]
4337    fn compute_lighting_direction_north_overhead() {
4338        let config = LightConfig {
4339            directional: DirectionalLight {
4340                direction: [0.0, 90.0], // azimuth=north, altitude=straight up
4341                ..Default::default()
4342            },
4343            ..Default::default()
4344        };
4345        let lit = compute_lighting(&config);
4346        // Straight up → direction should be approximately (0, 0, 1)
4347        assert!((lit.directional_dir[2] - 1.0).abs() < 0.01);
4348        assert!(lit.directional_dir[0].abs() < 0.01);
4349        assert!(lit.directional_dir[1].abs() < 0.01);
4350    }
4351
4352    // ---------------------------------------------------------------
4353    // Sky / atmosphere
4354    // ---------------------------------------------------------------
4355
4356    #[test]
4357    fn compute_sky_defaults_disabled() {
4358        let sky = ComputedSky::default();
4359        assert!((sky.sky_enabled - 0.0).abs() < f32::EPSILON);
4360    }
4361
4362    #[test]
4363    fn compute_sky_enabled_with_config() {
4364        let config = SkyConfig::default();
4365        let fallback = [210.0, 45.0];
4366        let sky = compute_sky(&config, fallback);
4367        assert!((sky.sky_enabled - 1.0).abs() < f32::EPSILON);
4368        assert!((sky.sun_intensity - 10.0).abs() < 0.01);
4369    }
4370
4371    #[test]
4372    fn compute_sky_inherits_sun_from_fallback() {
4373        let config = SkyConfig {
4374            sun_position: None,
4375            ..Default::default()
4376        };
4377        let fallback = [0.0, 90.0]; // north, overhead
4378        let sky = compute_sky(&config, fallback);
4379        // Sun should point straight up.
4380        assert!((sky.sun_direction[2] - 1.0).abs() < 0.01);
4381        assert!(sky.sun_direction[0].abs() < 0.01);
4382        assert!(sky.sun_direction[1].abs() < 0.01);
4383    }
4384
4385    #[test]
4386    fn compute_sky_explicit_sun_position() {
4387        let config = SkyConfig {
4388            sun_position: Some([90.0, 45.0]), // east, 45° up
4389            ..Default::default()
4390        };
4391        let sky = compute_sky(&config, [0.0, 0.0]);
4392        // Azimuth 90° (east) → x should be positive.
4393        assert!(sky.sun_direction[0] > 0.5);
4394        // Altitude 45° → z should be ~0.707.
4395        assert!((sky.sun_direction[2] - 0.707).abs() < 0.02);
4396    }
4397
4398    #[test]
4399    fn compute_sky_zero_opacity_disables() {
4400        let config = SkyConfig {
4401            opacity: 0.0,
4402            ..Default::default()
4403        };
4404        let sky = compute_sky(&config, [210.0, 45.0]);
4405        assert!((sky.sky_enabled - 0.0).abs() < f32::EPSILON);
4406    }
4407
4408    // ---------------------------------------------------------------
4409    // Style transition tests
4410    // ---------------------------------------------------------------
4411
4412    #[test]
4413    fn transition_spec_default_is_300ms() {
4414        let spec = TransitionSpec::default();
4415        assert!((spec.duration - 0.3).abs() < 1e-6);
4416        assert!((spec.delay - 0.0).abs() < 1e-6);
4417        assert!(spec.is_active());
4418    }
4419
4420    #[test]
4421    fn transition_spec_instant_is_zero() {
4422        let spec = TransitionSpec::INSTANT;
4423        assert!((spec.duration - 0.0).abs() < 1e-6);
4424        assert!(!spec.is_active());
4425    }
4426
4427    #[test]
4428    fn transitioning_settled_resolves_immediately() {
4429        let t = Transitioning::settled(0.5_f32);
4430        assert!((t.resolve(0.0) - 0.5).abs() < 1e-6);
4431        assert!((t.resolve(100.0) - 0.5).abs() < 1e-6);
4432        assert!(!t.is_active(0.0));
4433    }
4434
4435    #[test]
4436    fn transitioning_interpolates_over_duration() {
4437        let spec = TransitionSpec {
4438            duration: 1.0,
4439            delay: 0.0,
4440        };
4441        let t = Transitioning::new(0.0_f32, 1.0, 0.0, &spec);
4442
4443        // Before start
4444        assert!((t.resolve(0.0) - 0.0).abs() < 1e-6);
4445
4446        // Midway (ease_cubic_in_out(0.5) = 0.5)
4447        let mid = t.resolve(0.5);
4448        assert!((mid - 0.5).abs() < 0.05);
4449
4450        // After end
4451        assert!((t.resolve(1.0) - 1.0).abs() < 1e-6);
4452        assert!((t.resolve(2.0) - 1.0).abs() < 1e-6);
4453    }
4454
4455    #[test]
4456    fn transitioning_respects_delay() {
4457        let spec = TransitionSpec {
4458            duration: 1.0,
4459            delay: 0.5,
4460        };
4461        let t = Transitioning::new(0.0_f32, 1.0, 0.0, &spec);
4462
4463        // During delay period — should stay at prior value
4464        assert!((t.resolve(0.0) - 0.0).abs() < 1e-6);
4465        assert!((t.resolve(0.25) - 0.0).abs() < 1e-6);
4466        assert!((t.resolve(0.5) - 0.0).abs() < 1e-6);
4467
4468        // After delay, transition begins
4469        assert!(t.resolve(1.0) > 0.1);
4470        // After delay+duration, complete
4471        assert!((t.resolve(1.5) - 1.0).abs() < 1e-6);
4472    }
4473
4474    #[test]
4475    fn transitioning_retarget_starts_from_current() {
4476        let spec = TransitionSpec {
4477            duration: 1.0,
4478            delay: 0.0,
4479        };
4480        let mut t = Transitioning::new(0.0_f32, 1.0, 0.0, &spec);
4481
4482        // At t=0.5, value is ~0.5
4483        let mid = t.resolve(0.5);
4484        assert!(mid > 0.3 && mid < 0.7);
4485
4486        // Retarget to 2.0 at t=0.5
4487        t.retarget(2.0, 0.5, &spec);
4488
4489        // The prior is now ~mid, transitioning to 2.0
4490        let after_retarget = t.resolve(0.5);
4491        assert!((after_retarget - mid).abs() < 0.05);
4492
4493        // At t=1.5, should reach 2.0
4494        assert!((t.resolve(1.5) - 2.0).abs() < 1e-6);
4495    }
4496
4497    #[test]
4498    fn transitioning_color_interpolation() {
4499        let spec = TransitionSpec {
4500            duration: 1.0,
4501            delay: 0.0,
4502        };
4503        let red: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
4504        let blue: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
4505        let t = Transitioning::new(red, blue, 0.0, &spec);
4506
4507        let mid = t.resolve(0.5);
4508        // At midpoint, red channel should be ~0.5, blue ~0.5
4509        assert!(mid[0] > 0.3 && mid[0] < 0.7);
4510        assert!(mid[2] > 0.3 && mid[2] < 0.7);
4511
4512        // At end, should be blue
4513        let end = t.resolve(1.0);
4514        assert!((end[0] - 0.0).abs() < 1e-6);
4515        assert!((end[2] - 1.0).abs() < 1e-6);
4516    }
4517
4518    #[test]
4519    fn layer_transition_state_detects_changes() {
4520        let spec = TransitionSpec {
4521            duration: 0.5,
4522            delay: 0.0,
4523        };
4524        let mut state = LayerTransitionState::from_initial(
4525            spec,
4526            1.0,
4527            [1.0, 0.0, 0.0, 1.0],
4528            [0.0; 4],
4529            2.0,
4530            0.0,
4531            0.0,
4532        );
4533
4534        // No active transitions at start
4535        assert!(!state.has_active_transitions(0.0));
4536
4537        // Change opacity at t=1.0
4538        state.update(1.0, 0.5, [1.0, 0.0, 0.0, 1.0], [0.0; 4], 2.0, 0.0, 0.0);
4539        assert!(state.has_active_transitions(1.0));
4540
4541        // Resolve at midpoint
4542        let resolved = state.resolve(1.25);
4543        assert!(resolved.opacity > 0.5 && resolved.opacity < 1.0);
4544
4545        // Resolve after transition ends
4546        let resolved = state.resolve(1.5);
4547        assert!((resolved.opacity - 0.5).abs() < 1e-6);
4548    }
4549
4550    #[test]
4551    fn ease_cubic_in_out_boundary_values() {
4552        assert!((super::ease_cubic_in_out(0.0) - 0.0).abs() < 1e-6);
4553        assert!((super::ease_cubic_in_out(0.5) - 0.5).abs() < 1e-6);
4554        assert!((super::ease_cubic_in_out(1.0) - 1.0).abs() < 1e-6);
4555        // Monotonically increasing
4556        let v1 = super::ease_cubic_in_out(0.25);
4557        let v2 = super::ease_cubic_in_out(0.5);
4558        let v3 = super::ease_cubic_in_out(0.75);
4559        assert!(v1 < v2);
4560        assert!(v2 < v3);
4561    }
4562
4563    #[test]
4564    fn style_document_global_transition() {
4565        let mut doc = StyleDocument::new();
4566        // Default TransitionSpec has duration=0.3, which is active.
4567        assert!(doc.transition().is_active());
4568        assert!((doc.transition().duration - 0.3).abs() < 1e-6);
4569        doc.set_transition(TransitionSpec {
4570            duration: 0.5,
4571            delay: 0.1,
4572        });
4573        assert!((doc.transition().duration - 0.5).abs() < 1e-6);
4574        assert!((doc.transition().delay - 0.1).abs() < 1e-6);
4575    }
4576
4577    #[test]
4578    fn style_layer_meta_has_transition() {
4579        let meta = StyleLayerMeta::new("test");
4580        assert!((meta.transition.duration - 0.3).abs() < 1e-6);
4581    }
4582
4583    // ---------------------------------------------------------------
4584    // Shadow / CSM tests
4585    // ---------------------------------------------------------------
4586
4587    #[test]
4588    fn shadow_config_defaults() {
4589        let cfg = ShadowConfig::default();
4590        assert_eq!(cfg.cascade_count, 2);
4591        assert_eq!(cfg.map_resolution, 2048);
4592        assert!((cfg.intensity - 0.8).abs() < 1e-6);
4593        assert!((cfg.normal_offset - 3.0).abs() < 1e-6);
4594    }
4595
4596    #[test]
4597    fn computed_shadow_default_is_disabled() {
4598        let s = ComputedShadow::default();
4599        assert!(!s.enabled);
4600        // Default keeps cascade_count=2 and resolution=2048 for
4601        // resource pre-allocation; only `enabled` gates actual use.
4602        assert_eq!(s.cascade_count, 2);
4603    }
4604
4605    #[test]
4606    fn shadow_cascade_identity_vp_produces_valid_output() {
4607        let vp = glam::DMat4::IDENTITY;
4608        let light_dir = [0.0_f32, -0.707, 0.707];
4609        let config = ShadowConfig::default();
4610        let shadow = compute_shadow_cascades(&vp, light_dir, 500.0, &config);
4611        assert!(shadow.enabled);
4612        assert_eq!(shadow.cascade_count, 2);
4613        assert_eq!(shadow.map_resolution, 2048);
4614        // Matrices should be non-zero (at least some element > 0).
4615        let has_nonzero = shadow.light_matrices[0]
4616            .iter()
4617            .flat_map(|r| r.iter())
4618            .any(|v| v.abs() > 1e-10);
4619        assert!(has_nonzero, "cascade 0 matrix should not be all-zero");
4620    }
4621
4622    #[test]
4623    fn shadow_cascade_count_clamped_to_four() {
4624        let vp = glam::DMat4::IDENTITY;
4625        let light_dir = [0.0_f32, -0.707, 0.707];
4626        let config = ShadowConfig {
4627            cascade_count: 10,
4628            ..Default::default()
4629        };
4630        let shadow = compute_shadow_cascades(&vp, light_dir, 500.0, &config);
4631        // Should be clamped to MAX_CASCADES (4).
4632        assert!(shadow.cascade_count <= 4);
4633    }
4634
4635    #[test]
4636    fn shadow_disabled_when_light_dir_zero() {
4637        let vp = glam::DMat4::IDENTITY;
4638        let light_dir = [0.0_f32, 0.0, 0.0];
4639        let config = ShadowConfig::default();
4640        let shadow = compute_shadow_cascades(&vp, light_dir, 500.0, &config);
4641        assert!(!shadow.enabled);
4642    }
4643
4644    #[test]
4645    fn shadows_enabled_flag_from_lighting() {
4646        // Default directional light has cast_shadows = false, so shadows
4647        // should be disabled by default.
4648        let config = LightConfig::default();
4649        let lit = compute_lighting(&config);
4650        assert!(!lit.shadows_enabled);
4651
4652        // Explicitly enable cast_shadows → shadows on.
4653        let with_shadows = LightConfig {
4654            directional: DirectionalLight {
4655                cast_shadows: true,
4656                ..Default::default()
4657            },
4658            ..Default::default()
4659        };
4660        let lit_on = compute_lighting(&with_shadows);
4661        assert!(lit_on.shadows_enabled);
4662
4663        // Flat mode → lighting disabled → shadows should be off even with
4664        // cast_shadows = true.
4665        let flat = LightConfig {
4666            mode: LightingMode::Flat,
4667            directional: DirectionalLight {
4668                cast_shadows: true,
4669                ..Default::default()
4670            },
4671            ..Default::default()
4672        };
4673        let lit_flat = compute_lighting(&flat);
4674        assert!(!lit_flat.shadows_enabled);
4675    }
4676
4677    #[test]
4678    fn shadow_texel_size_matches_resolution() {
4679        let vp = glam::DMat4::IDENTITY;
4680        let light_dir = [0.0_f32, -0.707, 0.707];
4681        let config = ShadowConfig {
4682            map_resolution: 1024,
4683            ..Default::default()
4684        };
4685        let shadow = compute_shadow_cascades(&vp, light_dir, 500.0, &config);
4686        assert!((shadow.texel_size - 1.0 / 1024.0).abs() < 1e-6);
4687    }
4688}