rustial-renderer-bevy 1.0.0

Bevy Engine renderer for the rustial 2.5D map engine
// ---------------------------------------------------------------------------
//! # Built-in HTTP tile-layer wiring
//!
//! This module (behind the `http-tiles` feature flag) now wires Bevy's built-in
//! HTTP raster source path into an **engine-owned** [`TileLayer`](rustial_engine::TileLayer)
//! instead of maintaining a renderer-side mirror fetch/cache loop.
//!
//! ## Architecture
//!
//! ```text
//! Bevy plugin startup
//!   -> init_http_client()
//!   -> setup_engine_http_tile_layer()
//!        -> BevyHttpClient implements rustial_engine::HttpClient
//!        -> PooledTileSource
//!        -> TileLayer::new_with_selection_config(...)
//!        -> MapState::push_layer(...)
//!
//! MapState::update()
//!   -> TileLayer::update_with_view(...)
//!   -> TileManager::poll/request/cache/fallback/overzoom
//!   -> MapState.visible_tiles
//!
//! Bevy renderer
//!   -> sync_tiles
//!   -> upload_textures
//! ```
//!
//! ## Why this path exists
//!
//! The previous Bevy helper computed desired tiles separately from the engine,
//! fetched them with renderer-local logic, then pushed a synthetic visible-tile
//! list back into [`MapState`](crate::plugin::MapStateResource). That caused
//! divergence from the WGPU path and bypassed engine policies such as source
//! zoom clamping and overzoom fallback.
//!
//! The current design keeps:
//!
//! - tile selection in `rustial-engine`
//! - source zoom limits in `TileSelectionConfig`
//! - parent fallback and overzoom in `TileManager`
//! - renderer parity between Bevy and WGPU
//!
//! ## Runtime bridging
//!
//! `reqwest` still requires a tokio runtime.  This module therefore creates a
//! dedicated tokio runtime at startup and exposes a small adapter
//! ([`BevyHttpClient`]) that satisfies the engine's [`HttpClient`] trait.
//! Completed responses are returned through an internal shared queue and picked
//! up by the engine when `TileManager::poll()` runs during `MapState::update()`.
// ---------------------------------------------------------------------------

use crate::plugin::MapStateResource;
use bevy::prelude::*;
use rustial_engine::{
    DecodedImage, HttpClient, HttpRequest, HttpResponse, PooledRasterTileSourceConfig,
    PooledTileSource, SharedHttpClient, TileDecoder, TileLayer, TileSelectionConfig,
};
use std::sync::{Arc, Mutex};

const DEFAULT_TIMEOUT_SECS: u64 = 10;
const BUILTIN_TILE_LAYER_NAME: &str = "__rustial_builtin_http_tiles";

/// Configuration for the built-in Bevy HTTP raster-tile integration.
///
/// This resource controls how the Bevy plugin creates the engine-owned
/// built-in [`TileLayer`](rustial_engine::TileLayer) when the `http-tiles`
/// feature is enabled.
#[derive(Resource, Clone, Debug)]
pub struct TileFetchConfig {
    /// URL template with `{z}`, `{x}`, and `{y}` placeholders.
    pub url_template: String,
    /// HTTP `User-Agent` header sent with every request.
    pub user_agent: String,
    /// Minimum zoom level supported by the tile source.
    pub source_min_zoom: u8,
    /// Maximum zoom level supported by the tile source.
    ///
    /// Requests are clamped to this zoom and higher camera zoom levels are
    /// rendered as overzoomed display tiles backed by the clamped source tiles.
    pub source_max_zoom: u8,
    /// Maximum number of concurrent in-flight HTTP requests.
    pub max_concurrent: usize,
    /// Maximum retained tile count for the engine tile cache / visible budget.
    pub max_cached: usize,
}

impl From<&TileFetchConfig> for PooledRasterTileSourceConfig {
    fn from(value: &TileFetchConfig) -> Self {
        Self {
            url_template: value.url_template.clone(),
            headers: vec![("User-Agent".into(), value.user_agent.clone())],
            source_min_zoom: value.source_min_zoom,
            source_max_zoom: value.source_max_zoom,
            max_concurrent: value.max_concurrent,
            max_cached: value.max_cached,
        }
    }
}

impl Default for TileFetchConfig {
    fn default() -> Self {
        let shared = PooledRasterTileSourceConfig::default();
        let user_agent = shared
            .headers
            .iter()
            .find(|(name, _)| name.eq_ignore_ascii_case("User-Agent"))
            .map(|(_, value)| value.clone())
            .unwrap_or_default();

        Self {
            url_template: shared.url_template,
            user_agent,
            source_min_zoom: shared.source_min_zoom,
            source_max_zoom: shared.source_max_zoom,
            max_concurrent: shared.max_concurrent,
            max_cached: shared.max_cached,
        }
    }
}

