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";
#[derive(Resource, Clone, Debug)]
pub struct TileFetchConfig {
pub url_template: String,
pub user_agent: String,
pub source_min_zoom: u8,
pub source_max_zoom: u8,
pub max_concurrent: usize,
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>)>>>,
}
#[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())),
}
}
}
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>();
}
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),
};
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,
max_child_depth: 2,
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,
)));
}
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);
}
}