Skip to main content

bevy_slippy_tiles/
systems.rs

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        // Remove old requests outside the window
37        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        // Check if we can make a new request
46        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
124/// System that listens for DownloadSlippyTiles messages and submits individual tile requests in separate threads.
125pub 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    // First process any buffered requests
135    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        // Calculate tile range with overflow protection
148        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                    // This should only match when waiting on a file download.
179                    (_, AlreadyDownloaded::Yes, FileExists::No) => {
180                        // Check if the download has timed out
181                        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                    // Cache can not be used,
198                    (UseCache::No, _, _)
199                    // OR not downloading yet and no file exists on disk.
200                    | (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                    // Cache can be used and we have the file on disk.
210                    (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
460/// System that checks for completed slippy tile downloads and notifies via a SlippyTileDownloadedMessage message.
461pub 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            // Add to our map tiles.
473            slippy_tile_download_status.0.insert(
474                stdtk.clone(),
475                TileDownloadStatus {
476                    path: path.clone(),
477                    load_status: DownloadStatus::Downloaded,
478                },
479            );
480            // Notify any message consumers.
481            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            // Task is complete, remove entry.
491            to_be_removed.push(stdtk.clone());
492        }
493    }
494    // Clean up finished handled tasks.
495    for remove_key in to_be_removed {
496        slippy_tile_download_tasks.0.remove(&remove_key);
497    }
498}