#[derive(Resource)]
pub(crate) struct TokioRuntime(pub Arc<tokio::runtime::Runtime>);

#[derive(Resource, Clone)]
pub(crate) struct SharedReqwestClient(pub Arc<reqwest::Client>);

#[derive(Resource, Default)]
pub(crate) struct BevyHttpClientState {
    completed: Arc<Mutex<Vec<(String, Result<HttpResponse, String>)>>>,

}

/// Shared, deduplicating HTTP client resource.
///
/// Wraps the Bevy HTTP transport in a [`SharedHttpClient`] so that
/// multiple tile sources (raster, vector, terrain) issuing requests
/// for the same URL share a single network round-trip.  Clones of
/// this resource can be passed to source factories.
#[derive(Resource, Clone)]
pub struct SharedHttpClientResource(pub SharedHttpClient);

#[derive(Clone)]
struct BevyHttpClient {
    client: Arc<reqwest::Client>,
    runtime: Arc<tokio::runtime::Runtime>,
    completed: Arc<Mutex<Vec<(String, Result<HttpResponse, String>)>>>,

}

impl HttpClient for BevyHttpClient {
    fn send(&self, request: HttpRequest) {
        let client = Arc::clone(&self.client);
        let completed = Arc::clone(&self.completed);
        let method = request.method.clone();
        let url = request.url.clone();
        let headers = request.headers.clone();

        self.runtime.spawn(async move {
            let result = fetch_http_response(client.as_ref(), &method, &url, &headers).await;
            if let Ok(mut out) = completed.lock() {
                out.push((url, result));
            }
        });
    }

    fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
        if let Ok(mut out) = self.completed.lock() {
            std::mem::take(&mut *out)
        } else {
            Vec::new()
        }
    }
}

struct BevyImageTileDecoder;

impl TileDecoder for BevyImageTileDecoder {
    fn decode(&self, bytes: &[u8]) -> Result<DecodedImage, rustial_engine::TileError> {
        match image::load_from_memory(bytes) {
            Ok(img) => {
                let decoded = img.to_rgba8();
                Ok(DecodedImage {
                    width: decoded.width(),
                    height: decoded.height(),
                    data: decoded.into_raw().into(),
                })
            }
            Err(err) => Err(rustial_engine::TileError::Decode(err.to_string())),
        }
    }
}

/// Build the shared reqwest client and dedicated tokio runtime used by the
/// built-in engine-owned HTTP tile layer.
pub(crate) fn init_http_client(mut commands: Commands) {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(1)
        .enable_all()
        .build()
        .expect("failed to build tokio runtime for tile fetching");

    let client = rt.block_on(async {
        reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS))
            .pool_max_idle_per_host(8)
            .build()
            .expect("failed to build HTTP client")
    });

    commands.insert_resource(TokioRuntime(Arc::new(rt)));
    commands.insert_resource(SharedReqwestClient(Arc::new(client)));
    commands.init_resource::<BevyHttpClientState>();

    // The SharedHttpClientResource is created lazily in
    // setup_engine_http_tile_layer once the BevyHttpClientState is
    // available, because it needs the completed-response queue.
}

/// Install the built-in engine-owned HTTP raster tile layer into `MapState`.
pub(crate) fn setup_engine_http_tile_layer(
    mut commands: Commands,
    mut state: ResMut<MapStateResource>,
    config: Res<TileFetchConfig>,
    rt: Option<Res<TokioRuntime>>,
    client: Option<Res<SharedReqwestClient>>,
    http_state: Option<Res<BevyHttpClientState>>,
) {
    let Some(rt) = rt else { return };
    let Some(client) = client else { return };
    let Some(http_state) = http_state else { return };

    let already_present = state
        .0
        .layers()
        .iter()
        .any(|layer| layer.name() == BUILTIN_TILE_LAYER_NAME);
    if already_present {
        return;
    }

    let shared = PooledRasterTileSourceConfig::from(&*config);

    let bevy_client = BevyHttpClient {
        client: Arc::clone(&client.0),
        runtime: Arc::clone(&rt.0),
        completed: Arc::clone(&http_state.completed),
    };

    // Wrap in a SharedHttpClient for cross-source request dedup.
    // Multiple tile sources cloning this handle will share a single
    // HTTP round-trip for identical URLs.
    let shared_client = SharedHttpClient::new(Box::new(bevy_client));
    commands.insert_resource(SharedHttpClientResource(shared_client.clone()));

    let mut source = PooledTileSource::with_decoder(
        shared.url_template.clone(),
        Box::new(shared_client),
        shared.max_concurrent,
        Box::new(BevyImageTileDecoder),
    );
    for (name, value) in &shared.headers {
        source = source.with_header(name.clone(), value.clone());
    }

    let selection = TileSelectionConfig {
        visible_tile_budget: shared.max_cached,
        source_min_zoom: shared.source_min_zoom,
        source_max_zoom: shared.source_max_zoom,
        // Enable child fallback so cached higher-zoom tiles can cover
        // lower-zoom targets during zoom-out animations, avoiding gaps
        // while the new tiles load.
        max_child_depth: 2,
        // Smooth cross-fade between fallback and freshly loaded tiles.
        raster_fade_duration: 0.3,
        ..TileSelectionConfig::default()
    };

    state.0.push_layer(Box::new(TileLayer::new_with_selection_config(
        BUILTIN_TILE_LAYER_NAME,
        Box::new(source),
        shared.max_cached,
        selection,
    )));
}

