1use async_lock::Semaphore;
2use bevy::{
3 asset::{
4 io::{AssetReaderError, AssetSourceId},
5 AssetServer, AsyncWriteExt as _,
6 },
7 prelude::{debug, warn, Commands, MessageReader, MessageWriter, Res, ResMut, Resource},
8 tasks::{futures_lite::future, IoTaskPool, Task},
9};
10use std::{collections::VecDeque, path::Path, sync::Arc, time::Instant};
11
12use crate::{
13 AlreadyDownloaded, Coordinates, DownloadSlippyTilesMessage, DownloadStatus, FileExists,
14 SlippyTileCoordinates, SlippyTileDownloadStatus, SlippyTileDownloadTaskKey,
15 SlippyTileDownloadTaskResult, SlippyTileDownloadTasks, SlippyTileDownloadedMessage,
16 SlippyTilesSettings, TileDownloadStatus, TileSize, UseCache, ZoomLevel,
17};
18
19#[derive(Debug)]
20struct BufferedRequest {
21 coords: (u32, u32),
22 zoom_level: ZoomLevel,
23 tile_size: TileSize,
24 endpoint: String,
25 filename: String,
26}
27
28#[derive(Resource, Default)]
29pub struct DownloadRateLimiter {
30 requests: VecDeque<Instant>,
31 buffered_requests: VecDeque<BufferedRequest>,
32}
33
34impl DownloadRateLimiter {
35 fn can_make_request(&mut self, now: Instant, settings: &SlippyTilesSettings) -> bool {
36 while let Some(time) = self.requests.front() {
38 if now.duration_since(*time) > settings.rate_limit_window {
39 self.requests.pop_front();
40 } else {
41 break;
42 }
43 }
44
45 self.requests.len() < settings.rate_limit_requests
47 }
48
49 fn buffer_request(
50 &mut self,
51 coords: (u32, u32),
52 zoom_level: ZoomLevel,
53 tile_size: TileSize,
54 endpoint: String,
55 filename: String,
56 ) {
57 self.buffered_requests.push_back(BufferedRequest {
58 coords,
59 zoom_level,
60 tile_size,
61 endpoint,
62 filename,
63 });
64 }
65
66 fn process_buffered_requests(
67 &mut self,
68 slippy_tile_download_tasks: &mut ResMut<SlippyTileDownloadTasks>,
69 slippy_tile_download_status: &mut ResMut<SlippyTileDownloadStatus>,
70 asset_server: &AssetServer,
71 download_semaphore: &DownloadSemaphore,
72 settings: &SlippyTilesSettings,
73 ) {
74 let now = Instant::now();
75 while self.can_make_request(now, settings) {
76 if let Some(request) = self.buffered_requests.pop_front() {
77 let spc = SlippyTileCoordinates {
78 x: request.coords.0,
79 y: request.coords.1,
80 };
81
82 download_and_track_slippy_tile(
83 spc,
84 request.zoom_level,
85 request.tile_size,
86 request.endpoint,
87 request.filename,
88 slippy_tile_download_tasks,
89 slippy_tile_download_status,
90 asset_server,
91 download_semaphore,
92 settings,
93 );
94
95 self.requests.push_back(now);
96 } else {
97 break;
98 }
99 }
100 }
101}
102
103#[derive(Resource)]
104pub struct DownloadSemaphore(Arc<Semaphore>);
105
106impl DownloadSemaphore {
107 fn new(n: usize) -> Self {
108 Self(Arc::new(Semaphore::new(n)))
109 }
110
111 fn semaphore(&self) -> Arc<Semaphore> {
112 Arc::clone(&self.0)
113 }
114}
115
116pub(crate) fn initialize_semaphore(
117 mut commands: Commands,
118 slippy_tiles_settings: Res<SlippyTilesSettings>,
119) {
120 let semaphore = DownloadSemaphore::new(slippy_tiles_settings.max_concurrent_downloads);
121 commands.insert_resource(semaphore);
122}
123
124pub fn download_slippy_tiles(
126 mut download_slippy_tile_messages: MessageReader<DownloadSlippyTilesMessage>,
127 slippy_tiles_settings: Res<SlippyTilesSettings>,
128 mut slippy_tile_download_status: ResMut<SlippyTileDownloadStatus>,
129 mut slippy_tile_download_tasks: ResMut<SlippyTileDownloadTasks>,
130 mut rate_limiter: ResMut<DownloadRateLimiter>,
131 download_semaphore: Res<DownloadSemaphore>,
132 asset_server: Res<AssetServer>,
133) {
134 rate_limiter.process_buffered_requests(
136 &mut slippy_tile_download_tasks,
137 &mut slippy_tile_download_status,
138 &asset_server,
139 &download_semaphore,
140 &slippy_tiles_settings,
141 );
142
143 for download_slippy_tile in download_slippy_tile_messages.read() {
144 let radius = download_slippy_tile.radius.0;
145 let slippy_tile_coords = download_slippy_tile.get_slippy_tile_coordinates();
146
147 let min_x = slippy_tile_coords.x.saturating_sub(radius as u32);
149 let min_y = slippy_tile_coords.y.saturating_sub(radius as u32);
150 let max_x = slippy_tile_coords.x.saturating_add(radius as u32);
151 let max_y = slippy_tile_coords.y.saturating_add(radius as u32);
152
153 for x in min_x..=max_x {
154 for y in min_y..=max_y {
155 let spc = SlippyTileCoordinates { x, y };
156 let tiles_directory = slippy_tiles_settings.get_tiles_directory_string();
157 let filename = get_tile_filename(
158 tiles_directory,
159 download_slippy_tile.zoom_level,
160 x,
161 y,
162 download_slippy_tile.tile_size,
163 );
164
165 let already_downloaded = slippy_tile_download_status.contains_key_with_coords(
166 spc,
167 download_slippy_tile.zoom_level,
168 download_slippy_tile.tile_size,
169 );
170
171 let file_exists = async_file_exists(&asset_server, &filename);
172
173 match (
174 UseCache::new(download_slippy_tile.use_cache),
175 AlreadyDownloaded::new(already_downloaded),
176 FileExists::new(file_exists),
177 ) {
178 (_, AlreadyDownloaded::Yes, FileExists::No) => {
180 if let Some(status) = slippy_tile_download_status.0.get(&SlippyTileDownloadTaskKey {
182 slippy_tile_coordinates: spc,
183 zoom_level: download_slippy_tile.zoom_level,
184 tile_size: download_slippy_tile.tile_size,
185 }) {
186 if matches!(status.load_status, DownloadStatus::Downloading) {
187 rate_limiter.buffer_request(
188 (x, y),
189 download_slippy_tile.zoom_level,
190 download_slippy_tile.tile_size,
191 slippy_tiles_settings.endpoint.clone(),
192 filename,
193 );
194 }
195 }
196 }
197 (UseCache::No, _, _)
199 | (UseCache::Yes, AlreadyDownloaded::No, FileExists::No) => {
201 rate_limiter.buffer_request(
202 (x, y),
203 download_slippy_tile.zoom_level,
204 download_slippy_tile.tile_size,
205 slippy_tiles_settings.endpoint.clone(),
206 filename,
207 );
208 }
209 (UseCache::Yes, _, FileExists::Yes) => load_and_track_slippy_tile_from_disk(
211 spc,
212 download_slippy_tile.zoom_level,
213 download_slippy_tile.tile_size,
214 filename,
215 &mut slippy_tile_download_tasks,
216 &mut slippy_tile_download_status,
217 ),
218 }
219 }
220 }
221 }
222}
223
224fn get_tile_filename(
225 tiles_directory: String,
226 zoom_level: ZoomLevel,
227 x: u32,
228 y: u32,
229 tile_size: TileSize,
230) -> String {
231 format!(
232 "{}{}.{}.{}.{}.tile.png",
233 tiles_directory,
234 zoom_level.to_u8(),
235 x,
236 y,
237 tile_size.to_pixels()
238 )
239}
240
241fn async_file_exists(asset_server: &AssetServer, filename: &str) -> bool {
242 let asset_source = match asset_server.get_source(AssetSourceId::Default) {
243 Ok(source) => source,
244 Err(_) => return false,
245 };
246
247 let asset_reader = asset_source.reader();
248 match future::block_on(asset_reader.read(Path::new(filename))) {
249 Ok(_) => true,
250 Err(AssetReaderError::NotFound(_)) => false,
251 Err(_) => false,
252 }
253}
254
255#[allow(clippy::too_many_arguments)]
256fn download_and_track_slippy_tile(
257 spc: SlippyTileCoordinates,
258 zoom_level: ZoomLevel,
259 tile_size: TileSize,
260 endpoint: String,
261 filename: String,
262 slippy_tile_download_tasks: &mut ResMut<SlippyTileDownloadTasks>,
263 slippy_tile_download_status: &mut ResMut<SlippyTileDownloadStatus>,
264 asset_server: &AssetServer,
265 download_semaphore: &DownloadSemaphore,
266 settings: &SlippyTilesSettings,
267) {
268 let task = download_slippy_tile(
269 spc,
270 zoom_level,
271 tile_size,
272 endpoint,
273 filename.clone(),
274 asset_server,
275 download_semaphore,
276 settings.max_retries,
277 );
278
279 slippy_tile_download_tasks.insert(spc.x, spc.y, zoom_level, tile_size, task);
280 slippy_tile_download_status.insert_with_coords(
281 spc,
282 zoom_level,
283 tile_size,
284 filename,
285 DownloadStatus::Downloading,
286 );
287}
288
289#[allow(clippy::too_many_arguments)]
290fn download_slippy_tile(
291 spc: SlippyTileCoordinates,
292 zoom_level: ZoomLevel,
293 tile_size: TileSize,
294 endpoint: String,
295 filename: String,
296 asset_server: &AssetServer,
297 download_semaphore: &DownloadSemaphore,
298 max_retries: u32,
299) -> Task<SlippyTileDownloadTaskResult> {
300 debug!(
301 "Fetching map tile at position {:?} with zoom level {:?} from {:?}",
302 spc, zoom_level, endpoint
303 );
304 let tile_url = get_tile_url(endpoint, tile_size, zoom_level, spc.x, spc.y);
305 spawn_slippy_tile_download_task(
306 tile_url,
307 filename,
308 asset_server,
309 download_semaphore,
310 max_retries,
311 )
312}
313
314fn get_tile_url(
315 endpoint: String,
316 tile_size: TileSize,
317 zoom_level: ZoomLevel,
318 x: u32,
319 y: u32,
320) -> String {
321 format!(
322 "{}/{}/{}/{}{}.png",
323 endpoint,
324 zoom_level.to_u8(),
325 x,
326 y,
327 tile_size.get_url_postfix()
328 )
329}
330
331fn spawn_slippy_tile_download_task(
332 tile_url: String,
333 filename: String,
334 asset_server: &AssetServer,
335 download_semaphore: &DownloadSemaphore,
336 max_retries: u32,
337) -> Task<SlippyTileDownloadTaskResult> {
338 let thread_pool = IoTaskPool::get();
339 let asset_server = asset_server.clone();
340 let semaphore = download_semaphore.semaphore();
341
342 thread_pool.spawn(async move {
343 let mut retries = 0;
344 let result = loop {
345 if retries >= max_retries {
346 warn!("Max retries reached for tile download: {}", tile_url);
347 break Err("Max retries reached".to_string());
348 }
349
350 let request = ehttp::Request {
351 method: "GET".to_owned(),
352 url: tile_url.clone(),
353 body: vec![],
354 headers: ehttp::Headers::new(&[
355 ("User-Agent", "bevy_slippy_tiles/0.7.0 (https://github.com/edouardpoitras/bevy_slippy_tiles)"),
356 ("Accept", "image/png"),
357 ]),
358 };
359
360 let result = {
361 let _guard = semaphore.acquire().await;
362 ehttp::fetch_async(request).await
363 };
364 match result {
365 Ok(response) => {
366 if response.status == 200 {
367 let asset_source = asset_server.get_source(AssetSourceId::Default).unwrap();
368 let asset_writer = match asset_source.writer() {
369 Ok(writer) => writer,
370 Err(e) => {
371 warn!("Failed to get asset writer: {:?}", e);
372 retries += 1;
373 continue;
374 }
375 };
376
377 let mut writer = match asset_writer.write(Path::new(&filename)).await {
378 Ok(writer) => writer,
379 Err(e) => {
380 warn!("Failed to create file writer: {:?}", e);
381 retries += 1;
382 continue;
383 }
384 };
385
386 if let Err(e) = writer.write_all(&response.bytes).await {
387 warn!("Failed to write tile data: {:?}", e);
388 retries += 1;
389 continue;
390 }
391
392 if let Err(e) = writer.close().await {
393 warn!("Failed to close file writer: {:?}", e);
394 retries += 1;
395 continue;
396 }
397
398 break Ok(());
399 } else {
400 warn!("HTTP error {}: {}", response.status, response.status_text);
401 retries += 1;
402 continue;
403 }
404 }
405 Err(e) => {
406 warn!("Download error: {:?}", e);
407 retries += 1;
408 continue;
409 }
410 }
411 };
412
413 match result {
414 Ok(()) => SlippyTileDownloadTaskResult {
415 path: Path::new(&filename).to_path_buf(),
416 },
417 Err(e) => {
418 warn!("Failed to download tile: {}", e);
419 SlippyTileDownloadTaskResult {
420 path: Path::new(&filename).to_path_buf(),
421 }
422 }
423 }
424 })
425}
426
427fn load_and_track_slippy_tile_from_disk(
428 spc: SlippyTileCoordinates,
429 zoom_level: ZoomLevel,
430 tile_size: TileSize,
431 filename: String,
432 slippy_tile_download_tasks: &mut ResMut<SlippyTileDownloadTasks>,
433 slippy_tile_download_status: &mut ResMut<SlippyTileDownloadStatus>,
434) {
435 let task = load_slippy_tile_from_disk(filename.clone());
436 slippy_tile_download_tasks.insert_with_coords(spc, zoom_level, tile_size, task);
437 slippy_tile_download_status.insert_with_coords(
438 spc,
439 zoom_level,
440 tile_size,
441 filename,
442 DownloadStatus::Downloaded,
443 );
444}
445
446fn load_slippy_tile_from_disk(filename: String) -> Task<SlippyTileDownloadTaskResult> {
447 debug!("Loading slippy tile from disk - {}", filename);
448 spawn_fake_slippy_tile_download_task(filename)
449}
450
451fn spawn_fake_slippy_tile_download_task(filename: String) -> Task<SlippyTileDownloadTaskResult> {
452 let thread_pool = IoTaskPool::get();
453 thread_pool.spawn(async move {
454 SlippyTileDownloadTaskResult {
455 path: Path::new(&filename).to_path_buf(),
456 }
457 })
458}
459
460pub fn download_slippy_tiles_completed(
462 mut slippy_tile_download_status: ResMut<SlippyTileDownloadStatus>,
463 mut slippy_tile_download_tasks: ResMut<SlippyTileDownloadTasks>,
464 mut slippy_tile_downloaded_messages: MessageWriter<SlippyTileDownloadedMessage>,
465) {
466 let mut to_be_removed: Vec<SlippyTileDownloadTaskKey> = Vec::new();
467 for (stdtk, task) in slippy_tile_download_tasks.0.iter_mut() {
468 if let Some(SlippyTileDownloadTaskResult { path }) =
469 future::block_on(future::poll_once(task))
470 {
471 debug!("Done fetching map tile: {:?}", path);
472 slippy_tile_download_status.0.insert(
474 stdtk.clone(),
475 TileDownloadStatus {
476 path: path.clone(),
477 load_status: DownloadStatus::Downloaded,
478 },
479 );
480 slippy_tile_downloaded_messages.write(SlippyTileDownloadedMessage {
482 zoom_level: stdtk.zoom_level,
483 tile_size: stdtk.tile_size,
484 coordinates: Coordinates::from_slippy_tile_coordinates(
485 stdtk.slippy_tile_coordinates.x,
486 stdtk.slippy_tile_coordinates.y,
487 ),
488 path: path.clone(),
489 });
490 to_be_removed.push(stdtk.clone());
492 }
493 }
494 for remove_key in to_be_removed {
496 slippy_tile_download_tasks.0.remove(&remove_key);
497 }
498}