rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
// ---------------------------------------------------------------------------
//! # Loading placeholders and skeleton rendering
//!
//! When tiles are in the visible set but have no data yet, the engine emits
//! [`LoadingPlaceholder`] descriptors so that renderers can draw styled
//! rectangles at the tile's world bounds instead of leaving blank gaps.
//! This matches the Mapbox "skeleton chrome" loading experience.
//!
//! ## Data flow
//!
//! ```text
//! VisibleTile { data: None }          PlaceholderStyle (per-source config)
//!          |                                    |
//!          +----> PlaceholderGenerator::generate +
//!                         |
//!                 Vec<LoadingPlaceholder>
//!                         |
//!                    FrameOutput.placeholders
//!                         |
//!             +-----------+-----------+
//!             |                       |
//!      WGPU renderer           Bevy renderer
//!      (solid quad pass)       (entity sync)
//! ```
//!
//! ## Animation
//!
//! Each placeholder carries an `animation_phase` in `[0.0, 1.0)` that
//! renderers can use to drive a pulsing opacity or horizontal shimmer
//! effect.  The phase is derived from a monotonic time value so that all
//! placeholders pulse in sync regardless of spawn order.
//!
//! ## Style
//!
//! [`PlaceholderStyle`] is a lightweight value-type that controls the
//! visual appearance.  It lives on [`MapState`] (and can be set via
//! the style document) so that both Bevy and WGPU renderers see the
//! same configuration.
//!
//! [`MapState`]: crate::MapState
// ---------------------------------------------------------------------------

use crate::tile_manager::VisibleTile;
use rustial_math::{tile_bounds_world, TileId, WorldBounds};

// ---------------------------------------------------------------------------
// PlaceholderStyle
// ---------------------------------------------------------------------------

/// Visual configuration for loading-placeholder tiles.
///
/// A single instance is stored on [`MapState`](crate::MapState) and
/// applies to all sources.  All colours use linear RGBA `[f32; 4]`.
#[derive(Debug, Clone, PartialEq)]
pub struct PlaceholderStyle {
    /// Background fill colour for the placeholder quad.
    ///
    /// Default: light grey `[0.90, 0.90, 0.90, 1.0]`.
    pub background_color: [f32; 4],

    /// Colour of optional skeleton-chrome lines drawn inside the
    /// placeholder to hint at future geometry (roads, blocks, etc.).
    ///
    /// Set the alpha to 0 to disable skeleton lines entirely.
    ///
    /// Default: slightly darker grey `[0.82, 0.82, 0.82, 1.0]`.
    pub skeleton_line_color: [f32; 4],

    /// Whether the pulsing shimmer animation is enabled.
    ///
    /// When `true`, renderers modulate the placeholder opacity using
    /// [`LoadingPlaceholder::animation_phase`].
    ///
    /// Default: `true`.
    pub animate: bool,

    /// Speed of the shimmer animation in cycles per second.
    ///
    /// Higher values produce a faster pulse.  Ignored when `animate`
    /// is `false`.
    ///
    /// Default: `1.2` Hz.
    pub shimmer_speed: f32,

    /// Peak-to-trough opacity amplitude of the shimmer effect.
    ///
    /// A value of `0.15` means the opacity oscillates between
    /// `1.0 - 0.15 = 0.85` and `1.0`.
    ///
    /// Default: `0.15`.
    pub shimmer_amplitude: f32,
}

impl Default for PlaceholderStyle {
    fn default() -> Self {
        Self {
            background_color: [0.90, 0.90, 0.90, 1.0],
            skeleton_line_color: [0.82, 0.82, 0.82, 1.0],
            animate: true,
            shimmer_speed: 1.2,
            shimmer_amplitude: 0.15,
        }
    }
}

impl PlaceholderStyle {
    /// Create a placeholder style with the default settings.
    pub fn new() -> Self {
        Self::default()
    }

    /// Builder: set the background fill colour.
    pub fn with_background_color(mut self, color: [f32; 4]) -> Self {
        self.background_color = color;
        self
    }

    /// Builder: set the skeleton line colour.
    pub fn with_skeleton_line_color(mut self, color: [f32; 4]) -> Self {
        self.skeleton_line_color = color;
        self
    }

    /// Builder: enable or disable shimmer animation.
    pub fn with_animate(mut self, animate: bool) -> Self {
        self.animate = animate;
        self
    }

    /// Builder: set the shimmer animation speed in Hz.
    pub fn with_shimmer_speed(mut self, speed: f32) -> Self {
        self.shimmer_speed = speed;
        self
    }