/// Placeholder hook kept for schedule compatibility.
///
/// The engine now updates the built-in tile layer directly during
/// `MapState::update()`, so this system intentionally does nothing.
pub(crate) fn sync_builtin_tile_layer_visibility(_state: ResMut<MapStateResource>) {}

async fn fetch_http_response(
    client: &reqwest::Client,
    method: &str,
    url: &str,
    headers: &[(String, String)],
) -> Result<HttpResponse, String> {
    let req_method = reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::GET);
    let mut req = client.request(req_method, url);
    for (name, value) in headers {
        req = req.header(name, value);
    }

    let resp = req.send().await.map_err(|err| err.to_string())?;
    let status = resp.status().as_u16();
    let mut out_headers = Vec::new();
    for (name, value) in resp.headers() {
        if let Ok(value) = value.to_str() {
            out_headers.push((name.as_str().to_owned(), value.to_owned()));
        }
    }
    let body = resp.bytes().await.map_err(|err| err.to_string())?;

    Ok(HttpResponse {
        status,
        body: body.to_vec(),
        headers: out_headers,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plugin::MapStateResource;
    use rustial_engine::{GeoCoord, MapState};

    fn test_runtime() -> Arc<tokio::runtime::Runtime> {
        Arc::new(
            tokio::runtime::Builder::new_multi_thread()
                .worker_threads(1)
                .enable_all()
                .build()
                .expect("tokio runtime"),
        )
    }

    fn test_client() -> Arc<reqwest::Client> {
        Arc::new(reqwest::Client::builder().build().expect("reqwest client"))
    }

    #[test]
    fn setup_engine_http_tile_layer_inserts_builtin_tile_layer() {
        let mut app = App::new();
        app.insert_resource(MapStateResource(MapState::new()));
        app.insert_resource(TileFetchConfig::default());
        app.insert_resource(TokioRuntime(test_runtime()));
        app.insert_resource(SharedReqwestClient(test_client()));
        app.init_resource::<BevyHttpClientState>();
        app.add_systems(Startup, setup_engine_http_tile_layer);

        app.update();

        let state = app.world().resource::<MapStateResource>();
        assert!(state.0.layers().iter().any(|layer| layer.name() == BUILTIN_TILE_LAYER_NAME));
    }

    #[test]
    fn map_state_with_builtin_tile_layer_produces_visible_tiles_after_update() {
        let mut app = App::new();
        app.insert_resource(MapStateResource(MapState::new()));
        app.insert_resource(TileFetchConfig::default());
        app.insert_resource(TokioRuntime(test_runtime()));
        app.insert_resource(SharedReqwestClient(test_client()));
        app.init_resource::<BevyHttpClientState>();
        app.add_systems(Startup, setup_engine_http_tile_layer);

        {
            let mut state = app.world_mut().resource_mut::<MapStateResource>();
            state.0.set_camera_target(GeoCoord::from_lat_lon(51.1, 17.0));
            state.0.set_camera_distance(997_600.0);
            state.0.set_viewport(1280, 720);
        }

        app.update();
        {
            let mut state = app.world_mut().resource_mut::<MapStateResource>();
            state.0.update();
        }

        let state = app.world().resource::<MapStateResource>();
        assert!(!state.0.visible_tiles().is_empty());
    }

    #[test]
    fn tile_fetch_config_maps_to_shared_engine_config() {
        let config = TileFetchConfig::default();
        let shared = PooledRasterTileSourceConfig::from(&config);

        assert_eq!(shared.url_template, config.url_template);
        assert_eq!(shared.source_min_zoom, config.source_min_zoom);
        assert_eq!(shared.source_max_zoom, config.source_max_zoom);
        assert_eq!(shared.max_concurrent, config.max_concurrent);
        assert_eq!(shared.max_cached, config.max_cached);
        assert_eq!(shared.headers.len(), 1);
        assert_eq!(shared.headers[0].0, "User-Agent");
        assert_eq!(shared.headers[0].1, config.user_agent);
    }
}