Skip to main content

rustial_engine/
loading_placeholder.rs

1// ---------------------------------------------------------------------------
2//! # Loading placeholders and skeleton rendering
3//!
4//! When tiles are in the visible set but have no data yet, the engine emits
5//! [`LoadingPlaceholder`] descriptors so that renderers can draw styled
6//! rectangles at the tile's world bounds instead of leaving blank gaps.
7//! This matches the Mapbox "skeleton chrome" loading experience.
8//!
9//! ## Data flow
10//!
11//! ```text
12//! VisibleTile { data: None }          PlaceholderStyle (per-source config)
13//!          |                                    |
14//!          +----> PlaceholderGenerator::generate +
15//!                         |
16//!                 Vec<LoadingPlaceholder>
17//!                         |
18//!                    FrameOutput.placeholders
19//!                         |
20//!             +-----------+-----------+
21//!             |                       |
22//!      WGPU renderer           Bevy renderer
23//!      (solid quad pass)       (entity sync)
24//! ```
25//!
26//! ## Animation
27//!
28//! Each placeholder carries an `animation_phase` in `[0.0, 1.0)` that
29//! renderers can use to drive a pulsing opacity or horizontal shimmer
30//! effect.  The phase is derived from a monotonic time value so that all
31//! placeholders pulse in sync regardless of spawn order.
32//!
33//! ## Style
34//!
35//! [`PlaceholderStyle`] is a lightweight value-type that controls the
36//! visual appearance.  It lives on [`MapState`] (and can be set via
37//! the style document) so that both Bevy and WGPU renderers see the
38//! same configuration.
39//!
40//! [`MapState`]: crate::MapState
41// ---------------------------------------------------------------------------
42
43use crate::tile_manager::VisibleTile;
44use rustial_math::{tile_bounds_world, TileId, WorldBounds};
45
46// ---------------------------------------------------------------------------
47// PlaceholderStyle
48// ---------------------------------------------------------------------------
49
50/// Visual configuration for loading-placeholder tiles.
51///
52/// A single instance is stored on [`MapState`](crate::MapState) and
53/// applies to all sources.  All colours use linear RGBA `[f32; 4]`.
54#[derive(Debug, Clone, PartialEq)]
55pub struct PlaceholderStyle {
56    /// Background fill colour for the placeholder quad.
57    ///
58    /// Default: light grey `[0.90, 0.90, 0.90, 1.0]`.
59    pub background_color: [f32; 4],
60
61    /// Colour of optional skeleton-chrome lines drawn inside the
62    /// placeholder to hint at future geometry (roads, blocks, etc.).
63    ///
64    /// Set the alpha to 0 to disable skeleton lines entirely.
65    ///
66    /// Default: slightly darker grey `[0.82, 0.82, 0.82, 1.0]`.
67    pub skeleton_line_color: [f32; 4],
68
69    /// Whether the pulsing shimmer animation is enabled.
70    ///
71    /// When `true`, renderers modulate the placeholder opacity using
72    /// [`LoadingPlaceholder::animation_phase`].
73    ///
74    /// Default: `true`.
75    pub animate: bool,
76
77    /// Speed of the shimmer animation in cycles per second.
78    ///
79    /// Higher values produce a faster pulse.  Ignored when `animate`
80    /// is `false`.
81    ///
82    /// Default: `1.2` Hz.
83    pub shimmer_speed: f32,
84
85    /// Peak-to-trough opacity amplitude of the shimmer effect.
86    ///
87    /// A value of `0.15` means the opacity oscillates between
88    /// `1.0 - 0.15 = 0.85` and `1.0`.
89    ///
90    /// Default: `0.15`.
91    pub shimmer_amplitude: f32,
92}
93
94impl Default for PlaceholderStyle {
95    fn default() -> Self {
96        Self {
97            background_color: [0.90, 0.90, 0.90, 1.0],
98            skeleton_line_color: [0.82, 0.82, 0.82, 1.0],
99            animate: true,
100            shimmer_speed: 1.2,
101            shimmer_amplitude: 0.15,
102        }
103    }
104}
105
106impl PlaceholderStyle {
107    /// Create a placeholder style with the default settings.
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Builder: set the background fill colour.
113    pub fn with_background_color(mut self, color: [f32; 4]) -> Self {
114        self.background_color = color;
115        self
116    }
117
118    /// Builder: set the skeleton line colour.
119    pub fn with_skeleton_line_color(mut self, color: [f32; 4]) -> Self {
120        self.skeleton_line_color = color;
121        self
122    }
123
124    /// Builder: enable or disable shimmer animation.
125    pub fn with_animate(mut self, animate: bool) -> Self {
126        self.animate = animate;
127        self
128    }
129
130    /// Builder: set the shimmer animation speed in Hz.
131    pub fn with_shimmer_speed(mut self, speed: f32) -> Self {
132        self.shimmer_speed = speed;
133        self
134    }
135
136    /// Builder: set the shimmer amplitude.
137    pub fn with_shimmer_amplitude(mut self, amplitude: f32) -> Self {
138        self.shimmer_amplitude = amplitude;
139        self
140    }
141
142    /// Compute the shimmer opacity multiplier for a given animation phase.
143    ///
144    /// Returns a value in `[1.0 - amplitude, 1.0]` when `animate` is
145    /// `true`, or `1.0` when disabled.
146    #[inline]
147    pub fn shimmer_opacity(&self, phase: f32) -> f32 {
148        if !self.animate {
149            return 1.0;
150        }
151        // Smooth pulse using a cosine curve: 1.0 at phase=0, trough at
152        // phase=0.5, back to 1.0 at phase=1.0.
153        let t = (phase * std::f32::consts::TAU).cos(); // [-1, 1]
154        1.0 - self.shimmer_amplitude * 0.5 * (1.0 - t)
155    }
156}
157
158// ---------------------------------------------------------------------------
159// LoadingPlaceholder
160// ---------------------------------------------------------------------------
161
162/// A single loading-placeholder tile emitted for rendering.
163///
164/// Renderers draw a styled rectangle at `bounds` with the colour and
165/// opacity derived from the associated [`PlaceholderStyle`].
166#[derive(Debug, Clone, PartialEq)]
167pub struct LoadingPlaceholder {
168    /// The tile this placeholder stands in for.
169    pub tile: TileId,
170
171    /// World-space bounding box of the tile (Web Mercator meters).
172    pub bounds: WorldBounds,
173
174    /// Animation phase in `[0.0, 1.0)` for the shimmer effect.
175    ///
176    /// Derived from `(time * shimmer_speed) % 1.0` so all placeholders
177    /// pulse in sync.
178    pub animation_phase: f32,
179}
180
181// ---------------------------------------------------------------------------
182// PlaceholderGenerator
183// ---------------------------------------------------------------------------
184
185/// Builds [`LoadingPlaceholder`] entries from the current visible tile set.
186///
187/// Stateless -- call [`generate`](Self::generate) each frame with the
188/// latest visible tiles and a monotonic time value.
189pub struct PlaceholderGenerator;
190
191impl PlaceholderGenerator {
192    /// Scan the visible tile set and emit a [`LoadingPlaceholder`] for
193    /// every tile that has no data.
194    ///
195    /// # Arguments
196    ///
197    /// * `visible_tiles` -- The current frame's visible tile set from
198    ///   the tile manager.
199    /// * `style` -- Active placeholder style (controls animation speed).
200    /// * `time_seconds` -- Monotonic time in seconds (e.g.
201    ///   `std::time::Instant::elapsed().as_secs_f64()`).  Used to
202    ///   compute the shimmer animation phase.
203    pub fn generate(
204        visible_tiles: &[VisibleTile],
205        style: &PlaceholderStyle,
206        time_seconds: f64,
207    ) -> Vec<LoadingPlaceholder> {
208        let phase = if style.animate {
209            ((time_seconds * style.shimmer_speed as f64) % 1.0) as f32
210        } else {
211            0.0
212        };
213
214        visible_tiles
215            .iter()
216            .filter(|t| t.data.is_none())
217            .map(|t| LoadingPlaceholder {
218                tile: t.target,
219                bounds: tile_bounds_world(&t.target),
220                animation_phase: phase,
221            })
222            .collect()
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Tests
228// ---------------------------------------------------------------------------
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::tile_source::{DecodedImage, TileData};
234    use rustial_math::TileId;
235    use std::sync::Arc;
236
237    /// Helper: create a `VisibleTile` with no data.
238    fn missing_tile(zoom: u8, x: u32, y: u32) -> VisibleTile {
239        let id = TileId::new(zoom, x, y);
240        VisibleTile {
241            target: id,
242            actual: id,
243            data: None,
244            fade_opacity: 1.0,
245        }
246    }
247
248    /// Helper: create a `VisibleTile` with dummy raster data.
249    fn loaded_tile(zoom: u8, x: u32, y: u32) -> VisibleTile {
250        let id = TileId::new(zoom, x, y);
251        VisibleTile {
252            target: id,
253            actual: id,
254            data: Some(TileData::Raster(DecodedImage {
255                width: 1,
256                height: 1,
257                data: Arc::new(vec![0u8; 4]),
258            })),
259            fade_opacity: 1.0,
260        }
261    }
262
263    // -- PlaceholderStyle ---------------------------------------------------
264
265    #[test]
266    fn default_style_has_expected_values() {
267        let s = PlaceholderStyle::default();
268        assert_eq!(s.background_color, [0.90, 0.90, 0.90, 1.0]);
269        assert!(s.animate);
270        assert!((s.shimmer_speed - 1.2).abs() < 1e-5);
271    }
272
273    #[test]
274    fn shimmer_opacity_is_one_when_disabled() {
275        let s = PlaceholderStyle::new().with_animate(false);
276        assert!((s.shimmer_opacity(0.0) - 1.0).abs() < 1e-6);
277        assert!((s.shimmer_opacity(0.5) - 1.0).abs() < 1e-6);
278    }
279
280    #[test]
281    fn shimmer_opacity_peaks_at_phase_zero() {
282        let s = PlaceholderStyle::new()
283            .with_animate(true)
284            .with_shimmer_amplitude(0.2);
285        // At phase 0 the cosine is 1 -> opacity = 1.0.
286        assert!((s.shimmer_opacity(0.0) - 1.0).abs() < 1e-6);
287        // At phase 0.5 the cosine is -1 -> opacity = 1.0 - 0.2 = 0.8.
288        assert!((s.shimmer_opacity(0.5) - 0.8).abs() < 1e-5);
289    }
290
291    #[test]
292    fn builder_overrides_defaults() {
293        let s = PlaceholderStyle::new()
294            .with_background_color([1.0, 0.0, 0.0, 1.0])
295            .with_skeleton_line_color([0.0, 1.0, 0.0, 1.0])
296            .with_shimmer_speed(2.0)
297            .with_shimmer_amplitude(0.3);
298        assert_eq!(s.background_color, [1.0, 0.0, 0.0, 1.0]);
299        assert_eq!(s.skeleton_line_color, [0.0, 1.0, 0.0, 1.0]);
300        assert!((s.shimmer_speed - 2.0).abs() < 1e-6);
301        assert!((s.shimmer_amplitude - 0.3).abs() < 1e-6);
302    }
303
304    // -- PlaceholderGenerator -----------------------------------------------
305
306    #[test]
307    fn generates_placeholders_for_missing_tiles_only() {
308        let tiles = vec![
309            missing_tile(10, 512, 512),
310            loaded_tile(10, 513, 512),
311            missing_tile(10, 514, 512),
312        ];
313        let style = PlaceholderStyle::default();
314        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.0);
315
316        assert_eq!(placeholders.len(), 2);
317        assert_eq!(placeholders[0].tile, TileId::new(10, 512, 512));
318        assert_eq!(placeholders[1].tile, TileId::new(10, 514, 512));
319    }
320
321    #[test]
322    fn generates_nothing_when_all_loaded() {
323        let tiles = vec![loaded_tile(5, 10, 10), loaded_tile(5, 11, 10)];
324        let style = PlaceholderStyle::default();
325        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 1.0);
326
327        assert!(placeholders.is_empty());
328    }
329
330    #[test]
331    fn generates_nothing_for_empty_tile_set() {
332        let style = PlaceholderStyle::default();
333        let placeholders = PlaceholderGenerator::generate(&[], &style, 0.0);
334        assert!(placeholders.is_empty());
335    }
336
337    #[test]
338    fn animation_phase_is_zero_when_disabled() {
339        let tiles = vec![missing_tile(5, 0, 0)];
340        let style = PlaceholderStyle::new().with_animate(false);
341        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 99.9);
342        assert!((placeholders[0].animation_phase).abs() < 1e-6);
343    }
344
345    #[test]
346    fn animation_phase_wraps_around_one() {
347        let tiles = vec![missing_tile(5, 0, 0)];
348        let style = PlaceholderStyle::new()
349            .with_animate(true)
350            .with_shimmer_speed(1.0);
351        // time=2.7, speed=1.0 -> phase = 2.7 % 1.0 = 0.7
352        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 2.7);
353        assert!((placeholders[0].animation_phase - 0.7).abs() < 1e-5);
354    }
355
356    #[test]
357    fn bounds_match_tile_bounds_world() {
358        let tiles = vec![missing_tile(3, 4, 2)];
359        let style = PlaceholderStyle::default();
360        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.0);
361
362        let expected = tile_bounds_world(&TileId::new(3, 4, 2));
363        let actual = &placeholders[0].bounds;
364        assert!((actual.min.position.x - expected.min.position.x).abs() < 1e-3);
365        assert!((actual.min.position.y - expected.min.position.y).abs() < 1e-3);
366        assert!((actual.max.position.x - expected.max.position.x).abs() < 1e-3);
367        assert!((actual.max.position.y - expected.max.position.y).abs() < 1e-3);
368    }
369
370    #[test]
371    fn all_placeholders_share_same_phase() {
372        let tiles = vec![
373            missing_tile(5, 0, 0),
374            missing_tile(5, 1, 0),
375            missing_tile(5, 2, 0),
376        ];
377        let style = PlaceholderStyle::default();
378        let placeholders = PlaceholderGenerator::generate(&tiles, &style, 0.42);
379        let p0 = placeholders[0].animation_phase;
380        for ph in &placeholders {
381            assert!((ph.animation_phase - p0).abs() < 1e-6);
382        }
383    }
384}