    /// Builder: set the shimmer amplitude.
    pub fn with_shimmer_amplitude(mut self, amplitude: f32) -> Self {
        self.shimmer_amplitude = amplitude;
        self
    }

    /// Compute the shimmer opacity multiplier for a given animation phase.
    ///
    /// Returns a value in `[1.0 - amplitude, 1.0]` when `animate` is
    /// `true`, or `1.0` when disabled.
    #[inline]
    pub fn shimmer_opacity(&self, phase: f32) -> f32 {
        if !self.animate {
            return 1.0;
        }
        // Smooth pulse using a cosine curve: 1.0 at phase=0, trough at
        // phase=0.5, back to 1.0 at phase=1.0.
        let t = (phase * std::f32::consts::TAU).cos(); // [-1, 1]
        1.0 - self.shimmer_amplitude * 0.5 * (1.0 - t)
    }
}

// ---------------------------------------------------------------------------
// LoadingPlaceholder
// ---------------------------------------------------------------------------

/// A single loading-placeholder tile emitted for rendering.
///
/// Renderers draw a styled rectangle at `bounds` with the colour and
/// opacity derived from the associated [`PlaceholderStyle`].
#[derive(Debug, Clone, PartialEq)]
pub struct LoadingPlaceholder {
    /// The tile this placeholder stands in for.
    pub tile: TileId,

    /// World-space bounding box of the tile (Web Mercator meters).
    pub bounds: WorldBounds,

    /// Animation phase in `[0.0, 1.0)` for the shimmer effect.
    ///
    /// Derived from `(time * shimmer_speed) % 1.0` so all placeholders
    /// pulse in sync.
    pub animation_phase: f32,
}

// ---------------------------------------------------------------------------
// PlaceholderGenerator
// ---------------------------------------------------------------------------

/// Builds [`LoadingPlaceholder`] entries from the current visible tile set.
///
/// Stateless -- call [`generate`](Self::generate) each frame with the
/// latest visible tiles and a monotonic time value.
pub struct PlaceholderGenerator;

impl PlaceholderGenerator {
    /// Scan the visible tile set and emit a [`LoadingPlaceholder`] for
    /// every tile that has no data.
    ///
    /// # Arguments
    ///
    /// * `visible_tiles` -- The current frame's visible tile set from
    ///   the tile manager.
    /// * `style` -- Active placeholder style (controls animation speed).
    /// * `time_seconds` -- Monotonic time in seconds (e.g.
    ///   `std::time::Instant::elapsed().as_secs_f64()`).  Used to
    ///   compute the shimmer animation phase.
    pub fn generate(
        visible_tiles: &[VisibleTile],
        style: &PlaceholderStyle,
        time_seconds: f64,
    ) -> Vec<LoadingPlaceholder> {
        let phase = if style.animate {
            ((time_seconds * style.shimmer_speed as f64) % 1.0) as f32
        } else {
            0.0
        };

        visible_tiles
            .iter()
            .filter(|t| t.data.is_none())
            .map(|t| LoadingPlaceholder {
                tile: t.target,
                bounds: tile_bounds_world(&t.target),
                animation_phase: phase,
            })
            .collect()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tile_source::{DecodedImage, TileData};
    use rustial_math::TileId;
    use std::sync::Arc;

    /// Helper: create a `VisibleTile` with no data.
    fn missing_tile(zoom: u8, x: u32, y: u32) -> VisibleTile {
        let id = TileId::new(zoom, x, y);
        VisibleTile {
            target: id,
            actual: id,
            data: None,
            fade_opacity: 1.0,
        }
    }

    /// Helper: create a `VisibleTile` with dummy raster data.
    fn loaded_tile(zoom: u8, x: u32, y: u32) -> VisibleTile {
        let id = TileId::new(zoom, x, y);
        VisibleTile {
            target: id,
            actual: id,
            data: Some(TileData::Raster(DecodedImage {
                width: 1,
                height: 1,
                data: Arc::new(vec![0u8; 4]),
            })),
            fade_opacity: 1.0,
        }
    }

    // -- PlaceholderStyle ---------------------------------------------------

    #[test]
    fn default_style_has_expected_values() {
        let s = PlaceholderStyle::default();
        assert_eq!(s.background_color, [0.90, 0.90, 0.90, 1.0]);
        assert!(s.animate);
        assert!((s.shimmer_speed - 1.2).abs() < 1e-5);
    }

    #[test]
    fn shimmer_opacity_is_one_when_disabled() {
        let s = PlaceholderStyle::new().with_animate(false);
        assert!((s.shimmer_opacity(0.0) - 1.0).abs() < 1e-6);
        assert!((s.shimmer_opacity(0.5) - 1.0).abs() < 1e-6);
    }

    #[test]
    fn shimmer_opacity_peaks_at_phase_zero() {
        let s = PlaceholderStyle::new()
            .with_animate(true)
            .with_shimmer_amplitude(0.2);
        // At phase 0 the cosine is 1 -> opacity = 1.0.
        assert!((s.shimmer_opacity(0.0) - 1.0).abs() < 1e-6);
        // At phase 0.5 the cosine is -1 -> opacity = 1.0 - 0.2 = 0.8.
        assert!((s.shimmer_opacity(0.5) - 0.8).abs() < 1e-5);
    }

    #[test]
    fn builder_overrides_defaults() {
        let s = PlaceholderStyle::new()
            .with_background_color([1.0, 0.0, 0.0, 1.0])
            .with_skeleton_line_color([0.0, 1.0, 0.0, 1.0])
            .with_shimmer_speed(2.0)
            .with_shimmer_amplitude(0.3);
        assert_eq!(s.background_color, [1.0, 0.0, 0.0, 1.0]);
        assert_eq!(s.skeleton_line_color, [0.0, 1.0, 0.0, 1.0]);
        assert!((s.shimmer_speed - 2.0).abs() < 1e-6);
        assert!((s.shimmer_amplitude - 0.3).abs() < 1e-6);
    }

    // -- PlaceholderGenerator -----------------------------------------------

    #[test]
    fn generates_placeholders_for_missing_tiles_only() {
        let tiles = vec![
            missing_tile(10, 512, 512),
            loaded_tile(10, 513, 512),
            missing_tile(10, 514, 512),
        ];
        let style = PlaceholderStyle::default();
        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.0);

        assert_eq!(placeholders.len(), 2);
        assert_eq!(placeholders[0].tile, TileId::new(10, 512, 512));
        assert_eq!(placeholders[1].tile, TileId::new(10, 514, 512));
    }

    #[test]
    fn generates_nothing_when_all_loaded() {
        let tiles = vec![loaded_tile(5, 10, 10), loaded_tile(5, 11, 10)];
        let style = PlaceholderStyle::default();
        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 1.0);

        assert!(placeholders.is_empty());
    }

    #[test]
    fn generates_nothing_for_empty_tile_set() {
        let style = PlaceholderStyle::default();
        let placeholders = PlaceholderGenerator::generate(&[], &style, 0.0);
        assert!(placeholders.is_empty());
    }

    #[test]
    fn animation_phase_is_zero_when_disabled() {
        let tiles = vec![missing_tile(5, 0, 0)];
        let style = PlaceholderStyle::new().with_animate(false);
        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 99.9);
        assert!((placeholders[0].animation_phase).abs() < 1e-6);
    }

    #[test]
    fn animation_phase_wraps_around_one() {
        let tiles = vec![missing_tile(5, 0, 0)];
        let style = PlaceholderStyle::new()
            .with_animate(true)
            .with_shimmer_speed(1.0);
        // time=2.7, speed=1.0 -> phase = 2.7 % 1.0 = 0.7
        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 2.7);
        assert!((placeholders[0].animation_phase - 0.7).abs() < 1e-5);
    }

    #[test]
    fn bounds_match_tile_bounds_world() {
        let tiles = vec![missing_tile(3, 4, 2)];
        let style = PlaceholderStyle::default();
        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.0);

        let expected = tile_bounds_world(&TileId::new(3, 4, 2));
        let actual = &placeholders[0].bounds;
        assert!((actual.min.position.x - expected.min.position.x).abs() < 1e-3);
        assert!((actual.min.position.y - expected.min.position.y).abs() < 1e-3);
        assert!((actual.max.position.x - expected.max.position.x).abs() < 1e-3);
        assert!((actual.max.position.y - expected.max.position.y).abs() < 1e-3);
    }

    #[test]
    fn all_placeholders_share_same_phase() {
        let tiles = vec![
            missing_tile(5, 0, 0),
            missing_tile(5, 1, 0),
            missing_tile(5, 2, 0),
        ];
        let style = PlaceholderStyle::default();
        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.42);
        let p0 = placeholders[0].animation_phase;
        for ph in &placeholders {
            assert!((ph.animation_phase - p0).abs() < 1e-6);
        }
    }
}